Compare commits

...

26 Commits

Author SHA1 Message Date
Kit Langton 64c82bedec chore: format workspace files 2026-08-05 22:40:59 -04:00
Kit Langton 6cbc71a2eb feat(core): hosted apply_patch through the workspace environment 2026-08-05 22:38:35 -04:00
Kit Langton 756ac88039 test(core): scripted-model session e2e on hosted locations 2026-08-05 22:27:56 -04:00
Kit Langton a4eeaefd5b fix(core): stat shell workdir through the workspace environment 2026-08-05 22:27:55 -04:00
Kit Langton 6e0066247c fix(server): route hosted sessions through workspace location 2026-08-05 21:48:28 -04:00
Kit Langton 65ac161373 test(server): run hosted location graph live on modal 2026-08-05 21:43:07 -04:00
Kit Langton 9c0883054b feat(server): add modal workspace driver 2026-08-05 21:39:09 -04:00
Kit Langton 79eed3d3a0 refactor(core): apply simplify review to hosted workspace slice 2026-08-05 21:29:19 -04:00
Kit Langton be74fad688 test(core): prove hosted workspace end to end with directory-backed fake driver 2026-08-05 21:15:16 -04:00
Kit Langton 5d93a35a1d feat(core): exclude host-search tools from hosted locations 2026-08-05 21:08:26 -04:00
Kit Langton 395eb8a9c8 feat(core): run hosted shell commands through workspace environment 2026-08-05 21:06:48 -04:00
Kit Langton 14e7b547c5 feat(core): route hosted mutation resolution and writes through workspace environment 2026-08-05 21:03:24 -04:00
Kit Langton 97a5497664 feat(core): serve hosted filesystem reads through workspace environment 2026-08-05 20:58:39 -04:00
Kit Langton e544b8927b feat(core): branch hosted location graph construction 2026-08-05 20:42:29 -04:00
Kit Langton 44481e1ecc docs: record hosted project identity decisions 2026-08-05 20:16:26 -04:00
Kit Langton 5168652082 feat(core): admit hosted sessions without host project discovery 2026-08-05 19:33:27 -04:00
Kit Langton 977a35c222 refactor(core): use struct for workspace info 2026-08-05 19:28:57 -04:00
Kit Langton 5d64dc8bfd feat(core): add workspace domain service 2026-08-05 19:25:06 -04:00
Kit Langton 479821e621 feat(core): add workspace driver seam, environment contract, and table 2026-08-05 19:23:09 -04:00
Kit Langton 5ce6626a44 Merge remote-tracking branch 'origin/remove-old-workspaces' into workspace-plan 2026-08-05 19:06:22 -04:00
Kit Langton eef60bcd64 docs: add workspaces.get metadata read 2026-08-05 18:55:54 -04:00
Kit Langton b1abfd4bd9 docs: require explicit workspace provider 2026-08-05 18:51:40 -04:00
Kit Langton 71251dd246 docs: tighten workspaces proposal to code-first form 2026-08-05 18:35:12 -04:00
Kit Langton 8461226ccd docs: add driver registration example and drop ripgrep seam field 2026-08-05 18:35:12 -04:00
Kit Langton e1b863dcb3 docs: name environment files interface and explain ripgrep seam 2026-08-05 18:35:12 -04:00
Kit Langton 56405af7d6 docs: propose workspace domain model and driver seam 2026-08-05 18:35:11 -04:00
42 changed files with 3055 additions and 119 deletions
+40
View File
@@ -690,8 +690,10 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -1523,6 +1525,18 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1731,6 +1745,10 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1825,6 +1843,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -3311,6 +3331,8 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3551,6 +3573,10 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4583,6 +4609,8 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4809,6 +4837,8 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4847,6 +4877,10 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5645,6 +5679,8 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6605,6 +6641,8 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6749,6 +6787,8 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
+75 -2
View File
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "6307bc18-2612-47e2-acfc-2a6a68ff28c5",
"id": "70de00ff-f8ce-47f6-a1db-5213168971ae",
"prevIds": [
"e43ed7e2-b9fc-4178-beae-3646e4a976e1"
"6307bc18-2612-47e2-acfc-2a6a68ff28c5"
],
"ddl": [
{
@@ -86,6 +86,10 @@
"name": "session_share",
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
@@ -1506,6 +1510,66 @@
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "binding",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "root",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "workspace"
},
{
"columns": [
"active_account_id"
@@ -1869,6 +1933,15 @@
"table": "session_share",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
{
+1
View File
@@ -60,5 +60,6 @@ export const migrations = (
import("./migration/20260729022634_session_fork_boundary"),
import("./migration/20260730195856_optional_session_title"),
import("./migration/20260805225117_remove_workspace"),
import("./migration/20260805232152_add_workspace"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,20 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260805232152_add_workspace",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`root\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration
+10
View File
@@ -241,6 +241,16 @@ export default {
CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`root\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
+56 -7
View File
@@ -5,6 +5,7 @@ import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { WorkspaceEnvironment } from "./workspace/environment"
export interface Target {
readonly canonical: string
@@ -36,6 +37,13 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.canonical,
resource: target.resource,
existed,
})
/**
* Serialize file changes by canonical target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
@@ -51,13 +59,6 @@ const layer = Layer.effect(
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.canonical,
resource: target.resource,
existed,
})
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
@@ -90,6 +91,54 @@ const layer = Layer.effect(
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
// Same cooperative locking, writes through WorkspaceEnvironment.Files. The
// environment write reports prior existence, so no stat pre-check round trip.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const encoder = new TextEncoder()
const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
const bytes = (content: string | Uint8Array) => (typeof content === "string" ? encoder.encode(content) : content)
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
env.files
.write(input.target.canonical, bytes(input.content))
.pipe(
Effect.orDie,
Effect.map((result) => writeResult(input.target, result.existed)),
),
),
)
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* WorkspaceEnvironment.optional(env.files.read(input.target.canonical))
const text = Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)
const result = yield* env.files.write(input.target.canonical, bytes(text)).pipe(Effect.orDie)
return writeResult(input.target, result.existed)
}),
),
)
return Service.of({ write, writeTextPreservingBom })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
/**
* Deferred until the corresponding integrations exist.
*/
+60
View File
@@ -8,6 +8,7 @@ import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
import { WorkspaceEnvironment } from "./workspace/environment"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -115,3 +116,62 @@ export const node = makeLocationNode({
layer: baseLayer,
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
})
// Mirrors baseLayer over WorkspaceEnvironment.Files with posix path rules.
// Host filesystem services never see provider paths. Type mismatches surface
// from the environment operation itself; no stat pre-checks.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.posix.resolve(location.directory, input ?? ".")
if (!FSUtil.containsPosix(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* env.files.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.containsPosix(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
return Service.of({
// Graceful like the hosted tool-catalog filter: completion surfaces keep
// working with empty results instead of defecting.
find: () => Effect.logWarning("find is not supported for hosted locations yet").pipe(Effect.as([])),
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
return {
content: yield* env.files.read(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
return yield* env.files.list(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.posix.join(target.absolute, item.name)
const relative = path.posix.relative(target.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? "/" : "")),
type: item.type,
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node, Location.node],
})
+64 -1
View File
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { WorkspaceEnvironment } from "./workspace/environment"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
@@ -25,7 +26,7 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literal("non_directory_ancestor"),
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
}) {}
export interface ExternalDirectoryAuthorization {
@@ -155,3 +156,65 @@ export const node = makeLocationNode({
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
})
// Mirrors the local resolve walk over WorkspaceEnvironment.Files with posix
// rules. Hosted paths are never external: everything outside the Location is
// rejected instead of routed to external_directory approval, because the
// approval boundary vocabulary is host-relative.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
const existing = yield* WorkspaceEnvironment.optional(env.files.realPath(absolute))
if (existing !== undefined) {
const info = yield* WorkspaceEnvironment.optional(env.files.stat(existing))
if (info === undefined) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
return {
canonical: existing,
type: info.type,
directory: info.type === "Directory" ? existing : path.posix.dirname(existing),
} satisfies ResolvedPath
}
let anchor = path.posix.dirname(absolute)
while (true) {
const canonical = yield* WorkspaceEnvironment.optional(env.files.realPath(anchor))
if (canonical !== undefined) {
const info = yield* WorkspaceEnvironment.optional(env.files.stat(canonical))
if (info === undefined || info.type !== "Directory") {
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
}
return {
canonical: path.posix.resolve(canonical, path.posix.relative(anchor, absolute)),
directory: canonical,
} satisfies ResolvedPath
}
const parent = path.posix.dirname(anchor)
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
anchor = parent
}
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.posix.resolve(location.directory, input.path)
if (!FSUtil.containsPosix(location.directory, absolute))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
const resolved = yield* resolvePath(absolute)
return {
canonical: resolved.canonical,
resource: path.posix.relative(location.directory, absolute) || ".",
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer.pipe(Layer.orDie),
deps: [WorkspaceEnvironment.node, Location.node],
})
+20 -1
View File
@@ -47,6 +47,7 @@ import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { Tool } from "./tool"
import { Vcs } from "./vcs"
import { WorkspaceEnvironment } from "./workspace/environment"
export { LocationServiceMap } from "./location-service-map"
@@ -118,7 +119,25 @@ export function buildLocationServiceMap(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const workspaceID = ref.workspaceID
// Hosted graphs state their Location (no host discovery), read only
// global config sources, and bind the workspace environment; local
// graphs are byte-identical to before.
const allReplacements = replacements.concat(
workspaceID
? [
[Location.node, Location.hostedBoundNode(ref, workspaceID)],
[Config.node, Config.configured({ project: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false })],
[WorkspaceEnvironment.node, WorkspaceEnvironment.hostedNode(workspaceID)],
[FileSystem.node, FileSystem.hostedNode],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
[Shell.node, Shell.hostedNode],
[PluginSupervisor.node, PluginSupervisor.hostedNode],
]
: [[Location.node, Location.boundNode(ref)]],
)
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
+25
View File
@@ -1,6 +1,8 @@
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Project } from "./project"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
@@ -37,3 +39,26 @@ export const boundNode = (ref: Ref) =>
layer: layer(ref),
deps: [Project.node],
})
/**
* Hosted Locations state their Project instead of discovering it: host git
* and filesystem walks must never run against a provider directory. The
* Workspace root comes from the already-connected environment, avoiding a
* second workspace row read per graph build.
*/
export const hostedBoundNode = (ref: Ref, workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
layer: Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return Service.of({
directory: ref.directory,
workspaceID,
project: Project.hostedGlobal(env.directory),
})
}),
),
deps: [WorkspaceEnvironment.node],
})
+16 -2
View File
@@ -1,7 +1,8 @@
export * as PluginInternal from "./internal"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Effect, Scope } from "effect"
import { Context, Effect, Option, Scope } from "effect"
import { WorkspaceEnvironment } from "../workspace/environment"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent"
import { Catalog } from "../catalog"
@@ -95,7 +96,14 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const wellknown = yield* WellKnown.Service
// Bound only in hosted Location graphs; plugins read it with serviceOption
// to route filesystem checks at the provider instead of the host.
const environment = yield* Effect.serviceOption(WorkspaceEnvironment.Service)
return Context.mergeAll(
Option.match(environment, {
onSome: (value) => Context.make(WorkspaceEnvironment.Service, value),
onNone: () => Context.empty(),
}),
Context.make(Agent.Service, agent),
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
@@ -171,7 +179,13 @@ const post = [
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
// Not advertised for hosted Locations until their execution is
// environment-backed: glob/grep spawn the host ripgrep binary against
// provider paths. Hosted glob/grep later spawn rg inside the workspace image.
const hostedExcluded: ReadonlySet<string> = new Set([GlobTool.Plugin.id, GrepTool.Plugin.id])
export const list = Effect.fn("PluginInternal.list")(function* () {
const location = yield* Location.Service
const context = yield* services()
const resolve = (plugins: readonly InternalPlugin[]) =>
plugins.map(
@@ -181,7 +195,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
}),
)
return {
pre: resolve(pre),
pre: resolve(location.workspaceID ? pre.filter((plugin) => !hostedExcluded.has(plugin.id)) : pre),
post: resolve(post),
}
})
+49 -37
View File
@@ -40,6 +40,7 @@ import { ReadToolFileSystem } from "../tool/read-filesystem"
import { Tool } from "../tool"
import { WebSearch } from "../websearch"
import { WellKnown } from "../wellknown"
import { WorkspaceEnvironment } from "../workspace/environment"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk"
@@ -282,7 +283,9 @@ const layer = Layer.effect(
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -308,45 +311,54 @@ const layer = Layer.effect(
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
const nodeDeps = [
Plugin.node,
SdkPlugins.node,
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
Tool.node,
Watcher.node,
WebSearch.node,
WellKnown.node,
] as const
export const node = makeLocationNode({
service: Service,
layer: nodeLayer,
deps: [
Plugin.node,
SdkPlugins.node,
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
Tool.node,
Watcher.node,
WebSearch.node,
WellKnown.node,
],
deps: nodeDeps,
})
/** Hosted graphs bind the workspace environment so internal plugins can reach it. */
export const hostedNode = makeLocationNode({
service: Service,
layer: nodeLayer,
deps: [...nodeDeps, WorkspaceEnvironment.node],
})
export { layer }
+12
View File
@@ -44,6 +44,18 @@ export interface Resolved {
readonly vcs?: Vcs
}
/**
* The stated Project for hosted Locations: global identity until rediscovery
* inside the Workspace finds a repository. Canonical "/" matches the local
* non-VCS fallback on posix hosts, so persisting it never repoints the global
* row at a provider path.
*/
export const hostedGlobal = (root: string): Resolved => ({
id: ID.global,
directory: AbsolutePath.make(root),
canonical: AbsolutePath.make("/"),
})
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
+25 -4
View File
@@ -27,7 +27,8 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
import { ForkEmptyError, MessageDecodeError, NotFoundError, WorkspaceDirectoryError } from "./session/error"
import { Workspace } from "./workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map"
import { SessionEvent } from "./session/event"
@@ -150,7 +151,9 @@ export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[]
}>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly create: (
input: CreateInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | Workspace.NotFoundError | WorkspaceDirectoryError>
readonly fork: (
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
@@ -282,6 +285,7 @@ const layer = Layer.effect(
const db = database.db
const bus = yield* Bus.Service
const projects = yield* Project.Service
const workspaces = yield* Workspace.Service
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
@@ -293,6 +297,17 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
// Host Project discovery must not run against provider directories; hosted
// Sessions state the global Project until rediscovery inside the Workspace.
const hostedProject = Effect.fn("Session.hostedProject")(function* (
workspaceID: Workspace.ID,
directory: AbsolutePath,
) {
const workspace = yield* workspaces.get(workspaceID)
if (!FSUtil.containsPosix(workspace.root, directory))
return yield* new WorkspaceDirectoryError({ workspaceID, directory, root: workspace.root })
return Project.hostedGlobal(workspace.root)
})
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
@@ -330,7 +345,9 @@ const layer = Layer.effect(
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
const project = location.workspaceID
? yield* hostedProject(location.workspaceID, location.directory)
: yield* projects.resolve(location.directory)
yield* persistProject(project)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
@@ -338,9 +355,12 @@ const layer = Layer.effect(
slug: Slug.create(),
version: app.version,
projectID: project.id,
workspaceID: location.workspaceID,
parentID: input.parentID,
directory: location.directory,
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
path: location.workspaceID
? path.posix.relative(project.directory, location.directory)
: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
title: input.title,
agent: input.agent,
model: input.model
@@ -1017,6 +1037,7 @@ export const node = makeGlobalNode({
Database.node,
Bus.node,
Project.node,
Workspace.node,
SessionExecution.node,
SessionStore.node,
LocationServiceMap.node,
+14
View File
@@ -2,6 +2,7 @@ export * as SessionErrors from "./error"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -18,6 +19,19 @@ export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("S
}
}
export class WorkspaceDirectoryError extends Schema.TaggedErrorClass<WorkspaceDirectoryError>()(
"Session.WorkspaceDirectoryError",
{
workspaceID: WorkspaceID,
directory: Schema.String,
root: Schema.String,
},
) {
override get message() {
return `Directory ${this.directory} is outside workspace ${this.workspaceID} root ${this.root}`
}
}
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
+1 -1
View File
@@ -57,7 +57,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
return {
id: info.id,
project_id: info.projectID,
workspace_id: null,
workspace_id: info.workspaceID ?? null,
parent_id: info.parentID,
slug: info.slug,
directory: info.directory,
+56 -14
View File
@@ -12,6 +12,7 @@ import { Bus } from "./bus"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
import { WorkspaceEnvironment } from "./workspace/environment"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks"
@@ -65,14 +66,24 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) => Layer.effect(
/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */
interface Backend {
readonly spawn: (command: ChildProcess.Command) => ReturnType<AppProcess.Interface["spawn"]>
/** Shell executable; re-evaluated per command so config changes apply. */
readonly shell: Effect.Effect<string>
readonly args: (shell: string, command: string) => readonly string[]
/** Base environment for spawned commands. Hosted backends must not leak host process.env. */
readonly env: Readonly<Record<string, string | undefined>>
readonly detached: boolean
}
const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const spawner = yield* backend
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
@@ -141,12 +152,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const name = () => spawner.shell.pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
@@ -186,9 +192,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
shell: yield* spawner.shell,
env: {
...process.env,
...spawner.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
@@ -197,7 +203,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const args = spawner.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
@@ -218,12 +224,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
const handle = yield* spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
detached: spawner.detached,
forceKillAfter: Duration.seconds(3),
}),
)
@@ -339,6 +345,23 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
}),
)
export const layer = (options?: ShellSelect.Options) =>
layerWith(
Effect.gen(function* () {
const config = yield* Config.Service
const appProcess = yield* AppProcess.Service
return {
spawn: (command) => appProcess.spawn(command),
shell: config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))),
args: ShellSelect.args,
env: process.env,
detached: process.platform !== "win32",
} satisfies Backend
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
@@ -348,3 +371,22 @@ export function configured(options?: ShellSelect.Options) {
}
export const node = configured()
// Commands run inside the workspace: provider spawner, image shell lowering,
// and no host process.env leakage. Output capture files remain host-owned.
export const hostedNode = makeLocationNode({
service: Service,
layer: layerWith(
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return {
spawn: (command) => env.process.spawn(command),
shell: Effect.succeed(env.shell.executable),
args: (_shell, command) => env.shell.args(command),
env: env.shell.environmentOverrides,
detached: env.shell.detached,
} satisfies Backend
}),
),
deps: [Bus.node, Location.node, Global.node, WorkspaceEnvironment.node, PluginHooks.node],
})
+84 -42
View File
@@ -4,7 +4,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
@@ -13,6 +13,7 @@ import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
import { WorkspaceEnvironment } from "../../workspace/environment"
import DESCRIPTION from "../patch.txt"
export const name = "patch"
@@ -66,6 +67,33 @@ interface Target {
}
}
type BackendError = PlatformError | globalThis.Error | WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError
/** The four verbs patch needs, provided by the host filesystem or the workspace environment. */
interface Backend {
readonly read: (path: string) => Effect.Effect<{ readonly bom: boolean; readonly text: string }, BackendError>
readonly stat: (path: string) => Effect.Effect<{ readonly type: FileSystem.File.Type }, BackendError>
readonly write: (path: string, content: string) => Effect.Effect<void, BackendError>
readonly remove: (path: string) => Effect.Effect<void, BackendError>
}
const hostBackend = (fs: FSUtil.Interface): Backend => ({
read: (target) => Bom.readFile(fs, target),
stat: (target) => fs.stat(target),
write: (target, content) => fs.writeWithDirs(target, content),
remove: (target) => fs.remove(target),
})
const environmentBackend = (environment: WorkspaceEnvironment.Interface): Backend => {
const encoder = new TextEncoder()
return {
read: (target) => Effect.map(environment.files.read(target), Bom.fromBytes),
stat: (target) => environment.files.stat(target),
write: (target, content) => Effect.asVoid(environment.files.write(target, encoder.encode(content))),
remove: (target) => environment.files.remove(target),
}
}
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
@@ -73,6 +101,11 @@ export const Plugin = {
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
// Hosted Locations bind the workspace environment: file verbs run at the
// provider, paths resolve as posix, and host formatters never run.
const environment = Option.getOrUndefined(yield* Effect.serviceOption(WorkspaceEnvironment.Service))
const hosted = environment !== undefined
const backend = environment ? environmentBackend(environment) : hostBackend(fs)
yield* ctx.tool
.transform((draft) =>
@@ -111,7 +144,7 @@ export const Plugin = {
const updates = new Map<string, string>()
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path)
const target = resolveTarget(location, hunk.path, hosted)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
@@ -141,7 +174,7 @@ export const Plugin = {
return
}
if (hunk.type === "delete") {
const content = yield* Bom.readFile(fs, target.canonical).pipe(
const content = yield* backend.read(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -156,7 +189,7 @@ export const Plugin = {
const original =
previous ??
(yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.canonical).pipe(
const stats = yield* backend.stat(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -169,7 +202,7 @@ export const Plugin = {
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.canonical).pipe(
const content = yield* backend.read(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -185,7 +218,7 @@ export const Plugin = {
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath, hosted) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
@@ -239,8 +272,8 @@ export const Plugin = {
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* fs
.writeWithDirs(
yield* backend
.write(
change.target.canonical,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
@@ -257,7 +290,7 @@ export const Plugin = {
return
}
if (change.type === "delete") {
yield* fs
yield* backend
.remove(change.target.canonical)
.pipe(
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
@@ -271,10 +304,10 @@ export const Plugin = {
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* fs
.writeWithDirs(moveTarget.canonical, change.content)
yield* backend
.write(moveTarget.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs.remove(change.target.canonical).pipe(
yield* backend.remove(change.target.canonical).pipe(
Effect.mapError((error) =>
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
@@ -286,8 +319,8 @@ export const Plugin = {
})
return
}
yield* fs
.writeWithDirs(change.target.canonical, change.content)
yield* backend
.write(change.target.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -297,25 +330,29 @@ export const Plugin = {
}),
{ discard: true },
)
// Hosted Locations skip formatting: formatters are host
// binaries and cannot run against provider paths. patchFile
// then falls back to the in-memory after-content.
const formatted = new Map<string, string>()
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
if (!hosted)
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
@@ -356,6 +393,7 @@ export const Plugin = {
}
function errorMessage(error: unknown) {
if (error instanceof WorkspaceEnvironment.NotFoundError) return "file does not exist"
if (error instanceof PlatformError) {
if (error.reason._tag === "NotFound") return "file does not exist"
return error.reason.description ?? error.reason.message
@@ -415,23 +453,27 @@ function trimDiff(diff: string) {
.join("\n")
}
function resolveTarget(location: Location.Interface, value: string): Target {
function resolveTarget(location: Location.Interface, value: string, hosted: boolean): Target {
// Hosted paths live in the provider filesystem: always posix, regardless of
// the host platform.
const paths = hosted ? path.posix : path
const contains = hosted ? FSUtil.containsPosix : FSUtil.contains
const canonical =
process.platform === "win32"
!hosted && process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
: paths.resolve(location.directory, value)
const projectRoot = paths.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
const directory = path.dirname(canonical)
!contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !contains(location.project.directory, canonical))
const directory = paths.dirname(canonical)
const resource =
process.platform === "win32"
!hosted && process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
: paths.join(directory, "*").replaceAll("\\", "/")
return {
canonical,
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
resource: paths.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
+18 -6
View File
@@ -4,8 +4,9 @@ import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Deferred, Effect, Option, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { WorkspaceEnvironment } from "../../workspace/environment"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -83,6 +84,21 @@ export const Plugin = {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const fsUtil = yield* FSUtil.Service
// Hosted Locations bind the workspace environment; the workdir check must
// stat the provider filesystem there, never the host's.
const environment = Option.getOrUndefined(yield* Effect.serviceOption(WorkspaceEnvironment.Service))
const statWorkdir = (canonical: string) =>
environment
? environment.files.stat(canonical).pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () =>
Effect.fail(new Error(`Working directory does not exist: ${canonical}`)),
),
)
: fsUtil.stat(canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${canonical}`)),
),
)
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
@@ -176,11 +192,7 @@ export const Plugin = {
agent: context.agent,
source,
})
const workdir = yield* fsUtil.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
),
)
const workdir = yield* statWorkdir(target.canonical)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
+71
View File
@@ -1,6 +1,77 @@
export * as Workspace from "./workspace"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "./database/database"
import { WorkspaceDriver } from "./workspace/driver"
import { WorkspaceTable } from "./workspace/sql"
export const ID = Workspace.ID
export type ID = typeof ID.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
id: ID,
}) {}
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
provider: Schema.String,
root: AbsolutePath,
}).annotate({ identifier: "Workspace.Info" })
export interface Interface {
/** Allocate through the named driver and persist. Resolves when the Workspace is usable. */
readonly create: (input: {
readonly provider: string
}) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFoundError>
/** Metadata read; never contacts a driver. */
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
/** Provider key and opaque binding for WorkspaceDriver.connect. Hosted Location construction only. */
readonly binding: (id: ID) => Effect.Effect<
{ readonly provider: string; readonly binding: WorkspaceDriver.Binding },
NotFoundError
>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const registry = yield* WorkspaceDriver.RegistryService
const require = Effect.fn("Workspace.require")(function* (id: ID) {
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ id })
return row
})
return Service.of({
create: Effect.fn("Workspace.create")(function* (input) {
const driver = yield* registry.get(input.provider)
const id = ID.create()
const created = yield* driver.create({ workspaceID: id })
yield* db
.insert(WorkspaceTable)
.values({ id, provider: input.provider, binding: created.binding, root: created.root })
.pipe(Effect.orDie)
return Info.make({ id, provider: input.provider, root: AbsolutePath.make(created.root) })
}),
get: Effect.fn("Workspace.get")(function* (id) {
const row = yield* require(id)
return Info.make({ id: row.id, provider: row.provider, root: AbsolutePath.make(row.root) })
}),
binding: Effect.fn("Workspace.binding")(function* (id) {
const row = yield* require(id)
return { provider: row.provider, binding: row.binding }
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, WorkspaceDriver.registryNode] })
+70
View File
@@ -0,0 +1,70 @@
export * as WorkspaceDriver from "./driver"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { WorkspaceEnvironment } from "./environment"
/**
* Smallest provider-owned JSON value required to reconnect to the same
* provider resource. Core stores it opaquely and hands it back; only the
* owning driver reads inside.
*/
export const Binding = Schema.Record(Schema.String, Schema.Json)
export type Binding = typeof Binding.Type
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
provider: Schema.String,
message: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WorkspaceDriver.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Interface {
/** Allocate a new environment. Resolves only when it is ready to use. */
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ readonly binding: Binding; readonly root: string }, Error>
/**
* Binding -> live capabilities; the only way to obtain an environment.
* Fresh-create and restart-reconnect both flow through here. Closing the
* scope drops the connection, never the provider resource.
*/
readonly connect: (binding: Binding) => Effect.Effect<WorkspaceEnvironment.Interface, Error, Scope.Scope>
/** Permanently release provider resources. */
readonly destroy: (binding: Binding) => Effect.Effect<void, Error>
}
/** Identity constructor so driver definitions get contextual typing. */
export const make = (driver: Interface) => driver
export interface Registry {
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/WorkspaceDriverRegistry") {}
/** Immutable registry fixed at composition time. */
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
get: (provider) => {
const driver = drivers[provider]
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFoundError({ provider }))
},
})
/**
* Defaults to no drivers, so every provider fails with ProviderNotFoundError.
* Server composition (or a test) replaces this binding with real drivers;
* core never constructs one.
*/
export const registryNode = makeGlobalNode({
service: RegistryService,
layer: Layer.succeed(RegistryService, RegistryService.of(registry({}))),
deps: [],
})
+108
View File
@@ -0,0 +1,108 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Workspace } from "../workspace"
import { WorkspaceDriver } from "./driver"
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
operation: Schema.String,
path: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
/** Distinct so callers (e.g. the LocationMutation ancestor walk) can catch absence. */
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("WorkspaceEnvironment.NotFoundError", {
path: Schema.String,
}) {}
export interface FileInfo {
readonly type: FileSystem.File.Type
}
export interface DirectoryEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
/**
* The minimal primitive set the hosted implementations of Filesystem,
* LocationMutation, and FileMutation consume. Grow it only when a real
* consumer appears. Every method is one provider round trip: type mismatches
* (read a directory, list a file) fail with Error instead of requiring a
* stat pre-check.
*/
export interface Files {
readonly stat: (path: string) => Effect.Effect<FileInfo, Error | NotFoundError>
/** Canonical path with symlinks resolved; identity for permissions and locking. */
readonly realPath: (path: string) => Effect.Effect<string, Error | NotFoundError>
readonly read: (path: string) => Effect.Effect<Uint8Array, Error | NotFoundError>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error | NotFoundError>
/** Creates parent directories, matching FSUtil.writeWithDirs. Reports whether the file already existed. */
readonly write: (path: string, content: Uint8Array) => Effect.Effect<{ readonly existed: boolean }, Error>
/** Removes one file. Removing a missing path fails with NotFoundError. */
readonly remove: (path: string) => Effect.Effect<void, Error | NotFoundError>
}
export interface Shell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
export interface Interface {
/** The Workspace root, absolute in the provider filesystem. */
readonly directory: string
readonly files: Files
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
/** NotFound becomes undefined; other environment failures become defects. */
export const optional = <A>(effect: Effect.Effect<A, Error | NotFoundError>): Effect.Effect<A | undefined> =>
effect.pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.orDie,
)
/** Present only in hosted Location graphs; local graphs never bind it. */
export const node = LayerNode.unbound(Service, tags.values.location)
/**
* Connects on graph boot and stays connected for the graph's cached lifetime:
* Layer.effect supplies the build scope, so releasing the LayerMap entry
* closes the connection. It never stops or deletes the provider resource.
*/
export const hostedNode = (workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
// Connect failures during graph build are defects, matching the local
// graph's E = never. Typed availability errors are a later design.
layer: Layer.effect(
Service,
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const registry = yield* WorkspaceDriver.RegistryService
const found = yield* workspaces.binding(workspaceID)
const driver = yield* registry.get(found.provider)
return yield* driver.connect(found.binding)
}),
).pipe(Layer.orDie),
deps: [Workspace.node, WorkspaceDriver.registryNode],
})
/** Identity constructor so environment literals get contextual typing. */
export const make = (environment: Interface) => environment
/** Default lowering for Linux sandbox images. */
export const linuxShell: Shell = {
executable: "/bin/bash",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
}
+12
View File
@@ -0,0 +1,12 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { WorkspaceDriver } from "./driver"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
provider: text().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
root: text().notNull(),
...Timestamps,
})
@@ -991,6 +991,9 @@ describe("DatabaseMigration", () => {
// must drop before the historical rename dance and recreate after.
yield* db.run(sql`DROP INDEX session_pending_session_compaction_idx`)
yield* db.run(sql`ALTER TABLE session_pending RENAME TO session_input`)
// The V2 workspace table now occupies the name; recreate the legacy
// shape this historical migration ran against.
yield* db.run(sql`DROP TABLE workspace`)
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionPendingMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [simplifySessionPendingMigration])
+169
View File
@@ -0,0 +1,169 @@
import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "fs/promises"
import nodePath from "path"
import { Effect, Layer } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
export const ROOT = "/workspace"
export const workspaceID = Workspace.ID.make("wrk_test")
/** A hosted Location at the workspace root, as hostedBoundNode would state it. */
export const hostedLocationLayer = (root: string = ROOT) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: AbsolutePath.make(root),
workspaceID,
project: Project.hostedGlobal(root),
}),
)
export interface MemoryEnvironment {
readonly environment: WorkspaceEnvironment.Interface
/** Live view of stored file contents by absolute path. */
readonly contents: (path: string) => string | undefined
}
/**
* Environment backed by a real host directory and spawner: the honest fake
* for end-to-end tests. Files go through fs/promises, commands actually run
* with the given spawner, so bash output is visible to reads.
*/
export const directoryEnvironment = (
root: string,
spawn: ChildProcessSpawner["Service"]["spawn"],
): WorkspaceEnvironment.Interface => {
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
Effect.tryPromise({
try: run,
catch: (cause) =>
(cause as NodeJS.ErrnoException).code === "ENOENT"
? new WorkspaceEnvironment.NotFoundError({ path })
: new WorkspaceEnvironment.Error({ operation, path, cause }),
})
return WorkspaceEnvironment.make({
directory: root,
files: {
stat: (path) =>
wrap("stat", path, async () => {
const info = await stat(path)
return {
type: info.isFile()
? ("File" as const)
: info.isDirectory()
? ("Directory" as const)
: ("Unknown" as const),
}
}),
realPath: (path) => wrap("realPath", path, () => realpath(path)),
read: (path) => wrap("read", path, async () => Uint8Array.from(await readFile(path))),
list: (path) =>
wrap("list", path, async () => {
const entries = await readdir(path, { withFileTypes: true })
return entries.map((entry) => ({
name: entry.name,
type: entry.isFile()
? ("file" as const)
: entry.isDirectory()
? ("directory" as const)
: entry.isSymbolicLink()
? ("symlink" as const)
: ("other" as const),
}))
}),
write: (path, content) =>
Effect.tryPromise({
try: async () => {
const existed = await stat(path).then(
(info) => info.isFile(),
() => false,
)
await mkdir(nodePath.dirname(path), { recursive: true })
await writeFile(path, content)
return { existed }
},
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
}),
remove: (path) => wrap("remove", path, () => rm(path)),
},
process: make(spawn),
shell: {
executable: "/bin/bash",
args: (command) => ["-c", command],
// Real commands need PATH; nothing else from the host environment leaks.
environmentOverrides: { PATH: process.env.PATH ?? "" },
detached: false,
},
})
}
/**
* In-memory workspace environment rooted at /workspace: file paths to
* contents, directories implied by keys, no symlinks, no processes. Type
* mismatches (read a directory, list a file) fail with Error per the Files
* contract.
*/
export const memoryEnvironment = (files: Record<string, string>): MemoryEnvironment => {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const store = new Map(Object.entries(files).map(([key, value]) => [key, Uint8Array.from(encoder.encode(value))]))
const isDirectory = (path: string) =>
path === ROOT || Array.from(store.keys()).some((key) => key.startsWith(path + "/"))
const exists = (path: string) => store.has(path) || isDirectory(path)
const notFound = (path: string) => Effect.fail(new WorkspaceEnvironment.NotFoundError({ path }))
const wrongType = (operation: string, path: string) =>
Effect.fail(new WorkspaceEnvironment.Error({ operation, path }))
const environment = WorkspaceEnvironment.make({
directory: ROOT,
files: {
stat: (path) =>
store.has(path)
? Effect.succeed({ type: "File" as const })
: isDirectory(path)
? Effect.succeed({ type: "Directory" as const })
: notFound(path),
realPath: (path) => (exists(path) ? Effect.succeed(path) : notFound(path)),
read: (path) => {
const content = store.get(path)
if (content) return Effect.succeed(content)
return isDirectory(path) ? wrongType("read", path) : notFound(path)
},
list: (path) => {
if (store.has(path)) return wrongType("list", path)
if (!isDirectory(path)) return notFound(path)
const names = new Map<string, "file" | "directory">()
for (const key of store.keys()) {
if (!key.startsWith(path + "/")) continue
const rest = key.slice(path.length + 1)
const [head] = rest.split("/")
if (head) names.set(head, rest.includes("/") ? "directory" : "file")
}
return Effect.succeed(Array.from(names, ([name, type]) => ({ name, type })))
},
write: (path, content) =>
Effect.sync(() => {
const existed = store.has(path)
store.set(path, Uint8Array.from(content))
return { existed }
}),
remove: (path) => {
if (store.delete(path)) return Effect.void
return isDirectory(path) ? wrongType("remove", path) : notFound(path)
},
},
process: make(() => Effect.die(new Error("no processes in the memory environment"))),
shell: WorkspaceEnvironment.linuxShell,
})
return {
environment,
contents: (path) => {
const stored = store.get(path)
return stored ? decoder.decode(stored) : undefined
},
}
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect } from "bun:test"
import { mkdtempSync } from "fs"
import { mkdtemp } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { AppProcess } from "@opencode-ai/util/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { directoryEnvironment } from "./lib/workspace"
const FakeBinding = Schema.Struct({ root: Schema.String })
const connects = { count: 0 }
// Replacement nodes may introduce tagged dependencies; declaring AppProcess
// lets the driver spawn through the real test spawner.
const registryLayer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const appProcess = yield* AppProcess.Service
const driver = WorkspaceDriver.make({
create: () =>
Effect.promise(async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-e2e-"))
return { binding: { root }, root }
}),
connect: (binding) =>
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
Effect.map((decoded) => {
connects.count++
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
}),
),
destroy: () => Effect.void,
})
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
}),
)
const registry = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: registryLayer,
deps: [AppProcess.node],
})
// Outer and hoisted location graphs must share one durable database, like the
// production file-backed configuration; :memory: would give each its own.
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-db-")), "e2e.db")
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Workspace.node, AppProcess.node, LocationServiceMap.node]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, registry],
],
),
)
describe("hosted workspace end to end", () => {
it.live("creates, works inside, evicts, and reconnects", () =>
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "fake" })
expect(created.provider).toBe("fake")
// Metadata read round-trips through sqlite without touching the driver.
const fetched = yield* workspaces.get(created.id)
expect(fetched.root).toBe(created.root)
const locations = yield* LocationServiceMap.Service
const ref = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
yield* Effect.gen(function* () {
const location = yield* Location.Service
expect(location.project.id).toBe(Project.ID.global)
expect(String(location.directory)).toBe(created.root)
const mutation = yield* LocationMutation.Service
const mutations = yield* FileMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
yield* mutations.write({ target, content: "hello from the workspace\n" })
const shell = yield* Shell.Service
const command = yield* shell.create({
command: "printf 'from-bash' > bash.txt && cat hello.txt",
timeout: 30_000,
})
const finished = yield* shell.wait(command.id)
expect(finished.status).toBe("exited")
const output = yield* shell.output(command.id)
expect(output.output).toContain("hello from the workspace")
const filesystem = yield* FileSystem.Service
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
}).pipe(Effect.provide(locations.get(ref)))
expect(connects.count).toBe(1)
// Evict the cached graph, then reconnect through the driver: the
// workspace contents survive because the binding names durable state.
yield* locations.invalidate(ref)
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
expect(new TextDecoder().decode(hello.content)).toBe("hello from the workspace\n")
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
}).pipe(Effect.provide(locations.get(ref)))
expect(connects.count).toBe(2)
}),
)
})
@@ -0,0 +1,57 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { RelativePath } from "@opencode-ai/core/schema"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { testEffect } from "./lib/effect"
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\n",
"/workspace/src/util/deep.ts": "export const deep = 1\n",
})
const it = testEffect(
AppNodeBuilder.build(FileSystem.hostedNode, [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
]),
)
describe("hosted FileSystem", () => {
it.effect("reads a file through the environment", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("README.md") })
expect(new TextDecoder().decode(result.content)).toBe("# hello\n")
expect(result.mime).toBe("text/markdown")
}),
)
it.effect("lists directories before files with posix separators", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list({ path: RelativePath.make("src") })
expect(entries.map((entry) => String(entry.path))).toEqual(["src/util/", "src/index.ts"])
}),
)
it.effect("lists the root when no path is given", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list()
expect(entries.map((entry) => String(entry.path))).toEqual(["src/", "README.md"])
}),
)
it.effect("refuses paths that escape the workspace", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const exit = yield* filesystem.read({ path: RelativePath.make("../etc/passwd") }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
})
@@ -0,0 +1,67 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\n",
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode]), [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
]),
)
describe("hosted mutation", () => {
it.effect("resolves an existing file to its canonical path and relative resource", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "README.md" })
expect(target.canonical).toBe("/workspace/README.md")
expect(target.resource).toBe("README.md")
expect(target.externalDirectory).toBeUndefined()
}),
)
it.effect("resolves a missing path below an existing directory", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "src/created/new.ts" })
expect(target.canonical).toBe("/workspace/src/created/new.ts")
expect(target.resource).toBe("src/created/new.ts")
}),
)
it.effect("rejects paths outside the workspace instead of granting external access", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const error = yield* mutation.resolve({ path: "/etc/passwd" }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}),
)
it.effect("writes new and existing files through the environment", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const created = yield* mutation.resolve({ path: "notes/todo.md" })
const first = yield* files.write({ target: created, content: "- ship it\n" })
expect(first.existed).toBe(false)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- ship it\n")
const second = yield* files.writeTextPreservingBom({ target: created, content: "- shipped\n" })
expect(second.existed).toBe(true)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
}),
)
})
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin } from "./lib/tool"
import { hostedLocationLayer, memoryEnvironment, ROOT } from "./lib/workspace"
import type { MemoryEnvironment } from "./lib/workspace"
const sessionID = Session.ID.make("ses_workspace_patch_test")
const assertions: Permission.AssertInput[] = []
const permission = Layer.succeed(
Permission.Service,
Permission.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
// Hosted patch must never format nor touch the host filesystem: both die.
const formatter = Layer.mock(Formatter.Service, {
file: () => Effect.die("hosted patch must not run host formatters"),
})
const poisoned = () => Effect.die("hosted patch must not touch the host filesystem")
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
stat: poisoned,
readFile: poisoned,
writeWithDirs: poisoned,
remove: poisoned,
})
}),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(memory: MemoryEnvironment, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const environment = Layer.succeed(WorkspaceEnvironment.Service, memory.environment)
const patchToolNode = makeLocationNode({
name: "test/workspace-patch-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node, WorkspaceEnvironment.node],
})
return Effect.gen(function* () {
assertions.length = 0
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, hostedLocationLayer()],
[Formatter.node, formatter],
[Permission.node, permission],
[WorkspaceEnvironment.node, environment],
]),
),
)
}
const call = (patchText: string, id = "call-patch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
})
const it = testEffect(Layer.empty)
describe("PatchTool on a hosted location", () => {
it.effect("applies add, update, and delete through the workspace environment", () => {
const memory = memoryEnvironment({
[`${ROOT}/update.txt`]: "before\n",
[`${ROOT}/remove.txt`]: "remove\n",
})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt", target: `${ROOT}/nested/new.txt` },
{ type: "update", resource: "update.txt" },
{ type: "delete", resource: "remove.txt" },
],
})
expect(memory.contents(`${ROOT}/nested/new.txt`)).toBe("created\n")
expect(memory.contents(`${ROOT}/update.txt`)).toBe("after\n")
expect(memory.contents(`${ROOT}/remove.txt`)).toBeUndefined()
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["nested/new.txt", "update.txt", "remove.txt"])
}),
)
})
it.effect("moves a file through the workspace environment", () => {
const memory = memoryEnvironment({ [`${ROOT}/old.txt`]: "before\n" })
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch"),
)
expect(settled.status).toBe("completed")
expect(memory.contents(`${ROOT}/old.txt`)).toBeUndefined()
expect(memory.contents(`${ROOT}/moved.txt`)).toBe("after\n")
}),
)
})
it.effect("reports a missing update target as a verification failure", () => {
const memory = memoryEnvironment({})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch"),
)
expect(settled.status).toBe("error")
if (settled.status !== "error") return
expect(settled.error?.message).toContain("file does not exist")
}),
)
})
it.effect("resolves external targets with posix containment", () => {
const memory = memoryEnvironment({})
return withTool(memory, (registry) =>
Effect.gen(function* () {
yield* executeTool(registry, call("*** Begin Patch\n*** Add File: /outside/new.txt\n+created\n*** End Patch"))
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.metadata).toMatchObject({ filepath: "/outside/new.txt", parentDir: "/outside" })
}),
)
})
})
@@ -0,0 +1,174 @@
import { describe, expect } from "bun:test"
import { mkdtempSync } from "fs"
import { mkdtemp, readFile } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { LanguageModel } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { AppProcess } from "@opencode-ai/util/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { directoryEnvironment } from "./lib/workspace"
const FakeBinding = Schema.Struct({ root: Schema.String })
const connects = { count: 0 }
const registryLayer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const appProcess = yield* AppProcess.Service
const driver = WorkspaceDriver.make({
create: () =>
Effect.promise(async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-session-"))
return { binding: { root }, root }
}),
connect: (binding) =>
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
Effect.map((decoded) => {
connects.count++
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
}),
),
destroy: () => Effect.void,
})
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
}),
)
const registry = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: registryLayer,
deps: [AppProcess.node],
})
// A gpt-style id so the patch plugin's context hook advertises apply_patch as
// the only editor, matching production GPT-5 sessions.
const model = LanguageModel.make({ id: "gpt-fake", provider: "fake", route: OpenAIChat.route })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
})
// Real permission flow would ask; the runner path under test is admission
// through drain, so approve everything.
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
// Outer and hoisted location graphs must share one durable database, like the
// production file-backed configuration; :memory: would give each its own.
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-session-db-")), "e2e.db")
// The real SessionExecution + Session nodes: drains route through
// LocationServiceMap.get(session.location), which is the seam under test.
// Replacements flow into every hoisted per-location graph via AppNodeBuilder.
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Workspace.node,
AppProcess.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
SessionExecution.node,
Session.node,
]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, registry],
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
[SessionRunnerModel.node, models],
[Permission.node, permission],
],
).pipe(Layer.provideMerge(TestLLM.layer({ fallback: [] }))),
)
const sessionModel = Model.Ref.make({ id: Model.ID.make("gpt-fake"), providerID: Provider.ID.make("fake") })
describe("hosted workspace session", () => {
it.live("runs a scripted model through the real runner on a hosted Location", () =>
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "fake" })
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "hosted session",
location: Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
}),
model: sessionModel,
})
yield* TestLLM.push(
TestLLM.tool("call-patch", "patch", {
patchText: "*** Begin Patch\n*** Add File: from-patch.txt\n+patched\n*** End Patch",
}),
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
TestLLM.text("done", "text-1"),
)
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
yield* sessions.resume(session.id)
// Both tools executed inside the workspace, not on a host path.
const written = yield* Effect.promise(() => readFile(path.join(created.root, "from-model.txt"), "utf8"))
expect(written).toBe("from-model")
const patched = yield* Effect.promise(() => readFile(path.join(created.root, "from-patch.txt"), "utf8"))
expect(patched).toBe("patched\n")
// The hosted catalog was advertised to the model: shell and patch (the
// gpt-style editor), without host-bound search tools or edit/write.
const requests = (yield* TestLLM.Service).requests
const advertised = requests[0]?.tools.map((tool) => tool.name) ?? []
expect(advertised).toContain("shell")
expect(advertised).toContain("patch")
expect(advertised).not.toContain("glob")
expect(advertised).not.toContain("grep")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
// Projected history shows the completed tool call and the final text.
// Instruction updates (e.g. the Code Mode catalog settling after boot)
// may interleave, so assert on the user and assistant messages directly.
const context = yield* sessions.context(session.id)
expect(context.at(0)).toMatchObject({ type: "user", text: "Write a file in the workspace" })
const assistants = context.filter((message) => message.type === "assistant")
expect(assistants.at(0)).toMatchObject({
content: [{ type: "tool", id: "call-patch", state: { status: "completed" } }],
})
expect(assistants.at(1)).toMatchObject({
content: [{ type: "tool", id: "call-shell", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
expect(connects.count).toBe(1)
}),
)
})
+3 -1
View File
@@ -20,9 +20,11 @@
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
"effect": "catalog:",
"modal": "0.9.0"
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
@@ -39,7 +39,7 @@ export const sessionLocationLayer = Layer.effect(
),
)
const row = yield* db
.select({ directory: SessionTable.directory })
.select({ directory: SessionTable.directory, workspace_id: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
@@ -53,8 +53,11 @@ export const sessionLocationLayer = Layer.effect(
return yield* effect.pipe(
Effect.provide(
locations.get(
// Hosted-ness is a server-side fact from the session row; clients
// never assert workspace identity per-request.
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspace_id ?? undefined,
}),
),
),
+3
View File
@@ -39,6 +39,8 @@ import { PtyEnvironment } from "./pty-environment"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { ServerWorkspaceDrivers } from "./workspace/drivers"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
@@ -83,6 +85,7 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
+18
View File
@@ -0,0 +1,18 @@
export * as ServerWorkspaceDrivers from "./drivers"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { ModalDriver } from "./modal"
/** Replaces core's empty default registry in Server composition. */
export const node = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: Layer.effect(
WorkspaceDriver.RegistryService,
Effect.map(ModalDriver.make, (modal) =>
WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ modal })),
),
),
deps: [],
})
+208
View File
@@ -0,0 +1,208 @@
export * as ModalDriver from "./modal"
import { Effect, Schema, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command } from "effect/unstable/process/ChildProcess"
import { ExitCode, ProcessId, make as makeSpawner, makeHandle } from "effect/unstable/process/ChildProcessSpawner"
import { ModalClient, type Sandbox, SandboxFilesystemNotFoundError, NotFoundError } from "modal"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
const PROVIDER = "modal"
const APP = "opencode-workspaces"
const ROOT = "/workspace"
/** Maximum sandbox lifetime; stop/resume/TTL lifecycle is a deferred design. */
const SANDBOX_TIMEOUT_MS = 60 * 60 * 1000
/**
* Backstop for commands: Modal exposes no per-process termination, so kill is
* best-effort and this deadline is what actually reaps orphaned commands.
*/
const EXEC_TIMEOUT_MS = 30 * 60 * 1000
const Binding = Schema.Struct({ sandboxId: Schema.String })
const fail = (message: string) => (cause: unknown) => new WorkspaceDriver.Error({ provider: PROVIDER, message, cause })
// git, bash, and rg are provisioned in the image, never discovered.
const IMAGE_COMMANDS = ["RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep ca-certificates"]
export const make = Effect.sync(() => {
// Credentials come from MODAL_TOKEN_ID/MODAL_TOKEN_SECRET or ~/.modal.toml.
const client = new ModalClient()
const decode = (binding: WorkspaceDriver.Binding) =>
Schema.decodeUnknownEffect(Binding)(binding).pipe(Effect.mapError(fail("invalid modal binding")))
return WorkspaceDriver.make({
create: ({ workspaceID }) =>
Effect.tryPromise({
try: async () => {
const app = await client.apps.fromName(APP, { createIfMissing: true })
const image = client.images.fromRegistry("ubuntu:24.04").dockerfileCommands(IMAGE_COMMANDS)
const sandbox = await client.sandboxes.create(app, image, {
name: workspaceID,
timeoutMs: SANDBOX_TIMEOUT_MS,
})
await sandbox.filesystem.makeDirectory(ROOT)
return { binding: { sandboxId: sandbox.sandboxId }, root: ROOT }
},
catch: fail("create"),
}),
connect: (binding) =>
Effect.gen(function* () {
const decoded = yield* decode(binding)
const sandbox = yield* Effect.tryPromise({
try: () => client.sandboxes.fromId(decoded.sandboxId),
catch: fail("connect"),
})
const exit = yield* Effect.tryPromise({ try: () => sandbox.poll(), catch: fail("connect") })
if (exit !== null)
return yield* new WorkspaceDriver.Error({
provider: PROVIDER,
message: `sandbox ${decoded.sandboxId} has terminated (exit ${exit})`,
})
return environment(sandbox)
}),
destroy: (binding) =>
Effect.gen(function* () {
const decoded = yield* decode(binding)
yield* Effect.tryPromise({
try: async () => {
const sandbox = await client.sandboxes.fromId(decoded.sandboxId)
await sandbox.terminate({ wait: true })
},
catch: fail("destroy"),
}).pipe(
// Already gone is destroyed.
Effect.catchIf(
(error) => error.cause instanceof NotFoundError,
() => Effect.void,
),
)
}),
})
})
const environment = (sandbox: Sandbox): WorkspaceEnvironment.Interface => ({
directory: ROOT,
files: files(sandbox),
process: makeSpawner(spawn(sandbox)),
shell: WorkspaceEnvironment.linuxShell,
})
const FILE_TYPE = { file: "File", directory: "Directory", symlink: "SymbolicLink" } as const
const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => {
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
Effect.tryPromise({
try: run,
catch: (cause) =>
cause instanceof SandboxFilesystemNotFoundError
? new WorkspaceEnvironment.NotFoundError({ path })
: new WorkspaceEnvironment.Error({ operation, path, cause }),
})
return {
stat: (path) =>
wrap("stat", path, async () => {
const info = await sandbox.filesystem.stat(path)
return { type: FILE_TYPE[info.type] }
}),
realPath: (path) =>
// No canonicalization API; realpath(1) from coreutils. A non-zero exit
// is reported as NotFound, which matches the dominant failure mode.
wrap("realPath", path, async () => {
// Both streams piped: the SDK's "ignore" path needs ReadableStream.from,
// which Bun does not implement.
// -e: every component must exist, matching node fs.realpath semantics.
const process = await sandbox.exec(["realpath", "-e", "--", path], { stdout: "pipe", stderr: "pipe" })
const [output, code] = await Promise.all([process.stdout.readText(), process.wait()])
if (code !== 0) throw new SandboxFilesystemNotFoundError(`realpath exited ${code}`)
return output.trim()
}),
read: (path) => wrap("read", path, () => sandbox.filesystem.readBytes(path)),
list: (path) =>
wrap("list", path, async () => {
const entries = await sandbox.filesystem.listFiles(path)
return entries.map((entry) => ({
name: entry.name,
type:
entry.type === "file"
? ("file" as const)
: entry.type === "directory"
? ("directory" as const)
: ("symlink" as const),
}))
}),
write: (path, content) =>
Effect.tryPromise({
try: async () => {
const existed = await sandbox.filesystem.stat(path).then(
(info) => info.type === "file",
() => false,
)
await sandbox.filesystem.writeBytes(content, path)
return { existed }
},
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
}),
remove: (path) => wrap("remove", path, () => sandbox.filesystem.remove(path)),
}
}
const spawnError = (method: string, options?: { description?: string; cause?: unknown }) =>
systemError({ _tag: "Unknown", module: "ModalDriver", method, ...options })
const spawn = (sandbox: Sandbox) => {
let pids = 0
return Effect.fnUntraced(function* (command: Command) {
if (command._tag === "PipedCommand")
return yield* Effect.fail(spawnError("spawn", { description: "piped commands unsupported" }))
const process = yield* Effect.tryPromise({
try: () =>
sandbox.exec([command.command, ...command.args], {
mode: "binary",
stdout: "pipe",
stderr: "pipe",
workdir: command.options.cwd,
env: compact(command.options.env),
timeoutMs: EXEC_TIMEOUT_MS,
}),
catch: (cause) => spawnError("spawn", { cause }),
})
let exited = false
let waited: Promise<number> | undefined
const wait = () =>
(waited ??= process.wait().then((code) => {
exited = true
return code
}))
const onError = (cause: unknown) => spawnError("process", { cause })
return makeHandle({
pid: ProcessId(++pids),
exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)),
isRunning: Effect.sync(() => !exited),
// Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans.
kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"),
stdin: Sink.forEach((chunk: Uint8Array) =>
Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }),
),
stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
all: Stream.merge(
Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
),
getInputFd: () => Sink.fail(spawnError("getInputFd", { description: "unsupported" })),
getOutputFd: () => Stream.fail(spawnError("getOutputFd", { description: "unsupported" })),
unref: Effect.succeed(Effect.void),
})
})
}
const compact = (env: Record<string, string | undefined> | undefined) => {
if (!env) return undefined
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
}
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync } from "fs"
import { homedir, tmpdir } from "os"
import path from "path"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-graph-")), "graph.db")
const layer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Workspace.node, LocationServiceMap.node]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
],
)
// The complete hosted stack against real Modal: workspace creation, the full
// Location graph, file mutation, real bash in the sandbox, eviction, reconnect.
describe.skipIf(!hasCredentials)("hosted location graph on modal (live)", () => {
test(
"runs the location graph against a modal sandbox",
async () => {
await Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "modal" })
const driver = yield* ModalDriver.make
const found = yield* workspaces.binding(created.id)
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
const locations = yield* LocationServiceMap.Service
const ref = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
yield* Effect.gen(function* () {
const location = yield* Location.Service
expect(location.project.id).toBe(Project.ID.global)
const mutation = yield* LocationMutation.Service
const mutations = yield* FileMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
yield* mutations.write({ target, content: "hello from opencode\n" })
const shell = yield* Shell.Service
const command = yield* shell.create({
command: "cat hello.txt && printf 'bash-made' > bash.txt",
timeout: 60_000,
})
const finished = yield* shell.wait(command.id)
expect(finished.status).toBe("exited")
const output = yield* shell.output(command.id)
expect(output.output).toContain("hello from opencode")
const filesystem = yield* FileSystem.Service
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("bash-made")
}).pipe(Effect.provide(locations.get(ref)))
yield* locations.invalidate(ref)
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
expect(new TextDecoder().decode(hello.content)).toBe("hello from opencode\n")
}).pipe(Effect.provide(locations.get(ref)))
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
},
{ timeout: 600_000 },
)
})
@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync } from "fs"
import { homedir, tmpdir } from "os"
import path from "path"
import { Effect, Layer } from "effect"
import { LanguageModel } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-session-")), "session.db")
// A gpt-style id so the patch plugin's context hook advertises apply_patch as
// the only editor, matching production GPT-5 sessions.
const model = LanguageModel.make({ id: "gpt-fake", provider: "fake", route: OpenAIChat.route })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
})
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Workspace.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
SessionExecution.node,
Session.node,
]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
[SessionRunnerModel.node, models],
[Permission.node, permission],
],
).pipe(Layer.provideMerge(TestLLM.layer({ fallback: [] })))
const sessionModel = Model.Ref.make({ id: Model.ID.make("gpt-fake"), providerID: Provider.ID.make("fake") })
// A scripted model through the real runner against a real Modal sandbox: the
// session-level counterpart of workspace-modal-graph.test.ts.
describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
test(
"runs a scripted model through the real runner on a modal sandbox",
async () => {
await Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "modal" })
const driver = yield* ModalDriver.make
const found = yield* workspaces.binding(created.id)
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
const sessions = yield* Session.Service
const location = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
const session = yield* sessions.create({
title: "hosted modal session",
location,
model: sessionModel,
})
yield* TestLLM.push(
TestLLM.tool("call-patch", "patch", {
patchText: "*** Begin Patch\n*** Add File: from-patch.txt\n+patched\n*** End Patch",
}),
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
TestLLM.text("done", "text-1"),
)
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
yield* sessions.resume(session.id)
const requests = (yield* TestLLM.Service).requests
const advertised = requests[0]?.tools.map((tool) => tool.name) ?? []
expect(advertised).toContain("shell")
expect(advertised).toContain("patch")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
const context = yield* sessions.context(session.id)
expect(context.at(0)).toMatchObject({ type: "user", text: "Write a file in the workspace" })
const assistants = context.filter((message) => message.type === "assistant")
expect(assistants.at(0)).toMatchObject({
content: [{ type: "tool", id: "call-patch", state: { status: "completed" } }],
})
expect(assistants.at(1)).toMatchObject({
content: [{ type: "tool", id: "call-shell", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
// Both tools executed inside the sandbox: read the files back through
// the hosted filesystem rather than any host path.
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const written = yield* filesystem.read({ path: RelativePath.make("from-model.txt") })
expect(new TextDecoder().decode(written.content)).toBe("from-model")
const patched = yield* filesystem.read({ path: RelativePath.make("from-patch.txt") })
expect(new TextDecoder().decode(patched.content)).toBe("patched\n")
}).pipe(Effect.provide(locations.get(location)))
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
},
{ timeout: 600_000 },
)
})
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test"
import { existsSync } from "fs"
import { homedir } from "os"
import path from "path"
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Workspace } from "@opencode-ai/core/workspace"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
// Live contract test against real Modal. First run may build the image
// (apt-get install), so the budget is generous.
describe.skipIf(!hasCredentials)("modal driver (live)", () => {
test(
"create, connect, files, exec, reconnect, destroy",
async () => {
await Effect.gen(function* () {
const driver = yield* ModalDriver.make
const workspaceID = Workspace.ID.create()
const created = yield* driver.create({ workspaceID })
expect(created.root).toBe("/workspace")
const cleanup = () => Effect.ignore(driver.destroy(created.binding))
yield* Effect.addFinalizer(cleanup)
const env = yield* driver.connect(created.binding)
expect(env.directory).toBe("/workspace")
// files: write reports creation, read round-trips, stat sees a file
const first = yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello modal\n"))
expect(first.existed).toBe(false)
const second = yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello again\n"))
expect(second.existed).toBe(true)
const bytes = yield* env.files.read("/workspace/hello.txt")
expect(new TextDecoder().decode(bytes)).toBe("hello again\n")
expect((yield* env.files.stat("/workspace/hello.txt")).type).toBe("File")
expect(yield* env.files.realPath("/workspace")).toBe("/workspace")
const listed = yield* env.files.list("/workspace")
expect(listed.map((entry) => entry.name)).toContain("hello.txt")
// missing files are typed NotFound
const missing = yield* env.files.read("/workspace/nope.txt").pipe(Effect.flip)
expect(missing._tag).toBe("WorkspaceEnvironment.NotFoundError")
// exec: bash sees the file, git and rg are provisioned
const handle = yield* env.process.spawn(
ChildProcess.make(env.shell.executable, [...env.shell.args("cat hello.txt && git --version && rg --version | head -1")], {
cwd: env.directory,
stdin: "ignore",
}),
)
const [output, code] = yield* Effect.all([
Stream.mkString(Stream.decodeText(handle.stdout)),
handle.exitCode,
])
expect(Number(code)).toBe(0)
expect(output).toContain("hello again")
expect(output).toContain("git version")
expect(output).toContain("ripgrep")
// reconnect by binding: durable contents survive a fresh connection
const again = yield* driver.connect(created.binding)
const persisted = yield* again.files.read("/workspace/hello.txt")
expect(new TextDecoder().decode(persisted)).toBe("hello again\n")
// destroy, then connect must fail typed
yield* driver.destroy(created.binding)
const dead = yield* driver.connect(created.binding).pipe(Effect.flip)
expect(dead._tag).toBe("WorkspaceDriver.Error")
}).pipe(Effect.scoped, Effect.runPromise)
},
{ timeout: 600_000 },
)
})
+4
View File
@@ -20,6 +20,10 @@ export function has(content: Uint8Array) {
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
}
export function fromBytes(content: Uint8Array) {
return split(decode(content))
}
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
return split(decode(yield* fs.readFile(filepath)))
})
+6
View File
@@ -273,4 +273,10 @@ export namespace FSUtil {
const result = relative(parent, child)
return result === "" || (!isAbsolute(result) && result !== ".." && !result.startsWith(`..${sep}`))
}
/** `contains` with posix rules regardless of host platform, for provider paths. */
export function containsPosix(parent: string, child: string) {
const result = path.posix.relative(parent, child)
return result === "" || (!path.posix.isAbsolute(result) && result !== ".." && !result.startsWith("../"))
}
}
+579
View File
@@ -0,0 +1,579 @@
# Hosted Workspace Execution
Status: superseded by `specs/v2/workspaces.md` for domain model and public API. Retained for execution-level research: provider feasibility gates, process laws, host-authority tripwires, and phase acceptance criteria. Where the two documents disagree (repository-at-creation, required Project identity, placement as a public concept, narrow process contract), `workspaces.md` wins.
Reader: the engineer implementing provider-neutral hosted Workspaces in OpenCode V2.
After reading this plan, that engineer can implement the smallest complete hosted coding slice in order, without moving the Session runner into a sandbox, leaking sandbox-driver details into Session APIs, or accidentally using the OpenCode server filesystem.
## The Runner Stays Central And Hosted Authority Enters Through Location
OpenCode will keep the Session runner in the central server. A Session with an explicit `Location.workspaceID` receives one hosted `WorkspaceEnvironment` when its Location graph is built.
```text
SessionExecution.resume(sessionID)
-> SessionStore.get(sessionID)
-> LocationServiceMap.get(session.location)
-> HostedWorkspaceStore.get(location.workspaceID)
-> SandboxDriver.acquire(persisted binding)
-> build hosted Location services
-> SessionRunner.run(sessionID)
```
The central server continues to own model calls, permissions, tool definitions, durable Session history, output limits, and tool settlement. Only Workspace file and foreground-process effects cross the sandbox-driver boundary.
An omitted `Location.workspaceID` continues through the current implicit-local graph. The first implementation does not rebuild local filesystem or process services behind the hosted interfaces.
The first milestone is an internal execution tracer, not a public Workspace product API. It connects to a pre-provisioned hosted resource, runs one real V2 Session, and reconnects after OpenCode rebuilds its Location graph. Public provisioning and lifecycle APIs follow only after the execution boundary works.
## One Concrete Scenario Defines The First Milestone
The milestone is complete when this scenario passes against a deterministic fake and the sandbox provider selected by Phase 0:
1. Register a pre-provisioned hosted resource containing a fixture Git repository.
2. Create a V2 Session whose Location references that hosted Workspace.
3. Confirm the provider directory does not exist on the OpenCode server host.
4. Prompt the Session to read and edit a file.
5. Run `git status` and one foreground shell command in the hosted checkout.
6. Interrupt a long command and confirm the provider command terminates.
7. Evict and rebuild the Location graph.
8. Reconnect to the same provider resource and verify the edit remains.
9. Run the existing implicit-local Session path without behavior changes.
The first milestone deliberately omits public create, deactivate, reactivate, and delete operations. The test fixture owns provider cleanup.
## Vocabulary Is Stable Across Core And Drivers
### Hosted Workspace
A Hosted Workspace is a stable writable checkout identified by `Workspace.ID`. A Session references it only through `Location.workspaceID`.
The Hosted Workspace record stores private placement and project metadata. It does not copy or synchronize files. The provider filesystem is authoritative.
### Workspace Root
The Workspace root is the checkout root. It is an absolute POSIX path in the provider filesystem, not a path on the OpenCode server.
The first milestone supports one checkout root per Hosted Workspace. Do not add a separate project root until a concrete multi-root layout requires one.
### Location Directory
The Location directory is the Session working directory inside the Workspace root. It may equal the root or name a nested directory. It uses the provider POSIX namespace.
`Location.Ref.directory` remains required. Core validates a hosted directory with POSIX path rules and never passes it to Node, Bun, native search, native watcher, or host Git APIs.
### Placement
Placement is the private durable fact that names the sandbox driver and provider resource for one Hosted Workspace.
Placement contains:
- `Workspace.ID`;
- the sandbox-driver key;
- a placement format version;
- a provider-validated JSON binding;
- the Workspace root;
- the logical Project ID;
- optional VCS metadata already discovered inside the provider resource.
Provider credentials, SDK clients, preview URLs, process handles, and model-provider credentials are never placement data.
### Binding
A binding is the smallest provider-specific JSON value required to reconnect to the same provider resource. Each sandbox driver owns an Effect Schema for its binding.
The first contract requires a stable binding. `acquire` cannot replace it or silently create another resource. Providers whose durable identity changes during activation require a later transactional binding-transition design.
### Sandbox Driver
A sandbox driver is an internal adapter for one hosted-environment provider. “Provider” without the “sandbox” qualifier remains reserved for model providers in user-facing APIs.
### WorkspaceEnvironment
`WorkspaceEnvironment` is the scoped live data-plane connection for one Hosted Workspace. Closing its scope releases local SDK and stream resources and cleans up scope-owned foreground commands. It never stops, snapshots, or deletes the durable provider resource.
## Placement Reconnects Without Acquiring Compute
V2 must read Hosted Workspace metadata without contacting a sandbox provider. This lets Session lists, routing, and Location validation remain cheap and available while provider compute is stopped or unavailable.
Add a V2-owned internal table rather than overloading the V1-owned `workspace.type` and `workspace.extra` columns:
```text
hosted_workspace
id primary key, Workspace.ID
driver sandbox-driver key
version placement encoding version
root provider POSIX Workspace root
project_id logical Project ID
vcs optional VCS kind
binding provider-validated JSON
time_created
time_updated
```
No V2 placement format has shipped, so the first migration has no compatibility decoder. The implementation does not add rows to the shared V1 `workspace` table and does not modify `packages/opencode`.
Hosted registration transactionally rejects an ID already present in the V1 `workspace` table. Session projection identifies hosted IDs through `hosted_workspace` and skips the legacy `WorkspaceTable.time_used` update for them. Hosted execution never inserts, updates, or deletes a V1 Workspace row.
The current Session schema requires `project_id` to reference `ProjectTable`. Registration therefore ensures a Project row exists. If no local checkout exists, its current `worktree` column temporarily contains the provider POSIX root. If the logical Project already has a local row, registration preserves its local `worktree` rather than overwriting it. V2 hosted code must never interpret a provider root through host filesystem services. This is an acknowledged shared-schema limitation, not a declaration that provider and host absolute paths share a namespace.
A later Project persistence redesign may separate logical Project identity from checkout locations. That redesign is not required to prove hosted execution.
## Hosted Session Admission Must Bypass Host Project Discovery
Current `SessionV2.create` always calls host-backed `Project.resolve(input.location.directory)` and computes the Session path with host `path.relative`. Hosted Session admission must branch before either operation.
```text
workspaceID omitted
-> current Project.resolve and host path behavior
workspaceID present
-> HostedWorkspaceStore.get(workspaceID)
-> validate Location directory inside Workspace root with path.posix
-> ensure ProjectTable row from persisted logical project metadata
-> compute Session path with path.posix.relative
-> persist the existing Location.Ref and project ID
```
Hosted registration supplies project identity before Session creation. The first Vercel fixture discovers it by running Git inside the sandbox. Session admission does not wake compute merely to rediscover it.
Tests place tripwires on `Project.resolve`, host Git, and host filesystem services and assert that hosted Session creation calls none of them.
Path-bearing Session operations outside the Location graph need explicit guards too:
- `Session.move` rejects when either the current or requested Location has `workspaceID`, before path expansion, host filesystem access, or `Project.resolve`;
- hosted prompts accept inline `data:` attachments but reject `file:` attachments before prompt materialization;
- provider-file attachments and hosted Session movement require later `WorkspaceFiles` designs.
## The Hosted Graph Replaces Every Reachable Host Authority
The current Location graph statically includes services that ultimately use host filesystem, process, native search, watcher, PTY, snapshots, project copies, plugins, and configuration. Replacing only `FSUtil` or only `AppProcess` is unsafe:
- replacing all `FSUtil` would redirect host-owned config and managed outputs into the sandbox;
- retaining global `FSUtil` would read project files from the server host;
- retaining current plugin loading could import Workspace modules into the central process;
- retaining PTY, watcher, FFF, snapshot, or project-copy services would act on provider paths as if they were host paths.
The hosted branch therefore supplies dedicated implementations for the services it supports and explicit rejecting implementations for the rest.
### Host-Owned Services
These remain process-global and continue using host storage:
- database and durable event storage;
- global OpenCode configuration;
- credentials and auth stores;
- caches and downloaded assets;
- managed tool output storage;
- model and sandbox-driver credentials;
- built-in and global skills;
- remote URL instruction fetching.
### Workspace-Owned Services In The First Milestone
These use `WorkspaceEnvironment`:
- `Location` metadata;
- one hosted read path;
- one hosted exact-edit path;
- hosted `AGENTS.md` discovery needed by initial System Context;
- a hosted foreground Bash implementation;
- request-driven `git status` through Bash;
- hosted environment facts.
### Hosted Capability Table
| State | Capabilities |
| --- | --- |
| Advertised | read, exact edit, foreground Bash |
| Endpoint remains but returns `UnsupportedWorkspaceCapability` | Session move, `file:` prompt attachments, PTY, project copies, snapshots/revert |
| Not advertised and local producer is not activated | external file paths, apply-patch, glob, grep, project config, project skills, project plugins, application tools, FFF, watcher, LSP, full `Git.Service` |
The first milestone narrows the tool catalog for hosted Sessions. It must not advertise a tool whose execution would fall back to host authority.
Some current service interfaces and Protocol endpoints cannot represent “unsupported” as a typed result. The hosted graph work must add a sanitized `UnsupportedWorkspaceCapability` error where needed; carry placement and acquisition errors through `LocationServiceMap`, `SessionExecution.resume`, `SessionRunner.RunError`, Session error mapping, and HTTP middleware; and remove any `Layer.orDie` that would convert expected hosted availability errors into defects. Public `HttpApi` changes require client regeneration.
## WorkspaceEnvironment Is A Small Data-Plane Interface
```ts
interface WorkspaceEnvironment {
readonly platform: "linux"
readonly root: string
readonly files: WorkspaceFiles
readonly processes: WorkspaceProcesses
readonly shell: {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environment: Readonly<Record<string, string>>
}
}
```
The environment does not expose driver keys, provider resource IDs, bindings, lifecycle methods, preview URLs, or an untyped `raw` escape hatch.
The first environment has no general capability map. Hosted graph construction knows the milestone's fixed supported set. Add capability negotiation only after two real drivers expose a difference a caller must handle dynamically.
## File Operations Preserve Current Cooperative Mutation Semantics
Provider file operations receive normalized Workspace-relative POSIX paths. Core never sends host absolute paths across this boundary.
```ts
interface WorkspaceFiles {
readonly resolve: (
path: WorkspacePath,
) => Effect.Effect<ResolvedWorkspacePath, WorkspaceFileError>
readonly read: (
path: WorkspacePath,
) => Effect.Effect<Uint8Array, WorkspaceFileError>
readonly writeIfUnchanged: (
path: WorkspacePath,
expected: Uint8Array,
content: Uint8Array,
) => Effect.Effect<void, WorkspaceFileError | StaleContentError>
}
```
`WorkspacePath` is relative, slash-separated, and cannot escape with `..`. `ResolvedWorkspacePath` returns a canonical Workspace-relative identity for permission resources and OpenCode mutation locking.
Path normalization has one implementation:
1. Reject an absolute model-facing file path.
2. Resolve the relative input against the absolute provider Location directory with `path.posix`.
3. Verify lexical containment within the Workspace root.
4. Convert the result to a normalized Workspace-root-relative `WorkspacePath`.
5. Ask the driver to resolve symlinks and return the canonical Workspace-relative identity used for permissions and mutation locking.
The driver validates lexical containment and resolves symlinks at operation time. Under the assumed provider-isolation model, a path escape remains inside the provider environment rather than reaching the OpenCode host. The milestone does not validate the provider's isolation boundary or claim race-free containment against a concurrently malicious shell process inside the same sandbox.
`writeIfUnchanged` means OpenCode-coordinated conditional replacement:
- one driver call compares expected bytes and replaces the target;
- the fake serializes this operation with other `WorkspaceFiles` mutations;
- the Vercel implementation uses one provider-side helper invocation and same-filesystem atomic replacement where available;
- unrelated shell or external provider mutations may still race because provider filesystems expose no general CAS primitive.
This matches the first milestone's single mutating Session and no-background-command scope. Do not describe it as a global filesystem transaction.
The Vercel feasibility tracer decides whether direct SDK methods are sufficient or whether all supported file operations need a versioned helper installed in the sandbox image.
## Foreground Commands Use A Narrow Contract Instead Of ChildProcessSpawner
Effect's `ChildProcessSpawner` requires stdin, additional file descriptors, `unref`, PID semantics, combined streams, and other behavior the researched provider adapters do not prove. It also cannot see `AppProcess.RunOptions.timeout`, so adapting only the spawner cannot forward provider-native deadlines.
Define a hosted foreground-command contract at the level the first tools need:
```ts
interface WorkspaceProcesses {
readonly start: (
input: ForegroundCommand,
) => Effect.Effect<WorkspaceProcess, WorkspaceProcessError, Scope.Scope>
}
interface ForegroundCommand {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: WorkspacePath
readonly env: Readonly<Record<string, string>>
readonly timeoutMs: number
}
interface WorkspaceProcess {
readonly stdout: Stream.Stream<Uint8Array, WorkspaceProcessError>
readonly stderr: Stream.Stream<Uint8Array, WorkspaceProcessError>
readonly exit: Effect.Effect<ProcessExit, WorkspaceProcessError>
readonly terminateAndConfirm: Effect.Effect<void, UnconfirmedProcessTermination>
}
```
Hosted Bash and hosted Git-status execution use this service directly. Register a distinct hosted Bash tool implementation in the hosted-safe tool bootstrap; do not reuse the current host-assuming Bash layer. Local Bash remains on the current `AppProcess` path.
Process laws:
- a pre-interrupted call creates no provider command;
- command and argv remain distinct from shell-string lowering;
- non-zero exit is a terminal process result;
- each output channel preserves provider order;
- `exit` settles once and supports multiple waiters;
- timeout is forwarded to the provider before a central deadline is used as a backstop;
- explicit cancellation runs `terminateAndConfirm` uninterruptibly with its own bounded confirmation deadline;
- the selected driver uses the kill-and-confirm sequence proven in Phase 0, with a fresh bounded confirmation signal rather than the caller's aborted signal;
- scope finalization performs best-effort cleanup and logs failures, because Effect finalizers cannot return a typed error;
- process handles are not reconnectable after an OpenCode restart.
The first contract has no stdin, background mode, PTY, provider PID exposure, or combined-output ordering guarantee. Hosted Bash renders stdout and stderr honestly rather than inventing interleaving.
Provider start has a bounded deadline. Use an interruptible checkpoint before one uninterruptible provider-start and finalizer-registration region. Interruption before the checkpoint makes zero provider calls. Once a handle is returned, cleanup is registered exactly once. A transport failure before the provider returns a handle is an indeterminate-start error and is never retried automatically.
The process scope and `WorkspaceEnvironment` scope remain open until streams and terminal settlement finish. Follow one explicit stream shape: `Stream.unwrap` acquires the environment and process in the stream execution scope, drains required channels there, and runs process cleanup before environment release.
## Effect Scopes Own Connections, Not Provider Lifecycle
Each cached hosted Location acquires its own `WorkspaceEnvironment`. Sharing one environment across simultaneous Location directories in the same Workspace is deferred, so the first milestone does not need a second keyed `WorkspaceEnvironmentMap`.
`LocationServiceMap` already owns a scoped, cached Location layer. The hosted Location layer acquires its `WorkspaceEnvironment` directly:
```ts
Layer.effect(
WorkspaceEnvironment.Service,
driver.acquire(binding),
)
```
The driver implements `acquire` once with scoped acquisition and best-effort release. Do not wrap a scoped `acquire` in a second `Effect.acquireRelease`, and do not add `RcMap`, `RcRef`, manual connection counters, or a second idle TTL.
The existing Location TTL therefore also retains the provider handle. This is acceptable for the tracer and must be tested with `TestClock`. Releasing it may be a no-op for stateless SDK objects. It never stops or deletes provider compute.
Introduce keyed environment sharing only when simultaneous Locations need to share a provider handle.
## Errors Describe Distinct Recovery Actions
Start with this Core boundary:
- `WorkspaceNotFound`: registration or placement is absent;
- `WorkspaceUnavailable`: binding decode, credentials, provider outage, missing resource, or acquire failure, with a sanitized category;
- `WorkspaceFileError`: hosted file operation failed;
- `WorkspaceProcessError`: hosted foreground process failed;
- `UnsupportedWorkspaceCapability`: the hosted graph intentionally does not provide an operation;
- `UnconfirmedProcessTermination`: explicit cancellation could not prove the command stopped.
Use `Schema.TaggedErrorClass` for domain errors. Preserve existing tool-facing failure shapes where callers do not need a new distinction. Do not add `WorkspaceDeleted` without a durable tombstone.
## Vercel Is Provisional Until Three Feasibility Gates Pass
Vercel is the provisional first driver because its named persistent sandbox, detached command handle, output logs, timeout, `kill`, `wait`, stop, and delete APIs appear closest to the required slice.
Before production contracts land, a disposable provider-only tracer must prove:
1. **Rooted file behavior:** read and conditional replace reject lexical and symlink escape with the chosen direct-SDK or helper design.
2. **Stable retained identity:** exact-name get reconnects to the same persistent resource without `getOrCreate` silently replacing it.
3. **Confirmed termination:** kill followed by a fresh bounded wait proves the command stopped; a sentinel command cannot mutate after cancellation reports success.
The tracer must also establish the versioned sandbox image or bootstrap containing `git`, the selected shell, and any file helper. Required tools are provisioned, not discovered opportunistically.
For retained Vercel resources, explicitly evaluate persistent mode, non-expiring snapshot policy, retained-snapshot count, billing, and storage behavior. Persist the exact provider resource name in the binding. Do not infer durable retention from SDK defaults.
If Vercel fails a gate, compare Daytona on the same gates before choosing the first driver. Do not weaken the common contract to preserve the initial choice.
## Implementation Proceeds In Mergeable Phases
Each phase contains small commits and its own acceptance criteria. A phase may span packages; a commit should not combine storage, graph wiring, a provider adapter, and end-to-end behavior.
### Phase 0: Prove Provider Feasibility Outside Production Core
Create a disposable tracer, then remove it or retain only a focused test fixture.
Commits or experiments:
1. Prove exact-name create/get/reconnect and persistence configuration.
2. Prove the rooted read/conditional-replace design.
3. Prove detached stdout/stderr plus kill-and-confirm.
4. Record image/bootstrap requirements and SDK versions.
Acceptance:
- all three feasibility gates pass against one provider;
- no production Core contract depends on an unproven SDK behavior;
- observed behavior and provider configuration are recorded in this document.
### Phase 1: Persist Placement And Admit Hosted Sessions Without Host I/O
Commits:
1. Add the internal Hosted Workspace schema/table and store.
2. Add the sandbox-driver binding decoder registry without SDK implementations.
3. Add internal registration for a pre-provisioned fake resource.
4. Add the hosted branch to `SessionV2.create` using `path.posix` and persisted Project metadata.
5. Add hosted metadata resolution without constructing a runnable Location graph.
Hosted registration and admission remain internal test-only operations until Phase 2 installs the runnable hosted graph in production composition. No merged production server may admit a durable Session it cannot resume.
Acceptance:
- registration and Session creation make zero driver acquire calls;
- a hosted Session can be recorded when the provider directory is absent from the host;
- hosted Session creation calls no host `Project.resolve`, Git, or filesystem service;
- unknown Workspace, unknown driver, malformed binding, and out-of-root Location directory fail with typed errors;
- registration rejects a V1 Workspace ID collision;
- hosted Session creation performs no mutation against the V1 `workspace` table;
- the implicit-local Session creation tests remain unchanged;
- no row is added to the V1 `workspace` table.
### Phase 2: Build A Host-Safe Read-Only Location Graph
Commits:
1. Add `WorkspaceEnvironment`, `WorkspaceFiles.read`, and the minimal fake driver.
2. Make `buildLocationServiceMap` select local or hosted graph construction.
3. Add hosted `Location`, read, and environment-fact services.
4. Split instruction production: host-global discovery reads only host-global sources, while hosted upward `AGENTS.md` discovery reads through `WorkspaceFiles`; Session instruction composition combines both.
5. Add a hosted Config projection that retains only approved host-owned model, provider, agent, permission, and built-in/global-skill inputs. It emits no plugin-discovery directories and removes plugin, MCP, command, reference, formatter, LSP, watcher, shell, and snapshot configuration.
6. Add hosted plugin/tool bootstrap that does not call the standard local `PluginInternal.list` or `PluginSupervisor` scan path.
7. Add rejecting implementations and Protocol errors for every endpoint retained by the Hosted Capability Table.
8. Materialize an explicit allowlist containing only the central model setup and hosted read tool.
Acceptance:
- the real Session runner completes a read-only hosted Session against the fake;
- graph boot and read operations trigger host-access tripwires zero times;
- configured global plugins, MCP servers, commands, references, and local tools are neither imported nor advertised for a hosted Location, while remaining active for implicit-local Locations;
- hosted move and `file:` attachment requests fail before host path expansion and trigger host tripwires zero times;
- capabilities follow the Hosted Capability Table;
- expected placement/acquire failures do not become defects;
- Location eviction releases the fake environment but performs no lifecycle operation;
- `TestClock` verifies the current Location TTL behavior;
- implicit-local graph tests remain unchanged.
### Phase 3: Add One Cooperative Exact-Edit Path
Commits:
1. Add `WorkspaceFiles.resolve` and `writeIfUnchanged` to the fake.
2. Add hosted mutation path and permission-resource derivation.
3. Add hosted exact-edit materialization.
4. Add deterministic stale-content and symlink tests.
Acceptance:
- exact edit changes only fake provider storage;
- lexical escape and operation-time symlink escape fail;
- competing `WorkspaceFiles` conditional writes produce one winner and one stale failure;
- unrelated shell races remain explicitly outside the guarantee;
- external hosted file paths are unsupported;
- local edit behavior remains unchanged.
### Phase 4: Add Foreground Bash And Git Status
Commits:
1. Add the fake `WorkspaceProcesses` implementation with deterministic barriers.
2. Add hosted Bash over the narrow process contract.
3. Add hosted authority descriptions and environment allowlisting.
4. Add request-driven `git status` fixture coverage.
Acceptance:
- the driver receives the requested timeout;
- stdout and stderr preserve per-channel order;
- non-zero exit settles normally;
- interruption confirms provider termination before the tool settles;
- abort before start creates no provider command;
- kill refusal returns `UnconfirmedProcessTermination` and logs one orphan diagnostic;
- a later command succeeds after timeout or interruption;
- OpenCode forwards only the explicit environment allowlist and no model or sandbox-driver credentials; provider-injected variables are recorded separately;
- `git status` observes the hosted edit;
- local Bash behavior remains unchanged.
### Phase 5: Prove The Complete Fake Session
Commits:
1. Add one end-to-end Session fixture using the real runner, permissions, tools, and durable settlement.
2. Add Location eviction and reconstruction around the same fake provider resource.
Acceptance:
- the concrete milestone scenario passes through exact edit and Bash;
- each tool call in the fixture produces one terminal durable settlement;
- interrupted process cleanup completes before environment release;
- rebuilding the Location graph reconnects to the same fake resource;
- this phase claims cache/graph reconstruction, not a process restart, because fake state is in memory.
### Phase 6: Add The First Real Sandbox Driver
Commits:
1. Add a lazy driver-loader thunk keyed by the selected sandbox driver.
2. Add the provider binding schema and acquire-only adapter.
3. Add the rooted file implementation chosen in Phase 0.
4. Add foreground process streaming, timeout, and kill confirmation.
5. Provide the selected driver registry from Server composition without loading its SDK on the local-only path.
6. Register a pre-provisioned real-provider binding through the internal registration path.
7. Add credential-gated live contract tests.
8. Add a true second-process restart test that reconstructs placement from SQLite.
Acceptance:
- the complete milestone scenario passes against the real provider;
- a second OpenCode process reconnects to the same resource and reads the edit;
- reconnect returns the same recorded provider identity and `acquire` never invokes create or get-or-create;
- interruption is confirmed with a post-cancel sentinel test;
- OpenCode forwards no model credentials, sandbox-driver credentials, or ambient host environment variables; provider-injected variables are recorded separately;
- implicit-local startup does not evaluate the provider SDK module;
- required live tests run in hosted-feature release validation, not only developer machines.
Result: the first hosted execution milestone is complete.
## Lifecycle Requires A Separate Reviewed Plan
Lifecycle is not part of the execution tracer. Effect `LayerMap.invalidate` does not wait for active references and is not a lifecycle fence.
A follow-up plan must design one shared gate for Session creation, Location lease admission, deactivate, reactivate, and delete; durable transition and retry rules; and explicit behavior for historical Sessions after deletion. Do not expose lifecycle publicly until that gate, authorization, Protocol errors, Server handlers, and generated clients are reviewed together.
## Broader Tools And A Second Driver Follow The Tracer
After the first real hosted Session works, broaden the hosted graph in this order:
1. apply-patch over the established mutation contract;
2. provider-provisioned `rg` and `find`, then glob and grep;
3. data-only project configuration with an explicit field allowlist;
4. project skills;
5. stdin and the required subset of `Git.Service`;
6. a second driver selected to pressure-test the established contract.
Daytona is the likely second driver when Vercel passes Phase 0. It would pressure-test exact resource reconnect, stop/start retention, whole-second timeout rounding, command-session termination confirmation, and current SDK behavior. If Daytona becomes the first driver, select a different second driver with meaningfully different lifecycle or process semantics. Change the common contract only for a concrete difference OpenCode must expose.
## Package Ownership Preserves Dependency Direction
- `packages/schema`: existing provider-neutral IDs and any browser-safe public Workspace metadata.
- `packages/core`: Hosted Workspace domain, placement store, sandbox-driver interface and registry service tag, hosted Location policies, fake driver, and tests.
- `packages/server`: provider SDK adapters, provider credentials/configuration, lazy driver registration, and future lifecycle HTTP handlers.
- `packages/protocol`: sanitized public errors and Workspace endpoints only when a product API is added.
- `packages/client`: generated output only.
Core never imports a provider SDK or Server implementation. Server provides registered drivers to Core composition. Client runtime code does not depend on Core or Server.
## Verification Runs From Package Directories
Expected validation as implementation lands:
```sh
cd packages/core
bun run test -- workspace
bun typecheck
cd ../server
bun run test -- workspace
bun typecheck
cd ../schema
bun typecheck
```
If public Protocol or Server `HttpApi` changes, run `bun run generate` from `packages/client` and inspect generated client diffs. Do not edit generated clients directly.
## Deferred Work Does Not Expand The First Milestone
- public Workspace provisioning and lifecycle APIs;
- replacement bindings and retired-resource reconciliation;
- force-delete and historical Session UX;
- shared environments across simultaneous Locations;
- concurrent mutating Sessions;
- background or reconnectable commands;
- stdin and full `ChildProcessSpawner` parity;
- PTY and LSP;
- filesystem watches;
- snapshots, revert, forks, and project copies;
- full Git mutation and worktree support;
- provider migration and file transfer;
- project plugins and unrestricted application tools;
- preview ports;
- clustered leases and Session execution;
- a public sandbox-driver plugin API;
- a general capability negotiation framework;
- a resident worker inside the sandbox.
## Research Basis
The plan is based on current source review of OpenCode V2, Effect v4, Flue `v2.0.3`, pi `v0.83.0`, `@sandbox-sdk/core`, OpenAI Agents SDK sandbox sessions, and Vercel Open Agents. Provider-specific claims remain Phase 0 feasibility gates rather than settled facts.
+257
View File
@@ -0,0 +1,257 @@
# Workspaces
Status: proposal
A **Workspace** is a durable place a Session executes: a filesystem root plus processes. Hosted execution (Modal, Vercel, ...) becomes a kind of Workspace. The Session model does not change.
## Decisions
- **Workspace is the noun; sandbox is a kind.** `Location.workspaceID` names a Workspace; omitted still means implicit local, unchanged.
- **A Workspace is an empty environment.** No repository, project, or name at creation. Cloning happens later, inside a Session. *A Workspace may contain a Project; a Workspace is not a Project.*
- **Creation is eager.** `create` resolves when the environment is usable. No pending states, no lazy attachment, no detached Sessions.
- **Providers are pluggable drivers** behind a three-verb seam, selected by provider string.
## Public API
```typescript
const workspace = await workspaces.create({ provider: "modal" })
const session = await sessions.create({
location: { workspaceID: workspace.id, directory: workspace.root },
})
```
Consumers persist the Workspace ID as their durable handle and re-derive everything else:
```typescript
const workspace = await workspaces.get(id) // { id, provider, root } — table read, never contacts a provider
```
- `provider` is required for now. A config default (`workspace.provider`) can be added later without breaking anything; skipping it keeps config untouched.
- `root` is an absolute POSIX path in the provider filesystem.
- `get` fails with typed `WorkspaceNotFound` — also the existence check. No `getOrCreate`: the consumer owns its "which Workspace is mine" mapping, and provider-level get-or-create can silently replace resources (Vercel tracer finding).
## Domain Model
| Concept | What it is | Visibility |
| --- | --- | --- |
| Workspace | Durable execution environment: `id` + `root` | Public |
| Sandbox | The hosted kind of Workspace | Vocabulary only, not an API noun |
| Binding | Smallest provider-owned JSON to reconnect to the same resource | Internal, stored opaquely |
| WorkspaceEnvironment | Scoped live connection: files + processes at the root | Internal seam |
| Project | Repository identity discovered *within* a Location | Public, becomes optional |
Binding is all that earlier drafts called "placement" — a column, not a concept. Provider resource replacement (Modal snapshot → new sandbox) is the same Workspace with an updated binding.
## Driver Seam
```typescript title="packages/core/src/workspace/driver.ts"
export * as WorkspaceDriver from "./driver"
export const Binding = Schema.Record(Schema.String, Schema.Json)
export type Binding = typeof Binding.Type
export interface Interface {
// allocate; resolve only when ready to use
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ binding: Binding; root: string }, Error>
// binding -> live capabilities; the ONLY way to obtain an environment
readonly connect: (binding: Binding) => Effect.Effect<WorkspaceEnvironment.Interface, Error, Scope.Scope>
// permanently release provider resources
readonly destroy: (binding: Binding) => Effect.Effect<void, Error>
}
// one error shape for all three verbs; ProviderNotFoundError stays separate
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
provider: Schema.String,
message: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WorkspaceDriver.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Registry {
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/WorkspaceDriverRegistry") {}
```
- Fresh-create and restart-reconnect both flow through `connect` — where the prior tracers found their bugs.
- `connect` is scoped: environment lifetime = the acquiring scope (the existing cached Location graph). Scope closure drops the connection, never the provider resource. No `close` verb.
- Errors are `Schema.TaggedErrorClass` values.
## Defining A Driver
A driver is a plain value built in `packages/server`. Its binding schema is driver-private — opaque JSON becomes typed again at this boundary:
```typescript title="packages/server/src/workspace/modal.ts"
export * as ModalDriver from "./modal"
const ModalBinding = Schema.Struct({ sandboxId: Schema.String })
const ROOT = "/workspace"
export const make = Effect.gen(function* () {
const app = yield* Effect.promise(() => App.lookup("opencode-workspaces", { createIfMissing: true }))
// git, bash, rg provisioned in the image — never discovered opportunistically
const image = Image.fromRegistry("ghcr.io/anomalyco/opencode-workspace:1")
const decode = (binding: WorkspaceDriver.Binding) =>
Schema.decodeUnknownEffect(ModalBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "modal", cause })),
)
return WorkspaceDriver.make({
create: ({ workspaceID }) =>
Effect.promise(() => Sandbox.create(app, { image, name: workspaceID })).pipe(
Effect.map((sandbox) => ({ binding: { sandboxId: sandbox.sandboxId }, root: ROOT })),
),
connect: Effect.fnUntraced(function* (binding) {
const decoded = yield* decode(binding)
const sandbox = yield* Effect.promise(() => Sandbox.fromId(decoded.sandboxId))
return WorkspaceEnvironment.make({
platform: "linux",
directory: ROOT,
files: modalFiles(sandbox), // Files over the sandbox filesystem API
process: modalSpawner(sandbox), // ChildProcessSpawner over sandbox.exec
shell: WorkspaceEnvironment.linuxShell,
})
}),
destroy: (binding) =>
decode(binding).pipe(
Effect.flatMap((decoded) =>
Effect.promise(async () => {
const sandbox = await Sandbox.fromId(decoded.sandboxId)
await sandbox.terminate()
}),
),
),
})
})
```
## Registering Drivers
Ordinary Server composition. The registry is an immutable map fixed at boot:
```typescript title="packages/server/src/workspace/drivers.ts"
export * as ServerWorkspaceDrivers from "./drivers"
export const layer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const drivers = {
modal: yield* ModalDriver.make,
vercel: yield* VercelDriver.make,
}
return WorkspaceDriver.RegistryService.of({
get: (provider) =>
drivers[provider]
? Effect.succeed(drivers[provider])
: Effect.fail(new WorkspaceDriver.ProviderNotFoundError({ provider })),
})
}),
)
export const node = makeGlobalNode({ service: WorkspaceDriver.RegistryService, layer, deps: [] })
```
Core consumes it blindly:
```typescript
// workspaces.create
const driver = yield* registry.get(input.provider)
const created = yield* driver.create({ workspaceID: id })
yield* store.insert({ id, provider: input.provider, binding: created.binding, root: created.root })
// hosted Location graph construction (inside the existing scoped cache)
const workspace = yield* store.get(location.workspaceID)
const driver = yield* registry.get(workspace.provider)
const env = yield* driver.connect(workspace.binding)
```
- Core defines the key and consumes; Server defines drivers and provides the layer; core never sees a provider SDK.
- Drivers ship in-tree for now. Plugin-contributed drivers later change only how the map is built; `Registry.get` and consumers are untouched.
## Environment
Reuses the seam proven on `origin/remote-workspaces-plan` (`fd92aeac66`) — a local implementation exists and the Location graph composes over it:
```typescript title="packages/core/src/workspace/environment.ts"
export * as WorkspaceEnvironment from "./environment"
export interface Interface {
readonly platform: NodeJS.Platform
readonly directory: string // the Workspace root, absolute in the provider filesystem
readonly files: Files // read / resolve / list / write / writeIfUnchanged / remove ...
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell // executable + args lowering for the bash tool; linuxShell default
}
```
- `files` is separate from `process`: providers expose direct filesystem APIs far faster than shelling out `cat`, and read/write/edit are the hottest ops.
- Core builds tools (bash, read, edit, glob, grep) on top. Drivers never know what a tool is.
- The branch's `ripgrep` field is dropped — tool implementation detail leaking into the seam. The image contract mandates `rg`; the hosted graph provides the existing `RipgrepBinary.Service` as `Effect.succeed("rg")`.
## Persistence
One V2-owned table; no interaction with the V1 `workspace` table. Metadata reads never contact a provider.
```typescript
const table = sqliteTable("workspace", {
id: text().primaryKey(), // Workspace.ID
provider: text().notNull(), // driver registry key
binding: text({ mode: "json" }).notNull(), // opaque driver-owned JSON
root: text().notNull(), // absolute POSIX root in provider filesystem
time_created: integer().notNull(),
time_updated: integer().notNull(),
})
```
## Core Changes
1. **Session admission** — `workspaceID` present skips host `Project.resolve`; directory validated with `path.posix` containment in the root. `session.project_id` becomes optional: an empty Workspace has no honest Project.
2. **Location graph** — `LocationServiceMap` selects local or hosted construction; hosted acquires via `driver.connect(binding)` inside the existing scoped cache.
3. **Tool catalog** — hosted Locations advertise only environment-backed tools. Nothing may fall back to host authority.
| In an empty Workspace | |
| --- | --- |
| Available | read/write/edit, bash, glob/grep, global config/agents/instructions, models, integrations |
| Needs a Project | git status/diffs, snapshots/revert, project instructions/config/skills/plugins, repo-scoped permissions |
### Project Identity For Hosted Sessions
- Admission consults **stored facts only** — never live discovery, never a driver. A stopped sandbox must not block `sessions.create`.
- An empty Workspace has no repository, so hosted Sessions reuse `Project.ID.global` — the same degenerate Project local non-VCS directories already get. This is "not discovered yet," not a new concept.
- Project identity is machine-independent by design (`Hash.fast("git-remote:" + origin)`, root-commit fallback). Once the rediscover slice runs discovery *inside* the Workspace, a hosted clone of a repo joins the same Project as local checkouts automatically.
- The rediscover slice adds nullable `workspace.project_id`, stamped when discovery finds a repo; admission then reads `workspace.project_id ?? global`. Not added now — nothing writes it in this slice.
## First Milestone
1. **Fake driver, real runner.** `create()` → Session at root → write file → run command → evict + rebuild Location graph → reconnect → file still there. Local paths byte-identical throughout.
2. **First real driver.** Vercel provisional, Modal fallback — decided by the feasibility gates in `remote-workspace-execution.md`. Credential-gated live tests; second-process restart test reconstructing the binding from SQLite.
Next slice (not this one): clone-a-repo-during-a-Session, which needs an explicit "rediscover Location context" operation (Project detection, config rebuild, instruction-epoch refresh).
Deferred: lazy attachment / detached Sessions, stop/resume + TTL policy, multi-Session Workspaces, PTY / LSP / watchers / snapshots, plugin driver API, preview ports.
## Open Questions
- Does `ChildProcessSpawner`'s full surface (stdin, extra fds, `unref`, PIDs) map honestly to provider process APIs? The superseded plan proposed a narrower foreground contract; resolve against the first real driver.
- `session.project_id` nullability migration and Project-requiring read models.
- Where `workspaces.create` surfaces first: SDK/HTTP only; TUI/web later.
## Prior Art
`origin/remote-workspaces-plan`: `09903e120f` (plan + live Vercel tracer), `fd92aeac66` (environment seam + local impl), `d1b9b6c9ce` (live Modal tracer: reconnect, snapshot, restore), `650d5a5e92` (lifecycle). Both tracers already worked repository-free; only that branch's outer Workspace API carried Project assumptions — dropped here.
`specs/v2/remote-workspace-execution.md` is superseded for domain model and API, retained for execution research: feasibility gates, process laws, host-authority tripwires, phase acceptance criteria.