Compare commits

...

33 Commits

Author SHA1 Message Date
Brendan Allan a33bb84ad5 Merge branch 'v2' into affected-checks 2026-08-27 09:44:22 +08:00
Brendan Allan 4908e70248 chore(ci): skip build checks after earlier failures 2026-08-27 09:42:20 +08:00
Kit Langton 66f326ddec fix(tui): restore default Kitty keyboard reporting (#45423) 2026-08-26 21:41:19 -04:00
Brendan Allan 4aae61a1db chore(ci): skip unaffected package verification 2026-08-27 09:38:55 +08:00
opencode-agent[bot] 83b81b39f1 fix(app): remove worktree user message accent (#45250)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 09:36:35 +08:00
Brendan Allan b20e4971db test(app): run CI e2e against production builds (#45422) 2026-08-27 09:28:23 +08:00
James Long c3378f2d34 fix(pty): upgrade opencode-pty to 0.1.12 (#45420) 2026-08-26 21:15:09 -04:00
Kit Langton d68031bc0d fix(core): preserve renamed files when undoing changes (#45383) 2026-08-26 20:42:40 -04:00
Kit Langton 4ac19d744a feat(tui): make session tab status explicit (#45347) 2026-08-26 20:12:15 -04:00
Aiden Cline b5cb5e2a55 refactor(plugin): manage MCP servers through transforms (#45408) 2026-08-26 18:42:28 -05:00
Luke Parker 53d171781a fix(app): reduce catalog retention and status animation work (#45406) 2026-08-26 23:30:49 +00:00
opencode-agent[bot] 3184427c03 feat(plugin): expose the current location in plugin context (#45403)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-26 19:09:16 -04:00
James Long 6b0613ac6f fix(tui): restore session focus during permission prompts (#45391) 2026-08-26 19:02:56 -04:00
Aiden Cline 5ac4156eb2 fix(core): preserve reasoning across model switches (#45401) 2026-08-26 17:52:41 -05:00
Luke Parker 954cdc7bc8 fix(desktop): restore compact Windows titlebar spacing (#45397) 2026-08-26 22:18:05 +00:00
Aiden Cline 767115cd69 fix(ai): default native Mantle to Responses (#45394) 2026-08-26 16:47:39 -05:00
James Long 5b25ee8430 style(tui): brighten embedded terminal palette (#45393) 2026-08-26 17:30:23 -04:00
James Long df6317d23c refactor(tui): simplify terminal selection state (#45387) 2026-08-26 17:18:44 -04:00
opencode-agent[bot] 524e39db02 chore: update nix node_modules hashes 2026-08-26 20:58:41 +00:00
James Long 6170221e21 fix(pty): upgrade opencode-pty to 0.1.11 (#45380) 2026-08-26 16:39:24 -04:00
Kit Langton 74a0b2d0ad feat(core): make the portable shell scanner authoritative 2026-08-26 16:35:16 -04:00
opencode-agent[bot] 9d46941e70 chore: update nix node_modules hashes 2026-08-26 20:31:57 +00:00
Aiden Cline d48a5d16ff fix(core): avoid nested MCP code mode (#45377) 2026-08-26 15:23:14 -05:00
Kit Langton 6c392355dd feat(tui): render LaTeX as Unicode math (#45339) 2026-08-26 16:14:37 -04:00
Aiden Cline dd4ee94682 refactor(ai): remove derived HTTP request ID (#45376) 2026-08-26 15:12:53 -05:00
Aiden Cline 009eca8b5b test(core): shrink webfetch stress fixtures (#45375) 2026-08-26 15:03:49 -05:00
Aiden Cline 94bd9f6c8b fix(ai): isolate native provider metadata ownership (#45155) 2026-08-26 14:54:12 -05:00
Aiden Cline 33909f48d7 fix(core): label migrated credentials by auth type (#45369) 2026-08-26 14:41:31 -05:00
James Long 80653a0a1a fix(tui): preserve resolved server directory (#45354) 2026-08-26 15:40:00 -04:00
James Long f7913a04d2 fix(cli): stop PTY daemon on explicit service restart (#45373) 2026-08-26 15:39:29 -04:00
Kit Langton cf347cd5e4 refactor(core): advance sessions before running steps (#45358)
Centralize control dispatch and first-Step preparation in advanceToStep. Keep input delivery outside logical-Step retries and preserve queue ordering, Location handoff, context refresh, and durable settlement.
2026-08-26 15:25:13 -04:00
opencode-agent[bot] 6600d59635 chore: update nix node_modules hashes 2026-08-26 19:07:20 +00:00
Aiden Cline 8b6a2450d5 fix(core): isolate invalid tool registrations (#45325) 2026-08-26 13:49:56 -05:00
171 changed files with 10429 additions and 1879 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
+10
View File
@@ -0,0 +1,10 @@
---
"@opencode-ai/core": patch
---
Make the experimental portable shell scanner authoritative, with no Tree-sitter
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
expressions, quoting, and substitutions natively. Preserve existing redirect and
declaration permission matching, and make PowerShell saved approvals cover the
original command spelling. Parser failures remain visible without changing the
permission engine. The default Tree-sitter path is unchanged.
+35 -16
View File
@@ -81,6 +81,33 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Find affected verification checks
id: checks
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
for package in cli codemode sdk; do
echo "$package=true" >> "$GITHUB_OUTPUT"
done
exit 0
fi
bun turbo ls --affected --output=json > affected.json
bun -e '
const result = await Bun.file("affected.json").json()
const packages = new Set(result.packages.items.map((item) => item.name))
const affected = (name) => packages.has(`@opencode-ai/${name}`)
const checks = {
// CLI builds embed the web app outside the workspace dependency graph.
cli: affected("cli") || affected("app"),
// Codemode publishing imports the shared script package directly.
codemode: affected("codemode") || affected("script"),
sdk: affected("sdk"),
}
for (const [name, enabled] of Object.entries(checks)) console.log(`${name}=${enabled}`)
' >> "$GITHUB_OUTPUT"
- name: Test Effect simplification rules
if: runner.os == 'Linux'
run: bun run test:effect-simplification-rules
@@ -123,25 +150,17 @@ jobs:
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify published codemode package
if: runner.os == 'Linux'
if: runner.os == 'Linux' && steps.checks.outputs.codemode == 'true'
working-directory: packages/codemode
run: bun run script/publish.ts --dry-run
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
if: runner.os == 'Linux' && steps.checks.outputs.sdk == 'true'
timeout-minutes: 15
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo verify:package --filter=@opencode-ai/sdk
exit 0
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: bun turbo verify:package --filter=@opencode-ai/sdk
- name: Verify compiled service lifecycle
if: always()
if: steps.checks.outputs.cli == 'true'
timeout-minutes: 10
working-directory: packages/cli
env:
@@ -151,13 +170,13 @@ jobs:
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: always()
if: steps.checks.outputs.cli == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: always()
if: steps.checks.outputs.cli == 'true'
timeout-minutes: 15
working-directory: packages/cli
env:
@@ -237,9 +256,9 @@ jobs:
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
- name: Run app e2e tests against production build
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:local
run: bun --cwd packages/app test:e2e:built
env:
CI: true
timeout-minutes: 30
+26 -9
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -364,7 +364,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -553,6 +553,20 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/latex": {
"name": "@opencode-ai/latex",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/merman": {
"name": "@opencode-ai/merman",
"version": "0.0.0",
@@ -879,6 +893,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/latex": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -2150,6 +2165,8 @@
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/latex": ["@opencode-ai/latex@workspace:packages/latex"],
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
@@ -2158,19 +2175,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.10", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.10", "@opencode-ai/pty-darwin-x64": "0.1.10", "@opencode-ai/pty-linux-arm64-gnu": "0.1.10", "@opencode-ai/pty-linux-arm64-musl": "0.1.10", "@opencode-ai/pty-linux-x64-gnu": "0.1.10", "@opencode-ai/pty-linux-x64-musl": "0.1.10" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-cEJT1ADtmnb+df2wrlUcsGny6Q7pTe9Sa7keISzCO0xN1FrL1aS6+eleBPpDimHjgM/sXqvLwJv0UiAeiAvgxQ=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.12", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.12", "@opencode-ai/pty-darwin-x64": "0.1.12", "@opencode-ai/pty-linux-arm64-gnu": "0.1.12", "@opencode-ai/pty-linux-arm64-musl": "0.1.12", "@opencode-ai/pty-linux-x64-gnu": "0.1.12", "@opencode-ai/pty-linux-x64-musl": "0.1.12" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-dl4FyJUhTXThsWYY8txG/8/nwN7dE0M5Sic9r4L9f2pvtJnbR5zrCrPoiPIBIxZle1wVks1dhz4z/CfqLf5sCg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j7aszDFRwCIazGUT9eIy4PZwh4rltjvRmoicPRTK3kONN3v0MMflstkmAFDYYpqDPTNh3qJ6xkQmB+DugEbhAg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tMvoriq3VegVlj1uEglc6qE0M7VXy61nyf9Si7tTO7xa8JiyxuFJSXOZ1pGeErDu+pe24hvTyVOR+gkdew8w9g=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UAMP/E4lo9RGQF7xrfIwpW2ZEemj308rCogJy14ruKYJt5MwHeGNTynGiHE/1JlDLRy+21wV50jpugADgT71ag=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Sn5vMLL5giHOhx7J5H6zwDp4YjjXorY+QV0IEYY+SCT4wQfRBliokIyj23pRl6P2RK3u9bDLXJHDNMfDVZ2Rxg=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-lTPlZNQ66koFHZqoPmvvq0SetlepKVQYgnLryhlVfYtcryWDJM7gV4+P66V12RwqWQTjt2u8j12mtg3axSKg2w=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HbnlKZy052l7G527wK0+05EXaUpZ4ykVAmNBEzqWCoi4TeQj2+Nr9kJ9trx9o1KrVcT4Ki58CCvN5QOls6Z0yQ=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-IDmWHRylMR/ZfMw9/AAktO/Edi4TITPC+Tq7Xx3JZHsDgSba3QdyE11uNL0zM1myTGdk6Yrt4rpdAzaItPnDjw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2nTN7ggu1h9XgjNcoQMYjP5sirfYnAskpdFCOqjokLqhytX/IMMmkRTQs+foaEaPz0dAIQD3DQplR2jZIgxp1w=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Q1yob0/8X2JoJZzFmNKUc32XDRAe0avKQ8PLKkpJr30qWXSrGmhltgcDmn94Q70zW9Ght9on84T7cmge9brvdQ=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-FnD5ndnObTQKAoaVvxLKi5W+r3/+dsaMsobz6uK0B9hlmffXxY5CQ6HQyUU/h3aIKLWXxho5XYkA2b9yrp8/gA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7RLHWQxX/wfUKJJP2ZMMtkXaPsrgoMNKzE6PL/LbnYbMBtkqfld9EDcMv1RFZ0CqjNFgI0Hg4eRk6x+ZNc/wyQ=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-prkrNu6uvjqoffxdGiDHSU5C0Y+kCSfv+lslu7dfRPgPKenVELNpRTAbOduyrWPac2vGt8j5NM61icJyodbJmA=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2IiQK8EPwoDber8epX9NlZhaQwvDyN17nTMSPEjtk4s=",
"aarch64-linux": "sha256-/ggedGSQ2RWbhX/CahTpLz1TLqlf+hu95u1ys9F/CdI=",
"aarch64-darwin": "sha256-RaEiXwQEfpFiehG4m3IMulopP1b54PtrAECY+e2aJ6Q=",
"x86_64-darwin": "sha256-MANNi8GyHkvJzU7Xw6FQJHsx3fBCFJfGqlSOY9zez7k="
"x86_64-linux": "sha256-NV1PD2fCgWEKsr9kR0pV9jgkC400dzoF7/DnI/fY5yI=",
"aarch64-linux": "sha256-TDTdwE0mcHLrrKPDwPPBk3qIDl/PXJrLX6Zbwp7EH3I=",
"aarch64-darwin": "sha256-6MEoiV1UKAWgC7C6PR4USCP/LLZXROfBfPg6sb2VVWg=",
"x86_64-darwin": "sha256-8JV6YVZFq1BC++zpARxBWhQ+wuNJrWgTZJ6jfQhDybs="
}
}
+42 -32
View File
@@ -406,6 +406,7 @@ const AnthropicEvent = Schema.Struct({
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
@@ -440,18 +441,18 @@ const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefin
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
const anthropic = metadata?.anthropic
if (!ProviderShared.isRecord(anthropic)) return undefined
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
const signatureFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
const provider = metadata?.[key]
if (!ProviderShared.isRecord(provider)) return undefined
return typeof provider.signature === "string" ? provider.signature : undefined
}
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
const anthropic = metadata?.anthropic
if (!ProviderShared.isRecord(anthropic)) return undefined
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
const provider = metadata?.[key]
if (!ProviderShared.isRecord(provider)) return undefined
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
@@ -511,13 +512,16 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
return undefined
}
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) {
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
part: ToolResultPart,
providerMetadataKey: string,
) {
const wireType = serverToolResultType(part.name)
if (!wireType)
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
const payload = part.providerMetadata?.[providerMetadataKey]?.["result"] ?? part.result.value
return {
type: wireType,
tool_use_id: scrubToolCallID(part.id),
@@ -804,6 +808,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
breakpoints: Cache.Breakpoints,
) {
const messages: AnthropicMessage[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
@@ -849,8 +854,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, providerMetadataKey)
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
@@ -879,7 +884,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "tool-result" && part.providerExecuted) {
content.push(yield* lowerServerToolResult(part))
content.push(yield* lowerServerToolResult(part, providerMetadataKey))
continue
}
return yield* invalid(
@@ -1069,7 +1074,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are included in `output_tokens`; newer responses also
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
@@ -1083,7 +1088,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
cacheWriteInputTokens: cacheWrite,
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { anthropic: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -1092,7 +1097,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
// recomputed from the merged breakdown so the inclusive total stays
// consistent with `nonCached + cacheRead + cacheWrite`.
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerMetadataKey: string) => {
if (!left) return right
if (!right) return left
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
@@ -1110,7 +1115,9 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: {
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
[providerMetadataKey]:
mergeJsonRecords(left.providerMetadata?.[providerMetadataKey], right.providerMetadata?.[providerMetadataKey]) ??
{},
},
})
}
@@ -1128,7 +1135,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: string): LLMEvent | undefined => {
if (!block.type || !isServerToolResultType(block.type)) return undefined
const errorPayload =
typeof block.content === "object" && block.content !== null && "type" in block.content
@@ -1142,7 +1149,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefine
providerExecuted: true,
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
})
}
@@ -1151,8 +1158,8 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const usage = mapUsage(event.message?.usage)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
const usage = mapUsage(event.message?.usage, state.providerMetadataKey)
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.providerMetadataKey) } : state, NO_EVENTS]
}
const onContentBlockStart = (
@@ -1204,14 +1211,16 @@ const onContentBlockStart = (
if (block.type === "thinking" && block.thinking !== undefined) {
const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}`
const providerMetadata =
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
const metadata =
block.signature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
return [
{
...state,
lifecycle: block.thinking
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, metadata)
: lifecycle,
reasoningSignatures:
event.index === undefined || block.signature === undefined
@@ -1234,14 +1243,14 @@ const onContentBlockStart = (
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
anthropicMetadata({ redactedData: block.data }),
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
),
},
events,
]
}
const result = serverToolResultEvent(block)
const result = serverToolResultEvent(block, state.providerMetadataKey)
if (!result) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
@@ -1321,7 +1330,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : anthropicMetadata({ signature }),
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
)
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
@@ -1333,7 +1342,7 @@ const onMessageDelta = (
state: ParserState,
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
return [
{
...state,
@@ -1346,7 +1355,7 @@ const onMessageDelta = (
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
@@ -1472,7 +1481,8 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(AnthropicEvent),
initial: () => ({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
+24 -25
View File
@@ -258,19 +258,21 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ tool: { name } }) as const,
})
const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ bedrock: metadata })
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const reasoningSignature = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return (
part.encrypted ??
(ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
)
}
const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
? metadata.redactedData
: undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
@@ -318,6 +320,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
breakpoints: BedrockCache.Breakpoints,
) {
const messages: BedrockMessage[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
if (message.role === "system") {
@@ -365,8 +368,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = reasoningSignature(part)
const redactedData = reasoningRedactedData(part)
const signature = reasoningSignature(part, providerMetadataKey)
const redactedData = reasoningRedactedData(part, providerMetadataKey)
if (signature === undefined && redactedData !== undefined) {
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
@@ -466,7 +469,7 @@ const mapFinishReason = (reason: string): FinishReason => {
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
@@ -480,11 +483,12 @@ const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
@@ -541,20 +545,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedData = reasoning.redactedContent ?? reasoning.data
const providerMetadata = reasoning.signature
? bedrockMetadata({ signature: reasoning.signature })
const metadata = reasoning.signature
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
: redactedData !== undefined
? bedrockMetadata({ redactedData })
? providerMetadata(state.providerMetadataKey, { redactedData })
: undefined
const lifecycle =
reasoning.text !== undefined || providerMetadata !== undefined
? Lifecycle.reasoningDelta(
state.lifecycle,
events,
`reasoning-${index}`,
reasoning.text ?? "",
providerMetadata,
)
reasoning.text !== undefined || metadata !== undefined
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
: state.lifecycle
return [
{
@@ -596,7 +594,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
events,
`reasoning-${index}`,
state.reasoningSignatures[index]
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
: undefined,
)
events.push(...resultEvents)
@@ -633,7 +631,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
return [
{
...state,
@@ -698,7 +696,8 @@ export const protocol = Protocol.make({
},
stream: {
event: BedrockEvent,
initial: () => ({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
pendingFinish: undefined,
hasToolCalls: false,
+36 -21
View File
@@ -229,6 +229,7 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly providerMetadataKey: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -285,22 +286,23 @@ const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPar
return { inlineData: { mimeType: media.mime, data: media.base64 } }
})
const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
const google = providerMetadata?.google
return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
? google.thoughtSignature
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) => {
const value = metadata?.[key]
return ProviderShared.isRecord(value) && typeof value.thoughtSignature === "string"
? value.thoughtSignature
: undefined
}
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string) => ({
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
thoughtSignature: thoughtSignature(part.providerMetadata),
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
})
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
const omitCallIds = omitsFunctionCallIds(request.model.id)
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
@@ -342,15 +344,19 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey) })
continue
}
if (part.type === "reasoning") {
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
parts.push({
text: part.text,
thought: true,
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
})
continue
}
if (part.type === "tool-call") {
const lowered = lowerToolCall(part, omitCallIds)
const lowered = lowerToolCall(part, omitCallIds, metadataKey)
const signature = lowered.thoughtSignature
parts.push({
...lowered,
@@ -498,7 +504,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
// to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined) => {
const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
if (!usage) return undefined
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
// token arithmetic below treats them like absent counts.
@@ -519,7 +525,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
cacheReadInputTokens: cached,
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: { google: usage },
providerMetadata: providerMetadata(metadataKey, usage),
})
}
@@ -567,14 +573,14 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
"text-0",
googleMetadata({ thoughtSignature: state.textSignature }),
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
@@ -584,7 +590,9 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
},
usage: state.usage,
providerMetadata:
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
state.promptFeedback === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
})
return events
}
@@ -593,7 +601,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
usage: event.usageMetadata
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
: state.usage,
}
const candidate = event.candidates?.[0]
if (!candidate?.content)
@@ -637,7 +647,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
"reasoning-0",
part.text,
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
)
continue
}
@@ -645,14 +655,16 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
"text-0",
part.text,
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
textSignature = undefined
continue
@@ -672,7 +684,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
@@ -681,7 +695,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
@@ -720,6 +734,7 @@ export const protocol = Protocol.make({
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
route: `${request.model.provider}/${request.model.route.id}`,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
}),
+49 -19
View File
@@ -253,6 +253,7 @@ interface PendingToolDelta {
}
export interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
readonly toolCallEvents: ReadonlyArray<LLMEvent>
@@ -324,17 +325,18 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const reasoningField = (part: ReasoningPart) => {
const field = part.providerMetadata?.openai?.reasoningField
const reasoningField = (part: ReasoningPart, providerMetadataKey: string) => {
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
return typeof field === "string" ? field : undefined
}
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.openai?.reasoningDetails
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
return observed
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
@@ -366,7 +368,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -388,10 +390,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
}
}
const text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
const observedField = reasoning
.map((part) => reasoningField(part, options.providerMetadataKey))
.find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const fullyStructured = reasoning.every((part) =>
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
)
const field = (() => {
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
return configuredField
@@ -459,7 +465,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant")
@@ -495,6 +501,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
toolCallID: (id: string) => {
if (mistral)
return id
@@ -820,7 +827,7 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
// Providers differ on cache-hit location: OpenAI uses
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
@@ -839,7 +846,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { openai: usage },
providerMetadata: { [providerMetadataKey]: usage },
})
}
@@ -913,8 +920,12 @@ const conflictingReasoningTextDetails = (previous: Record<string, unknown>, curr
const conflictingDetailValue = (previous: unknown, current: unknown) =>
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
openai: {
const reasoningMetadata = (
providerMetadataKey: string,
field: ParserState["reasoningField"],
details?: ReadonlyArray<unknown>,
) => ({
[providerMetadataKey]: {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
@@ -941,7 +952,10 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
// `choice.usage` instead of the top-level `usage` field.
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const usage =
mapUsage(event.usage, state.providerMetadataKey) ??
(choiceUsage ? mapUsage(choiceUsage, state.providerMetadataKey) : undefined) ??
state.usage
const rawFinishReason = choice?.finish_reason
const finishReason = rawFinishReason
? {
@@ -979,7 +993,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField)
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if (
@@ -995,7 +1009,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
@@ -1005,7 +1023,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle,
events,
"reasoning-0",
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
reasoningMetadata(
state.providerMetadataKey,
reasoningField,
reasoningDetailsObserved ? state.reasoningDetails : undefined,
),
)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
}
@@ -1066,6 +1088,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
providerMetadataKey: state.providerMetadataKey,
tools: finished?.tools ?? tools,
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
@@ -1109,12 +1132,18 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
const metadata = reasoningMetadata(
state.providerMetadataKey,
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
)
const started =
state.reasoningDetailsObserved && !state.reasoningEmitted
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
? Lifecycle.reasoningStart(
state.lifecycle,
events,
"reasoning-0",
reasoningMetadata(state.providerMetadataKey, state.reasoningField),
)
: state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
@@ -1141,6 +1170,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
pendingTools: {},
toolCallEvents: [],
@@ -23,13 +23,14 @@ export interface Settings extends ProviderPackage.Settings {
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: OpenAIResponses.route.providerMetadataKey,
providerMetadataKey: "mantle",
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
@@ -40,6 +41,7 @@ const responsesRoute = Route.make({
const chatRoute = OpenAIChat.route.with({
id: "bedrock-mantle-chat",
provider: id,
providerMetadataKey: "mantle",
})
export const routes = [responsesRoute, chatRoute]
@@ -76,7 +78,7 @@ export const configure = (input: Config = {}) => {
return {
id,
model: chat,
model: responses,
chat,
responses,
configure,
@@ -94,6 +96,7 @@ const config = (settings: Settings): Config => {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
@@ -109,4 +112,4 @@ export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProvider
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel
export const model = responsesModel
@@ -35,6 +35,7 @@ const configuredRoute = (input: Config) => {
return BedrockConverse.route.with({
...rest,
provider: id,
providerMetadataKey: "bedrock",
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
})
@@ -1,2 +1,2 @@
export { chatModel as model } from "../amazon-bedrock-mantle.js"
export { responsesModel as model } from "../amazon-bedrock-mantle.js"
export type { Settings } from "../amazon-bedrock-mantle.js"
@@ -27,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleChat.route.with({
id: "google-vertex-chat",
provider: id,
providerMetadataKey: "vertex",
})
export const routes = [route]
@@ -27,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings {
const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses",
provider: id,
providerMetadataKey: "vertex",
providerOptions: { store: false },
})
+1 -1
View File
@@ -68,7 +68,7 @@ const protocol = {
const route = Route.make({
id: "google-vertex-gemini",
provider: id,
providerMetadataKey: "google",
providerMetadataKey: "vertex",
protocol,
endpoint: Endpoint.path(({ request }) => {
const model = String(request.model.id)
+1
View File
@@ -164,6 +164,7 @@ const bodyOptions = (input: unknown) => {
export const route = Route.make({
id: ADAPTER,
provider: profile.provider,
providerMetadataKey: "openrouter",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: Framing.sse,
+7 -1
View File
@@ -89,6 +89,7 @@ export interface RouteDefaultsInput {
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly id?: string
readonly provider?: string | ProviderID
readonly providerMetadataKey?: string
readonly auth?: Auth.Definition
readonly transport?: Transport<Body, Prepared, unknown>
readonly endpoint?: EndpointPatch<Body>
@@ -289,11 +290,16 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, auth, transport, endpoint, ...defaults } = patch
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
...routeInput,
id: id ?? routeInput.id,
provider: provider ?? routeInput.provider,
providerMetadataKey:
providerMetadataKey ??
(provider !== undefined && String(provider) !== String(routeInput.provider)
? String(provider)
: routeInput.providerMetadataKey),
auth: auth ?? routeInput.auth,
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
-15
View File
@@ -40,17 +40,6 @@ const headerDetails = (headers: Headers.Headers) =>
const normalizedHeaders = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
const requestId = (headers: Record<string, string>) => {
return (
headers["x-request-id"] ??
headers["request-id"] ??
headers["x-amzn-requestid"] ??
headers["x-amz-request-id"] ??
headers["x-goog-request-id"] ??
headers["cf-ray"]
)
}
const retryAfterMs = (headers: Record<string, string>) => {
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
@@ -147,14 +136,12 @@ const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly body: ReturnType<typeof responseBody>
readonly requestId?: string | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
}) =>
new HttpContext({
request: requestDetails(input.request),
response: responseDetails(input.response),
...input.body,
requestId: input.requestId,
rateLimit: input.rateLimit,
})
@@ -179,7 +166,6 @@ const statusError =
request,
response,
body: details,
requestId: requestId(headers),
rateLimit,
}),
}),
@@ -216,7 +202,6 @@ export const classifyHttpFailure = (input: {
? undefined
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
...details,
requestId: requestId(headers),
rateLimit,
}),
})
-1
View File
@@ -29,7 +29,6 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String),
bodyTruncated: Schema.optional(Schema.Boolean),
requestId: Schema.optional(Schema.String),
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
-1
View File
@@ -327,7 +327,6 @@ describe("RequestExecutor", () => {
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src/index.js"
import { Auth, LLMClient } from "../../src/route.js"
import { Auth, Endpoint, LLMClient, Route } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { GoogleVertexMessages } from "../../src/providers.js"
@@ -810,6 +810,99 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("round-trips compatible provider metadata in its own namespace", () =>
Effect.gen(function* () {
const compatible = Route.make({
id: "custom-anthropic-messages",
provider: "custom-anthropic",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
auth: Auth.header("x-api-key", "test"),
framing: AnthropicMessages.framing,
}).model({ id: "custom-model" })
const result = [
{
type: "web_search_result",
url: "https://example.com",
citations: [{ type: "web_search_result_location", cited_text: "Example" }],
},
]
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Search." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5, custom_start: true } } },
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "Thinking." } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "custom_sig" } },
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "redacted_thinking", data: "custom_redacted" },
},
{ type: "content_block_stop", index: 1 },
{
type: "content_block_start",
index: 2,
content_block: {
type: "server_tool_use",
id: "custom_tool",
name: "web_search",
input: { query: "example" },
},
},
{ type: "content_block_stop", index: 2 },
{
type: "content_block_start",
index: 3,
content_block: { type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
},
{ type: "content_block_stop", index: 3 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "custom_stop" },
usage: { output_tokens: 2, custom_terminal: true },
},
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toMatchObject([
{ type: "reasoning", text: "Thinking.", providerMetadata: { "custom-anthropic": { signature: "custom_sig" } } },
{ type: "reasoning", text: "", providerMetadata: { "custom-anthropic": { redactedData: "custom_redacted" } } },
{ type: "tool-call", id: "custom_tool", providerExecuted: true },
{
type: "tool-result",
providerExecuted: true,
providerMetadata: { "custom-anthropic": { blockType: "web_search_tool_result", result } },
},
])
expect(response.usage?.providerMetadata).toEqual({
"custom-anthropic": { input_tokens: 5, custom_start: true, output_tokens: 2, custom_terminal: true },
})
expect(response.events.at(-1)).toMatchObject({
providerMetadata: { "custom-anthropic": { stopSequence: "custom_stop" } },
})
const prepared = yield* compileRequest(
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Thinking.", signature: "custom_sig" },
{ type: "redacted_thinking", data: "custom_redacted" },
{ type: "server_tool_use", id: "custom_tool", name: "web_search", input: { query: "example" } },
{ type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
],
},
])
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -569,6 +569,57 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("round-trips reassigned provider reasoning and usage metadata in its own namespace", () =>
Effect.gen(function* () {
const compatible = model.route.with({ provider: "custom-bedrock" }).model({ id: model.id })
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: compatible })).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "custom_sig" } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
[
"contentBlockDelta",
{ contentBlockIndex: 1, delta: { reasoningContent: { redactedContent: redactedData } } },
],
["contentBlockStop", { contentBlockIndex: 1 }],
["messageStop", { stopReason: "end_turn" }],
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Let me think.",
providerMetadata: { "custom-bedrock": { signature: "custom_sig" } },
},
{ type: "reasoning", text: "", providerMetadata: { "custom-bedrock": { redactedData } } },
])
expect(response.usage?.providerMetadata).toEqual({
"custom-bedrock": { inputTokens: 5, outputTokens: 2, totalTokens: 7 },
})
const prepared = yield* compileRequest(
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "custom_sig" } } },
{ reasoningContent: { redactedContent: redactedData } },
],
},
])
}),
)
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -17,13 +18,16 @@ const credentials = {
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
it.effect("uses Responses by default and exposes Chat explicitly", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.responses("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
)
expect(chat).toMatchObject({
@@ -36,6 +40,23 @@ describe("Amazon Bedrock Mantle provider", () => {
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
}),
)
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
Effect.gen(function* () {
const settings = { apiKey: "test-key", topP: 0.8 }
const chat = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
)
const responses = yield* compileRequest(
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
)
expect(chat.body.top_p).toBe(0.8)
expect(responses.body.top_p).toBe(0.8)
}),
)
@@ -107,6 +128,9 @@ describe("Amazon Bedrock Mantle provider", () => {
LLM.request({ model, messages: [response.message, Message.user("Continue.")] }),
)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
mantle: { itemId: "msg_95d4d0af4350432a", reasoningEncryptedContent: "mantle-state" },
})
expect(prepared.body.input).toEqual([
{
type: "reasoning",
+1 -1
View File
@@ -126,7 +126,7 @@ describe("Cloudflare", () => {
expect(response.reasoning).toBe("Thinking")
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: merged },
"cloudflare-ai-gateway": { reasoningField: "reasoning", reasoningDetails: merged },
})
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
@@ -6,7 +6,7 @@ import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexRespo
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { deltaChunk, finishChunk } from "../lib/openai-chunks.js"
import { sseEvents } from "../lib/sse.js"
@@ -89,7 +89,7 @@ describe("Google Vertex providers", () => {
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "provider_call_1" } },
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
}),
]),
Message.tool({
@@ -97,7 +97,7 @@ describe("Google Vertex providers", () => {
name: "lookup",
result: "sunny",
resultType: "text",
providerMetadata: { google: { functionCallId: "provider_call_1" } },
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
}),
],
}),
@@ -122,6 +122,91 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("round-trips Vertex Gemini metadata through signed content, tool calls, and usage", () =>
Effect.gen(function* () {
const model = GoogleVertex.configure({
accessToken: "vertex-token",
project: "vertex-project",
}).model("gemini-3.5-flash")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
{ text: "Checking.", thoughtSignature: "text_sig" },
{
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
thoughtSignature: "tool_sig",
},
],
},
finishReason: "STOP",
},
],
promptFeedback: { blockReasonMessage: "Reviewed" },
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
}),
),
),
)
const reasoning = response.events.find((event) => event.type === "reasoning-end")
const text = response.events.find((event) => event.type === "text-delta")
const toolCall = response.toolCalls[0]
expect(reasoning?.providerMetadata).toEqual({ vertex: { thoughtSignature: "reasoning_sig" } })
expect(text?.providerMetadata).toEqual({ vertex: { thoughtSignature: "text_sig" } })
expect(toolCall).toMatchObject({
id: "provider_call_1",
providerMetadata: { vertex: { thoughtSignature: "tool_sig" } },
})
expect(response.usage?.providerMetadata).toEqual({
vertex: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
})
expect(response.events.at(-1)?.providerMetadata).toEqual({
vertex: { promptFeedback: { blockReasonMessage: "Reviewed" } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "reasoning", text: "Thinking.", providerMetadata: reasoning?.providerMetadata },
{ type: "text", text: "Checking.", providerMetadata: text?.providerMetadata },
ToolCallPart.make({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input,
providerMetadata: toolCall.providerMetadata,
}),
]),
Message.tool({ id: toolCall.id, name: toolCall.name, result: "sunny", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
{ text: "Checking.", thoughtSignature: "text_sig" },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
],
},
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
},
])
}),
)
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
Effect.gen(function* () {
const model = GoogleVertexMessages.configure({
@@ -2,13 +2,80 @@ import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
import {
AmazonBedrock,
AmazonBedrockMantle,
Anthropic,
AnthropicCompatible,
Azure,
Cerebras,
CloudflareAIGateway,
CloudflareWorkersAI,
DeepInfra,
Google,
GoogleVertex,
GoogleVertexChat,
GoogleVertexMessages,
GoogleVertexResponses,
Groq,
OpenAI,
OpenAICompatible,
OpenAICompatibleResponses,
OpenRouter,
TogetherAI,
XAI,
} from "../../src/providers/index.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
Effect.gen(function* () {
const vertex = { project: "project", accessToken: "token" }
const providers = [
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
[Google.configure({ apiKey: "test" }).model("model"), "google"],
[GoogleVertex.configure(vertex).model("model"), "vertex"],
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
"minimax",
],
[
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
"custom",
],
[
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
"model",
),
"custom",
],
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
] as const
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
}),
)
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
@@ -68,11 +68,13 @@ for (const item of cases) {
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata?.[
item.model.route.providerMetadataKey ?? String(item.model.provider)
]
expect(metadata?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
expect(Array.isArray(metadata?.reasoningDetails)).toBe(item.structured)
if (!item.structured) return
const details = metadata?.openai?.reasoningDetails
const details = metadata?.reasoningDetails
if (!Array.isArray(details)) return
expect(
details.some(
@@ -126,7 +128,11 @@ for (const item of cases) {
).toMatch(/^Paris is sunny\.?$/)
const details = events
.filter(LLMEvent.is.reasoningEnd)
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
.map(
(event) =>
event.providerMetadata?.[item.model.route.providerMetadataKey ?? String(item.model.provider)]
?.reasoningDetails,
)
.find(Array.isArray)
expect(Array.isArray(details)).toBe(item.structured)
if (!item.structured || !Array.isArray(details)) return
@@ -903,6 +903,70 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("uses the configured provider metadata namespace for reasoning and usage", () =>
Effect.gen(function* () {
const selected = LanguageModel.update(model, {
route: { ...model.route, providerMetadataKey: "vendor" },
})
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed" }]
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
),
),
),
)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
vendor: { reasoningField: "reasoning", reasoningDetails: details },
})
expect(response.usage?.providerMetadata).toEqual({
vendor: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
})
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
])
}),
)
it.effect("falls back to the selected provider for the metadata namespace", () =>
Effect.gen(function* () {
const compatible = model.route.with({ provider: "deepseek" }).model({ id: "deepseek-chat" })
const selected = LanguageModel.update(compatible, {
route: { ...compatible.route, providerMetadataKey: undefined },
})
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ reasoning_content: "thinking" }),
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
),
),
),
)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
deepseek: { reasoningField: "reasoning_content" },
})
expect(response.usage?.providerMetadata).toEqual({
deepseek: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
})
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
}),
)
it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () {
const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
@@ -437,7 +437,7 @@ describe("OpenAI-compatible Chat route", () => {
outputTokens: undefined,
totalTokens: undefined,
providerMetadata: {
openai: {
deepseek: {
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
@@ -195,19 +195,19 @@ describe("Open Responses-compatible route", () => {
model,
messages: [
Message.assistant([
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
{ type: "text", text: "Kept.", providerMetadata: { "openai-compatible": { itemId: "history_1" } } },
{
type: "text",
text: "Long.",
providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } },
providerMetadata: { "openai-compatible": { itemId: `history_${"a".repeat(64)}` } },
},
{
type: "text",
text: "Opaque.",
providerMetadata: { openresponses: { itemId: "provider_value/with+symbols" } },
providerMetadata: { "openai-compatible": { itemId: "provider_value/with+symbols" } },
},
{ type: "text", text: "No suffix.", providerMetadata: { openresponses: { itemId: "msg_" } } },
{ type: "text", text: "No prefix.", providerMetadata: { openresponses: { itemId: "_item" } } },
{ type: "text", text: "No suffix.", providerMetadata: { "openai-compatible": { itemId: "msg_" } } },
{ type: "text", text: "No prefix.", providerMetadata: { "openai-compatible": { itemId: "_item" } } },
]),
],
}),
@@ -267,7 +267,7 @@ describe("Open Responses-compatible route", () => {
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openresponses: { itemId: item.id } },
providerMetadata: { example: { itemId: item.id } },
}),
),
}),
@@ -302,7 +302,7 @@ describe("Open Responses-compatible route", () => {
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
{ type: "text", text: "Indexed", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
}),
)
@@ -368,7 +368,7 @@ describe("Open Responses-compatible route", () => {
),
)
const metadata = { openresponses: { itemId: routing.id } }
const metadata = { "openai-compatible": { itemId: routing.id } }
if (fixture.item.type === "function_call") {
expect(response.toolCalls).toEqual([
expect.objectContaining({
@@ -386,7 +386,7 @@ describe("Open Responses-compatible route", () => {
type: "reasoning",
text: "Preserved",
providerMetadata: {
openresponses: { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
},
},
])
@@ -438,22 +438,26 @@ describe("Open Responses-compatible route", () => {
{
type: "reasoning",
text: "First.",
providerMetadata: { openresponses: { itemId: routing.id } },
providerMetadata: { "openai-compatible": { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
expect.objectContaining({
id: `${routing.id}:0`,
providerMetadata: { openresponses: { itemId: routing.id } },
providerMetadata: { "openai-compatible": { itemId: routing.id } },
}),
expect.objectContaining({
id: `${routing.id}:1`,
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
},
}),
])
}),
@@ -483,7 +487,7 @@ describe("Open Responses-compatible route", () => {
id: "call_1",
name: "lookup",
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "" } },
providerMetadata: { "openai-compatible": { itemId: "" } },
}),
])
}),
@@ -510,7 +514,7 @@ describe("Open Responses-compatible route", () => {
)
expect(response.message.content).toEqual([
{ type: "text", text: "Before after", providerMetadata: { openresponses: { itemId: "msg_1" } } },
{ type: "text", text: "Before after", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(response.events.map((event) => event.type)).toEqual([
"step-start",
@@ -662,7 +666,7 @@ describe("Open Responses-compatible route", () => {
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "item_1" } },
providerMetadata: { example: { itemId: "item_1" } },
})
}),
)
@@ -694,7 +698,7 @@ describe("Open Responses-compatible route", () => {
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
})
}),
)
@@ -743,7 +747,7 @@ describe("Open Responses-compatible route", () => {
Message.assistant({
type: "text",
text: "Unclassified.",
providerMetadata: { openresponses: { phase: null } },
providerMetadata: { "openai-compatible": { phase: null } },
}),
],
}),
@@ -802,7 +806,7 @@ describe("Open Responses-compatible route", () => {
{
type: "text",
text: "I can't help with that.",
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
providerMetadata: { example: { itemId: "msg_refusal" } },
},
])
@@ -891,7 +895,7 @@ describe("Open Responses-compatible route", () => {
expect(response.toolCalls).toEqual([])
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
providerMetadata: { openresponses: { responseId: "resp_1" } },
providerMetadata: { example: { responseId: "resp_1" } },
})
}),
)
+3 -3
View File
@@ -295,7 +295,7 @@ describe("OpenRouter", () => {
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
},
]),
],
@@ -328,7 +328,7 @@ describe("OpenRouter", () => {
Message.assistant({
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
@@ -354,7 +354,7 @@ describe("OpenRouter", () => {
Message.assistant({
type: "reasoning",
text: "AB",
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
}),
],
}),
+9
View File
@@ -40,4 +40,13 @@ describe("Route.with", () => {
"x-patch": "patch",
})
})
test("assigns metadata ownership to a replacement provider and preserves explicit overrides", () => {
const route = OpenAIChat.route.with({ provider: "azure" })
const overridden = route.with({ providerMetadataKey: "custom-azure" }).with({ headers: { "x-test": "value" } })
expect(route.providerMetadataKey).toBe("azure")
expect(overridden.providerMetadataKey).toBe("custom-azure")
expect(overridden.defaults).not.toHaveProperty("providerMetadataKey")
})
})
+28 -4
View File
@@ -31,7 +31,7 @@ Your app is ready to be deployed!
## E2E Testing
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
Locally, Playwright starts the Vite dev server automatically via `webServer`, or reuses one already running at the configured address. The browser suite uses isolated API fixtures rather than a live opencode backend.
```bash
bunx playwright install chromium
@@ -39,11 +39,35 @@ bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
```bash
bun run test:e2e:built
bun run test:e2e:built -- --grep "settings"
```
To test an already-running dev server without starting or building a server:
```bash
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
```
For an already-running production build, also set `PLAYWRIGHT_BUILD=1` so the fixture API uses the app's origin:
```bash
PLAYWRIGHT_BUILD=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
```
External targets must use HTTP because fixture URLs use HTTP. `PLAYWRIGHT_BASE_URL` skips server startup and building in either mode.
Compiled CLI startup and service lifecycle coverage runs separately in CI via `packages/cli/script/service-smoke.ts`.
Environment options:
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
## Deployment
+12
View File
@@ -44,6 +44,7 @@ The suite contains:
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
- retained renderer heap with a large model catalog across repeated session navigation
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
@@ -78,6 +79,17 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
## Retained renderer memory
Run the catalog workload against the production app bundle:
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/provider-memory-benchmark.spec.ts --repeat-each=3
```
`PROVIDER_MEMORY_MODELS` defaults to 1,200 and `PROVIDER_MEMORY_SWITCHES` defaults to 10. Each sample records Chromium's `Runtime.getHeapUsage` and `Memory.getDOMCounters` after an explicit garbage collection. This measures retained state, not allocation peaks or normal GC timing. It does not include worker heaps, the Electron main/GPU processes, or the OpenCode server, and must not be reported as total desktop RAM. Use identical model counts and navigation sequences for before/after comparisons.
## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
@@ -1,7 +1,7 @@
import config from "../../playwright.config"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
process.env.PLAYWRIGHT_SERVER_PORT = new URL(process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`).port || "80"
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default {
@@ -12,9 +12,11 @@ export default {
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
webServer: {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
},
webServer: config.webServer
? {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
}
: undefined,
}
@@ -0,0 +1,98 @@
import { benchmark, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
import type { CatalogUpdated } from "@opencode-ai/client/promise"
benchmark("measures retained renderer memory with a large model catalog", async ({ page, report }) => {
benchmark.setTimeout(120_000)
const count = Number(process.env.PROVIDER_MEMORY_MODELS ?? 1200)
const switches = Number(process.env.PROVIDER_MEMORY_SWITCHES ?? 10)
const provider = fixture.provider.all[0]
const selected = { ...provider.models["claude-opus-4-6"] }
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
pageMessages,
provider: {
...fixture.provider,
all: [
{
...provider,
models: {
[selected.id]: selected,
...Object.fromEntries(
Array.from({ length: count - 1 }, (_, index) => {
const id = `catalog-model-${index}`
return [
id,
{
id,
name: `Catalog model ${index}`,
cost: { input: 1, output: 2 },
limit: { context: 200_000, output: 8192 },
variants: { high: { reasoningEffort: "high" } },
},
]
}),
),
},
},
],
},
})
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
const cdp = await page.context().newCDPSession(page)
const samples = []
for (let index = 0; index <= switches; index++) {
if (index > 0) {
const target = index % 2 === 1
const id = target ? fixture.targetID : fixture.sourceID
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(id)}"]`).click()
await expectSessionTitle(page, target ? fixture.expected.targetTitle : fixture.expected.sourceTitle)
await waitForStableTimeline(
page,
(target ? fixture.expected.targetMessageIDs : fixture.expected.sourceMessageIDs).at(-1)!,
)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
}
// GC is an explicit retained-heap measurement, not an application optimization or readiness wait.
await cdp.send("HeapProfiler.collectGarbage")
samples.push({
switches: index,
heap: await cdp.send("Runtime.getHeapUsage"),
dom: await cdp.send("Memory.getDOMCounters"),
})
}
expect(samples).toHaveLength(switches + 1)
expect(samples.every((sample) => sample.heap.usedSize > 0)).toBe(true)
selected.name = "Updated catalog model"
await page.evaluate(
(event) => {
const host = window as Window & { __mockServerStream?: { push: (events: CatalogUpdated[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([event])
},
{
id: "evt_catalog_refresh",
created: Date.now(),
type: "catalog.updated",
location: { directory: fixture.directory },
data: {},
} satisfies CatalogUpdated,
)
await expect(page.locator('[data-action="composer-model"]')).toContainText(selected.name)
report(
{ samples },
{ models: count, switches, gc: "explicit", scope: "renderer main isolate; not total desktop RAM" },
)
await cdp.detach()
})
@@ -3,7 +3,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = "http://127.0.0.1:4096"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
@@ -12,17 +12,17 @@ test("closing the active server's last tab opens the remaining server tab", asyn
const requests: string[] = []
await mockServers(page, requests)
await page.addInitScript(
({ serverB, sessionA, sessionB }) => {
({ serverA, serverB, sessionA, sessionB }) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverA, sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
]),
)
},
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
@@ -55,7 +55,7 @@ function session(id: string, directory: string, title: string) {
async function mockServers(page: Page, requests: string[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
requests.push(url.toString())
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
@@ -308,7 +308,7 @@ async function mockServers(
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
@@ -51,7 +51,7 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page) {
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const current = url.origin === serverA ? sessionA : sessionB
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
@@ -238,7 +238,7 @@ function session(id: string, title: string) {
async function mockServer(page: Page) {
const sessions = [sessionA, sessionB, sessionC]
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
+3 -1
View File
@@ -144,12 +144,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
)
page.on("close", () => void transport.dispose())
await page.route("**/*", async (route) => {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.origin !== server && url.port !== appPort) return route.fallback()
// Production serves the UI and API from one origin; leave app assets to Vite.
if (!url.pathname.startsWith("/api/")) return route.fallback()
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
+1
View File
@@ -25,6 +25,7 @@
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:built": "PLAYWRIGHT_BUILD=1 playwright test",
"test:e2e:local": "playwright test",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
+23 -12
View File
@@ -2,10 +2,19 @@ import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
const url = new URL(baseURL)
if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// app URL")
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
// Production connects to its own origin, so fixture URLs must match the preview server.
if (built) {
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
}
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const command = built
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
@@ -23,16 +32,18 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
url: baseURL,
reuseExistingServer: reuse,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command,
url: baseURL,
reuseExistingServer: !built,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
use: {
baseURL,
trace: "on-first-retry",
+1 -1
View File
@@ -17,7 +17,7 @@ export function Home() {
return (
<div
class={`
m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
+1 -1
View File
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
@@ -1,10 +1,16 @@
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/runtime/server/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { unwrap } from "solid-js/store"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/workspaces/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const providerCatalogs = new WeakMap<
ProviderListOutput["data"],
WeakMap<ModelListOutput["data"], ProviderListResponse>
>()
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
if (input.every((agent) => !("request" in agent))) return input as Agent[]
return (input as AgentListOutput["data"]).map((agent) => ({
@@ -30,12 +36,16 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
}
export function normalizeProviderList(
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
input: ProviderListOutput["data"] | ProviderListResponse,
catalog?: ModelListOutput["data"],
): ProviderListResponse {
if (!Array.isArray(providers)) {
return providers
}
if (!Array.isArray(input)) return input
// Client sync replaces whole catalog lists. Track those reads at the caller,
// not every model field, and share conversions without retaining old lists.
const providers = unwrap(input)
const models = unwrap(catalog)
const cached = models && providerCatalogs.get(providers)?.get(models)
if (cached) return cached
const all = new Map<string, Provider>()
for (const provider of providers) {
@@ -101,7 +111,7 @@ export function normalizeProviderList(
}
}
return {
const result = {
all,
connected: providers.map((provider) => provider.id),
default: Object.fromEntries(
@@ -111,6 +121,12 @@ export function normalizeProviderList(
}),
),
}
if (models) {
const cache = providerCatalogs.get(providers) ?? new WeakMap<ModelListOutput["data"], ProviderListResponse>()
cache.set(models, result)
providerCatalogs.set(providers, cache)
}
return result
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
+2 -4
View File
@@ -44,9 +44,7 @@ function TargetSessionSettingsCommand() {
return null
}
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
) {
function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>) {
return (
<ErrorBoundary
fallback={(error) => (
@@ -106,7 +104,7 @@ function PendingSessionState(props: { sessionID: string }) {
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 p-2">
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
</div>
)
+1 -1
View File
@@ -164,7 +164,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
+4 -1
View File
@@ -2,7 +2,10 @@ import type { ParentProps } from "solid-js"
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative flex size-full flex-col overflow-hidden" classList={{ "p-2": props.padded }}>
<div
class="relative flex size-full flex-col overflow-hidden"
classList={{ "px-2 pb-2 pt-[var(--shell-top-inset,8px)]": props.padded }}
>
{props.children}
</div>
)
@@ -481,7 +481,6 @@ function MessageTimelineView(
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={backgroundHintPresence.present()}>
<div
@@ -55,7 +55,6 @@ type Input = {
type ViewProps = {
header: JSX.Element
bottomSpacer?: JSX.Element
workspaceSession: Accessor<boolean>
deferred: (row: TimelineRow.TimelineRow) => boolean
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
}
@@ -371,7 +370,7 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div class="relative w-full h-full min-w-0">
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
+1 -1
View File
@@ -36,7 +36,7 @@ export function AppRoutes() {
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 p-2">
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
+8 -1
View File
@@ -43,6 +43,13 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// The native Windows titlebar already includes the gap above the content panels.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "0px"
: "8px",
}}
>
<Titlebar
@@ -59,7 +66,7 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 py-2"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-2 pt-[var(--shell-top-inset,8px)]"
style={{ width: `${state.tabsWidth}px` }}
>
<ResizeHandle
+2 -1
View File
@@ -27,6 +27,7 @@ import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
const titlebarHeight = 36
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
@@ -59,7 +60,7 @@ export function Titlebar(props: {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `env(titlebar-area-height, ${titlebarHeight / Math.min(titlebarZoom(), 1)}px)`
if (windows()) return `env(titlebar-area-height, ${windowsTitlebarHeight / Math.min(titlebarZoom(), 1)}px)`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -0,0 +1,70 @@
import { expect, test } from "bun:test"
import path from "node:path"
test.each([
{ name: "local development", ci: "", build: "", built: false },
{ name: "local production build", ci: "", build: "1", built: true },
{ name: "CI production build", ci: "true", build: "", built: true },
{ name: "CI cannot opt into development", ci: "true", build: "0", built: true },
])("Playwright uses $name", ({ ci, build, built }) => {
const result = Bun.spawnSync({
cmd: [process.execPath, "-e", 'import config from "./playwright.config.ts"; console.log(JSON.stringify(config))'],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
CI: ci,
PLAYWRIGHT_BUILD: build,
PLAYWRIGHT_BASE_URL: undefined,
PLAYWRIGHT_PORT: "4321",
PLAYWRIGHT_SERVER_HOST: "127.0.0.2",
PLAYWRIGHT_SERVER_PORT: "4322",
},
})
expect(result.exitCode).toBe(0)
const config = JSON.parse(result.stdout.toString())
expect(config.use.baseURL).toBe("http://127.0.0.1:4321")
expect(config.webServer.url).toBe(config.use.baseURL)
expect(config.webServer.command).toBe(
built
? "bun run build && bun run serve -- --host 127.0.0.1 --port 4321 --strictPort"
: "bun run dev -- --host 127.0.0.1 --port 4321 --strictPort",
)
expect(config.webServer.reuseExistingServer).toBe(!built)
expect(config.webServer.env).toEqual({
VITE_OPENCODE_SERVER_HOST: built ? "127.0.0.1" : "127.0.0.2",
VITE_OPENCODE_SERVER_PORT: built ? "4321" : "4322",
})
})
test.each([
"./playwright.config.ts",
"./e2e/performance/playwright.config.ts",
"./e2e/performance/playwright.uncapped.config.ts",
"./e2e/performance/timeline-stability/playwright.config.ts",
])("%s leaves an explicit external app unmanaged", (file) => {
const result = Bun.spawnSync({
cmd: [
process.execPath,
"-e",
`import config from ${JSON.stringify(file)}; console.log(JSON.stringify({ ...config, fixtureHost: process.env.PLAYWRIGHT_SERVER_HOST, fixturePort: process.env.PLAYWRIGHT_SERVER_PORT }))`,
],
cwd: path.resolve(import.meta.dirname, ".."),
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "http://127.0.0.1:4444" },
})
expect(result.exitCode).toBe(0)
const config = JSON.parse(result.stdout.toString())
expect(config.webServer).toBeUndefined()
expect(config.use.baseURL).toBe("http://127.0.0.1:4444")
expect(config.fixtureHost).toBe("127.0.0.1")
expect(config.fixturePort).toBe("4444")
})
test("Playwright rejects HTTPS targets unsupported by the API fixtures", () => {
const result = Bun.spawnSync({
cmd: [process.execPath, "-e", 'import "./playwright.config.ts"'],
cwd: path.resolve(import.meta.dirname, ".."),
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "https://e2e.example.com" },
})
expect(result.exitCode).not.toBe(0)
expect(result.stderr.toString()).toContain("E2E fixtures require an http:// app URL")
})
@@ -0,0 +1,52 @@
import { expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
test("preserves an already normalized reactive catalog", () => {
const [store] = createStore({ catalog: { all: new Map<string, never>(), connected: [], default: {} } })
expect(normalizeProviderList(store.catalog)).toBe(store.catalog)
})
test("shares catalog snapshots and reacts to replacement lists", () => {
createRoot((dispose) => {
const provider = { id: "openai", name: "OpenAI", package: "@ai-sdk/openai", activation: "enabled" as const }
const model = {
id: "gpt-5",
modelID: "gpt-5",
providerID: "openai",
name: "GPT-5",
settings: {},
headers: {},
capabilities: { tools: true, input: ["text" as const], output: ["text" as const] },
variants: [],
time: { released: 1 },
cost: [],
status: "active" as const,
enabled: true,
limit: { context: 128_000, output: 8192 },
}
const [store, setStore] = createStore({ providers: [provider], models: [model] })
const first = createMemo(() => normalizeProviderList(store.providers, store.models))
const second = createMemo(() => normalizeProviderList(store.providers, store.models))
expect(first()).toBe(second())
const initial = first()
setStore("models", [{ ...model, name: "Renamed", limit: { context: 256_000, output: 16384 } }])
expect(first()).not.toBe(initial)
expect(first()).toBe(second())
expect(first().all.get("openai")?.models[model.id]).toMatchObject({
name: "Renamed",
limit: { context: 256_000, output: 16384 },
})
const renamed = first()
setStore("providers", [{ ...provider, name: "Custom OpenAI" }])
expect(first()).not.toBe(renamed)
expect(first()).toBe(second())
expect(first().all.get("openai")?.name).toBe("Custom OpenAI")
expect(normalizeProviderList(store.providers).all.get("openai")?.models).toEqual({})
setStore("models", [])
expect(first().all.get("openai")?.models).toEqual({})
expect(first().default).toEqual({})
dispose()
})
})
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -4,11 +4,14 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
// Keep this explicit: automatic service replacement must preserve terminals.
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
const transport = yield* Service.ensure(options)
process.stdout.write(transport.url + EOL)
+1 -1
View File
@@ -118,7 +118,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -80,10 +80,11 @@ export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migrati
}
: undefined,
})
const label = credential.type === "oauth" ? "OAuth" : "API key"
const now = Date.now()
yield* tx.run(sql`
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
VALUES (${Credential.ID.create()}, ${integrationID}, 'default', ${JSON.stringify(credential)}, ${now}, ${now})
VALUES (${Credential.ID.create()}, ${integrationID}, ${label}, ${JSON.stringify(credential)}, ${now}, ${now})
`)
}
+2
View File
@@ -507,9 +507,11 @@ const layer = Layer.effect(
from: TreeID
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
+4 -1
View File
@@ -207,7 +207,10 @@ export const connect = Effect.fnUntraced(function* (
}
if (!URL.canParse(config.url))
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
return new StreamableHTTPClientTransport(new URL(config.url), {
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
const url = new URL(config.url)
if (config.codemode !== false && !url.searchParams.has("codemode")) url.searchParams.set("codemode", "false")
return new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
})
+2 -21
View File
@@ -73,6 +73,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
return {
app,
location: locationInfo(),
options: {},
agent: {
get: (input) => {
@@ -288,26 +289,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
return response(mcp.servers())
},
add: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
return mcp.add(input.server, input.config)
},
remove: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
return mcp.remove(input.server)
},
connect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
return mcp.connect(input.server)
},
disconnect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
return mcp.disconnect(input.server)
},
reload: mcp.reload,
transform: (callback) =>
mcp.transform((draft) => {
@@ -384,7 +365,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
add: (tool) => draft.add(tool),
}),
)
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
.pipe(Effect.as({ dispose: Effect.void })),
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
const urls = [/^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({
id: "opencode.mcp.codemode.exclusion",
-17
View File
@@ -2,7 +2,6 @@ export * as PluginRuntime from "./runtime.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
@@ -40,10 +39,6 @@ export interface Interface {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
}
}
}
@@ -101,10 +96,6 @@ export const layerWithCell = (cell: Cell) =>
},
mcp: {
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
},
},
}),
@@ -149,14 +140,6 @@ export const providerLayerWithCell = (cell: Cell) =>
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
add: (ref, server, config) =>
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
remove: (ref, server) =>
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
connect: (ref, server) =>
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
disconnect: (ref, server) =>
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
},
},
}
+123 -140
View File
@@ -78,83 +78,143 @@ const layer = Layer.effect(
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const sessionID = input.sessionID
let force = input.force
let continuation = input.continuation
let continuing = input.continuation !== undefined
let step = input.continuation?.step ?? 1
let entering = true
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID)
while (true) {
// Scope gates input promotion, not a between-step control that is next in line.
if (yield* runPendingCompaction(input.sessionID, "input")) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return DrainResult.Moved({})
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
if (!force && !continuing) {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (
!pending ||
(pending.delivery === "queue" &&
promotable === "steer" &&
pending.type !== "compaction" &&
pending.type !== "move")
)
return DrainResult.Complete()
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result._tag === "Moved") return result
force = false
continuation = undefined
}
})
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
if (promotable === "input") return false
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
return next?.type === "compaction" || next?.type === "move"
})
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
while (true) {
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const next = yield* SessionInbox.nextPromotable(
db,
sessionID,
entering || !continuing ? "input" : "steer",
)
if (next?.type === "compaction")
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: next.id }],
])
if (next?.type === "move")
yield* restore(
Effect.gen(function* () {
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
[SessionEvent.Moved, { sessionID, ...next.payload }],
])
}),
)
return next
}),
)
if (!continuing && pending?.delivery !== "steer") {
entering = true
step = 1
}
if (pending?.type === "move")
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
})
}),
).pipe(Effect.exit)
if (Exit.isFailure(compacted)) {
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: pending.id,
})
return yield* Effect.failCause(compacted.cause)
}
force = false
continue
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
return yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const promoted = yield* SessionInbox.promote(
db,
bus,
sessionID,
entering && !continuing ? promotable : "steer",
)
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
if (promoted > 0) step = 1
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
}
}),
),
)
/** Queued inputs wait until the current model work reaches idle; later Steps absorb only steers. */
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
) {
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
let step = continuation?.step ?? 1
let next = continuation
let first = true
while (true) {
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return DrainResult.Moved({ continuation: next })
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer"))) return DrainResult.Complete()
const result = yield* runStep(sessionID, promotable, step)
first = false
promotable = "steer"
step = result.step + 1
next = result.needsContinuation ? { step } : undefined
const next = yield* advanceToStep()
if (next._tag !== "Ready") return next
continuing = yield* runStep(next.context, step)
step++
force = false
entering = false
}
})
const prepareContext = Effect.fn("SessionRunner.prepareContext")(function* (sessionID: SessionSchema.ID) {
const selected = yield* context.select(sessionID)
// A blocked initial instruction baseline must leave admitted input pending.
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
return selected
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
step: number,
) {
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentStep = step
let initial: SessionContext.Loaded | undefined = first
let recoverOverflow = true
let recoverContinuation = true
while (true) {
const selected = yield* context.select(sessionID)
// A blocked initial instruction baseline must leave admitted input pending.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = currentPromotable
? yield* SessionInbox.promote(db, bus, selected.session.id, currentPromotable)
: 0
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
currentStep = promoted > 0 ? 1 : currentStep
currentPromotable = undefined
const loaded = yield* context.load(selected)
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
@@ -162,7 +222,7 @@ const layer = Layer.effect(
assistantMessageID = SessionMessage.ID.create()
continue
}
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
@@ -197,7 +257,7 @@ const layer = Layer.effect(
: Effect.succeed(false),
),
})
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
if (outcome._tag === "Completed") return outcome.needsContinuation
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
@@ -223,77 +283,6 @@ const layer = Layer.effect(
}
})
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
])
return selected
}),
)
if (pending?.type !== "compaction") return false
const session = yield* getSession(sessionID)
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return true
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: pending.id,
})
return yield* Effect.failCause(compacted.cause)
}),
)
})
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
[
SessionEvent.Moved,
{
sessionID,
location: pending.payload.location,
projectID: pending.payload.projectID,
subpath: pending.payload.subpath,
},
],
])
return true
}),
)
})
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
@@ -319,12 +308,6 @@ const layer = Layer.effect(
}
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return session
})
return Service.of({ drain })
}),
)
@@ -157,6 +157,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
// Let the destination adapter handle readable reasoning after a model/provider switch.
if (item.type === "reasoning")
return reuseProviderMetadata
? [
@@ -167,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
},
]
: item.text.length > 0
? [{ type: "text", text: item.text }]
? [{ type: message.error === undefined ? "reasoning" : "text", text: item.text }]
: []
// Call-side metadata is model-scoped proof of generation (Gemini thought
// signatures, OpenAI encrypted reasoning): only the producing model may
+60 -405
View File
@@ -7,12 +7,17 @@ import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select.js"
import { Wildcard } from "../util/wildcard.js"
type Part = { type: string; text: string }
type SourceToken = { raw: string; value: string }
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
export type Result = {
commands: Array<{ resource: string; save: string }>
directories: string[]
}
const ARITY: Record<string, number> = {
cat: 1,
cd: 1,
@@ -152,6 +157,7 @@ const ARITY: Record<string, number> = {
"yarn dlx": 3,
"yarn run": 3,
}
const PREFIX_LENGTH = Math.max(...Object.values(ARITY))
export const scan = Effect.fnUntraced(function* (
command: string,
@@ -159,7 +165,7 @@ export const scan = Effect.fnUntraced(function* (
cwd: string,
options?: { portable?: boolean },
) {
if (options?.portable) return yield* Effect.promise(() => scanPortable(command, shell, cwd))
if (options?.portable) return yield* scanPortable(command, shell, cwd)
return yield* scanLegacy(command, shell, cwd)
})
@@ -197,416 +203,65 @@ const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string,
)
})
async function scanPortable(command: string, shell: string, cwd: string) {
const { ShellScan } = await import("./scan.js")
export const scanPortable = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
const { ShellScan } = yield* Effect.tryPromise({
try: () => import("./scan.js"),
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
})
const powershell = ShellSelect.ps(shell)
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] }
const carriage = powershell ? command.search(/\r(?!\n)/) : -1
if (carriage >= 0) return { commands: [], directories: [] }
if (result.kind === "opaque")
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
const parsed = result.commands.reduce(
(output, item) => {
const index = item[ShellScan.Nested] ? -1 : command.indexOf(item.resource, output.cursor)
const offset = item[ShellScan.Nested]
? command.lastIndexOf(item.resource, output.cursor - 1)
: index < 0
? command.indexOf(item.resource)
: index
if (index >= 0) output.cursor = index + item.resource.length
const before = command.slice(0, Math.max(0, offset))
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
if (!name) return output
if (powershell && name === "<") return output
if (
powershell &&
name === "foreach-object" &&
item.words.some((word) => word.startsWith("{")) &&
!/\|\s*$/.test(before)
const output: Result = { commands: [], directories: [] }
for (const item of result.commands) {
// The legacy command walk skips declarations, not the substitutions within them.
if (item.declaration) continue
const words = item.redirectWordCount === undefined ? item.rawWords : item.rawWords.slice(0, item.redirectWordCount)
// The shipped PowerShell grammar treats bare statement-head foreach prefixes as control flow.
if (powershell && item.statementHead && /^foreach(?:-|$)/i.test(words[0] ?? "")) continue
const name = powershell ? words[0]?.toLowerCase() : words[0]
if (CWD.has(name)) {
output.directories.push(
...directoryArgs(
words.flatMap((text): Part[] => {
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
}),
powershell,
cwd,
shell,
),
)
return output
const tokens = powershell ? powerShellSourceTokens(item.resource) : sourceTokens(item.resource)
const sourceHead = powershell ? item.words[0] : tokens.find((token) => token.value === item.words[0])?.raw
if (CWD.has(name) && (powershell || sourceHead === item.words[0])) {
output.directories.push(...portableDirectoryArgs(item.words, tokens, powershell, cwd, shell))
return output
}
const save = powershell ? powerShellSourcePrefix(tokens, item.words) : bashSourcePrefix(tokens, item.words)
output.commands.push({
resource: powershell ? item.resource : bashResource(item.resource, before),
save: `${save} *`,
})
return output
},
{
commands: [] as Array<{ resource: string; save: string }>,
directories: [] as string[],
cursor: 0,
},
)
return { commands: parsed.commands, directories: parsed.directories }
}
function bashResource(resource: string, before: string) {
if (!/(?:&&|\|\||\|&)\s*$|\|\s*$/.test(before)) return resource
const redirect = bashRedirect(resource)
return redirect < 0 ? resource : resource.slice(0, redirect).replace(/\d+$/, "").trim()
}
function bashRedirect(resource: string) {
let quote: "single" | "double" | undefined
for (let index = 0; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (quote === "double") {
if (char === "$" && resource[index + 1] === "(") index = bashParenthesizedEnd(resource, index + 1)
else if (char === "`") index = bashBacktickEnd(resource, index)
continue
}
if (char === "'") {
quote = "single"
continue
}
if ((char === "$" || char === "<" || char === ">") && resource[index + 1] === "(") {
index = bashParenthesizedEnd(resource, index + 1)
continue
}
if (char === "`") {
index = bashBacktickEnd(resource, index)
continue
}
if (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">")) return index
const selected = prefix(words.slice(0, PREFIX_LENGTH))
const conventional = `${selected.join(" ")} *`
const end = item.wordEnds?.[selected.length - 1]
// Keep existing grants stable unless normalized spacing loses the original source boundary.
const save =
!powershell || end === undefined || Wildcard.match(item.resource, conventional)
? conventional
: (() => {
const boundary =
item.wordEnds?.find(
(value) => value >= end && (value >= item.resource.length || /\s/.test(item.resource[value])),
) ?? end
const separator = /^\s+(?:`(?:\r\n|\r|\n)\s*)?/.exec(item.resource.slice(boundary))?.[0]
return `${item.resource.slice(0, boundary)}${separator ?? " "}*`
})()
output.commands.push({
resource: item.resource,
save,
})
}
return -1
}
function bashParenthesizedEnd(resource: string, start: number) {
let level = 1
let quote: "single" | "double" | undefined
for (let index = start + 1; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (quote === "double") continue
if (char === "'") {
quote = "single"
continue
}
if (char === "(") level++
if (char === ")" && --level === 0) return index
}
return resource.length - 1
}
function bashBacktickEnd(resource: string, start: number) {
for (let index = start + 1; index < resource.length; index++) {
if (resource[index] === "\\") index++
else if (resource[index] === "`") return index
}
return resource.length - 1
}
function portableDirectoryArgs(
command: string[],
tokens: SourceToken[],
powershell: boolean,
cwd: string,
shell: string,
) {
if (!powershell) {
const start = tokens.findIndex((token) => token.value === command[0])
if (start < 0) return []
return directoryArgs(
tokens.slice(start).map((token) => ({ type: "word", text: token.raw })),
false,
cwd,
shell,
)
}
const start = tokens.findIndex((token) => token.value.toLowerCase() === command[0]?.toLowerCase())
if (start < 0) return []
const directories: string[] = []
let expectsPath = false
for (const part of tokens.slice(start + 1).map((token) => token.raw)) {
if (expectsPath) {
const value = directoryArgument(part, true, cwd, shell)
if (value) directories.push(value)
expectsPath = false
continue
}
if (part.startsWith("-")) {
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
continue
}
const value = directoryArgument(part, true, cwd, shell)
if (value) directories.push(value)
}
return directories
}
function sourceTokens(resource: string) {
const tokens: SourceToken[] = []
let raw = ""
let value = ""
let quote: "single" | "double" | "backtick" | undefined
let substitution = 0
let redirect = false
const finish = () => {
if (!raw) return
if (!redirect) tokens.push({ raw, value })
raw = ""
value = ""
redirect = false
}
for (let index = 0; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
raw += char
if (char === "'") quote = undefined
else value += char
continue
}
if (quote === "double") {
raw += char
if (char === '"') quote = undefined
else if (char === "\\" && index + 1 < resource.length) {
const next = resource[index + 1]
if ('$`"\\\n'.includes(next)) {
raw += resource[++index]
if (next !== "\n") value += next
} else value += char
} else value += char
continue
}
if (quote === "backtick") {
raw += char
value += char
if (char === "`" && resource[index - 1] !== "\\") quote = undefined
continue
}
if (char === "'") {
raw += char
quote = "single"
continue
}
if (char === '"') {
raw += char
quote = "double"
continue
}
if (char === "`") {
raw += char
value += char
quote = "backtick"
continue
}
if (char === "\\" && index + 1 < resource.length) {
if (resource[index + 1] === "\n") {
finish()
index++
continue
}
if (!raw && /\s/.test(resource[index + 1])) {
index++
continue
}
raw += char + resource[++index]
value += resource[index]
continue
}
if ((char === "<" || char === ">") && resource[index + 1] === "(") {
const end = bashParenthesizedEnd(resource, index + 1)
if (raw) {
raw += resource.slice(index, end + 1)
value += resource.slice(index, end + 1)
}
index = end
continue
}
if (char === "$" && resource[index + 1] === "(") substitution++
if (char === ")" && substitution > 0) substitution--
if (substitution === 0 && /\s/.test(char)) {
finish()
continue
}
if (substitution === 0 && (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">"))) {
if (/^\d+$/.test(value)) {
raw = ""
value = ""
} else finish()
redirect = true
if (char === "&") index++
while (/[<>&|]/.test(resource[index + 1] ?? "")) index++
continue
}
raw += char
value += char
}
finish()
return tokens
}
function bashSourcePrefix(tokens: SourceToken[], words: string[]) {
const start = tokens.findIndex((token) => token.value === words[0])
if (start < 0) {
const command = tokens.findIndex((token) => !/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token.raw))
return prefix(tokens.slice(Math.max(0, command)).map((token) => token.raw)).join(" ")
}
const source = tokens
.slice(start)
.map((token) => token.raw)
.filter((token) => !/^\$\([\s\S]*\)$/.test(token) && !/^`[\s\S]*`$/.test(token))
return prefix(source).join(" ")
}
function powerShellSourcePrefix(tokens: SourceToken[], words: string[]) {
const start = tokens.findIndex((token) => token.value.toLowerCase() === words[0]?.toLowerCase())
if (start < 0) return prefix(words).join(" ")
return prefix(tokens.slice(start).map((token) => token.raw)).join(" ")
}
function powerShellSourceTokens(resource: string) {
const tokens: SourceToken[] = []
let raw = ""
let value = ""
let quote: "single" | "double" | undefined
let redirect = false
const finish = () => {
if (!raw) return
if (!redirect) tokens.push({ raw, value })
raw = ""
value = ""
redirect = false
}
for (let index = 0; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
raw += char
if (char === "'" && resource[index + 1] === "'") {
raw += resource[++index]
value += "'"
} else if (char === "'") quote = undefined
else value += char
continue
}
if (quote === "double") {
raw += char
if (char === '"') quote = undefined
else if (char === "`" && index + 1 < resource.length) {
raw += resource[++index]
value += resource[index]
} else value += char
continue
}
if (char === "'") {
raw += char
quote = "single"
continue
}
if (char === '"') {
raw += char
quote = "double"
continue
}
if (char === "`" && index + 1 < resource.length) {
raw += char + resource[++index]
if (resource[index] !== "\n" && resource[index] !== "\r") value += resource[index]
continue
}
if (char === "{" && !raw) {
const end = powerShellBracedEnd(resource, index)
raw = resource.slice(index, end + 1)
value = raw
index = end
continue
}
if (/\s/.test(char)) {
finish()
continue
}
if (char === ">") {
if (resource[index + 1] && !/[\s>&]/.test(resource[index + 1])) {
raw += char
value += char
continue
}
if (/^\d+$/.test(value)) {
raw = ""
value = ""
} else if (raw === "*") {
raw = ""
value = ""
} else finish()
redirect = true
while (/[>&\d]/.test(resource[index + 1] ?? "")) index++
continue
}
if ((char === "&" || char === ".") && !raw && tokens.length === 0) continue
raw += char
value += char
}
finish()
return tokens
}
function powerShellBracedEnd(resource: string, start: number) {
let level = 1
let quote: "single" | "double" | undefined
for (let index = start + 1; index < resource.length; index++) {
const char = resource[index]
if (char === "`" && quote !== "single") {
index++
continue
}
if (quote === "single") {
if (char === "'" && resource[index + 1] === "'") index++
else if (char === "'") quote = undefined
continue
}
if (quote === "double") {
if (char === '"') quote = undefined
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = "double"
continue
}
if (char === "{") level++
if (char === "}" && --level === 0) return index
}
return resource.length - 1
}
return output
})
function parts(node: Node) {
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
File diff suppressed because it is too large Load Diff
+30 -33
View File
@@ -25,7 +25,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
export interface Interface {
readonly transform: (
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, never, Scope.Scope>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -140,45 +140,35 @@ const layer = Layer.effect(
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
const tools: Array<Tool.Info> = []
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
yield* Effect.forEach(
tools.flatMap((tool) => (tool.options?.namespace === undefined ? [] : [tool.options.namespace])),
validateNamespace,
{ discard: true },
)
const entries = normalizedEntries(tools)
yield* Effect.forEach(entries, (entry) => validateName(normalizedName(entry.tool)), { discard: true })
const collision = entries.find(
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
)
if (collision)
return yield* Effect.fail(
new RegistrationError({
name: collision.key,
message: `Duplicate normalized tool name: ${collision.key}`,
}),
)
const reserved = entries.find((entry) => entry.tool.options?.codemode === false && entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({
name: reserved.key,
message: 'Tool name "execute" is reserved for CodeMode',
}),
)
if (entries.length === 0) return
yield* Effect.forEach(
entries,
(entry) =>
Effect.try({
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
Effect.gen(function* () {
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
yield* validateName(normalizedName(entry.tool))
if (entry.tool.options?.codemode === false && entry.key === "execute")
return yield* new RegistrationError({
name: entry.key,
message: 'Tool name "execute" is reserved for CodeMode',
})
yield* Effect.try({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
}),
}),
{ discard: true },
})
return true
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
)
// Reject every ambiguous entry rather than choosing a winner.
const entries = yield* Effect.filter(valid, (entry) => {
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
return skipRegistration(
entry.tool,
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
)
})
if (entries.length === 0) return
yield* Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
@@ -270,6 +260,13 @@ function schemaMakeError(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}).pipe(Effect.as(false))
const validateName = (name: string) =>
/^[A-Za-z0-9_-]{1,64}$/.test(name)
? Effect.void
+1 -1
View File
@@ -115,7 +115,7 @@ export const layer = Layer.effect(
})
}
})
.pipe(Scope.provide(next), Effect.orDie)
.pipe(Scope.provide(next))
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
+5 -3
View File
@@ -222,9 +222,11 @@ describe("AISDKNative", () => {
},
headers: { "x-test": "value" },
})
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
for (const modelID of ["openai.gpt-oss-safeguard-20b", "openai.gpt-oss-safeguard-120b"]) {
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, modelID)?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
}
expect(
map(
"@ai-sdk/amazon-bedrock/mantle",
+27 -2
View File
@@ -385,6 +385,9 @@ describe("DatabaseMigration", () => {
const content = JSON.stringify({
openai: { type: "oauth", refresh: "refresh", access: "access", expires: 123, accountId: "account" },
anthropic: { type: "api", key: "legacy-key", metadata: { region: "us" } },
google: { type: "api", key: "google-key", metadata: { region: "us" } },
"github-copilot": { type: "oauth", refresh: "refresh", access: "access", expires: 123 },
"custom-provider": { type: "api", key: "custom-key" },
"https://example.com/": { type: "wellknown", key: "TOKEN", token: "wellknown-key" },
invalid: { type: "unknown" },
})
@@ -402,6 +405,7 @@ describe("DatabaseMigration", () => {
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
[
@@ -410,14 +414,35 @@ describe("DatabaseMigration", () => {
label: "Existing",
value: JSON.stringify({ type: "key", key: "current-key" }),
},
{
integration_id: "custom-provider",
label: "API key",
value: JSON.stringify({ type: "key", key: "custom-key" }),
},
{
integration_id: "github-copilot",
label: "OAuth",
value: JSON.stringify({
type: "oauth",
methodID: "device",
refresh: "refresh",
access: "access",
expires: 123,
}),
},
{
integration_id: "google",
label: "API key",
value: JSON.stringify({ type: "key", key: "google-key", metadata: { region: "us" } }),
},
{
integration_id: "https://example.com",
label: "default",
label: "API key",
value: JSON.stringify({ type: "key", key: "wellknown-key" }),
},
{
integration_id: "openai",
label: "default",
label: "OAuth",
value: JSON.stringify({
type: "oauth",
methodID: "chatgpt-browser",
+31
View File
@@ -131,6 +131,37 @@ describe("Git worktrees", () => {
})
describe("Git trees", () => {
it.live("lists both sides of a rename as separate file changes", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await initRepo(root.path)
await $`git config diff.renames true`.cwd(root.path).quiet()
await Bun.write(path.join(root.path, "old name.txt"), "Preserve this content.\n")
})
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
yield* Effect.promise(() => fs.rename(path.join(root.path, "old name.txt"), path.join(root.path, "new name.txt")))
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
RelativePath.make("new name.txt"),
RelativePath.make("old name.txt"),
])
expect(
(yield* git.tree.diff({ repository, from: before, to: after })).map((file) => [file.file, file.status]),
).toEqual([
["new name.txt", "added"],
["old name.txt", "deleted"],
])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+169 -1
View File
@@ -35,7 +35,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Sink, Stream } from "effect"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
@@ -77,6 +77,7 @@ function resourceServer(
templateLists: 0,
toolLists: 0,
initializations: 0,
urls: [] as string[],
}
const protocol = new Server(
{ name: "mcp-resources", version: "1.0.0" },
@@ -145,6 +146,7 @@ function resourceServer(
const http = Bun.serve({
port: 0,
fetch: async (request) => {
state.urls.push(request.url)
const body: unknown = request.method === "POST" ? await request.clone().json() : undefined
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
@@ -718,6 +720,40 @@ test("applies configured MCP timeouts to resource operations", async () => {
await expect(read).rejects.toThrow("Request timed out")
})
for (const entry of [
{ name: "default", query: "", codemode: undefined, expected: "?codemode=false" },
{ name: "explicit local code mode", query: "", codemode: true, expected: "?codemode=false" },
{ name: "direct tools", query: "", codemode: false, expected: "" },
{
name: "existing query",
query: "?source=opencode",
codemode: undefined,
expected: "?source=opencode&codemode=false",
},
{ name: "explicit remote code mode", query: "?codemode=true", codemode: undefined, expected: "?codemode=true" },
{ name: "explicit remote opt-out", query: "?codemode=false", codemode: undefined, expected: "?codemode=false" },
{ name: "portal opt-out", query: "?codemode=off", codemode: undefined, expected: "?codemode=off" },
]) {
testEffect(Layer.empty).live(`remote MCP code mode preference: ${entry.name}`, () =>
Effect.gen(function* () {
const server = yield* resourceServer()
const config = new ConfigMCP.Remote({
type: "remote",
url: server.url + entry.query,
oauth: false,
codemode: entry.codemode,
})
const connection = yield* connect("resources", config, import.meta.dir)
yield* connection.tools()
expect(server.state.initializations).toBe(1)
expect(server.state.toolLists).toBe(1)
expect(server.state.urls.length).toBeGreaterThanOrEqual(3)
expect(new Set(server.state.urls)).toEqual(new Set([server.url + entry.expected]))
expect(config.url).toBe(server.url + entry.query)
}),
)
}
test("lists, reads, and reports MCP resource changes", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -996,6 +1032,58 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
Effect.gen(function* () {
const service = yield* MCP.Service
yield* Effect.scoped(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.set("dynamic", {
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
})
})
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
"connected",
)
expect(yield* service.tools()).toHaveLength(2)
const settings = { disabled: true }
yield* service.transform((draft) => {
draft.update("dynamic", (server) => {
server.disabled = settings.disabled
})
})
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe("disabled")
expect(yield* service.tools()).toEqual([])
settings.disabled = false
yield* service.reload()
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
"connected",
)
expect(yield* service.tools()).toHaveLength(2)
const removed = yield* service.transform((draft) => draft.remove("dynamic"))
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
expect(yield* service.tools()).toEqual([])
yield* removed.dispose
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
"connected",
)
expect(yield* service.tools()).toHaveLength(2)
}),
)
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
expect(yield* service.tools()).toEqual([])
}),
)
test("restores runtime MCP config when a transform is disposed", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1193,6 +1281,86 @@ test("serializes concurrent MCP lifecycle operations", async () => {
)
})
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
Effect.gen(function* () {
const tool = (server: string, name: string) =>
new MCP.Tool({
server: MCP.ServerName.make(server),
name,
codemode: false,
inputSchema: { type: "object", properties: {} },
})
const healthy = [tool("demo", "search"), tool("other", "lookup")]
const namespace = tool("x".repeat(65), "lookup")
const catalog = yield* Ref.make([tool("demo", "x".repeat(65)), ...healthy, namespace])
yield* Effect.gen(function* () {
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const bus = yield* Bus.Service
yield* registration.flush
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_search",
"other_lookup",
"execute",
])
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_added")
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"other_lookup",
"execute",
])
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
...toolIdentity,
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
)
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_status")
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"demo_status",
"other_lookup",
"repaired_lookup",
"execute",
])
}).pipe(
Effect.provide(
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () => Ref.get(catalog),
callTool: (input) =>
Effect.succeed(
new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "healthy" }],
}),
),
}),
],
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
[Image.node, imagePassthrough],
]),
),
),
)
}),
)
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+2 -1
View File
@@ -1126,7 +1126,7 @@ describe("ModelResolver", () => {
const mantle = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-120b",
settings: { region: "us-east-1" },
settings: { region: "us-east-1", topP: 0.6 },
}),
)
@@ -1158,6 +1158,7 @@ describe("ModelResolver", () => {
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
expect(mantle.route.id).toBe("bedrock-mantle-responses")
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
}),
)
+332
View File
@@ -17,6 +17,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { ShellParse } from "@opencode-ai/core/shell/parse"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -384,3 +385,334 @@ describe("Permission", () => {
}),
)
})
describe("shell scanner permission impact", () => {
// Fixed cases require matching outcomes; remaining differences are investigation snapshots, not contracts.
// These service-level cases all have command resources; tool tests cover skipped checks and directories.
// Outcome pairs are [legacy, native].
for (const fixture of [
{
name: "timed command preserves wrapper approvals",
shell: "bash",
command: "time -p git status",
approved: ["time *"],
exact: ["time -p git status"],
denied: "time -p git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "coprocess command preserves wrapper approvals",
shell: "bash",
command: "coproc git status",
approved: ["coproc *"],
exact: ["coproc git status"],
denied: "coproc git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "declarations and unset",
shell: "bash",
command: "export X=value; unset X; git status",
approved: ["git status *"],
exact: ["git status"],
denied: "export *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "export with an approved command substitution",
shell: "bash",
command: "export VERSION=$(git describe --tags); npm run build",
approved: ["git describe *", "npm run build *"],
exact: ["git describe --tags", "npm run build"],
denied: "export *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "export retains checks on the command substitution",
shell: "bash",
command: "export VERSION=$(git describe --tags); npm run build",
approved: ["git describe *", "npm run build *"],
exact: ["git describe --tags", "npm run build"],
denied: "git describe *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "redirect after a conditional list",
shell: "bash",
command: "printf ok && git status > output",
approved: ["printf *", "git status *"],
exact: ["printf ok", "git status"],
denied: "git status",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "redirect after a pipeline",
shell: "bash",
command: "printf ok | cat < input > output",
approved: ["printf *", "cat *"],
exact: ["printf ok", "cat"],
denied: "cat",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "assignment redirect followed by a command",
shell: "bash",
command: "FOO=bar > output; printf done",
approved: ["printf *"],
exact: ["printf done"],
denied: "FOO=bar > output; printf done",
savedEffect: ["ask", "allow"],
exactEffect: ["ask", "allow"],
deniedEffect: ["deny", "allow"],
},
{
name: "assignment redirect with an approved command substitution",
shell: "bash",
command: "VERSION=$(git describe --tags) > build/version.txt",
approved: ["git describe *"],
exact: ["git describe --tags"],
denied: "VERSION=$(git describe --tags) > build/version.txt",
savedEffect: ["ask", "allow"],
exactEffect: ["ask", "allow"],
deniedEffect: ["deny", "allow"],
},
{
name: "substitution in a saved prefix",
shell: "bash",
command: "git $(printf diff) --stat",
approved: ["git *", "printf *"],
exact: ["git $(printf diff) --stat", "printf diff"],
denied: "git $(printf diff) --stat",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["deny", "deny"],
},
{
name: "standalone PowerShell scriptblock caller",
shell: "pwsh",
command: "ForEach-Object { Write-Output value }",
approved: ["Write-Output *"],
exact: ["Write-Output value"],
denied: "ForEach-Object *",
savedEffect: ["allow", "allow"],
exactEffect: ["allow", "allow"],
deniedEffect: ["allow", "allow"],
},
{
name: "tab-separated PowerShell command",
shell: "pwsh",
command: "git\tstatus; Write-Output done",
approved: ["git status *", "Write-Output *"],
exact: ["git status", "Write-Output done"],
denied: "git\tstatus",
savedEffect: ["allow", "ask"],
exactEffect: ["allow", "ask"],
deniedEffect: ["allow", "deny"],
},
{
name: "PowerShell equals-joined argument",
shell: "pwsh",
command: "git --work-tree=src status",
approved: ["git --work-tree *"],
exact: ["git --work-tree"],
denied: "git --work-tree",
savedEffect: ["allow", "ask"],
exactEffect: ["allow", "ask"],
deniedEffect: ["deny", "allow"],
},
] as const) {
for (const scenario of [
{ name: "no approval", saved: [], rules: [], expected: ["ask", "ask"] },
{ name: "saved wildcard", saved: ["*"], rules: [], expected: ["allow", "allow"] },
{ name: "saved command approvals", saved: fixture.approved, rules: [], expected: fixture.savedEffect },
{
name: "exact configured approvals",
saved: [],
rules: fixture.exact.map((resource): Permission.Rule => ({ action: "shell", resource, effect: "allow" })),
expected: fixture.exactEffect,
},
{
name: "exact saved approvals",
saved: fixture.exact,
rules: [],
expected: fixture.exactEffect,
},
{
name: "configured deny despite saved wildcard",
saved: ["*"],
rules: [{ action: "shell", resource: fixture.denied, effect: "deny" }] satisfies Permission.Ruleset,
expected: fixture.deniedEffect,
},
] as const) {
it.live(`${fixture.name}: ${scenario.name}`, () =>
Effect.gen(function* () {
yield* setup(scenario.rules)
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: scenario.saved })
const service = yield* Permission.Service
for (const [index, portable] of [false, true].entries()) {
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
expect(parsed.commands.length).toBeGreaterThan(0)
expect(parsed.directories).toEqual([])
const result = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(result.effect, portable ? "native" : "legacy").toBe(scenario.expected[index])
const pending = yield* service.list()
expect(pending).toHaveLength(result.effect === "ask" ? 1 : 0)
if (result.effect !== "ask") continue
expect(pending[0]?.resources).toEqual(parsed.commands.map((command) => command.resource))
expect(pending[0]?.save).toEqual(parsed.commands.map((command) => command.save))
yield* service.reply({ requestID: result.id, reply: "once" })
expect(yield* service.list()).toEqual([])
}
}),
)
}
}
// Grant/repeat rows select the granting parser; repeat columns select the parser used afterwards.
for (const fixture of [
{
name: "numeric npm script prefix",
shell: "bash",
command: "npm run 123",
grants: [["npm run *"], ["npm run 123 *"]],
repeat: [
["allow", "allow"],
["allow", "allow"],
],
next: "npm run build",
nextEffect: ["allow", "ask"],
},
{
name: "numeric AWS option prefix",
shell: "bash",
command: "aws --cli-read-timeout 60 s3 ls",
grants: [["aws --cli-read-timeout s3 *"], ["aws --cli-read-timeout 60 *"]],
repeat: [
["ask", "ask"],
["allow", "allow"],
],
next: "aws --cli-read-timeout 60 ec2 describe-instances",
nextEffect: ["ask", "allow"],
},
{
name: "substitution prefix",
shell: "bash",
command: "git $(printf diff) --stat",
grants: [
["git --stat *", "printf *"],
["git $(printf diff) *", "printf *"],
],
repeat: [
["ask", "ask"],
["allow", "allow"],
],
next: "git --stat",
nextEffect: ["allow", "ask"],
},
{
name: "redirect prefix",
shell: "bash",
command: "printf ok && git status > output",
grants: [
["printf *", "git status *"],
["printf *", "git status *"],
],
repeat: [
["allow", "allow"],
["allow", "allow"],
],
next: "git status --short",
nextEffect: ["allow", "allow"],
},
{
name: "assignment redirect prefix",
shell: "bash",
command: "FOO=bar > output; printf done",
grants: [["printf *"], ["printf *"]],
// Identical saved rules cover only the native resource, regardless of which parser saved them.
repeat: [
["ask", "allow"],
["ask", "allow"],
],
next: "printf next",
nextEffect: ["allow", "allow"],
},
{
name: "PowerShell tab prefix",
shell: "pwsh",
command: "git\tstatus; Write-Output done",
grants: [["Write-Output *"], ["git\tstatus *", "Write-Output *"]],
repeat: [
["allow", "ask"],
["allow", "allow"],
],
next: "git status",
nextEffect: ["ask", "ask"],
},
] as const) {
for (const [origin, portable] of [false, true].entries()) {
it.live(`${fixture.name}: always allow from ${portable ? "native" : "legacy"}, then use either parser`, () =>
Effect.gen(function* () {
yield* setup()
const service = yield* Permission.Service
const saved = yield* PermissionSaved.Service
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
const first = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(first.effect).toBe("ask")
expect(yield* service.list()).toHaveLength(1)
yield* service.reply({ requestID: first.id, reply: "always" })
expect(yield* service.list()).toEqual([])
expect((yield* saved.list({ projectID: Project.ID.global })).map((rule) => rule.resource).sort()).toEqual(
[...fixture.grants[portable ? 1 : 0]].sort(),
)
for (const [index, target] of [false, true].entries()) {
for (const command of [fixture.command, fixture.next]) {
const parsed = yield* ShellParse.scan(command, fixture.shell, "/project", { portable: target })
const result = yield* service.ask(
assertion({
action: "shell",
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
}),
)
expect(result.effect, `${target ? "native" : "legacy"}: ${command}`).toBe(
command === fixture.next ? fixture.nextEffect[origin] : fixture.repeat[origin]?.[index],
)
if (result.effect === "ask") yield* service.reply({ requestID: result.id, reply: "once" })
expect(yield* service.list()).toEqual([])
}
}
}),
)
}
}
})
+72 -12
View File
@@ -25,6 +25,34 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("exposes the current location to activated plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const location = yield* Location.Service
const seen: Location.Info[] = []
yield* plugins.activate([
versioned(
EffectPlugin.define({
id: "location-context",
effect: (ctx) =>
Effect.sync(() => {
seen.push(ctx.location)
}),
}),
"1",
),
])
expect(seen).toEqual([
new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
])
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -43,7 +71,7 @@ describe("Plugin", () => {
}),
)
it.effect("routes explicit MCP locations through the plugin runtime", () =>
it.effect("exposes MCP reads and transforms and routes explicit read locations", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
@@ -72,10 +100,6 @@ describe("Plugin", () => {
data: [],
}
}),
add: (ref) => Effect.sync(() => routed.push(`add:${ref.directory}`)),
remove: (ref) => Effect.sync(() => routed.push(`remove:${ref.directory}`)),
connect: (ref) => Effect.sync(() => routed.push(`connect:${ref.directory}`)),
disconnect: (ref) => Effect.sync(() => routed.push(`disconnect:${ref.directory}`)),
},
},
}),
@@ -83,14 +107,9 @@ describe("Plugin", () => {
)
const location = { directory: target }
yield* host.mcp
.add({ location, server: "routed", config: { type: "local", command: ["unused"], disabled: true } })
.pipe(Effect.orDie)
yield* host.mcp.remove({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect(Object.keys(host.mcp).sort()).toEqual(["list", "reload", "transform"])
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
expect(routed).toEqual(["list:/target"])
}),
)
@@ -282,6 +301,47 @@ describe("Plugin", () => {
}),
)
it.effect("keeps plugins active when a tool registration is invalid", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const agents = yield* Agent.Service
yield* plugins.activate([
{
id: "partial-tools",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((draft) => {
const tool = {
name: "healthy",
description: "Healthy tool",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
}
draft.add({ ...tool, name: "invalid", options: { namespace: "invalid..namespace" } })
draft.add(tool)
})
yield* ctx.agent.transform((draft) =>
draft.update("configured", (agent) => {
agent.description = "setup continued"
}),
)
}),
},
])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, status: "active", tui: false },
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
yield* plugins.activate([])
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("restores the previous plugin when its replacement fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+10 -4
View File
@@ -18,6 +18,16 @@ type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
export function host(overrides: Overrides = {}): Plugin.Context {
return {
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
location:
overrides.location ??
new Location.Info({
directory: AbsolutePath.make("/workspace"),
project: {
id: Project.ID.global,
directory: AbsolutePath.make("/workspace"),
canonical: AbsolutePath.make("/workspace"),
},
}),
options: {},
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
@@ -77,10 +87,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
mcp: overrides.mcp ?? {
list: () => Effect.die("unused mcp.list"),
add: () => Effect.die("unused mcp.add"),
remove: () => Effect.die("unused mcp.remove"),
connect: () => Effect.die("unused mcp.connect"),
disconnect: () => Effect.die("unused mcp.disconnect"),
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
},
@@ -21,7 +21,12 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
{
name: "cloudflare code mode",
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp/" },
codemode: false,
codemode: undefined,
},
{
name: "cloudflare raw tools",
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp?codemode=false" },
codemode: undefined,
},
{
name: "cloudflare docs",
+39 -1
View File
@@ -17,6 +17,7 @@ import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { Project } from "@opencode-ai/core/project"
import { Workspace } from "@opencode-ai/core/workspace"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { define } from "@opencode-ai/plugin/promise/plugin"
import { Money } from "@opencode-ai/schema/money"
@@ -28,6 +29,41 @@ import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("exposes the host location including workspace and project metadata", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const location = yield* Location.Service
const expected = new Location.Info({
directory: AbsolutePath.make("/worktree/packages/app"),
workspaceID: Workspace.ID.make("wrk_plugin_location"),
project: {
id: Project.ID.global,
directory: AbsolutePath.make("/worktree"),
canonical: AbsolutePath.make("/project"),
},
})
const host = yield* PluginHost.make(plugins).pipe(
Effect.provideService(Location.Service, {
...location,
directory: expected.directory,
workspaceID: expected.workspaceID,
project: expected.project,
}),
)
const seen: Location.Info[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-location",
setup: (ctx) => {
seen.push(ctx.location)
},
}),
).effect(host)
expect(seen).toEqual([expected])
}),
)
it.effect("adapts plugin storage methods", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -242,12 +278,14 @@ describe("fromPromise", () => {
const promisePlugin = define({
id: "promise-client-reads",
setup: async (ctx) => {
expect(Object.keys(ctx.mcp).sort()).toEqual(["list", "reload", "transform"])
const results = await Promise.all([
ctx.agent.list(),
ctx.catalog.provider.list(),
ctx.catalog.model.list(),
ctx.command.list(),
ctx.integration.list(),
ctx.mcp.list(),
ctx.plugin.list(),
ctx.reference.list(),
ctx.skill.list(),
@@ -259,7 +297,7 @@ describe("fromPromise", () => {
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(seen).toHaveLength(8)
expect(seen).toHaveLength(9)
expect(new Set(seen).size).toBe(1)
}),
)
+117
View File
@@ -0,0 +1,117 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Global.node, tempGlobalLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
describe("Session.revert files", () => {
it.live("undoes and restores a file rename without losing either path", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const directory = path.join(tmp.path, "project")
const original = path.join(directory, "old name.txt")
const renamed = path.join(directory, "new name.txt")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await Bun.write(original, "Preserve this content.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(() => fs.rename(original, renamed))
const after = yield* snapshot.capture()
if (!after) throw new Error("Renamed snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: after,
files: yield* snapshot.files({ from: before, to: after }),
})
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
expect((yield* session.get(created.id)).revert).toBeUndefined()
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
)
})
@@ -984,7 +984,7 @@ Recent work
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Visible thought" },
{ type: "reasoning", text: "Visible thought" },
{
type: "tool-call",
id: "hosted-old-model",
@@ -10,7 +10,7 @@ import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { z } from "zod"
import { testEffect } from "./lib/effect"
@@ -71,30 +71,49 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.effect("rejects invalid dotted namespaces", () =>
it.effect("logs and skips invalid dotted namespaces", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { namespace: "slack..admin" })
expect(output).toEqual([
[
"Skipping invalid tool registration",
{ name: "echo", namespace: "slack..admin", error: 'Invalid tool namespace: "slack..admin"' },
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog).toEqual([])
}).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const error = yield* transform(service, { echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("rejects invalid and colliding normalized names", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
for (const name of ["", "x".repeat(65)]) {
const invalid = yield* transform(service, { [name]: make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe(`Invalid tool name: ${name}`)
}
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
Effect.flip,
yield* transform(
service,
{
before: make(),
"": make(),
["x".repeat(65)]: make(),
"echo.tool": make(),
echo_tool: make(),
execute: make(),
after: make(),
},
{ codemode: false },
)
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"])
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
expect((yield* snapshot.execute(call("echo_tool")).pipe(Effect.flip)).message).toBe("Unknown tool: echo_tool")
expect(snapshot.codeModeCatalog).toEqual([])
}),
)
@@ -159,40 +178,72 @@ describe("Tool", () => {
}),
)
it.effect("validates a registration batch before installing any tools", () =>
it.effect("keeps healthy tools when another namespace is invalid", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const error = yield* service
.transform((draft) => {
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
})
.pipe(Effect.flip)
yield* service.transform((draft) => {
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
})
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
}),
)
it.effect("rejects invalid tool definitions before installing any tools", () =>
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
expect(output).toEqual([
[
"Skipping invalid tool registration",
{
name: "phone_type",
namespace: undefined,
error: expect.stringContaining('Expected string\n at ["description"]'),
},
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("skipped registrations leave existing tools and scoped cleanup intact", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const error = yield* service
.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.name).toBe("phone_type")
expect(error.message).toContain('Expected string\n at ["description"]')
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
yield* transform(service, { echo: constant("original") }, { codemode: false })
yield* Effect.scoped(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.add({ ...constant("invalid"), name: "echo", description: undefined } as unknown as Info)
draft.add({ ...make(), name: "temporary", options: { codemode: false } })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "temporary", "execute"])
expect((yield* snapshot.execute(call("echo"))).output).toEqual({ text: "original" })
}),
)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
}),
)
+226
View File
@@ -1444,6 +1444,49 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("delivers controls without preflighting unavailable initial instructions", () =>
Effect.gen(function* () {
const session = yield* setup
const database = yield* Database.Service
const bus = yield* Bus.Service
const runner = yield* SessionRunner.Service
systemUnavailable = true
let reads = 0
systemLoadHook = Effect.sync(() => {
reads++
})
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* SessionInbox.admit(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
projectID: Project.ID.global,
},
delivery: "queue",
},
})
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
expect(reads).toBe(0)
expect(requests).toHaveLength(0)
expect(yield* session.inbox(sessionID)).toEqual([])
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
}),
)
it.effect("delivers a queued move atomically at the idle boundary", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -1554,6 +1597,56 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs a queued control on Location entry before a carried continuation", () =>
Effect.gen(function* () {
const session = yield* setup
const database = yield* Database.Service
const bus = yield* Bus.Service
const runner = yield* SessionRunner.Service
yield* admit(session, "Echo before moving")
yield* TestLLM.push(
TestLLM.tool("call-entry", "echo", { text: "moving" }),
TestLLM.text("Entry summary", "entry-summary"),
TestLLM.text("Continued", "entry-continuation"),
)
const stream = yield* TestLLM.gate
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* SessionInbox.admit(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
yield* stream.release
const moved = yield* Fiber.join(run)
expect(moved).toEqual(SessionRunner.DrainResult.Moved({ continuation: { step: 2 } }))
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.find(database.db, compaction.id)).toMatchObject({ id: compaction.id })
if (moved._tag !== "Moved") throw new Error("Expected a Location handoff")
// Location entry considers queued controls even when model work carries across the move.
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("seeds a fork with the parent's newest instruction values", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -2562,6 +2655,74 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("refreshes preparation after overflow compaction without promoting new input", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
const bus = yield* Bus.Service
let reads = 0
let resolutions = 0
systemLoadHook = Effect.sync(() => {
reads++
})
modelResolveHook = Effect.sync(() => {
resolutions++
})
yield* admit(session, "Continue")
yield* TestLLM.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("Overflow summary", "overflow-summary"),
TestLLM.text("Recovered", "overflow-recovered"),
TestLLM.stop(),
TestLLM.stop(),
)
const first = yield* TestLLM.gate
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* first.started
expect(reads).toBe(1)
expect(resolutions).toBe(1)
expect(requests[0]?.model).toBe(recoveryModel)
const summary = yield* TestLLM.gate
yield* first.release
yield* summary.started
systemBaseline = "Changed during compaction"
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
})
const queued = yield* session.prompt({
sessionID,
text: "Queued during compaction",
delivery: "queue",
resume: false,
})
const steered = yield* admit(session, "Steered during compaction")
const retry = yield* TestLLM.gate
yield* summary.release
yield* retry.started
expect(reads).toBe(2)
expect(resolutions).toBe(2)
expect(requests).toHaveLength(3)
expect(requests[2]?.model).toBe(replacementModel)
expect(requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(requests[2])).toContain("Changed during compaction")
expect(userTexts(requests[2])[0]).toContain("<summary>\nOverflow summary\n</summary>")
expect(userTexts(requests[2]).join("\n")).not.toContain("Queued during compaction")
expect(userTexts(requests[2]).join("\n")).not.toContain("Steered during compaction")
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([queued.id, steered.id])
yield* retry.release
yield* Fiber.join(run)
expect(requests).toHaveLength(5)
expect(userTexts(requests[3])).toContain("Steered during compaction")
expect(userTexts(requests[3])).not.toContain("Queued during compaction")
expect(userTexts(requests[4])).toContain("Queued during compaction")
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("does not recover provider context overflow when automatic compaction is disabled", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
@@ -3320,6 +3481,71 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("keeps queued input parked when a steer is cancelled during preparation", () =>
Effect.gen(function* () {
const session = yield* setup
const runner = yield* SessionRunner.Service
yield* admit(session, "A")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
yield* session.prompt({ sessionID, text: "B", delivery: "queue", resume: false })
const steer = yield* admit(session, "S")
systemLoadHook = Effect.gen(function* () {
systemLoadHook = Effect.void
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
})
yield* stream.release
yield* Fiber.join(run)
expect(requests.map(userTexts)).toEqual([["A"], ["A"], ["A", "B"]])
expect((yield* session.messages({ sessionID })).some((message) => message.id === steer.id)).toBe(false)
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("dispatches a queued move when a steer is cancelled during preparation", () =>
Effect.gen(function* () {
const session = yield* setup
const runner = yield* SessionRunner.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const location = Location.Ref.make({ directory: AbsolutePath.make("/moved") })
yield* admit(session, "A")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
yield* SessionInbox.admit(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: { location, projectID: Project.ID.global },
delivery: "queue",
},
})
const steer = yield* admit(session, "S")
systemLoadHook = Effect.gen(function* () {
systemLoadHook = Effect.void
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
})
yield* stream.release
expect({ result: yield* Fiber.join(run), location: (yield* session.get(sessionID)).location }).toEqual({
result: SessionRunner.DrainResult.Moved({}),
location,
})
expect(requests.map(userTexts)).toEqual([["A"], ["A"]])
expect(closedTransports).toEqual([sessionID])
expect(yield* recordedEventTypes(sessionID)).toContain(Bus.versionedType(SessionEvent.Moved.type, 1))
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("preserves durable queued input for a later wake after interruption", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -0,0 +1,234 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
describe("portable shell parser compatibility", () => {
test.each([
["bash", "echo $((1+1))", {}],
["bash", "echo $((1 + $(printf hidden)))", {}],
["bash", "cd ~/project", {}],
["bash", "cd src&&cd..", {}],
["bash", "cd src && cd .. && git status", {}],
["zsh", "git status", {}],
["fish", "git status", {}],
["bash", "git status", { BASH_ENV: "/startup" }],
["bash", "git status", { "BASH_FUNC_cd%%": "() { :; }" }],
["bash", "cd $HOME; pwd", { HOME: "/session-home" }],
["bash", "cd; pwd", { HOME: "/session-home" }],
["bash", 'target=/outside; cd "$target"; pwd', {}],
["bash", "cd 'src dir' && git status", {}],
["bash", 'cd "src dir"; cd escaped\\ space', {}],
["bash", "cd '$HOME'; cd '~/outside'", {}],
["bash", 'g""it status', {}],
["bash", 'npm "run" test', {}],
["bash", "git '*'", {}],
["bash", "FOO=bar git status", {}],
["bash", "HOME=/outside; cd; pwd", {}],
["bash", "CDPATH=/outside cd child", {}],
["bash", "cd child; pwd", { CDPATH: "/outside" }],
["bash", "cd /workspace > output", {}],
["bash", "export X=value; unset X; git status", {}],
["bash", "printf ok && git status > output", {}],
["bash", "printf ok | cat < input > output", {}],
["bash", "cd -- -/../../../etc; pwd", {}],
["bash", "cd -; pushd; popd; pwd", {}],
["bash", "command cd /outside; builtin cd /elsewhere", {}],
["pwsh", "Get-ChildItem | ForEach-Object { Write-Output $_ }", {}],
["pwsh", "ForEach-Object { Remove-Item victim }", {}],
["pwsh", "Set-Location -LiteralPath '../outside'; Get-ChildItem", {}],
["pwsh", "Set-Location -LiteralPath:/outside", {}],
["pwsh", "Set-Location -LiteralPath:'/outside path'", {}],
["pwsh", 'Set-Location -PATH:"../outside path"', {}],
["pwsh", "Set-Location $HOME; Set-Location $PWD; Set-Location $target", { HOME: "/session-home" }],
["pwsh", "Set-Item Env:T /outside; Set-Location $env:T", { T: "/workspace" }],
["pwsh", "sl /outside; Microsoft.PowerShell.Management\\Set-Location /outside", {}],
] as const)(
"matches supported legacy resources, saved prefixes, and directories natively: %s %s %j",
async (shell, command, env) => {
if (Object.keys(env).length > 0) {
const child = Bun.spawn({
cmd: [
process.execPath,
"--eval",
`
import { Effect } from "effect"
import { ShellParse } from "./src/shell/parse.ts"
const command = ${JSON.stringify(command)}
const shell = ${JSON.stringify(shell)}
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
console.log(JSON.stringify([legacy, portable, native]))
`,
],
cwd: `${import.meta.dir}/..`,
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [output, error, code] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
expect(code, error).toBe(0)
const [legacy, portable, native] = JSON.parse(output)
expect(portable).toEqual(legacy)
expect(native).toEqual(legacy)
return
}
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
expect(portable).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(legacy)
},
)
test("derives the legacy prefix for long argument lists", async () => {
const command = `echo ${"x ".repeat(16_000)}`.trimEnd()
const result = await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))
expect(result).toEqual({ commands: [{ resource: command, save: "echo *" }], directories: [] })
})
test("extracts inline PowerShell directory flags with case-insensitive names and quoted values", async () => {
const result = await Effect.runPromise(
ShellParse.scanPortable(
"Set-Location -LITERALPATH:C:\\outside; Set-Location -pAtH:'../other dir'",
"pwsh",
"/workspace",
),
)
expect(result).toEqual({ commands: [], directories: ["C:\\outside", "../other dir"] })
})
})
describe("current native and legacy parity gaps", () => {
// These are observed parser gaps, not permission-policy changes that must be preserved.
for (const fixture of [
{
name: "native omits the legacy empty command-name node for an assignment with redirection",
shell: "bash",
command: "FOO=bar > output",
legacy: { commands: [{ resource: "FOO=bar > output", save: " *" }], directories: [] },
native: { commands: [], directories: [] },
},
{
name: "native retains nested executable commands without the legacy empty assignment command-name node",
shell: "bash",
command: "FOO=$(printf value) > output",
legacy: {
commands: [
{ resource: "FOO=$(printf value) > output", save: " *" },
{ resource: "printf value", save: "printf *" },
],
directories: [],
},
native: { commands: [{ resource: "printf value", save: "printf *" }], directories: [] },
},
{
name: "native keeps numeric arguments in saved prefixes",
shell: "bash",
command: "git 2 status",
legacy: { commands: [{ resource: "git 2 status", save: "git status *" }], directories: [] },
native: { commands: [{ resource: "git 2 status", save: "git 2 *" }], directories: [] },
},
{
name: "native keeps numeric directory names and operator-shaped arguments",
shell: "bash",
command: "cd 123; git == value",
legacy: { commands: [{ resource: "git == value", save: "git value *" }], directories: [] },
native: { commands: [{ resource: "git == value", save: "git == *" }], directories: ["123"] },
},
{
name: "native preserves substitution source in saved prefixes instead of skipping the argument",
shell: "bash",
command: "git $(printf status) diff",
legacy: {
commands: [
{ resource: "git $(printf status) diff", save: "git diff *" },
{ resource: "printf status", save: "printf *" },
],
directories: [],
},
native: {
commands: [
{ resource: "git $(printf status) diff", save: "git $(printf status) *" },
{ resource: "printf status", save: "printf *" },
],
directories: [],
},
},
{
name: "directory line continuations remain unresolved source rather than legacy split operands",
shell: "bash",
command: "cd before\\\nafter",
legacy: { commands: [], directories: ["before", "after"] },
native: { commands: [], directories: ["before\\\nafter"] },
},
{
name: "native recognizes PowerShell carriage-return separators omitted by the legacy AST",
shell: "pwsh",
command: "Get-ChildItem\rRemove-Item victim",
legacy: { commands: [], directories: [] },
native: {
commands: [
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
{ resource: "Remove-Item victim", save: "Remove-Item *" },
],
directories: [],
},
},
{
name: "native recognizes tab-separated PowerShell commands omitted by the legacy AST",
shell: "pwsh",
command: "git\tstatus",
legacy: { commands: [], directories: [] },
native: { commands: [{ resource: "git\tstatus", save: "git\tstatus *" }], directories: [] },
},
{
name: "native preserves complete PowerShell flag=value resources",
shell: "pwsh",
command: "git --flag=value",
legacy: { commands: [{ resource: "git --flag", save: "git --flag *" }], directories: [] },
native: { commands: [{ resource: "git --flag=value", save: "git --flag=value *" }], directories: [] },
},
{
name: "native does not split comma-separated PowerShell directory operands",
shell: "pwsh",
command: "Set-Location a,b",
legacy: { commands: [], directories: ["a", ",b"] },
native: { commands: [], directories: ["a,b"] },
},
]) {
test(fixture.name, async () => {
const native = await Effect.runPromise(ShellParse.scanPortable(fixture.command, fixture.shell, "/workspace"))
expect(native).toEqual(fixture.native)
expect(await Effect.runPromise(ShellParse.scan(fixture.command, fixture.shell, "/workspace"))).toEqual(
fixture.legacy,
)
expect(
await Effect.runPromise(ShellParse.scan(fixture.command, fixture.shell, "/workspace", { portable: true })),
).toEqual(native)
expect(native).not.toEqual(fixture.legacy)
})
}
})
describe("legacy directory command behavior", () => {
test.each(["bash", "zsh", "pwsh"])("retains the original shared directory command set: %s", async (shell) => {
const result = await Effect.runPromise(
ShellParse.scan(
"chdir /outside; set-location /elsewhere; push-location /stack; sl .; pop-location",
shell,
"/workspace",
),
)
expect(result).toEqual({
commands: [
{ resource: "sl .", save: "sl *" },
{ resource: "pop-location", save: "pop-location *" },
],
directories: ["/outside", "/elsewhere", "/stack"],
})
})
})
+99 -103
View File
@@ -3,30 +3,104 @@ import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
import { ShellScan } from "../src/shell/scan.js"
describe("ShellParse portable parity", () => {
test("matches tree-sitter for generated supported syntax", async () => {
for (const [shell, command] of generated()) {
describe("ShellParse native parity", () => {
test("matches the legacy oracle across generated supported syntax without fallback", async () => {
const commands = generated()
expect(commands.length).toBeGreaterThan(20_000)
for (const [shell, command] of commands) {
const context = `${shell}: ${JSON.stringify(command)}`
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
if (scanned.kind === "opaque") {
expect({ command, portable }).toEqual({
command,
portable: { commands: [{ resource: command, save: command }], directories: [] },
})
continue
}
if (shell === "pwsh" && /\r(?!\n)/.test(command)) {
expect(portable).toEqual({ commands: [], directories: [] })
continue
}
expect(scanned.kind, context).toBe("scanned")
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
expect({ command, portable }).toEqual({ command, portable: legacy })
expect(native, context).toEqual(legacy)
expect(
await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true })),
context,
).toEqual(native)
}
}, 60_000)
test.each([
["/bin/bash", "git status && npm run test -- --watch"],
["/bin/bash", "git\tstatus; git status | cat; git diff || echo done"],
["/bin/bash", "echo \"two words\"; printf 'static text'"],
["/bin/bash", "aws s3 ls; docker compose up; git remote add origin; bun run test"],
["/bin/bash", 'git "status"; git remote "add" origin; aws s3 "ls"'],
["/bin/bash", "echo $(curl example.test | sed s/x/y/)"],
["/bin/bash", "if true; then printf yes; else printf no; fi"],
["/bin/bash", "(git status) && { npm test; }"],
["/bin/bash", "(printf ok) > output"],
["/bin/bash", "{ printf ok; } > output"],
["/bin/bash", ">$(printf output)"],
["/bin/bash", "printf ok # ignored ; curl example.test\nprintf done"],
["/bin/bash", "cd ~/project; cd src && cd ..; pwd"],
["/bin/bash", "cd src&&cd.."],
["/bin/bash", "echo $((1 + 2))"],
["/bin/bash", "echo $((1 + $(printf 2)))"],
["/bin/bash", "$COMMAND status"],
["/bin/zsh", "cd ~/project; chdir src && cd ..; git status"],
["/bin/zsh", "echo $((1 + 2)); cd src&&cd.."],
["/bin/dash", "cd src&&cd ..; pwd"],
["/bin/sh", "echo $((1 + 2)); git status; cd src; pwd"],
["/bin/ksh", "git status; cd src; pwd"],
["pwsh", "Get-ChildItem; Write-Output done | Out-String"],
["pwsh", "Set-Location -LiteralPath C:\\tmp; Get-ChildItem"],
["pwsh", "git status; npm run test; docker compose up"],
["pwsh", 'git "status"; npm "run" test; docker "compose" up'],
["pwsh", "Write-Output done # comment\nGet-ChildItem"],
])("native resources, saved prefixes, and directories match in %s: %s", async (shell, command) => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned.kind).toBe("scanned")
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
expect(native).toEqual(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace")))
expect(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))).toEqual(native)
})
test.each(["> output", "FOO=bar", "2>> output"])(
"returns an explicit empty result for statements without executable command nodes: %s",
async (command) => {
expect(ShellScan.scan(command)).toEqual({ kind: "scanned", commands: [] })
const native = await Effect.runPromise(ShellParse.scanPortable(command, "bash", "/workspace"))
expect(native).toEqual({ commands: [], directories: [] })
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace"))).toEqual(native)
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))).toEqual(
native,
)
},
)
})
describe("ShellParse malformed native syntax", () => {
test.each([
["bash", 'echo "unterminated', "unterminated-quote"],
["bash", "printf done &&", "invalid-structure"],
["bash", "cat >", "invalid-redirect"],
["bash", ">", "invalid-redirect"],
["bash", "FOO=bar >", "invalid-redirect"],
["bash", "echo \\", "unterminated-escape"],
["pwsh", 'Write-Output "unterminated', "unterminated-quote"],
["pwsh", "git 12>bar", "invalid-redirect"],
["pwsh", "Write-Output `", "unterminated-escape"],
] as const)("fails explicitly for malformed %s syntax: %s", async (shell, command, reason) => {
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
expect(scanned).toEqual({ kind: "opaque", reason })
expect(await Effect.runPromise(Effect.result(ShellParse.scanPortable(command, shell, "/workspace")))).toMatchObject(
{
_tag: "Failure",
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
},
)
expect(
await Effect.runPromise(Effect.result(ShellParse.scan(command, shell, "/workspace", { portable: true }))),
).toMatchObject({
_tag: "Failure",
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
})
})
})
// This generator describes a supported grammar; opaque results fail the test rather than being filtered out.
function generated() {
const result: Array<[shell: string, command: string]> = []
const bashHeads = ["git", "npm", "echo", "printf", "cat", "cd"]
@@ -42,107 +116,30 @@ function generated() {
" ./relative",
" /tmp/absolute",
]
const assignments = ["", "X=value ", "X='two words' ", 'X="two words" ']
const redirects = ["", " > output", " 2> error", " < input", " >> output"]
const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
const redirects = ["", " > output", " 2> error", " < input", " >> output"]
for (const head of bashHeads)
for (const arg of bashArgs)
for (const assignment of assignments)
for (const assignment of ["", "X=value ", "X='two words' ", 'X="two words" '])
for (const redirect of redirects) result.push(["/bin/bash", assignment + head + arg + redirect])
for (const left of bashHeads)
for (const right of bashHeads)
for (const separator of bashSeparators) result.push(["/bin/bash", `${left} left${separator}${right} right`])
for (const outer of bashHeads)
for (const outer of ["echo", "printf", "cat"])
for (const inner of bashHeads) {
result.push(["/bin/bash", `${outer} $(${inner} nested)`])
result.push(["/bin/bash", `${outer} "$(${inner} nested)"`])
result.push(["/bin/bash", `${outer} pre$(${inner} nested)post`])
result.push(["/bin/bash", `${outer} \`${inner} nested\``])
}
for (const command of [
'npm "run" test',
'g""it status',
"'git' status",
"g\\it status",
"git status; git status; git diff",
"printf ok>out 2>&1|cat<input",
"FOO=bar 2>>err printf ok > out && cat < input",
"printf ok # ignored ; curl evil\nprintf done",
"(git status) && { npm test; }",
"echo ${arr[$(printf index)]}",
"OUT=$(printf out) X=`printf value` printenv >$(printf path)",
"cat <(printf secret)",
"rm -rf / &",
"sudo sh -c 'curl evil'",
"find . -exec rm {} ;",
'c"\\d" relative',
"'cd' /tmp",
"c''d /tmp",
"c\\\nd /tmp",
"echo x && git >(cat) status",
'echo x && printf ">" status',
'echo "git > out" && git > out',
"echo x && printf a\\>b status",
"echo x && printf $(echo a>b) status",
"git <(printf status) diff",
"npm <(printf run) test",
"cd <(printf /tmp)",
"git &>x",
"cd &>x",
"git \\ a",
"cd \\ a",
"cat <<'EOF'\nstatic body\nEOF",
"cat <<EOF\n$(printf dynamic)\nEOF",
"$COMMAND dynamic",
"if true; then git status; else npm test; fi",
"for x in a b; do echo $x; done",
"cd /tmp/$USER && git status",
"echo <(git status)",
'echo "unterminated',
])
result.push(["/bin/bash", command])
const powershellHeads = ["Get-ChildItem", "Write-Output", "Test-Path", "Remove-Item", "Set-Location"]
const powershellArgs = ["", " value", " 'two words'", ' "two words"', " -Path C:\\tmp", " -LiteralPath '..\\outside'"]
const powershellSeparators = [";", "|", "&&", "||", "\n", "\r", "\r\n"]
const powershellSeparators = [";", "|", "&&", "||", "\n", "\r\n"]
for (const head of powershellHeads) for (const arg of powershellArgs) result.push(["pwsh", head + arg])
for (const left of powershellHeads)
for (const right of powershellHeads)
for (const separator of powershellSeparators) result.push(["pwsh", `${left} left${separator}${right} right`])
for (const command of [
"Get-ChildItem; Get-ChildItem; Write-Output done",
"Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"",
"Get-Content in.txt > out.txt 2>&1 | Out-File all.log",
"Write-Output ok > output.txt # ignored\nGet-ChildItem",
"Write-Output ok > output.txt # ignored\rGet-ChildItem",
"Write-Output ok > output.txt # ignored\r\nGet-ChildItem",
"& git status",
". ./deploy.ps1",
"Get-ChildItem | ForEach-Object { Remove-Item $_ }",
"ForEach-Object { Remove-Item $_ }",
"&Remove-Item victim",
"< #\nRemove-Item victim",
"Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y",
'git "status"',
"git st`atus",
'npm "run" test',
'docker "compose" up',
"git >x",
"git *>&1",
"git foo2>bar",
"git 12>bar",
"git a`;b",
"git & Write-Output q",
"Write-Output 'ForEach-Object { Remove-Item x }' | ForEach-Object { Remove-Item x }",
"$Command value",
"& $Command value",
'Write-Output "$(Get-ChildItem)"',
"if ($true) { Get-ChildItem } else { Remove-Item victim }",
"Set-Location $env:TEMP; Get-ChildItem",
'Write-Output "unterminated',
])
result.push(["pwsh", command])
let state = 0x5eed1234
const random = (length: number) => {
@@ -156,10 +153,10 @@ function generated() {
const separator = bashSeparators[random(bashSeparators.length)]
const bashForms = [
`${left}${arg}${separator}${right} fuzz${index}`,
`${left}${arg} $(${right} fuzz${index})`,
`echo $(${right} fuzz${index})`,
`${left}${arg} # ignored\n${right} fuzz${index}`,
`X=value ${left}${arg}${redirects[random(redirects.length)]}`,
`${left} before\\\nafter${separator}${right} fuzz${index}`,
`${left} 'two words'${separator}${right} fuzz${index}`,
]
result.push(["/bin/bash", bashForms[index % bashForms.length]])
@@ -171,10 +168,9 @@ function generated() {
`${powershellLeft}${powershellArg}${powershellSeparator}${powershellRight} fuzz${index}`,
`${powershellLeft}${powershellArg} # ignored\n${powershellRight} fuzz${index}`,
`${powershellLeft} fuzz${index} > output; ${powershellRight}${powershellArg}`,
`${powershellLeft}\`\n fuzz${index}; ${powershellRight}${powershellArg}`,
`${powershellLeft} "fuzz${index}" | ${powershellRight}${powershellArg}`,
]
result.push(["pwsh", powershellForms[index % powershellForms.length]])
}
return result
}
@@ -0,0 +1,114 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../src/shell/parse.js"
import { Wildcard } from "../src/util/wildcard.js"
describe("native shell syntax compatibility", () => {
test("PowerShell invocation approvals include the operator instead of saving an ineffective prefix", async () => {
const command = "& $Command value"
expect(await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))).toEqual({
commands: [{ resource: command, save: "$Command *" }],
directories: [],
})
expect(await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))).toEqual({
commands: [{ resource: command, save: "& $Command *" }],
directories: [],
})
})
test.each([
"ForEach-Object { Write-Output value }",
"Write-Output before; ForEach-Object { Write-Output value }",
"ForEach-Object { Write-Output value } | Write-Output done",
"Write-Output before | ForEach-Object { Write-Output $_ }",
"& ForEach-Object { Write-Output value }",
"& 'ForEach-Object' { Write-Output value }",
"% { Write-Output value }",
"Where-Object { Write-Output value }",
])("PowerShell scriptblock callers preserve permission resources and usable approvals: %s", async (command) => {
const legacy = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))
const native = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))
expect(native.commands.map((item) => item.resource)).toEqual(legacy.commands.map((item) => item.resource))
for (const item of native.commands) expect(Wildcard.match(item.resource, item.save), item.resource).toBe(true)
})
for (const shell of ["bash", "zsh"]) {
test.each([
"cat <<'EOF'\n$(not_a_command)\nEOF",
"cat <<EOF\n$(printf hello)\nEOF",
"cat <<-EOF\n\thello\n\tEOF",
"cat <<EOF\nhello\nEOF\nprintf done",
'cat <<< "$(printf hello)"',
"for file in a b; do printf '%s' \"$file\"; done",
"for file in $(printf file); do printf '%s' \"$file\"; done",
'for file in a b; do if test -n "$file"; then printf \'%s\' "$file"; fi; done',
"while IFS= read -r file; do printf '%s' \"$file\"; done < input",
"until test -f ready; do sleep 1; done",
"if true; then if false; then printf no; else printf yes; fi; fi",
"if true; then :; X=$(printf value); fi",
'case "$target" in *.ts) printf typescript;; *) printf other;; esac',
"greet() { printf hello; }; greet",
"function greet { printf hello; }; greet",
"printf before; { printf grouped; }; (printf subshell)",
"time git status",
"time -p git status",
"coproc git status",
"if [[ -f file ]]; then cat file; fi",
"[[ $(printf yes) = yes ]]",
"echo ${value:-default}",
'echo "${value:-$(printf fallback)}"',
"echo ${value//before/after}",
"echo ${arr[$(printf index)]}",
"printf '%s' $'line1\\nline2'",
'printf "%s" $"hello"',
"echo $((1 + ${value:-2}))",
"echo $((array[$(printf 0)]))",
"echo $[1 + 2]",
"((count++))",
"for ((i=0; i<2; i++)); do printf ok; done",
"echo `printf \\2`",
])(`${shell} extracts commands without rejecting ordinary syntax: %s`, async (command) => {
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
const native = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
expect(native).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(native)
})
}
test("does not invent commands from a quoted second heredoc body", async () => {
const command = "cat <<FIRST <<'SECOND'\n$(printf first)\nFIRST\n$(not_a_command)\nSECOND"
const expected = {
commands: [
{ resource: command, save: "cat *" },
{ resource: "printf first", save: "printf *" },
],
directories: [],
}
expect(await Effect.runPromise(ShellParse.scanPortable(command, "bash", "/workspace"))).toEqual(expected)
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))).toEqual(
expected,
)
})
test.each([
'Write-Output "$(Get-Location)"',
"$value = Get-Date; Write-Output $value",
"if ($true) { Write-Output yes } else { Write-Output no }",
"if (Test-Path file) { Get-Item file }",
"foreach ($value in @('a','b')) { Write-Output $value }",
"for ($i=0; $i -lt 2; $i++) { Write-Output $i }",
"while (Test-Path file) { Get-Item file; break }",
"function Show-Value { Write-Output value }; Show-Value",
"Get-Item -Path (Join-Path src file)",
'Write-Output "line1`nline2"',
"Write-Output `\n continued",
"<# comment #> Write-Output done",
"Write-Output @'\nhello\n'@",
"git st`atus",
])("PowerShell extracts commands without rejecting ordinary syntax: %s", async (command) => {
const legacy = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))
const native = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))
expect(native).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, "pwsh", "/workspace"))).toEqual(native)
})
})
+61 -11
View File
@@ -18,31 +18,34 @@ describe("ShellParse", () => {
})
})
test("portable scanning never adds permission resources", async () => {
test("portable scanning preserves supported command resources and directories", async () => {
const commands = [
"git status && npm run test -- --watch",
"echo $(curl evil | sed s/x/y/)",
"cat <<'EOF'\nstatic body\nEOF",
"cat <<EOF\n$(printf dynamic)\nEOF",
"cd /tmp/$USER && git status",
"$COMMAND status",
"if true; then printf yes; else printf no; fi",
"if true; then export X=$(printf value); unset X; fi",
"if export X=$(printf value); then printf done; fi",
"export X=value >$(printf output)",
"echo $((1 + 1))",
"cd ~; cd src&&cd ..; pwd",
]
for (const command of commands) {
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
expect(
portable.commands.every((item) => legacy.commands.some((candidate) => candidate.resource === item.resource)),
).toBe(true)
expect(portable.directories.every((item) => legacy.directories.includes(item))).toBe(true)
expect(portable, command).toEqual(legacy)
expect(await Effect.runPromise(ShellParse.scanPortable(command, "/bin/bash", "/workspace"))).toEqual(portable)
}
})
test("portable scanning authorizes opaque heredocs without inferring directories", async () => {
test("portable scanning handles heredocs with the existing permission resource", async () => {
const command = "cat <<'EOF'\nstatic body\nEOF"
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
expect(portable).toEqual({ commands: [{ resource: command, save: command }], directories: [] })
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(legacy.commands).toEqual([{ resource: command, save: "cat *" }])
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))).toEqual(
legacy,
)
})
test.each(['c"\\d" relative', "'cd' /tmp", "c''d /tmp", "c\\\nd /tmp"])(
@@ -54,6 +57,53 @@ describe("ShellParse", () => {
},
)
test.each(["declare", "typeset", "export", "readonly", "local", "unset", "unsetenv"])(
"preserves declaration permission behavior for %s without hiding nested commands",
async (name) => {
for (const command of [`${name} X`, `${name} "$(printf X)"; git status`]) {
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(legacy.commands).toEqual(
command.includes("$(")
? [
{ resource: "printf X", save: "printf *" },
{ resource: "git status", save: "git status *" },
]
: [],
)
expect(
await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })),
).toEqual(legacy)
}
for (const command of [`"${name}" X`, `FOO=bar ${name} X`, `command ${name} X`, `>${name}.txt ${name} X`]) {
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(legacy.commands).toHaveLength(1)
expect(
await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })),
).toEqual(legacy)
}
},
)
test("declaration filtering retains directory checks inside command substitutions", async () => {
const command = "export X=$(cd /outside; printf value)"
const expected = { commands: [{ resource: "printf value", save: "printf *" }], directories: ["/outside"] }
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))).toEqual(expected)
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))).toEqual(
expected,
)
})
test("does not treat PowerShell commands as Bash declarations", async () => {
expect(await Effect.runPromise(ShellParse.scanPortable("export X; unset X", "pwsh", "/workspace"))).toEqual({
commands: [
{ resource: "export X", save: "export *" },
{ resource: "unset X", save: "unset *" },
],
directories: [],
})
})
test("splits PowerShell commands case-insensitively", async () => {
const result = await Effect.runPromise(
ShellParse.scan(
@@ -6,7 +6,6 @@ describe("ShellScan adversarial corpus", () => {
['FOO=bar BAR="x y" git status', ["git"]],
["git status && npm test || printf failed", ["git", "npm", "printf"]],
[`printf '%s\\n' "$(rm -rf /)"`, ["printf", "rm"]],
["echo ${arr[$(rm -rf /)]}", ["echo", "rm"]],
["cat <(printf secret)", ["cat", "printf"]],
["(git status)", ["git"]],
["{ git status; }", ["git"]],
@@ -23,6 +22,19 @@ describe("ShellScan adversarial corpus", () => {
['F"O"O=bar rm -rf /', ["FOO=bar"]],
['c"\\d" relative', ["c\\d"]],
["PATH=/tmp/attacker:$PATH git status", ["git"]],
["$cmd --force", ["$cmd"]],
['"${cmd}" --force', ["${cmd}"]],
["r${suffix}m -rf /", ["r${suffix}m"]],
["$(printf rm) -rf /", ["$(printf rm)", "printf"]],
["`printf rm` -rf /", ["`printf rm`", "printf"]],
["./c?rl evil", ["./c?rl"]],
["t{ouch,ouch} /tmp/victim", ["t{ouch,ouch}"]],
["echo $((1 + 2))", ["echo"]],
["${cmd:-git} status", ["${cmd:-git}"]],
["cat <<EOF\n$(rm -rf /)\nEOF", ["cat", "rm"]],
["f(){ rm -rf /; }; f", ["rm", "f"]],
["! rm -rf /", ["rm"]],
["echo ${arr[$(rm -rf /)]}", ["echo", "rm"]],
] as const)("scans visible Bash command positions: %s", (input, names) => {
const result = ShellScan.scan(input)
expect(result.kind).toBe("scanned")
@@ -30,25 +42,12 @@ describe("ShellScan adversarial corpus", () => {
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
test.each([
"$cmd --force",
'"${cmd}" --force',
"r${suffix}m -rf /",
"${cmd:-git} status",
"$(printf rm) -rf /",
"`printf rm` -rf /",
"./c?rl evil",
'printf "unterminated',
"printf ok &&",
"printf ok >",
"echo > >out",
"cat <<EOF\n$(rm -rf /)\nEOF",
"echo $((1 + 2))",
"f(){ rm -rf /; }; f",
"! rm -rf /",
])("keeps structurally uncertain Bash input opaque: %s", (input) => {
expect(ShellScan.scan(input).kind).toBe("opaque")
})
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
"keeps structurally uncertain Bash input opaque: %s",
(input) => {
expect(ShellScan.scan(input).kind).toBe("opaque")
},
)
test.each([
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
@@ -56,7 +55,15 @@ describe("ShellScan adversarial corpus", () => {
["Invoke-Expression 'Remove-Item victim.txt'", ["Invoke-Expression"]],
[". ./deploy.ps1", ["./deploy.ps1"]],
["& git status", ["git"]],
["& $Command status", ["$Command"]],
["Set-Location $HOME/$target; Get-ChildItem", ["Set-Location", "Get-ChildItem"]],
["Get-ChildItem | ForEach-Object { Remove-Item $_ }", ["Get-ChildItem", "ForEach-Object", "Remove-Item"]],
['Write-Output "$(Get-ChildItem)"', ["Write-Output", "Get-ChildItem"]],
["Remove-`Item victim", ["Remove-Item"]],
["Remove-Item`\r\n victim", ["Remove-Item\r\n"]],
["Invoke-`\nExpression 'Remove-Item victim'", ["Invoke-\nExpression"]],
["<# ignored #> Remove-Item victim", ["Remove-Item"]],
["[string]$x = Remove-Item victim", ["Remove-Item"]],
] as const)("scans visible PowerShell command positions: %s", (input, names) => {
const result = ShellScan.scanPowerShell(input)
expect(result.kind).toBe("scanned")
@@ -64,19 +71,7 @@ describe("ShellScan adversarial corpus", () => {
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
test.each([
"$Command status",
"& $Command status",
'Write-Output "$(Get-ChildItem)"',
"Set-Location $HOME/$target; Get-ChildItem",
"Remove-`Item victim",
"Remove-Item`\r\n victim",
"Invoke-`\nExpression 'Remove-Item victim'",
"<# ignored #> Remove-Item victim",
"[string]$x = Remove-Item victim",
'Write-Output "unterminated',
"Get-ChildItem |",
])("keeps structurally uncertain PowerShell input opaque: %s", (input) => {
test.each(['Write-Output "unterminated', "Get-ChildItem |"])("reports incomplete PowerShell input: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
})
@@ -0,0 +1,143 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("Bash arithmetic expansions", () => {
test.each([
"$((1+1))",
"$((1 + 1))",
"$(((1 + 2) * (3 + (4))))",
"$((value + $other + ${third}))",
"$((value += 2, value > 1 ? value << 2 : ~value))",
"$((16#ff & 0xff | 2 ** 3))",
"$((1 + $((2 * 3))))",
"$((1 + \\\n2))",
"$((1 +\n2))",
"$((1 + ${value:-2}))",
"$((array[index]))",
"$((1 + $[2]))",
'$((1 + "2"))',
"$((1 + '2'))",
])("preserves arithmetic without evaluating it: %s", (expression) => {
for (const argument of [expression, `"${expression}"`]) {
expect(ShellScan.scan(`echo ${argument}`)).toEqual({
kind: "scanned",
commands: [{ resource: `echo ${argument}`, words: ["echo", expression], rawWords: ["echo", argument] }],
})
}
})
test.each([
"$((1 + $(printf 2)))",
"$((1 + `printf 2`))",
"$((1 + $((2 * $(printf 2)))))",
'$((1 + $(printf "%s" "$(printf 2)")))',
"$((array[$(printf 2)]))",
"$((1 + `printf \\2`))",
])("reports explicit commands inside arithmetic: %s", (expression) => {
for (const argument of [expression, `"${expression}"`]) {
const result = ShellScan.scan(`echo ${argument}; pwd`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[0]).toEqual({
resource: `echo ${argument}`,
words: ["echo", expression],
rawWords: ["echo", argument],
})
expect(result.commands.slice(1, -1).map((command) => command.words[0])).toEqual(
expression.includes('"$(printf') ? ["printf", "printf"] : ["printf"],
)
expect(result.commands.at(-1)).toEqual({ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] })
}
})
test.each([
'VALUE=$((1 + $(printf 2))) echo ok >"$((3 + $(printf 4)))"',
'echo "$(echo $((1 + $(printf 2))))"',
'{ echo "$((1 + $(printf 2)))"; }',
'(echo "$((1 + $(printf 2)))")',
'if true; then echo "$((1 + $(printf 2)))"; fi',
])("recognizes arithmetic in existing shell contexts: %s", (source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toContain("printf")
expect(result.commands.map((command) => command.words[0])).toContain("echo")
})
test("does not expand arithmetic inside single quotes", () => {
expect(ShellScan.scan("echo '$((1 + $(ignored)))'")).toEqual({
kind: "scanned",
commands: [
{
resource: "echo '$((1 + $(ignored)))'",
words: ["echo", "$((1 + $(ignored)))"],
rawWords: ["echo", "'$((1 + $(ignored)))'"],
},
],
})
})
test.each([
"echo $((1 + 2)",
"echo $(((1 + 2))",
"echo $((1 + $(printf 2))) &&",
"echo $((1 + $(printf 2 &&)))",
"echo $((1; printf 2))",
])("rejects malformed arithmetic syntax: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test("bounds arithmetic nesting, input size, and repeated conditional work", () => {
expect(ShellScan.scan(`echo $((${"(".repeat(33)}1${")".repeat(33)}))`).kind).toBe("opaque")
expect(ShellScan.scan(`echo ${"$((".repeat(33)}1${"))".repeat(33)}`).kind).toBe("opaque")
expect(ShellScan.scan(`echo $((${"1+".repeat(32 * 1024)}1))`).kind).toBe("opaque")
const source = Array.from({ length: 16 }).reduce<string>(
(source) => `if true; then echo $((1 + $(${source}))); fi`,
`printf ${"1".repeat(1024)}`,
)
expect(ShellScan.scan(source).kind).toBe("scanned")
expect(ShellScan.scan("echo $((1+1))").kind).toBe("scanned")
})
})
describe("Bash arithmetic real-shell oracle", () => {
const expressions = [
["$((1+1))", "2"],
["$(((1 + 2) * (3 + (4))))", "21"],
["$((value + $other + ${third}))", "9"],
["$((value += 2, value > 1 ? value << 2 : ~value))", "16"],
["$(((16#ff & 0xff) | (2 ** 3)))", "255"],
["$((1 + $((2 * 3))))", "7"],
["$((1 + $(scan_probe)))", "3"],
["$((1 + `scan_probe`))", "3"],
["$(((1 + $(scan_probe)) * $((2 + $(scan_probe)))))", "12"],
['$((1 + $(printf "%s" "$(scan_probe)")))', "3"],
] as const
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test.skipIf(!executable).each(expressions)(`${shell} evaluates %s independently`, (expression, output) => {
for (const argument of [expression, `"${expression}"`]) {
const source = `value=2; other=3; third=4; printf '%s\\n' ${argument}`
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'scan_probe\\n' >&2; printf 2; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stdout.toString()).toBe(`${output}\n`)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
const observed = execution.stderr.toString().trim().split("\n").filter(Boolean)
expect(observed).toEqual(Array.from({ length: expression.split("scan_probe").length - 1 }, () => "scan_probe"))
expect(result.commands.filter((command) => command.words[0] === "scan_probe")).toHaveLength(observed.length)
expect(result.commands[0]?.words).toEqual(["printf", "%s\\n", expression])
}
})
}
})
@@ -0,0 +1,266 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
const executions = [
['printf safe; "scan_probe"', "scan_probe"],
["printf safe; 'scan_probe'", "scan_probe"],
["printf safe; $(printf scan_probe)", "$(printf scan_probe)"],
["X=${unset:-a b} scan_probe", "scan_probe"],
["X=value # comment\nscan_probe", "scan_probe"],
['printf "%s" `\\$(scan_probe)`', "scan_probe"],
['printf %s `printf \\\\"; scan_probe; printf \\\\"`', "scan_probe"],
["if true; then X=x scan_probe; fi", "scan_probe"],
["if true; then >/dev/null X=x scan_probe; fi", "scan_probe"],
["printf safe; { scan_probe; }", "scan_probe"],
["if true; then { scan_probe; }; fi", "scan_probe"],
["s{can_probe,can_probe}", "s{can_probe,can_probe}"],
['printf safe; # comment\n"scan_probe"', "scan_probe"],
["printf safe; \\\n'scan_probe'", "scan_probe"],
] as const
describe("Bash execution safety", () => {
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test.skipIf(!executable).each([...executions])(`${shell} command syntax is visible: %s`, (source, head) => {
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'executed\\n' >&2; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.stderr.toString()).toContain("executed\n")
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain(head)
})
}
test.each([
"printf ok && # comment",
'"if" true; then printf safe; fi',
"X=x if true; then printf safe; fi",
"(printf ok) &&",
"{ printf ok; } ||",
"(printf ok) |",
"(printf ok) |&",
])("rejects malformed command positions: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test.each([" ", "\t"])("recognizes shell whitespace %j", (space) => {
expect(ShellScan.scan(`printf${space}ok`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `printf${space}ok`, words: ["printf", "ok"] }],
})
})
test.each(["\r", "\v", "\f", "\u00a0", "\ufeff"])("does not normalize non-shell whitespace %j", (space) => {
expect(ShellScan.scan(`${space}printf ok`).kind).toBe("opaque")
expect(ShellScan.scan(`printf${space}ok`).kind).toBe("opaque")
})
test.each(["'123'", '"123"', "1\\23"])("does not consume quoted command names as fd prefixes: %s", (head) => {
expect(ShellScan.scan(`${head}>/dev/null argument`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `${head}>/dev/null argument`, words: ["123", "argument"] }],
})
})
test.each([
"'' > output",
"''",
"> output (printf ok)",
"(printf ok) >output pwd",
"{ printf ok; } >output X=x",
"(printf ok) &>output pwd",
])("keeps unsupported group positions and empty command names opaque: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test.each(["X=value > /dev/null", "X=x 2> output", "X=x < input", "> /dev/null", "2>> output", ">output 2>&1"])(
"scans redirects without inventing an executable command: %s",
(source) => {
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
},
)
test.each([">", "X=x >", "X=x > # comment\nprintf ok", ">; printf ok", "(printf ok) >", "{ printf ok; } >"])(
"still requires redirect targets: %s",
(source) => {
expect(ShellScan.scan(source)).toEqual({ kind: "opaque", reason: "invalid-redirect" })
},
)
test.each([
"{ printf ok; } > output",
"{ printf ok; } 2> output",
"{ printf ok; } &> output",
"(printf ok) > output",
"{ printf ok; }; X=x > output",
])("scans leading groups and their redirects without synthetic commands: %s", (source) => {
expect(ShellScan.scan(source)).toEqual({
kind: "scanned",
commands: [{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] }],
})
})
test.each(["X=x >$(printf output)", ">$(printf output)", "{ :; } >$(printf output)", "(:) >$(printf output)"])(
"retains explicit substitutions in otherwise commandless redirects: %s",
(source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(
source.includes(":") ? [":", "printf"] : ["printf"],
)
expect(result.commands.at(-1)).toEqual({
resource: "printf output",
words: ["printf", "output"],
rawWords: ["printf", "output"],
})
},
)
test("scans a list following a group redirect suffix", () => {
expect(ShellScan.scan("(printf ok) >output && pwd")).toEqual({
kind: "scanned",
commands: [
{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] },
{ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] },
],
})
})
test("retains explicit colon commands and their redirects after a group", () => {
expect(ShellScan.scan("(printf ok); : > output")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "printf ok", words: ["printf", "ok"] },
{ resource: ": > output", words: [":"] },
],
})
})
test("does not repeatedly rescan nested conditionals", () => {
const source = Array.from({ length: 16 }).reduce<string>(
(source) => `if true; then echo $(${source}); fi`,
`printf ${"x".repeat(1024)}`,
)
expect(ShellScan.scan(source).kind).toBe("scanned")
expect(ShellScan.scan("if true; then echo $(printf safe); fi")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "true", words: ["true"] },
{ resource: "echo $(printf safe)", words: ["echo", "$(printf safe)"] },
{ resource: "printf safe", words: ["printf", "safe"] },
],
})
})
test.each([
"(printf safe # ) ignored\nscan_probe)",
"{ printf safe; # } ignored\nscan_probe; }",
'echo "$(printf "\'"; scan_probe)"',
'echo "$(printf "%s" "$(printf ")")"; scan_probe)"',
])("does not lose commands through delimiter or quote confusion: %s", (source) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
})
})
describe("Bash real-shell differential grammar", () => {
const probes = ["scan_first", "scan_second", "scan_third"]
const words = [
"scan_first",
"'scan_first'",
's"can_"first',
"scan_first 'literal; $(not_a_command)'",
"X=value scan_first",
'X="$(scan_second)" scan_first',
'scan_first "$(scan_second)"',
"scan_first `scan_second`",
'scan_first "$(printf "\'"; scan_second)"',
]
const contexts = [
(source: string) => source,
(source: string) => `\n\n${source}\n\n`,
(source: string) => `${source}; scan_third`,
(source: string) => `${source} && scan_third`,
(source: string) => `${source} | scan_third`,
(source: string) => `(${source})`,
(source: string) => `{ ${source}; }`,
(source: string) => `scan_third "$(${source})"`,
(source: string) => `if true; then ${source}; fi`,
(source: string) => `Y="$(${source})" scan_third`,
(source: string) => `${source} >/dev/null`,
]
for (const shell of ["bash", "zsh", Bun.which("dash") ? "dash" : "sh"]) {
const executable = Bun.which(shell)
const sources = (
shell === "bash" || shell === "zsh"
? [...words, "scan_first <(scan_second)", 'values=(value "$(scan_second)"); scan_first']
: words
).flatMap((source) => contexts.map((context) => context(source)))
test.skipIf(!executable).each(sources)(`${shell}: %s`, (source) => {
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : shell === "zsh" ? ["-f"] : []),
"-c",
probes.map((name) => `${name}() { printf '${name}\\n' >&2; }; `).join("") + source,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
const observed = execution.stderr.toString().trim().split("\n")
expect(observed.length).toBeGreaterThan(0)
expect(observed.every((name) => probes.includes(name))).toBe(true)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") throw new Error(result.reason)
for (const name of observed) expect(result.commands.map((command) => command.words[0])).toContain(name)
})
}
})
describe("Bash real-shell redirect-only statements", () => {
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
test
.skipIf(!executable)
.each([
"VALUE=ok >/dev/null; scan_probe",
">/dev/null; scan_probe",
"(scan_probe) >/dev/null",
"{ scan_probe; } >/dev/null",
"{ scan_probe; } >$(scan_target)",
"VALUE=ok >$(scan_target); scan_probe",
])(`${shell} preserves commands around redirect-only syntax: %s`, (source) => {
const execution = Bun.spawnSync(
[
executable!,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'scan_probe\\n' >&2; }; scan_target() { printf 'scan_target\\n' >&2; printf /dev/null; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stdout.toString()).toBe("")
const observed = execution.stderr.toString().trim().split("\n").sort()
expect(observed).toEqual(source.includes("scan_target") ? ["scan_probe", "scan_target"] : ["scan_probe"])
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0]).sort()).toEqual(observed)
})
}
})
@@ -0,0 +1,243 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const fixtures = [
["if true; then VALUE=$(scan_probe); fi", ["true", "scan_probe"]],
["if VALUE=$(scan_probe); then :; fi", ["scan_probe", ":"]],
["if true; then if false; then :; else scan_probe; fi; fi", ["true", "false", ":", "scan_probe"]],
["for value in one two; do if true; then scan_probe; fi; done", ["true", "scan_probe"]],
["for value in $(scan_probe); do :; done", ["scan_probe", ":"]],
["for ((i=0; i<1; i++)); do scan_probe; done", ["scan_probe"]],
["while false; do scan_probe; done; scan_after", ["false", "scan_probe", "scan_after"]],
["until true; do scan_probe; done; scan_after", ["true", "scan_probe", "scan_after"]],
["case value in value|other) scan_probe;; *) scan_after;; esac", ["scan_probe", "scan_after"]],
["case value in (value) case x in x) scan_probe;; esac;; esac", ["scan_probe"]],
["case if in if) scan_probe;; esac", ["scan_probe"]],
["case $(scan_probe) in value) :;; esac", ["scan_probe", ":"]],
["f() { case value in value) scan_probe;; esac; }; f", ["scan_probe", "f"]],
["function f { scan_probe; }; f", ["scan_probe", "f"]],
["f() (scan_probe); f", ["scan_probe", "f"]],
["printf ok; { scan_probe; } | scan_after", ["printf", "scan_probe", "scan_after"]],
["(scan_probe # ) ignored\nscan_after)", ["scan_probe", "scan_after"]],
["{ scan_probe; # } ignored\nscan_after; }", ["scan_probe", "scan_after"]],
["printf '%s' \"$(case value in value) scan_probe;; esac)\"", ["printf", "scan_probe"]],
["printf '%s' \"$(for value in one; do scan_probe; done)\"", ["printf", "scan_probe"]],
["printf '%s' \"$(printf %s case in; scan_probe)\"", ["printf", "printf", "scan_probe"]],
["cat <<EOF\nscan_ignored; $(scan_probe)\nEOF", ["cat", "scan_probe"]],
["cat <<'EOF'\nscan_ignored; $(scan_ignored)\nEOF", ["cat"]],
["cat <<\\EOF\n$(scan_ignored)\nEOF", ["cat"]],
["cat <<E'O'F\n$(scan_ignored)\nEOF", ["cat"]],
["cat <<-EOF\n\t$(scan_probe)\n\tEOF", ["cat", "scan_probe"]],
["cat <<EOF\n'$(scan_probe)'\nEOF", ["cat", "scan_probe"]],
["cat <<EOF\n\\$(scan_ignored)\nEOF", ["cat"]],
["cat <<EOF\n$(scan_probe)\nE\\\nOF\nscan_after", ["cat", "scan_probe", "scan_after"]],
["cat <<A <<'B'\n$(scan_probe)\nA\n$(scan_ignored)\nB\nscan_after", ["cat", "scan_probe", "scan_after"]],
["if cat <<EOF\n$(scan_probe)\nEOF\nthen scan_after; fi", ["cat", "scan_probe", "scan_after"]],
["printf '%s' \"$(cat <<EOF\n) $(scan_probe)\nEOF\n)\"", ["printf", "cat", "scan_probe"]],
["{ cat <<'EOF'\n} ignored\nEOF\nscan_probe; }", ["cat", "scan_probe"]],
['cat <<< "$(scan_probe)"', ["cat", "scan_probe"]],
["printf '%s' ${unset:-$(scan_probe)}", ["printf", "scan_probe"]],
["printf '%s' \"${unset:-'$(scan_probe)'}\"", ["printf", "scan_probe"]],
["printf '%s' ${unset:-'$(scan_ignored)'}", ["printf"]],
["printf '%s' \"${unset:-${other:-$(scan_probe)}}\"", ["printf", "scan_probe"]],
["printf '%s' \"${value%)}\"; scan_probe", ["printf", "scan_probe"]],
["printf '%s' \"${value//x/$(scan_probe)}\"", ["printf", "scan_probe"]],
["printf '%s' \"${array[$(scan_probe)]}\"", ["printf", "scan_probe"]],
["array[$(scan_probe)]=value; scan_after", ["scan_probe", "scan_after"]],
['array=(one "$(scan_probe)"); scan_after', ["scan_probe", "scan_after"]],
["array=(<(scan_probe)); scan_after", ["scan_probe", "scan_after"]],
["(( value = $(scan_probe) + 1 )); scan_after", ["scan_probe", "scan_after"]],
["printf '%s' $((array[$(scan_probe)] + 1))", ["printf", "scan_probe"]],
["printf '%s' $[1 + $(scan_probe)]", ["printf", "scan_probe"]],
["[[ $(scan_probe) = value ]] && scan_after", ["scan_probe", "scan_after"]],
["printf '%s' `printf \\2`; scan_probe", ["printf", "printf", "scan_probe"]],
["printf '%s' `printf \\`scan_probe\\``", ["printf", "printf", "scan_probe"]],
["printf '%s' $'literal\\\'$(scan_ignored)'; scan_probe", ["printf", "scan_probe"]],
['printf %s $"$(scan_probe)"', ["printf", "scan_probe"]],
["if true; then \\\nVALUE=$(scan_probe); fi", ["true", "scan_probe"]],
["! scan_probe", ["scan_probe"]],
["time scan_probe", ["time"]],
["{fd}>/dev/null scan_probe", ["scan_probe"]],
] as const
describe("ordinary Bash and Zsh syntax", () => {
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
for (const [source] of fixtures) {
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
test.skipIf(
!executable ||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
)(`${shell} accepts the source grammar: ${source}`, () => {
const result = Bun.spawnSync([
executable ?? shell,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-n",
"-c",
source + "\n:",
])
expect(result.stderr.toString()).toBe("")
// Zsh negates the skipped command's status even under NOEXEC.
expect(result.exitCode).toBe(shell === "zsh" && source.startsWith("! ") ? 1 : 0)
})
}
test
.skipIf(!executable)
.each([
"if true; then VALUE=$(scan_probe); fi",
"for value in one; do if true; then scan_probe; fi; done",
"f() { case value in value) scan_probe;; esac; }; f",
"printf '%s' \"$(case value in (value) scan_probe;; esac)\"",
"cat <<EOF\n'$(scan_probe)'\nEOF",
"cat <<A <<'B'\n$(scan_probe)\nA\n$(scan_ignored)\nB",
'cat <<< "$(scan_probe)"',
"printf '%s' \"${unset:-'$(scan_probe)'}\"",
"printf '%s' `printf \\`scan_probe\\``",
'array=(one "$(scan_probe)"); :',
"(( value = $(scan_probe) + 1 )); :",
])(`${shell} runs only the explicitly found probes: %s`, (source) => {
const execution = Bun.spawnSync(
[
executable ?? shell,
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
"-c",
`scan_probe() { printf 'scan_probe\\n' >&2; printf 1; }; scan_ignored() { printf 'unexpected\\n' >&2; }; ${source}`,
],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toBe("scan_probe\n")
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.filter((command) => command.words[0] === "scan_probe")).toHaveLength(1)
expect(result.commands.some((command) => command.words[0] === "scan_ignored")).toBe(false)
})
}
test.each([
"for value in one; do",
"while true; done",
"case value in x) echo ok",
"cat <<EOF\nunclosed",
"echo ${missing",
"echo $'missing",
])("rejects incomplete syntax: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
kind: "scanned",
commands: [{ words: ["printf", "line\n", "text"], rawWords: ["$'pri\\x6etf'", "$'line\\n'", '$"text"'] }],
})
})
test.each([
["coproc job { scan_probe; }", ["scan_probe"]],
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
['printf %s "${|scan_probe; }"', ["printf", "scan_probe"]],
] as const)("retains explicit substitutions without evaluating expressions: %s", (source, names) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
})
describe("Bash shared heredoc delimiter grammar", () => {
test.each([
'(cat <<"E\\OF"\nhello\nE\\OF\n)',
'cat <<"E\\$OF"\nhello\nE$OF',
'(cat <<"E\\$OF"\nhello\nE$OF\n)',
'(cat <<-"E\\OF"\n\thello\n\tE\\OF\n)',
])("preserves heredoc permission resources and saved prefixes: %s", async (source) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))).toEqual(legacy)
})
test.each([
["cat <<< hello\nprintf done", ["cat", "printf"]],
["(cat <<< hello\nprintf done)", ["cat", "printf"]],
['printf %s "$(cat <<< hello\nprintf done)"', ["printf", "cat", "printf"]],
['(cat <<< "$(printf hello)"\nprintf done)', ["cat", "printf", "printf"]],
] as const)("does not reinterpret the tail of a here-string operator: %s", async (source, names) => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))).toEqual(legacy)
})
test.each([
['<<"E\\OF"', "E\\OF", true, false],
['<<"E\\$OF"', "E$OF", true, false],
['<<"E\\`OF"', "E`OF", true, false],
['<<"E\\\"OF"', 'E"OF', true, false],
['<<"E\\\\OF"', "E\\OF", true, false],
["<<'E\\OF'", "E\\OF", true, false],
["<<E\\OF", "EOF", true, false],
["<<''", "", true, false],
["<<$'E\\x4fF'", "EOF", true, false],
["<<EO\\\nF", "EOF", false, false],
["<<-EOF", "EOF", false, true],
['<<-"E\\OF"', "E\\OF", true, true],
] as const)("uses identical delimiter decoding in every command context: %j", (header, delimiter, quoted, tabs) => {
const body = `cat ${header}\n${tabs ? "\t" : ""}$(printf probe)\n${tabs ? "\t" : ""}${delimiter}`
for (const source of [body + "\nprintf done", `(${body}\nprintf done)`, `printf %s "$(${body}\nprintf done)"`]) {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([
...(source.startsWith("printf") ? ["printf"] : []),
"cat",
...(quoted ? [] : ["printf"]),
"printf",
])
expect(result.commands.find((command) => command.words[0] === "cat")).toEqual({
resource: body.trim(),
words: ["cat"],
rawWords: ["cat"],
})
if (!quoted) expect(result.commands.some((command) => command.resource === "printf probe")).toBe(true)
}
})
const bash = Bun.which("bash")
test
.skipIf(!bash)
.each([
"(cat <<< hello\nprintf done)",
'printf %s "$(cat <<< hello\nprintf done)"',
'(cat <<"E\\OF"\n$(scan_probe)\nE\\OF\nprintf done)',
'(cat <<"E\\$OF"\n$(scan_probe)\nE$OF\nprintf done)',
"(cat <<''\n$(scan_probe)\n\nprintf done)",
"(cat <<$'E\\x4fF'\n$(scan_probe)\nEOF\nprintf done)",
'(cat <<-"E\\OF"\n\t$(scan_probe)\n\tE\\OF\nprintf done)',
"(cat <<EO\\\nF\n$(scan_probe)\nEOF\nprintf done)",
])("real Bash agrees with delimiter quoting: %s", (source) => {
const execution = Bun.spawnSync(
[bash!, "--noprofile", "--norc", "-c", `scan_probe() { printf 'executed\\n' >&2; }; ${source}`],
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
)
expect(execution.exitCode).toBe(0)
expect(execution.stderr.toString()).toBe(source.includes("<<EO\\\nF") ? "executed\n" : "")
expect(execution.stdout.toString()).toBe(
source.includes("<<<") ? "hello\ndone" : source.includes("<<EO\\\nF") ? "\ndone" : "$(scan_probe)\ndone",
)
})
})
+17 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
const opaque = ["$COMMAND hidden", "$(printf command) hidden", 'printf "unterminated'] as const
const opaque = ['printf "unterminated'] as const
const contexts = [
(source: string) => source,
(source: string) => `${source}; printf visible`,
@@ -29,3 +29,19 @@ describe("ShellScan quote suppression", () => {
expect(ShellScan.scan(`printf '%s' '${source.replaceAll("'", "")}'`).kind).toBe("scanned")
})
})
describe("ShellScan recursive parameter expansion coverage", () => {
for (const seed of ["${COMMAND:-hidden}", "$(printf ${value:-command}) hidden"]) {
for (const outer of contexts) {
for (const inner of contexts.slice(0, 5)) {
const source = outer(inner(seed))
test(source, () => {
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.some((command) => command.resource === seed)).toBe(true)
})
}
}
}
})
+34 -22
View File
@@ -14,7 +14,7 @@ describe("ShellScan generated properties", () => {
for (const [left, leftWords] of staticCommands) {
for (const separator of separators) {
for (const [right, rightWords] of staticCommands) {
expect(ShellScan.scan(left + separator + right)).toEqual({
expect(ShellScan.scan(left + separator + right)).toMatchObject({
kind: "scanned",
commands: [
{ resource: left, words: [...leftWords] },
@@ -35,7 +35,7 @@ describe("ShellScan generated properties", () => {
])
for (const form of forms) {
expect(ShellScan.scan(`printf %s ${form.source}`)).toEqual({
expect(ShellScan.scan(`printf %s ${form.source}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `printf %s ${form.source}`, words: ["printf", "%s", form.word] }],
})
@@ -59,19 +59,24 @@ describe("ShellScan generated properties", () => {
}
})
test("fails closed for generated dynamic command heads", () => {
test("retains generated dynamic command heads without resolving them", () => {
const heads = ["$COMMAND", "${COMMAND}", "pre$COMMAND", '"$COMMAND"', "$(printf git)", "`printf git`"]
const tails = ["status", "--version", "-rf /"]
for (const head of heads) {
for (const tail of tails) expect(ShellScan.scan(`${head} ${tail}`).kind).toBe("opaque")
for (const tail of tails) {
const result = ShellScan.scan(`${head} ${tail}`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[0]?.rawWords[0]).toBe(head)
expect(result.commands[0]?.words[0]).toBe(head.replaceAll('"', ""))
}
}
})
test("keeps wrappers and shell evaluators at their delegated boundary", () => {
const prefixes = ["", "FOO=bar ", "FOO=bar BAR=baz "]
const wrapped = [
"time git status",
"command git status",
"builtin printf ok",
"exec git status",
@@ -90,6 +95,7 @@ describe("ShellScan generated properties", () => {
for (const prefix of prefixes) {
for (const command of wrapped) expect(ShellScan.scan(prefix + command).kind).toBe("scanned")
expect(ShellScan.scan(`${prefix}time git status`).kind).toBe("scanned")
}
})
})
@@ -106,7 +112,7 @@ describe("ShellScan generated PowerShell properties", () => {
for (const [left, leftWords] of commands) {
for (const separator of separators) {
for (const [right, rightWords] of commands) {
expect(ShellScan.scanPowerShell(left + separator + right)).toEqual({
expect(ShellScan.scanPowerShell(left + separator + right)).toMatchObject({
kind: "scanned",
commands: [
{ resource: left, words: [...leftWords] },
@@ -127,14 +133,7 @@ describe("ShellScan generated PowerShell properties", () => {
])
for (const form of forms) {
if (form.source.startsWith("left`") && ";|&".includes(form.word[4] ?? "")) {
expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toEqual({
kind: "opaque",
reason: "invalid-structure",
})
continue
}
expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toEqual({
expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `Write-Output ${form.source}`, words: ["Write-Output", form.word] }],
})
@@ -154,10 +153,11 @@ describe("ShellScan generated PowerShell properties", () => {
for (const command of mutations) expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
})
test("distinguishes dynamic heads from delegated execution", () => {
const dynamic = ["$Command status", "${Command} status", "& $Command status"]
test("distinguishes variable expressions from delegated execution", () => {
const expressions = ["$Command", "${Command}"]
const delegated = [
"& git status",
"& $Command status",
". ./script.ps1",
"Invoke-Expression 'git status'",
"iex 'git status'",
@@ -167,7 +167,8 @@ describe("ShellScan generated PowerShell properties", () => {
const shells = ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]
const switches = ["-Command", "-c", "-EncodedCommand", "-e", "-File", "-f"]
for (const command of dynamic) expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
for (const command of expressions)
expect(ShellScan.scanPowerShell(command)).toEqual({ kind: "scanned", commands: [] })
for (const command of delegated) expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
for (const shell of shells) {
for (const flag of switches) {
@@ -176,14 +177,25 @@ describe("ShellScan generated PowerShell properties", () => {
}
})
test("fails closed for dynamic location changes but accepts known directory variables", () => {
test("extracts nested commands in directory expressions without evaluating variables", () => {
const locations = ["Set-Location", "cd", "chdir", "sl", "Push-Location"]
const dynamic = ["$target", "$(Resolve-Path ..)", "(Resolve-Path ..)"]
const known = ["$PWD/project", "$HOME/project", "$PSHOME/Modules", "$env:TEMP/project"]
const expressions = ["$(Resolve-Path ..)", "(Resolve-Path ..)"]
const variables = ["$target", "$PWD/project", "$HOME/project", "$PSHOME/Modules", "$env:TEMP/project"]
for (const location of locations) {
for (const target of dynamic) expect(ShellScan.scanPowerShell(`${location} ${target}`).kind).toBe("opaque")
for (const target of known) expect(ShellScan.scanPowerShell(`${location} ${target}`).kind).toBe("scanned")
for (const target of expressions)
expect(ShellScan.scanPowerShell(`${location} ${target}`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `${location} ${target}`, words: [location, target] },
{ resource: "Resolve-Path ..", words: ["Resolve-Path", ".."] },
],
})
for (const target of variables)
expect(ShellScan.scanPowerShell(`${location} ${target}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `${location} ${target}`, words: [location, target] }],
})
}
})
})
+12 -23
View File
@@ -2,28 +2,17 @@ import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("ShellScan structural mutation closure", () => {
test.each([
"$COMMAND status",
"${COMMAND} status",
'"$COMMAND" status',
"$(printf git) status",
"`printf git` status",
'printf "unterminated',
"printf ok &&",
"| printf ok",
"printf ok >",
])("keeps unknowable or malformed Bash input opaque: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test.each(['printf "unterminated', "printf ok &&", "| printf ok", "printf ok >"])(
"keeps malformed Bash input opaque: %s",
(source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
},
)
test.each([
"$Command status",
"${Command} status",
"& $Command status",
"Write-Output ok`",
'Write-Output "unterminated',
"Get-ChildItem |",
])("keeps unknowable or malformed PowerShell input opaque: %s", (source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("opaque")
})
test.each(["Write-Output ok`", 'Write-Output "unterminated', "Get-ChildItem |"])(
"keeps incomplete PowerShell input opaque: %s",
(source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("opaque")
},
)
})
@@ -0,0 +1,376 @@
import { expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
const fixtures = [
...[
"$result = Invoke-ProbeA; Invoke-ProbeB",
"$result = (Invoke-ProbeA); Invoke-ProbeB",
"[string]$result = Invoke-ProbeA; Invoke-ProbeB",
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
"foreach ($item in Invoke-ProbeA) { Invoke-ProbeB }",
"for ($i=0; $i -lt 2; $i++) { Invoke-ProbeB }",
"while (Invoke-ProbeA) { Invoke-ProbeB; break }",
"do { Invoke-ProbeB } until ($true)",
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
"function Get-Probe($x = (Invoke-ProbeA)) { Invoke-ProbeB }",
"try { Invoke-ProbeA } catch { Invoke-ProbeB } finally { Invoke-ProbeC }",
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
"Invoke-ProbeA @(Invoke-ProbeB; Invoke-ProbeC)",
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
"Invoke-ProbeA <# <# ignored } #> #> literal; Invoke-ProbeB",
'& "Inv`oke-ProbeA"; Invoke-ProbeB',
'Invoke-ProbeBlock { & "Invoke-ProbeA" literal#value; Invoke-ProbeB }',
'Invoke-ProbeBlock { . "Invoke-ProbeA" literal#value; Invoke-ProbeB }',
"Invoke-ProbeBlock { ${probe}# ignored\nInvoke-ProbeB }",
"Invoke-ProbeBlock { $result = Invoke-ProbeA literal#value; Invoke-ProbeB }",
"Invoke-ProbeA $probe[$(Invoke-ProbeB)]",
'Invoke-ProbeA "tab`tnewline`n`u{0041}"; Invoke-ProbeB',
"Invoke-ProbeA |\n\n# comment\nForEach-Object { Invoke-ProbeB }",
],
...[
"1",
"+1",
"-1",
".1",
"0x1",
"0b1",
"1L",
"1kb",
"1.0",
"1+1",
"1..2",
"-not 1",
"-bnot 1",
"!1",
"!!1",
",1",
"-join 1",
"\u2013not 1",
"\u2014not 1",
"\u2015not 1",
"\u2013join 1",
"\u2014join 1",
"\u2015join 1",
"'x' -eq 1",
'"x" -eq 1',
"{} -eq 1",
].flatMap((expression) => [
`${expression}#'\nInvoke-ProbeB\n#'`,
`Invoke-ProbeBlock { ${expression}#} '\nInvoke-ProbeB\n} #'`,
]),
...[
"$null",
"$probe",
"$HOME",
"${probe}",
"$env:PATH",
"$true",
"$false",
"1",
"1.0",
"1kb",
"0x1",
"-1",
"x2>&1",
"x6>&1",
">$null",
"> $null",
"'x'>$null",
].flatMap((prefix) => [
`Invoke-ProbeA ${prefix}#'\nInvoke-ProbeB\n#'`,
`Invoke-ProbeBlock { Invoke-ProbeA ${prefix}#} '\nInvoke-ProbeB\n} #'`,
]),
...[" ", "\t", "\v", "\f", "\u0085", "\u00a0", "\u2000", "\u2028", "\u2029", "\ufeff"].flatMap((space) => [
`Invoke-ProbeA \`${space}#'\nInvoke-ProbeB\n#'`,
`Invoke-ProbeBlock { Invoke-ProbeA 2>&1\`${space}#} '\nInvoke-ProbeB\n} #'`,
]),
...Array.from({ length: 2048 }, (_, index) => {
const tokens = [
"x",
" ",
"\t",
"\r",
"\n",
";",
"|",
"'",
'"',
"''",
'""',
"#",
"--%",
"`",
"{",
"}",
"2>&1",
"Invoke-ProbeC",
]
let seed = index + 1
const body = Array.from({ length: 8 }, () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
return tokens[(seed >>> 16) % tokens.length]
}).join("")
return index % 2
? `Invoke-ProbeA ${body}\nInvoke-ProbeB\n# '`
: `Invoke-ProbeBlock { Invoke-ProbeA ${body}\nInvoke-ProbeB\n} # '`
}),
...["&", "."].flatMap((operator) =>
["'Invoke-ProbeA'", '"Invoke-ProbeA"'].flatMap((head) =>
["argument", "'argument'", '"argument"', "#ignored", "--% '"].map(
(tail) => `${operator} ${head}${tail}\nInvoke-ProbeB\n# '`,
),
),
),
...["'", '"', "\u2018", "\u2019", "\u201c", "\u201d"].flatMap((open) =>
["'", '"', "\u2018", "\u2019", "\u201c", "\u201d"].flatMap((close) =>
["", "literal", "`", "'", '"', "''", '""', "{", "}", "#", "--%", "$(Invoke-ProbeB)"].flatMap((content) => [
`Invoke-ProbeA ${open}${content}${close}; Invoke-ProbeB; Invoke-ProbeC '${content}'`,
`Invoke-ProbeBlock { Invoke-ProbeA ${open}${content}${close}; Invoke-ProbeB }; Invoke-ProbeC '${content}'`,
]),
),
),
...["", "literal", "'quoted'", '"quoted"', "{}"].flatMap((prefix) =>
["#ignored", "<#ignored#>", "--% 'ignored", '--% "ignored'].flatMap((tail) =>
[";", "|", "\r", "\n", "\r\n"].map(
(separator) => `Invoke-ProbeA ${prefix}${tail}${separator}Invoke-ProbeB${separator}Invoke-ProbeC`,
),
),
),
...["''", "'x'", '"x"', "{}", "2>&1", "6>&1", "*>&1", "plain", "plain'quoted'"].flatMap((prefix) =>
["#", "--%", "--% ", "<# #>#"].flatMap((suffix) =>
["\r", "\n", "\r\n"].flatMap((newline) => [
`Invoke-ProbeA ${prefix}${suffix}'${newline}Invoke-ProbeB${newline}# '`,
`Invoke-ProbeA ${prefix}${suffix}\"${newline}Invoke-ProbeB${newline}# \"`,
`Invoke-ProbeBlock { Invoke-ProbeA ${prefix}${suffix}} '${newline}Invoke-ProbeB${newline}} # '`,
]),
),
),
...[
...Array.from({ length: 33 }, (_, index) => String.fromCharCode(index)),
"\u0085",
"\u00a0",
"\u1680",
"\u2000",
"\u2028",
"\u2029",
"\u202f",
"\u205f",
"\u3000",
"\ufeff",
].flatMap((space) => [
`Invoke-ProbeA${space}argument; Invoke-ProbeB`,
`Invoke-ProbeA # ignored${space}Invoke-ProbeB`,
`Invoke-ProbeA${space}Invoke-ProbeB`,
`&${space}'Invoke-ProbeA'; Invoke-ProbeB`,
]),
...["Invoke-ProbeA", "& Invoke-ProbeA", "& 'Invoke-ProbeA'", '. "Invoke-ProbeA"'].flatMap((head) =>
[
"plain",
"'single ; | & # { }'",
'"double ; | & # { }"',
"'single''quote'",
'"double""quote"',
"'`'",
'"a`"}b"',
'"it\'s } literal"',
"left`#right",
"left`;right",
"left`|right",
"left`&right",
'"$(Invoke-ProbeB)"',
'"$probe"',
'"${probe}"',
"\u2018smart single\u2019",
"\u201csmart double\u201d",
].flatMap((argument) =>
["; ", "\n", "\r", "\r\n", " | ", " && ", " || "].map(
(separator) => `${head} ${argument}${separator}Invoke-ProbeC`,
),
),
),
...["\n", "\r", "\r\n"].flatMap((newline) => [
`Invoke-ProbeA # '\" } ; ignored${newline}Invoke-ProbeB`,
`Invoke-ProbeBlock { # } ignored${newline}Invoke-ProbeB }`,
`Invoke-ProbeBlock { Invoke-ProbeA # } ignored${newline}Invoke-ProbeB }`,
`Invoke-ProbeA --% \"ignored${newline}Invoke-ProbeB${newline}\"`,
`Invoke-ProbeBlock { Invoke-ProbeA --% \"ignored${newline}Invoke-ProbeB${newline}\" }`,
`Invoke-ProbeA \`${newline}argument; Invoke-ProbeB`,
`Invoke-ProbeA @'${newline}literal ; }${newline}'@; Invoke-ProbeB`,
`Invoke-ProbeA @\"${newline}$(Invoke-ProbeB)${newline}\"@; Invoke-ProbeC`,
]),
...["&", ".", "Invoke-ProbeBlock", "Invoke-ProbeA | ForEach-Object"].flatMap((head) =>
[
"Invoke-ProbeB; Invoke-ProbeC",
"Invoke-ProbeB '`'; Invoke-ProbeC",
'Invoke-ProbeB "it\'s } literal"; Invoke-ProbeC',
'Invoke-ProbeB "a`\"}b"; Invoke-ProbeC',
'Invoke-ProbeB "a\"\"}b"; Invoke-ProbeC',
"Invoke-ProbeBlock { Invoke-ProbeB }; Invoke-ProbeC",
"<# <# } #> #> Invoke-ProbeB",
'Invoke-ProbeB "$(Invoke-ProbeC)"',
'Invoke-ProbeB "$(Invoke-ProbeA \"}\"); Invoke-ProbeC"',
].map((body) => `${head} { ${body} }; Invoke-ProbeA`),
),
...["> $null", ">> $null", "2>&1", "3>&1", "4>&1", "5>&1", "6>&1", "*>&1", "*> $null"].flatMap((redirect) => [
`Invoke-ProbeA ${redirect}; Invoke-ProbeB`,
`Invoke-ProbeBlock { Invoke-ProbeA ${redirect}; Invoke-ProbeB }`,
]),
"Invoke-ProbeA --% 'ignored|Invoke-ProbeB|Invoke-ProbeC '",
"Invoke-ProbeA --% ; Invoke-ProbeB",
"Invoke-ProbeA --% $(Invoke-ProbeB)",
"Invoke-ProbeA \u2018a'; Invoke-ProbeB; Invoke-ProbeC 'b\u2019",
'Invoke-ProbeA \u201ca"; Invoke-ProbeB; Invoke-ProbeC "b\u201d',
'Invoke-ProbeA "$(Invoke-ProbeB \"quoted\")"; Invoke-ProbeC',
"& $probe; Invoke-ProbeC",
"& ('Invoke-' + 'ProbeB'); Invoke-ProbeC",
"& { Invoke-ProbeB }; Invoke-ProbeC",
"$(Invoke-ProbeB); Invoke-ProbeC",
"@(Invoke-ProbeB); Invoke-ProbeC",
"Invoke-ProbeA <# ignored #>; Invoke-ProbeB",
"Invoke-ProbeA#literal; Invoke-ProbeB",
"Invoke-ProbeA ''#literal; Invoke-ProbeB",
'Invoke-ProbeA ""#literal; Invoke-ProbeB',
"Invoke-ProbeA { Invoke-ProbeB }#literal; Invoke-ProbeC",
"Inv'oke'-ProbeA; Invoke-ProbeB",
'Inv"oke"-ProbeA; Invoke-ProbeB',
"Invoke-ProbeA,Invoke-ProbeB; Invoke-ProbeC",
'Invoke-ProbeA --%"literal|Invoke-ProbeB|Invoke-ProbeC"',
'Invoke-ProbeA x--% "literal|Invoke-ProbeB|Invoke-ProbeC"',
"Invoke-ProbeA '--%' \"literal|Invoke-ProbeB|Invoke-ProbeC\"",
'Invoke-ProbeA "--%" "literal|Invoke-ProbeB|Invoke-ProbeC"',
'Invoke-ProbeA --`% "literal|Invoke-ProbeB|Invoke-ProbeC"',
'Invoke-ProbeA `-`-`% "literal|Invoke-ProbeB|Invoke-ProbeC"',
"Invoke-ProbeA ''--% \"literal|Invoke-ProbeB|Invoke-ProbeC\"",
"Invoke-ProbeA 2>&1; &{Invoke-ProbeB}; Invoke-ProbeC",
"Invoke-ProbeA 2>&1|Invoke-ProbeB",
"Invoke-ProbeA 'x'#'\nInvoke-ProbeB\nInvoke-ProbeC ''#'",
'Invoke-ProbeA "x"#"\nInvoke-ProbeB\nInvoke-ProbeC ""#"',
"Invoke-ProbeA {}#'\nInvoke-ProbeB\nInvoke-ProbeC ''#'",
"Invoke-ProbeBlock { Invoke-ProbeA 'x'#'\nInvoke-ProbeB\nInvoke-ProbeC ''#' }",
"Invoke-ProbeA 'x'# ' \nInvoke-ProbeB\n# '",
"Invoke-ProbeA { Invoke-ProbeB }# ' \nInvoke-ProbeC\n# '",
"Invoke-ProbeA { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
"Invoke-ProbeBlock { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
"& { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
"Invoke-ProbeA & Invoke-ProbeB",
"Invoke-ProbeA 'safe`'; Invoke-ProbeB; '`'",
]
// Runtime execution is restricted to probes and $null/stream redirections; other cases are parser-only.
const oracle = String.raw`
$ErrorActionPreference = 'Stop'
$probe = 'Invoke-ProbeB'
$script:seen = [System.Collections.Generic.List[string]]::new()
function Invoke-ProbeA { [void]$script:seen.Add('Invoke-ProbeA'); 1 }
function Invoke-ProbeB { [void]$script:seen.Add('Invoke-ProbeB'); 1 }
function Invoke-ProbeC { [void]$script:seen.Add('Invoke-ProbeC'); 1 }
function Invoke-ProbeBlock {
[void]$script:seen.Add('Invoke-ProbeBlock')
foreach ($argument in $args) {
if ($argument -is [scriptblock]) { & $argument }
}
}
$results = foreach ($source in (ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd()))) {
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors)
$nodes = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true))
$commands = @($nodes | ForEach-Object {
@{
name = $_.GetCommandName()
text = $_.Extent.Text
start = $_.Extent.StartOffset
end = $_.Extent.EndOffset
}
})
$script:seen.Clear()
$runtimeError = $null
$unsafe = @($nodes | Where-Object {
$name = $_.GetCommandName()
($name -and $name -notin @('Invoke-ProbeA', 'Invoke-ProbeB', 'Invoke-ProbeC', 'Invoke-ProbeBlock', 'ForEach-Object')) -or
(!$name -and $_.CommandElements[0] -isnot [System.Management.Automation.Language.ScriptBlockExpressionAst])
})
$files = @($ast.FindAll({ param($node)
$node -is [System.Management.Automation.Language.FileRedirectionAst] -and $node.Location.Extent.Text -ne '$null'
}, $true))
$background = @($ast.FindAll({ param($node)
$node -is [System.Management.Automation.Language.PipelineAst] -and $node.Background
}, $true))
if ($errors.Count -eq 0 -and $unsafe.Count -eq 0 -and $files.Count -eq 0 -and $background.Count -eq 0) {
try { & ([scriptblock]::Create($source)) | Out-Null }
catch { $runtimeError = $_.Exception.Message }
}
@{
source = $source
commands = $commands
errors = @($errors | ForEach-Object { $_.ErrorId })
executed = @($script:seen.ToArray())
runtimeError = $runtimeError
}
}
ConvertTo-Json -InputObject @($results) -Depth 10 -Compress
`
// Run with SHELL_SCAN_PWSH=/path/to/pwsh bun run test test/shell-scan/powershell-runtime.test.ts.
test.skipIf(!pwsh)(
"successful PowerShell scans cover real parser boundaries and executed probe calls",
async () => {
const process = Bun.spawn([pwsh!, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", oracle], {
stdin: new Blob([JSON.stringify(fixtures)]),
stdout: "pipe",
stderr: "pipe",
timeout: 45_000,
})
const [output, error, code] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited,
])
expect({ code, error }).toEqual({ code: 0, error: "" })
const results: Array<{
source: string
commands: Array<{ name: string | null; text: string; start: number; end: number }>
errors: string[]
executed: string[]
runtimeError: string | null
}> = JSON.parse(output)
expect(results).toHaveLength(fixtures.length)
const failures: string[] = []
let scanned = 0
let executed = 0
for (const result of results) {
const scan = ShellScan.scanPowerShell(result.source)
if (scan.kind === "opaque" || result.errors.length > 0) continue
scanned++
executed += result.executed.length
const missing = result.commands.filter(
(command) =>
command.name !== null &&
!scan.commands.some(
(candidate) =>
candidate.words[0]?.toLowerCase() === command.name?.toLowerCase() &&
candidate.resource === command.text.trim(),
),
)
const unobserved = result.executed.filter(
(name) => !scan.commands.some((command) => command.words[0]?.toLowerCase() === name.toLowerCase()),
)
if (missing.length || unobserved.length)
failures.push(
JSON.stringify({
source: result.source,
missing: missing.map((command) => command.text),
unobserved,
scanned: scan.commands.map((command) => command.words[0]),
}),
)
}
expect(scanned).toBeGreaterThan(100)
expect(executed).toBeGreaterThan(100)
expect(failures).toEqual([])
},
60_000,
)
@@ -0,0 +1,308 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("PowerShell scanner safety", () => {
test("backticks in single quotes cannot hide subsequent executions", () => {
const result = ShellScan.scanPowerShell("Write-Output '`'; Remove-Item victim; Write-Output '`'")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words)).toEqual([
["Write-Output", "`"],
["Remove-Item", "victim"],
["Write-Output", "`"],
])
})
test.each([" ", "\t", "\v", "\f", "\u00a0", "\u2000", "\u2028", "\u2029", "\n", "\r", "\r\n"])(
"recognizes comments after escaped whitespace at a token boundary %j",
(space) => {
expect(ShellScan.scanPowerShell(`Write-Output \`${space}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["Write-Output"] }, { words: ["Remove-Item", "victim"] }],
})
for (const redirect of ["2>&1", "6>&1", "*>&1"]) {
expect(
ShellScan.scanPowerShell(`% { Write-Output ${redirect}\`${space}#} '\nRemove-Item victim\n} #'`),
).toMatchObject({
kind: "scanned",
commands: [
{ words: ["%", expect.any(String)] },
{ words: ["Write-Output"] },
{ words: ["Remove-Item", "victim"] },
],
})
}
},
)
test.each([" ", "\t", "\v", "\f", "\u00a0", "\u2000", "\u2028", "\u2029"])(
"keeps backtick whitespace inside strings literal: %j",
(space) => {
const result = ShellScan.scanPowerShell(`% { Write-Output '\`${space}#literal' "\`${space}#literal" }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[1]?.words).toEqual(["Write-Output", `\`${space}#literal`, `${space}#literal`])
},
)
test.each(["x2>&1", "x6>&1"])("keeps embedded greater-than text distinct from redirects: %s", (token) => {
expect(ShellScan.scanPowerShell(`Write-Output ${token}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `Write-Output ${token.slice(0, 3)}`, words: ["Write-Output", token.slice(0, 3)] },
{ words: ["Remove-Item", "victim"] },
],
})
})
test.each([
"1",
",1",
"+1",
"-1",
".1",
"0x1",
"0b1",
"1L",
"1kb",
"1.0",
"1+1",
"1..2",
"-not 1",
"'x' -eq 1",
'"x" -eq 1',
"{} -eq 1",
])("recognizes expression-mode comments without inventing command heads: %s", (expression) => {
expect(ShellScan.scanPowerShell(`${expression}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["Remove-Item", "victim"] }],
})
expect(ShellScan.scanPowerShell(`% { ${expression}#} '\nRemove-Item victim\n} #'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["%", expect.any(String)] }, { words: ["Remove-Item", "victim"] }],
})
})
test.each(["\n", "\r", "\r\n"])("ends nested block comments at %j", (newline) => {
const result = ShellScan.scanPowerShell(`ForEach-Object { # } ignored${newline}Remove-Item victim }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["ForEach-Object", "Remove-Item"])
})
test.each([
'ForEach-Object { Write-Output "it\'s } literal"; Remove-Item victim }',
'ForEach-Object { Write-Output "a\'b{c}"; Remove-Item victim }',
"ForEach-Object { Write-Output '`'; Remove-Item victim }",
'ForEach-Object { Write-Output "a`\"}b"; Remove-Item victim }',
'ForEach-Object { Write-Output "a""}b"; Remove-Item victim }',
])("keeps block delimiters inside strings: %s", (input) => {
const result = ShellScan.scanPowerShell(input)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([
"ForEach-Object",
"Write-Output",
"Remove-Item",
])
})
test("preserves doubled double quotes in argument values", () => {
const result = ShellScan.scanPowerShell('Write-Output "a""b"')
expect(result).toMatchObject({
kind: "scanned",
commands: [{ resource: 'Write-Output "a""b"', words: ["Write-Output", 'a"b'] }],
})
})
test.each(["'safe'", '"safe"', "{ Get-Item x }"])(
"recognizes comments after complete literal tokens: %s",
(argument) => {
const result = ShellScan.scanPowerShell(`Write-Output ${argument}# '\nRemove-Item victim\n# '`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.some((command) => command.words[0] === "Remove-Item")).toBe(true)
expect(result.commands[0]?.resource).toBe(`Write-Output ${argument}`)
},
)
test("keeps generic-token quotes distinct from standalone strings", () => {
expect(ShellScan.scanPowerShell("Write-Output pre'safe'#literal; Get-Item x")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Write-Output pre'safe'#literal", words: ["Write-Output", "presafe#literal"] },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
expect(ShellScan.scanPowerShell("Write-Output 'safe'tail")).toMatchObject({
kind: "scanned",
commands: [{ resource: "Write-Output 'safe'tail", words: ["Write-Output", "safe", "tail"] }],
})
})
test("block matching agrees with tokenization after a merging redirect", () => {
const result = ShellScan.scanPowerShell("% { Get-Item x 2>&1# } '\nRemove-Item victim\n} # '")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["%", "Get-Item", "Remove-Item"])
})
test.each([
'cmd.exe \u0085--% "ignored\nRemove-Item victim\n# "',
"Write-Output \u2018a'; Remove-Item victim; Write-Output 'b\u2019",
'Write-Output \u201ca"; Remove-Item victim; Write-Output "b\u201d',
])("refuses unsupported lexical modes: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
test.each(["\n", "\r", "\r\n"])("ends stop-parsing at %j even adjacent to completed tokens", (newline) => {
for (const argument of ["'x'", '"x"', "{}"]) {
const result = ShellScan.scanPowerShell(`Write-Output ${argument}--% '${newline}Remove-Item victim${newline}# '`)
expect(result).toMatchObject({
kind: "scanned",
commands: [{ words: ["Write-Output", expect.any(String), "--%", "'"] }, { words: ["Remove-Item", "victim"] }],
})
const block = ShellScan.scanPowerShell(
`% { Write-Output ${argument}--%} '${newline}Remove-Item victim${newline}} # '`,
)
expect(block.kind).toBe("scanned")
if (block.kind === "scanned") expect(block.commands.at(-1)?.words).toEqual(["Remove-Item", "victim"])
}
})
test.each([
"& '' victim",
"Write-Output ok > > out",
"Write-Output ok > 2>&1",
"Write-Output ok 2>&",
"Write-Output ok 2>&2",
"Write-Output ok 1>&1",
"Write-Output ok >&1",
"Write-Output ok 7> out",
"Write-Output ok 2>>&1",
"Write-Output ok > # missing target\nRemove-Item victim",
"Write-Output ok | # missing pipeline\n",
"| Remove-Item victim",
"&& Remove-Item victim",
"& & Remove-Item victim",
"&",
".",
"Write-Output ok; &",
"Write-Output ok; .",
"Set-Location \u2013StackName old",
"Set-Location \u2014StackName old",
"Set-Location \u2015StackName old",
])("reports malformed or unsupported lexical syntax: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
test.each(["Set-Location", "SL", "cd", "chdir", "Push-Location", "pushd", "Microsoft.PowerShell.Management\\sl"])(
"preserves commands with directory variables for Core policy through %s",
(head) => {
expect(ShellScan.scanPowerShell(`${head} $target; Get-Item x`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `${head} $target`, words: [head, "$target"] },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
expect(ShellScan.scanPowerShell(`${head} $HOME/project; Get-Item x`).kind).toBe("scanned")
},
)
test.each([
"Pop-Location",
"popd",
"Microsoft.PowerShell.Management\\Pop-Location",
"Set-Location -",
"Set-Location +",
"Set-Location -StackName old",
"Set-Location -st old",
"Set-Location -Path:C:relative",
"Set-Location C:relative",
"Set-Location Registry::HKEY_CURRENT_USER",
"Set-Location $HOME/$target",
])("preserves directory command syntax without deciding directory policy: %s", (input) => {
expect(ShellScan.scanPowerShell(`${input}; Get-Item x`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: input, words: input.split(" ") },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
})
test.each(["%", "?", "foreach", "where", "iex"])("keeps command aliases visible: %s", (head) => {
const result = ShellScan.scanPowerShell(`Get-Item x | ${head} { Remove-Item victim }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["Get-Item", head, "Remove-Item"])
})
test.each(["Get-*", "./g?t", "./[gr]it", ".\\script.ps1", "..\\scripts\\run.ps1", "\\\\host\\share\\run.ps1"])(
"retains command names and paths without resolving them: %s",
(head) => {
expect(ShellScan.scanPowerShell(`& ${head} victim`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `& ${head} victim`, words: [head, "victim"], rawWords: [head, "victim"] }],
})
},
)
test("recursively extracts commands from nested script blocks", () => {
const result = ShellScan.scanPowerShell("Get-Item x | % { Get-Item y | ? { Remove-Item victim } }")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([
"Get-Item",
"%",
"Get-Item",
"?",
"Remove-Item",
])
})
test("keeps hashes within words out of block-comment detection", () => {
const result = ShellScan.scanPowerShell("% { Write-Output a#b }; Remove-Item victim")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["%", "Write-Output", "Remove-Item"])
})
test.each([";", "&", "\n", "\r", "\r\n"])("never invents a command after a trailing %j", (separator) => {
expect(ShellScan.scanPowerShell(`Get-Item x${separator}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: "Get-Item x", words: ["Get-Item", "x"] }],
})
})
test.each(["'victim'", '"victim"', "`victim", "{ Get-Item victim }"])(
"does not clear a dangling pipeline with a new empty statement: %s",
(tail) => {
expect(ShellScan.scanPowerShell(`Get-Item x | ; Write-Output ${tail}`).kind).toBe("opaque")
},
)
test("scans valid redirects without consuming command arguments", () => {
for (const redirect of [">", ">>", "1>", "2>>", "3>", "4>", "5>", "6>", "*>", "*>>"]) {
expect(ShellScan.scanPowerShell(`Get-Item x ${redirect} out.txt | Write-Output done`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `Get-Item x ${redirect} out.txt`, words: ["Get-Item", "x"] },
{ resource: "Write-Output done", words: ["Write-Output", "done"] },
],
})
}
for (const redirect of ["2>&1", "3>&1", "4>&1", "5>&1", "6>&1", "*>&1"]) {
expect(ShellScan.scanPowerShell(`Get-Item x ${redirect}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `Get-Item x ${redirect}`, words: ["Get-Item", "x"] }],
})
}
})
test("bounds script block nesting and input size", () => {
expect(ShellScan.scanPowerShell("% { ".repeat(33) + "Get-Item x" + " }".repeat(33)).kind).toBe("opaque")
expect(ShellScan.scanPowerShell(`Write-Output ${"x".repeat(64 * 1024)}`).kind).toBe("opaque")
})
})
@@ -0,0 +1,145 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("PowerShell practical syntax", () => {
test.each([
["$result = git status", ["git status"]],
["$result += (git status)", ["git status"]],
["[string]$result = git status", ["git status"]],
["$x = 1; $x++; git status", ["git status"]],
["$true; 1 + 2; 'literal'; git status", ["git status"]],
["${result}# ignored\ngit status", ["git status"]],
[
"Write-Output { ${result}# ignored\ngit status }",
["Write-Output { ${result}# ignored\ngit status }", "git status"],
],
["Switch-Branch main; Function-Name arg", ["Switch-Branch main", "Function-Name arg"]],
["(git status)", ["git status"]],
["@(git status; git diff)", ["git status", "git diff"]],
["$x = @{ status = git status; count = 1 }", ["git status"]],
["if (Test-Path file) { git status } else { git diff }", ["Test-Path file", "git status", "git diff"]],
["if ($true) { git status } elseif ($false) { git diff }", ["git status", "git diff"]],
["foreach ($file in (Get-ChildItem .)) { Get-Content $file }", ["Get-ChildItem .", "Get-Content $file"]],
["foreach ($file in Get-ChildItem .) { Get-Content $file }", ["Get-ChildItem .", "Get-Content $file"]],
["for ($i = 0; $i -lt 2; $i++) { git status }", ["git status"]],
["while (Test-Path file) { git status; break }", ["Test-Path file", "git status"]],
["do { git status } until ($true)", ["git status"]],
["function Get-Status { param($file); git status }; Get-Status", ["git status", "Get-Status"]],
["function Get-Status($file = (Get-Item .)) { git status }", ["Get-Item .", "git status"]],
[
"try { git status } catch { Write-Output $_ } finally { git diff }",
["git status", "Write-Output $_", "git diff"],
],
["& { git status }", ["& { git status }", "git status"]],
["return git status", ["git status"]],
["<# <# ignored } #> #> ignored\ngit status", ["git status"]],
["git <# ignored #> status", ["git <# ignored #> status"]],
['Write-Output "$(git status)"', ['Write-Output "$(git status)"', "git status"]],
[
'Write-Output "$(Write-Output "$(git status)")"',
['Write-Output "$(Write-Output "$(git status)")"', 'Write-Output "$(git status)"', "git status"],
],
["Write-Output @'\n$(not-a-command)\n'@; git status", ["Write-Output @'\n$(not-a-command)\n'@", "git status"]],
['Write-Output @"\n$(git status)\n"@', ['Write-Output @"\n$(git status)\n"@', "git status"]],
[
"cmd.exe --% $(literal) > literal.txt; still-literal\ngit status",
["cmd.exe --% $(literal) > literal.txt; still-literal", "git status"],
],
[
'cmd.exe --% "literal|still-literal" | Write-Output done',
['cmd.exe --% "literal|still-literal"', "Write-Output done"],
],
["Write-Output '--%' ; git status", ["Write-Output '--%'", "git status"]],
["Write-Output prefix--% literal; git status", ["Write-Output prefix--% literal", "git status"]],
["git status |\n\n# comment\nOut-String", ["git status", "Out-String"]],
] as const)("extracts command resources from %s", (source, resources) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
})
test.each(["\n", "\r", "\r\n"])(
"distinguishes standalone continuations from escapes inside tokens: %j",
(newline) => {
expect(ShellScan.scanPowerShell(`git \`${newline}\tstatus`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["git", "status"], rawWords: ["git", "status"] }],
})
expect(ShellScan.scanPowerShell(`Write-Output left\`${newline}right`)).toMatchObject({
kind: "scanned",
commands: [
{ words: ["Write-Output", `left${newline}right`], rawWords: ["Write-Output", `left\`${newline}right`] },
],
})
},
)
test("decodes literal backtick escapes without evaluating expressions", () => {
const source = '& "Wr`ite-Output" "tab`tnewline`n`u{1f642}" left`;right'
expect(ShellScan.scanPowerShell(source)).toMatchObject({
kind: "scanned",
commands: [
{
resource: source,
words: ["Write-Output", "tab\tnewline\n\u{1f642}", "left;right"],
rawWords: ['"Wr`ite-Output"', '"tab`tnewline`n`u{1f642}"', "left`;right"],
},
],
})
})
test("preserves word offsets relative to each raw command, including nested invocations", () => {
const source = ' & git\tstatus\t--short; Write-Output "$(git\tlog)"'
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[0]?.wordEnds).toEqual([5, 12, 20])
expect(result.commands[2]?.resource).toBe("git\tlog")
for (const command of result.commands) {
expect(command.wordEnds).toHaveLength(command.rawWords.length)
for (const [index, word] of command.rawWords.entries()) {
const end = command.wordEnds![index]!
expect(command.resource.slice(end - word.length, end)).toBe(word)
}
}
})
test.each([
["ForEach-Object { git status }", true],
["git status; ForEach-Object { git diff }", true],
["git status | ForEach-Object { git diff }", undefined],
["git status && ForEach-Object { git diff }", undefined],
["git status || ForEach-Object { git diff }", undefined],
["& ForEach-Object { git status }", undefined],
["& 'ForEach-Object' { git status }", undefined],
["% { git status }", true],
["foreachthing { git status }", true],
] as const)("retains caller and lexical statement-head context: %s", (source, statementHead) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
const caller = result.commands.find((command) => command.rawWords.at(-1)?.startsWith("{"))
expect(caller).toBeDefined()
expect(caller?.statementHead).toBe(statementHead)
expect(result.commands.at(-1)?.words[0]).toBe("git")
})
test.each([
"if ($true) { git status",
'Write-Output "$(git status"',
"Write-Output @(git status",
"Write-Output @'\nunclosed",
"<# unclosed",
"git status |\n# no operand",
"Write-Output `u{110000}",
"Write-Output `u{xyz}",
])("bounds and reports incomplete lexical structures: %s", (source) => {
expect(ShellScan.scanPowerShell(source).kind).toBe("opaque")
})
test("bounds mixed expression, string, and block recursion", () => {
expect(ShellScan.scanPowerShell("$(".repeat(40) + "git status" + ")".repeat(40)).kind).toBe("opaque")
expect(ShellScan.scanPowerShell('Write-Output "$('.repeat(40) + "git status" + ')"'.repeat(40)).kind).toBe("opaque")
})
})
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
import { Wildcard } from "../../src/util/wildcard.js"
async function parity(source: string) {
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(native, source).toEqual(legacy)
}
describe("Bash redirect resource oracle", () => {
test.each([
["printf hello | cat > marker", ["printf hello", "cat"]],
["printf ok && git status > output", ["printf ok", "git status"]],
["cat > output", ["cat > output"]],
["cat > output | cat", ["cat > output", "cat"]],
["pwd | > output cat > tail", ["pwd", "> output cat"]],
["pwd && cat > output file", ["pwd", "cat"]],
["pwd; cat > output", ["pwd", "cat > output"]],
["pwd\ncat > output", ["pwd", "cat > output"]],
] as const)("matches exact permission resources: %s", async (source, resources) => {
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
expect(legacy.commands.map((command) => command.resource)).toEqual([...resources])
await parity(source)
})
test("matches redirect positions across generated list and pipeline boundaries", async () => {
const redirects = [">output", ">>output", "<input", "2>err", "2>&1", "<&0", ">|output", "&>output", "&>>output"]
const separators = [" | ", " |& ", " && ", " || ", "; ", " & ", "\n"]
for (const redirect of redirects) {
for (const command of [
`${redirect} git status`,
`git ${redirect} status`,
`git status ${redirect}`,
`${redirect} git status 3>tail`,
`${redirect} FOO=bar git status 3>tail`,
`npm run ${redirect} test`,
]) {
await parity(command)
for (const separator of separators) {
await parity(`printf ok${separator}${command}`)
await parity(`${command}${separator}pwd >last`)
}
}
}
})
test.each([
"pwd | cat >out | tail >log",
"pwd && cat >out || tail >log",
"pwd && cat >out | tail >log",
"pwd | cat >out && tail >log",
"pwd |\n\n# comment\ncat >out",
"pwd &&\n# comment\ncat >out",
"pwd | cat >out # comment\ncat >log",
"pwd | cat # comment\n>out cat",
"pwd | cat \\\n 2>out",
"pwd | cat a\\\n>out",
"pwd | cat 2\\\n>out",
"pwd && FOO=bar >output git status 3>tail",
"(cat >out) | tail >log",
"{ cat >out; } && tail >log",
"(pwd | cat >out) >group",
"(cat >out) >$(printf log) && cat >tail",
"(cat >out); cat >tail",
"echo $(pwd | cat >out) >outer",
'echo "$(pwd && cat >out)" | cat >outer',
"echo `pwd | cat >out` >outer",
"pwd | cat >$(printf out)",
'pwd | cat >"$(printf out)"',
"pwd | cat >$(printf out | cat >inner)",
"pwd | cat >out $(printf arg) >tail",
"pwd | cat <(printf input) > >(cat >log)",
"cat <(pwd | cat >out) | cat >tail",
"pwd | cat >out <(printf arg) >tail",
"pwd | cat '>' \"2>out\" escaped\\>word >out",
"pwd | cat '2'>out",
"pwd | cat 2\\>out",
"if true; then printf ok && cat >$(printf path); fi",
"if true; then printf ok && git >out status; else cat >log; fi",
"pwd && cd >out /outside",
"time git status",
"time -p git status",
"coproc git status",
])("preserves nested commands, prefixes, and context: %s", parity)
test("keeps lexical words and nested redirect-target commands after narrowing the resource", () => {
const result = ShellScan.scan('pwd | git >"$(printf output)" status')
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(`Unexpected opacity: ${result.reason}`)
expect(result.commands[1]).toMatchObject({
resource: "git",
words: ["git", "status"],
rawWords: ["git", "status"],
redirectWordCount: 1,
})
expect(result.commands[2]).toMatchObject({ resource: "printf output", rawWords: ["printf", "output"] })
})
test("excludes ignored trailing continuations from narrowed command prefixes", async () => {
const source = "pwd | cat\\\n >out"
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(`Unexpected opacity: ${result.reason}`)
expect(result.commands.map((command) => command.resource)).toEqual(
legacy.commands.map((command) => command.resource),
)
expect(result.commands[1]?.rawWords).toEqual(["cat"])
expect(legacy.commands[1]).toEqual({ resource: "cat", save: "cat *" })
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(native).toEqual(legacy)
expect(native.commands.every((command) => Wildcard.match(command.resource, command.save))).toBe(true)
})
test.each(["cat\\\n", "cat \\\n", "cat\\\n\\\n", "cat\\\n;", "cat >out\\\n", "cat >out \\\n"])(
"saved prefixes cover their standalone continuation command: %j",
async (source) => {
await parity(source)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands[0]?.rawWords).toEqual(["cat"])
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(native.commands[0]).toEqual({ resource: source.includes(">out") ? "cat >out" : "cat", save: "cat *" })
expect(native.commands.every((command) => Wildcard.match(command.resource, command.save))).toBe(true)
},
)
test.each(["printf 'literal\\\n'\\\n", 'printf "literal\\\n"\\\n', "printf a\\\nb\\\n", 'printf a\\\n""\\\n'])(
"preserves meaningful raw syntax before an ignored trailing continuation: %j",
async (source) => {
await parity(source)
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands[0]?.resource).toBe(source.slice(0, -2))
expect(result.commands[0]?.rawWords).toEqual(["printf", source.slice("printf ".length, -2)])
},
)
test("known gap: assignment then redirect on a pipeline RHS retains the native command", async () => {
const source = "printf ok | FOO=bar >output git status 3>tail"
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(legacy.commands).toEqual([{ resource: "printf ok", save: "printf *" }])
expect(native.commands).toEqual([
{ resource: "printf ok", save: "printf *" },
{ resource: "FOO=bar >output git status", save: "git status *" },
])
})
})

Some files were not shown because too many files have changed in this diff Show More