From 952d4d446f5aaf25d7b8f3e5ae1e20df61d4655f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 7 Aug 2026 19:36:55 -0400 Subject: [PATCH] feat(server): add modal environment driver (#41118) --- bun.lock | 39 ++++ packages/core/package.json | 1 + packages/server/package.json | 3 +- packages/server/src/workspace/modal.ts | 179 ++++++++++++++++++ .../server/test/environment-modal.test.ts | 67 +++++++ 5 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/workspace/modal.ts create mode 100644 packages/server/test/environment-modal.test.ts diff --git a/bun.lock b/bun.lock index 3137dadf68b..38c97e4832d 100644 --- a/bun.lock +++ b/bun.lock @@ -679,6 +679,7 @@ "@opencode-ai/util": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", + "modal": "0.9.0", }, "devDependencies": { "@tsconfig/bun": "catalog:", @@ -1512,6 +1513,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=="], @@ -1720,6 +1733,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=="], @@ -1814,6 +1831,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=="], @@ -3298,6 +3317,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=="], @@ -3538,6 +3559,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=="], @@ -4570,6 +4595,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=="], @@ -4796,6 +4823,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=="], @@ -4834,6 +4863,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=="], @@ -5632,6 +5665,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=="], @@ -6590,6 +6625,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=="], @@ -6734,6 +6771,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=="], diff --git a/packages/core/package.json b/packages/core/package.json index 91df924d94c..a8b986d3c67 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,6 +18,7 @@ }, "exports": { "./environment": "./src/environment/index.ts", + "./testing/environment-conformance": "./test/lib/environment-conformance.ts", "./session/runner": "./src/session/runner/index.ts", "./instructions": "./src/instructions/index.ts", "./*": "./src/*.ts" diff --git a/packages/server/package.json b/packages/server/package.json index 861c5f8385c..0a574ea56d9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,7 +20,8 @@ "@opencode-ai/simulation": "workspace:*", "@opencode-ai/util": "workspace:*", "drizzle-orm": "catalog:", - "effect": "catalog:" + "effect": "catalog:", + "modal": "0.9.0" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/server/src/workspace/modal.ts b/packages/server/src/workspace/modal.ts new file mode 100644 index 00000000000..86391d3b180 --- /dev/null +++ b/packages/server/src/workspace/modal.ts @@ -0,0 +1,179 @@ +import { Effect, Sink, Stream } from "effect" +import { systemError } from "effect/PlatformError" +import type { Command, KillOptions } from "effect/unstable/process/ChildProcess" +import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner" +import type { Driver } from "@opencode-ai/core/environment" +import type { ModalClientParams, Sandbox, SandboxCreateParams } from "modal" + +const INNER_WRAPPER = ` +pidfile=$1 +shift +printf "%s" "$$" > "$pidfile" +trap 'rm -f -- "$pidfile"' EXIT +"$@" +` + +const KILL = ` +i=0 +while [ ! -s "$1" ] && [ "$i" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done +if [ -s "$1" ]; then + pid=$(cat "$1") + /bin/kill "-$2" "-$pid" 2>/dev/null || true +else + exit 47 +fi +` + +export interface ModalImageSpec { + readonly registry: string + readonly dockerfileCommands: ReadonlyArray +} + +export interface ModalSandboxOptions { + readonly app: string + readonly client?: ModalClientParams + readonly image?: ModalImageSpec + readonly sandbox?: SandboxCreateParams +} + +/** + * Ubuntu supplies the GNU coreutils and findutils required by the derived Files + * scripts. Busybox images do not satisfy the Environment contract. + */ +export const ubuntuImage: ModalImageSpec = { + registry: "ubuntu:24.04", + dockerfileCommands: [ + "RUN apt-get update && apt-get install -y --no-install-recommends git bash ripgrep ca-certificates coreutils findutils util-linux", + ], +} + +/** Creates a Modal sandbox lazily, keeping the SDK off the server startup path when Modal is unused. */ +export const createModalSandbox = async (options: ModalSandboxOptions) => { + const { ModalClient } = await import("modal") + const client = new ModalClient(options.client) + const app = await client.apps.fromName(options.app, { createIfMissing: true }) + const imageSpec = options.image ?? ubuntuImage + const image = client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands]) + const sandbox = await client.sandboxes.create(app, image, options.sandbox) + return { + driver: makeModalDriver(sandbox), + sandbox, + terminate: () => sandbox.terminate(), + } +} + +/** + * Adapts Modal exec to the Environment driver. Files intentionally has no native + * overrides: Modal exec and filesystem tools share the same roughly 175ms floor, + * so the derived exec defaults are the simplest implementation with no measured loss. + * + * Modal cannot signal a ContainerProcess. Each command therefore starts a new + * process group and records its leader in a unique pid file; kill runs a second + * sandbox command that signals that group. Pid files are removed best-effort. + */ +export const makeModalDriver = (sandbox: Sandbox): Driver => { + const spawn = Effect.fnUntraced(function* (command: Command) { + if (command._tag === "PipedCommand") { + return yield* Effect.fail(spawnError("spawn", "piped commands unsupported")) + } + if (command.options.additionalFds) { + return yield* Effect.fail(spawnError("spawn", "additional file descriptors unsupported")) + } + + const pidFile = `/tmp/opencode-process-${crypto.randomUUID()}.pid` + const env = compact(command.options.env) + const isolatedEnv = + command.options.extendEnv === false || (!command.options.extendEnv && command.options.env !== undefined) + const argv = isolatedEnv ? ["env", "-i", ...Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`)] : [] + const process = yield* Effect.tryPromise({ + try: () => + sandbox.exec( + ["setsid", "--wait", "sh", "-c", INNER_WRAPPER, "sh", pidFile, ...argv, command.command, ...command.args], + { + mode: "binary", + stdout: "pipe", + stderr: "pipe", + workdir: command.options.cwd, + env: isolatedEnv ? undefined : env, + }, + ), + catch: (cause) => spawnError("spawn", undefined, cause), + }) + + const onError = (cause: unknown) => spawnError("process", undefined, cause) + let exited = false + const writer = process.stdin.getWriter() + let closingStdin: Promise | undefined + const waited = process.wait().then((code) => { + exited = true + if (!closingStdin) writer.releaseLock() + return code + }) + const exitCode = Effect.tryPromise({ try: () => waited, catch: onError }).pipe(Effect.map(ExitCode)) + const kill = (options?: KillOptions) => { + if (exited) return Effect.void + return Effect.tryPromise({ + try: async () => { + const killer = await sandbox.exec(["sh", "-c", KILL, "sh", pidFile, options?.killSignal ?? "SIGTERM"], { + stdout: "pipe", + stderr: "pipe", + }) + const code = await killer.wait() + if (code !== 0) throw new Error(`modal kill exited ${code}`) + }, + catch: onError, + }).pipe(Effect.andThen(exitCode), Effect.asVoid) + } + + yield* Effect.addFinalizer(() => kill(command.options).pipe(Effect.ignore)) + + const closeStdin = Effect.tryPromise({ + try: () => (closingStdin ??= writer.close().finally(() => writer.releaseLock())), + catch: onError, + }) + const writeStdin = Sink.forEach((chunk: Uint8Array) => + Effect.tryPromise({ try: () => writer.write(chunk), catch: onError }), + ) + const inputConfig = command.options.stdin + const inputOptions = + inputConfig !== undefined && typeof inputConfig === "object" && !Stream.isStream(inputConfig) + ? inputConfig + : undefined + const input = inputOptions?.stream ?? inputConfig + const stdin = inputOptions?.endOnDone === false ? writeStdin : writeStdin.pipe(Sink.ensuring(closeStdin)) + if (input === "ignore") { + yield* closeStdin + } + if (Stream.isStream(input)) { + yield* Effect.forkScoped(Stream.run(input, stdin)) + } + + const stdout = Stream.fromReadableStream({ evaluate: () => process.stdout, onError }) + const stderr = Stream.fromReadableStream({ evaluate: () => process.stderr, onError }) + return makeHandle({ + pid: ProcessId(crypto.getRandomValues(new Uint32Array(1))[0]), + exitCode, + isRunning: Effect.sync(() => !exited), + kill, + stdin, + stdout, + stderr, + all: Stream.merge(stdout, stderr), + getInputFd: () => Sink.fail(spawnError("getInputFd", "unsupported")), + getOutputFd: () => Stream.fail(spawnError("getOutputFd", "unsupported")), + unref: Effect.succeed(Effect.void), + }) + }) + + return { spawner: make(spawn) } +} + +const compact = (env: Record | undefined) => { + if (!env) return undefined + return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]]))) +} + +const spawnError = (method: string, description?: string, cause?: unknown) => + systemError({ _tag: "Unknown", module: "ModalDriver", method, description, cause }) + +export * as ModalDriver from "./modal" diff --git a/packages/server/test/environment-modal.test.ts b/packages/server/test/environment-modal.test.ts new file mode 100644 index 00000000000..621d3f5b6ad --- /dev/null +++ b/packages/server/test/environment-modal.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterAll, expect, test } from "bun:test" +import { Effect } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { Failed, makeFiles } from "@opencode-ai/core/environment" +import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance" +import { createModalSandbox } from "../src/workspace/modal" + +const enabled = + !!process.env.OPENCODE_TEST_MODAL && + ((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) || + fs.existsSync(path.join(os.homedir(), ".modal.toml"))) +const modalTest = enabled ? test : test.skip +const root = `/tmp/opencode-environment-${crypto.randomUUID()}` +const sandbox = enabled + ? createModalSandbox({ + app: "opencode-environment-tests", + sandbox: { timeoutMs: 10 * 60 * 1000 }, + }) + : undefined + +modalTest( + "kills a running modal process", + async () => { + const value = await sandbox! + const started = performance.now() + const exitCode = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const handle = yield* value.driver.spawner.spawn(ChildProcess.make("sleep", ["300"])) + yield* handle.kill() + return yield* handle.exitCode + }), + ).pipe(Effect.timeout("10 seconds")), + ) + expect(exitCode).not.toBe(0) + expect(performance.now() - started).toBeLessThan(10_000) + }, + 15_000, +) + +environmentConformance( + "modal environment", + () => + Effect.promise(async () => { + const value = await sandbox! + return { + files: makeFiles(value.driver), + root, + symlink: (target, link) => + Effect.scoped(value.driver.spawner.exitCode(ChildProcess.make("ln", ["-s", "--", target, link]))).pipe( + Effect.flatMap((code) => + code === 0 ? Effect.void : Effect.fail(new Failed({ path: link, cause: new Error(`ln exited ${code}`) })), + ), + Effect.mapError((cause) => (cause instanceof Failed ? cause : new Failed({ path: link, cause }))), + ), + } + }), + !enabled, +) + +afterAll(async () => { + if (!sandbox) return + await (await sandbox).terminate() +})