fix(agents/orb): use standard SandboxAgent client for AgentOS sandbox

The in-process DockerSandboxClient could never satisfy AgentOS 0.2.10's
sandbox serialization contract: AgentOS serializes sandbox mounts through
getSerializableClientConfig, which reads client.baseUrl and passes it to the
sidecar. An in-process object has no network endpoint.

Replace the custom adapter with the supported boundary: SandboxAgent.start
with sandbox-agent/docker, which starts a sandbox-agent server inside a
Docker container with a dynamically mapped host port and returns a
SandboxAgent client whose baseUrl both the main process and the sidecar
subprocess reach over 127.0.0.1. Dispose calls destroySandbox so no
containers are left behind.

Remove 700+ lines of dead DockerSandboxClient infrastructure (PID tracking,
log files, signal handling) now handled natively by the sandbox-agent
server. Add a sqlite_file database descriptor to AgentOs.create so session
storage works. Wrap command execution in sh -c for the SandboxAgent API.
Apply chmod 600 to the real OpenCode config path containing the gateway key.

The real proof with the main .env now passes all three stages:
ORB_STAGE_DOCKER_OK, ORB_STAGE_AGENTOS_OK, ORB_STAGE_MODEL_TURN_OK,
ending ORB_PROOF_PASSED with zero leftover containers.

Update README to describe the real SandboxAgent + Docker topology and
remove the misleading 'missing sidecar' limitation.
This commit is contained in:
-Puter
2026-07-24 22:38:11 +05:30
parent e31f50af32
commit 8ddecc098f
7 changed files with 297 additions and 1033 deletions

View File

@@ -161,14 +161,18 @@
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"dockerode": "^5.0.1",
"effect": "catalog:",
"get-port": "^7.2.0",
"hono": "4.12.31",
"sandbox-agent": "0.4.2",
"valibot": "^1.4.2",
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"@types/dockerode": "^4.0.1",
"typescript": "catalog:",
},
},
@@ -567,6 +571,8 @@
"@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="],
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
"@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="],
"@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="],
@@ -833,6 +839,10 @@
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
"@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.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
"@hono/node-server": ["@hono/node-server@2.0.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA=="],
"@hono/standard-validator": ["@hono/standard-validator@0.2.3", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=3.9.0" } }, "sha512-bp9vHu6Va6SfMHC3D4ZLBbT/woi+AZ9CRdTXQu3kLJuLh2W/Gb9UO4hijS+BQAGFXi4EGpXdetxpzwTAawSVeg=="],
@@ -933,6 +943,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=="],
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="],
"@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="],
@@ -1355,6 +1367,20 @@
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@sandbox-agent/cli": ["@sandbox-agent/cli@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli-darwin-arm64": "0.4.2", "@sandbox-agent/cli-darwin-x64": "0.4.2", "@sandbox-agent/cli-linux-arm64": "0.4.2", "@sandbox-agent/cli-linux-x64": "0.4.2", "@sandbox-agent/cli-win32-x64": "0.4.2" }, "bin": { "sandbox-agent": "bin/sandbox-agent" } }, "sha512-trO//ypJBSt5xkewuol9LOykvDgHwUXq8R+yQVS+0CmpN3lYUtewHkb+At9RVGRhDMmJZY2oasaXDnhfurQ33w=="],
"@sandbox-agent/cli-darwin-arm64": ["@sandbox-agent/cli-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+L1O8SI7k/LLhyB4dG0ghmz1cJHa0WtVjuRTrEE2gw/5EbGLWopPBsCVCmQ7snrQ4fPwtaiZDhfExcEj1VI7aw=="],
"@sandbox-agent/cli-darwin-x64": ["@sandbox-agent/cli-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dDg/EwWsdgVVbJiiCX1scSNRRA48u77SsC7Tuqrfzx4fIJMLuLiIcmEtXQyCBWysSyQNV2Cr+PYXXQfCb3xg8g=="],
"@sandbox-agent/cli-linux-arm64": ["@sandbox-agent/cli-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-TGmTUexMoubmWQyTeaOJu0rDVl2h0Ifh1pZ0ceZy7u/6Eoqs2n46CbfQtasUxZJf10uxPgRyzEDhcdDrTYVQUA=="],
"@sandbox-agent/cli-linux-x64": ["@sandbox-agent/cli-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-H9Rbqq0DRkCHvakzefJUDrDa2y+vJjlYd5/tefzKbQ34locE13TGNygRLxdEVXpBECjK9wVdBwTVEphQNsOcjw=="],
"@sandbox-agent/cli-shared": ["@sandbox-agent/cli-shared@0.4.2", "", {}, "sha512-sjZXRkKeFXCSKR6hHzF2Af8CCRO3F3WFwVQJ22+sLTXJ2xskV8lkUE4egknQU9B5BC1Zumts/YiNCFQWG85awQ=="],
"@sandbox-agent/cli-win32-x64": ["@sandbox-agent/cli-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-lZNfHWPwQe/VH51Yvrl/ATCUvBZ3a+c8mwovojhQcmZlv4QuUQPkuvxhPqHRh9AyBx78L5J/ha46es2doa34nQ=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@secure-exec/core": ["@secure-exec/core@0.2.1", "", { "dependencies": { "better-sqlite3": "^12.8.0" } }, "sha512-HsnUv6gClpMA1BBRmX86j30TKTZtgJC/fO1tVavr7IpM2zNKbHU8LgSlBd7mv2SNy02ImTmU/GnQ3aYB4NSbEg=="],
@@ -1591,6 +1617,10 @@
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
"@types/dockerode": ["@types/dockerode@4.0.1", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -1629,6 +1659,8 @@
"@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.165.0", "", { "dependencies": { "@tweenjs/tween.js": "~23.1.1", "@types/stats.js": "*", "@types/webxr": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw=="],
@@ -1723,6 +1755,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acp-http-client": ["acp-http-client@0.4.2", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.16.1" } }, "sha512-3wtPieF08YIU4vNXaoL5up/1D0if4i9IX3Ye5q/bwbcwg1BKsazIK/VNNfvN4ldbPjWul69IqIOpGRS3I0qo3Q=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"agent-cli-detector": ["agent-cli-detector@0.1.4", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q=="],
@@ -1757,6 +1791,8 @@
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="],
"assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
@@ -1801,6 +1837,8 @@
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"better-auth": ["better-auth@1.6.15", "", { "dependencies": { "@better-auth/core": "1.6.15", "@better-auth/drizzle-adapter": "1.6.15", "@better-auth/kysely-adapter": "1.6.15", "@better-auth/memory-adapter": "1.6.15", "@better-auth/mongo-adapter": "1.6.15", "@better-auth/prisma-adapter": "1.6.15", "@better-auth/telemetry": "1.6.15", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-0nuQuEru3ZrLF+9xFUuN3llAmR+6gHLtLunoXaZxB9lXGjSmfBcc6SZUgYq4DfzugPnLvdnzYazsyprZFSFC4Q=="],
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
@@ -1863,6 +1901,8 @@
"buffer-xor": ["buffer-xor@1.0.3", "", {}, "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
@@ -1999,6 +2039,8 @@
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"create-ecdh": ["create-ecdh@4.0.4", "", { "dependencies": { "bn.js": "^4.1.0", "elliptic": "^6.5.3" } }, "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A=="],
"create-hash": ["create-hash@1.2.0", "", { "dependencies": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg=="],
@@ -2169,6 +2211,10 @@
"dnssd-advertise": ["dnssd-advertise@1.1.6", "", {}, "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg=="],
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
"dockerode": ["dockerode@5.0.1", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4" } }, "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dom-helpers": ["dom-helpers@3.4.0", "", { "dependencies": { "@babel/runtime": "^7.1.2" } }, "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA=="],
@@ -2447,6 +2493,8 @@
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
"get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
@@ -2769,6 +2817,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.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
@@ -3005,6 +3055,8 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"nanostores": ["nanostores@1.4.1", "", {}, "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q=="],
@@ -3443,6 +3495,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sandbox-agent": ["sandbox-agent@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2", "acp-http-client": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli": "0.4.2" }, "peerDependencies": { "@cloudflare/sandbox": ">=0.1.0", "@daytonaio/sdk": ">=0.12.0", "@e2b/code-interpreter": ">=1.0.0", "@fly/sprites": ">=0.0.1", "@vercel/sandbox": ">=0.1.0", "computesdk": ">=0.1.0", "dockerode": ">=4.0.0", "get-port": ">=7.0.0", "modal": ">=0.1.0" }, "optionalPeers": ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@fly/sprites", "@vercel/sandbox", "computesdk", "dockerode", "get-port", "modal"] }, "sha512-fH6WDQEaIrgiu93LxZcy+4Dx+t+/cslu+hzXImDyUlsaL6jV2jIv4fdxELkALlo7uzyEDVK9lmqs9qy65RHwBQ=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -3539,6 +3593,8 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
@@ -3547,6 +3603,8 @@
"sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
@@ -3701,6 +3759,8 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
@@ -3973,6 +4033,8 @@
"@google/genai/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
"@grpc/grpc-js/@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=="],
"@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="],
"@jest/types/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
@@ -4051,6 +4113,12 @@
"@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"@types/docker-modem/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/dockerode/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/ws/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/yauzl/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
@@ -4609,6 +4677,12 @@
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"@types/docker-modem/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"@types/dockerode/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/ws/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"@types/yauzl/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],

View File

@@ -12,21 +12,25 @@
"run:zopu": "bun --env-file=../../.env flue run zopu"
},
"dependencies": {
"@agentos-software/opencode": "0.2.7",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
"dockerode": "^5.0.1",
"effect": "catalog:",
"get-port": "^7.2.0",
"hono": "4.12.31",
"valibot": "^1.4.2",
"@agentos-software/opencode": "0.2.7"
"sandbox-agent": "0.4.2",
"valibot": "^1.4.2"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"@types/dockerode": "^4.0.1",
"typescript": "catalog:"
}
}

View File

@@ -11,9 +11,9 @@ One logical execution workspace for one ProjectIssue run. An Orb bundles an Agen
├──────────────────────────────────────────────────┤
│ OrbHandle │
│ ┌─────────────┐ ┌────────────────────────┐ │
│ │ AgentOS VM │ │ Docker Sandbox │ │
│ │ (OpenCode │◄──►│ (bun, tests, builds, │ │
│ │ ACP agent) │ │ repo checkout) │ │
│ │ AgentOS VM │ │ SandboxAgent + Docker │ │
│ │ (OpenCode │◄──►│ (sandbox-agent server │ │
│ │ ACP agent) │ │ in a Docker container)│ │
│ └─────────────┘ └────────────────────────┘ │
│ │ │ │
│ session events runProcess / │
@@ -22,7 +22,7 @@ One logical execution workspace for one ProjectIssue run. An Orb bundles an Agen
└──────────────────────────────────────────────────┘
```
The AgentOS VM runs the OpenCode ACP adapter (lightweight agent loop, session management, durable identity). Heavy execution — package installs, test suites, builds — runs inside the Docker sandbox via `runProcess`. The sandbox filesystem is mounted into the VM at `/mnt/sandbox`.
The AgentOS VM runs the OpenCode ACP adapter (lightweight agent loop, session management, durable identity). Heavy execution — package installs, test suites, builds — runs inside a Docker container hosting a sandbox-agent server. The `DockerSandboxProvider` calls `SandboxAgent.start({ sandbox: docker(...) })`, which starts the server in a Docker container with a dynamically-mapped host port and returns a `SandboxAgent` client whose `baseUrl` both the main process and the AgentOS sidecar subprocess reach over `127.0.0.1`. The sandbox filesystem is mounted into the VM at `/mnt/sandbox`.
## Domain model
@@ -64,11 +64,11 @@ No permanent provider credentials are stored in project files. API keys are inje
## Docker requirements
- Docker daemon running and accessible via `docker` CLI
- The proof fixture uses `oven/bun:1.3-debian` by default
- Containers run with `--cap-drop=ALL --security-opt=no-new-privileges`
- Memory limited to 2g, CPUs to 2
- Writable mount is the project workspace only
- Docker daemon running and accessible via the Docker socket (`/var/run/docker.sock`)
- The sandbox uses `rivetdev/sandbox-agent` as its Docker image by default
- The `sandbox-agent/docker` provider creates containers with `AutoRemove` and a dynamically allocated host port
- Writable bind mount is the project workspace only (mounted at `/home/sandbox` inside the container)
- The AgentOS sidecar subprocess reaches the sandbox-agent server over `127.0.0.1:<hostPort>`
## Local startup
@@ -87,21 +87,19 @@ Stable markers: `ORB_PROOF_PASSED` (exit 0), `ORB_PROOF_BLOCKED` (exit 2), `ORB_
## Filesystem layout
```
Container (/workspace = host bind mount)
/workspace/repository/ — project checkout
/workspace/control/ — issue + context files
/tmp/orb-pids/ — process PID tracking
SandboxAgent container (/home/sandbox = host bind mount)
/home/sandbox/repository/ — project checkout
/home/sandbox/control/ — issue + context files
AgentOS VM
/mnt/sandbox/ — sandbox mount point
AgentOS VM (host process)
/mnt/sandbox/ — sandbox mount (via SandboxAgent baseUrl)
/mnt/sandbox/repository/ — repo (via sandbox)
/root/.config/opencode/ — OpenCode config
/root/.config/opencode/ — OpenCode config (chmod 600)
```
## Current limitations
- AgentOS VM creation requires the sidecar binary; the proof fixture exercises the Docker sandbox path and skips the OpenCode session when no sidecar is available.
- `sendProcessInput` is not supported by the Docker sandbox.
- No automatic merge or production deployment capability.
- No multi-region support.
- Interactive PTY sessions are not wired through the Docker sandbox.
- Interactive PTY sessions are not wired through the sandbox agent.
- The model turn stage depends on a reachable OpenAI-compatible gateway; if the gateway is unreachable the proof reports BLOCKED at that stage but still passes Docker and AgentOS/OpenCode.

View File

@@ -3,34 +3,17 @@ import { spawn } from "node:child_process";
import { setTimeout as sleepTimer } from "node:timers/promises";
import { Effect } from "effect";
import { afterEach, describe, expect, it as vitestIt } from "vitest";
import { describe, expect, it as vitestIt } from "vitest";
import { DockerSandboxProvider } from "./docker-sandbox";
import { OrbSandboxError } from "./domain";
// Real containers need more than the 5s default; bind a generous timeout here.
const it = (name: string, fn: () => Promise<void>): void => {
vitestIt(name, fn, 30_000);
};
// Node-compatible CLI helpers so the suite runs under vitest/node workers as
// well as the Bun runtime. `Bun.*` globals are absent under vitest.
const runCliText = (args: readonly string[]): Promise<string> => {
const [command = "docker", ...rest] = args;
const proc = spawn(command, rest, {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
// eslint-disable-next-line promise/avoid-new -- wrapping one-shot stream events in a single promise
return new Promise((resolve) => {
proc.stdout?.on("data", (c: Buffer) => chunks.push(c));
proc.on("close", () => {
resolve(Buffer.concat(chunks).toString("utf-8"));
});
});
vitestIt(name, fn, 60_000);
};
// Node-compatible Docker availability check.
const runCliExit = (args: readonly string[]): Promise<number | null> =>
// eslint-disable-next-line promise/avoid-new -- wrapping one-shot child close in a single promise
new Promise((resolve) => {
@@ -56,221 +39,121 @@ const dockerAvailable = async (): Promise<boolean> => {
}
};
const containerExists = async (name: string): Promise<boolean> => {
const listing = await runCliText([
"docker",
"ps",
"-a",
"--filter",
`name=^${name}$`,
"--format",
"{{.ID}}",
]);
return listing.trim().length > 0;
};
const created: string[] = [];
afterEach(async () => {
for (const name of created.splice(0)) {
// eslint-disable-next-line no-await-in-loop -- sequential cleanup of test containers
await runCliText(["docker", "rm", "-f", name]);
}
});
// Top-level await so describe.skipIf can evaluate Docker availability at registration.
// Top-level await so describe.skipIf evaluates Docker availability at registration.
const HAVE_DOCKER = await dockerAvailable();
const uniqueName = (label: string): string => {
const name = `orb-test-${label}-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
created.push(name);
return name;
};
const tmpDir = (): string =>
`/tmp/orb-test-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
const statusOf = async (
client: {
listProcesses: () => Promise<{
processes: { id: string; status?: string }[];
}>;
},
id: string
): Promise<string | undefined> => {
const list = await client.listProcesses();
return list.processes.find((p) => p.id === id)?.status;
};
describe.skipIf(!HAVE_DOCKER)(
"DockerSandboxProvider (SandboxAgent + Docker)",
() => {
it("starts a SandboxAgent server and exposes a reachable baseUrl", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
try {
const client = await provider.start();
expect(provider.isStarted).toBe(true);
const waitForStatus = async (
check: () => Promise<string | undefined>,
target: string,
timeoutMs = 8000
): Promise<string | undefined> => {
const deadline = Date.now() + timeoutMs;
let status: string | undefined;
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop -- polling probes sequentially
status = await check();
if (status === target) {
return status;
}
// eslint-disable-next-line no-await-in-loop -- polling probes sequentially
await sleepTimer(250);
}
return status;
};
// The SandboxAgent client must have a baseUrl property — this is what
// AgentOS serializes so the sidecar can reach the sandbox.
const { baseUrl } = client as unknown as { baseUrl: string };
expect(baseUrl).toBeTruthy();
expect(baseUrl).toMatch(/^https?:\/\//u);
describe.skipIf(!HAVE_DOCKER)("DockerSandboxProvider lifecycle", () => {
it("starts a container and removes it on dispose", async () => {
const name = uniqueName("start");
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
containerName: name,
hostWorkspacePath: `/tmp/${name}`,
})
);
const client = await provider.start();
expect(provider.isStarted).toBe(true);
// Run a command to verify the sandbox-agent server actually works.
const result = await client.runProcess({
args: ["-c", "echo hello-orb"],
command: "sh",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("hello-orb");
} finally {
await provider.dispose();
}
});
const version = await client.runProcess({ command: "bun --version" });
expect(version.exitCode).toBe(0);
expect((version.stdout ?? "").trim()).toMatch(/\d/u);
it("is idempotent: start returns the same client on repeated calls", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
try {
const c1 = await provider.start();
const c2 = await provider.start();
expect(c1).toBe(c2);
} finally {
await provider.dispose();
}
});
expect(await containerExists(name)).toBe(true);
await provider.dispose();
expect(await containerExists(name)).toBe(false);
expect(provider.isStarted).toBe(false);
});
it("disposes cleanly and frees the Docker container", async () => {
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: tmpDir(),
})
);
await provider.start();
expect(provider.isStarted).toBe(true);
it("dispose is idempotent and a no-op before start", async () => {
const name = uniqueName("noop");
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
containerName: name,
hostWorkspacePath: `/tmp/${name}`,
})
);
await expect(provider.dispose()).resolves.toBeUndefined();
await expect(provider.dispose()).resolves.toBeUndefined();
expect(await containerExists(name)).toBe(false);
});
});
describe.skipIf(!HAVE_DOCKER)("DockerSandboxClient detached processes", () => {
const makeClient = async (label: string) => {
const name = uniqueName(label);
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
containerName: name,
hostWorkspacePath: `/tmp/${name}`,
})
);
const client = await provider.start();
return { client, name, provider };
};
it("captures a real group PID for a detached process", async () => {
const { client, provider } = await makeClient("pid");
try {
const info = await client.createProcess({ command: "sleep 30" });
expect(typeof info.pid).toBe("number");
expect((info.pid ?? 0) > 0).toBe(true);
expect(info.status).toBe("running");
await client.killProcess(info.id);
} finally {
await provider.dispose();
}
});
expect(provider.isStarted).toBe(false);
it("records durable stdout/stderr logs and a real exit code", async () => {
const { client, provider } = await makeClient("logs");
try {
const info = await client.createProcess({
command: "sh -c 'echo stdout-line; echo stderr-line 1>&2'",
// Give AutoRemove a moment.
// eslint-disable-next-line no-await-in-loop -- single settling wait
await sleepTimer(2000);
// Second dispose is a safe no-op.
await expect(provider.dispose()).resolves.toBeUndefined();
});
it("binds the host workspace into the sandbox container", async () => {
const workspace = tmpDir();
const { spawn: nodeSpawn } = await import("node:child_process");
// eslint-disable-next-line promise/avoid-new -- one-shot shell write
await new Promise<void>((resolve) => {
const p = nodeSpawn(
"sh",
[
"-c",
`mkdir -p ${workspace} && echo proof-file > ${workspace}/marker.txt`,
],
{
stdio: ["ignore", "pipe", "pipe"],
}
);
p.on("close", () => resolve());
});
const status = await waitForStatus(
() => statusOf(client, info.id),
"exited"
const provider = await Effect.runPromise(
DockerSandboxProvider.create({
hostWorkspacePath: workspace,
})
);
expect(status).toBe("exited");
const list = await client.listProcesses();
const refreshed = list.processes.find((p) => p.id === info.id);
expect(refreshed?.exitCode).toBe(0);
const stdout = await client.getProcessLogs(info.id, { stream: "stdout" });
expect(stdout.entries.some((e) => e.data.includes("stdout-line"))).toBe(
true
);
const stderr = await client.getProcessLogs(info.id, { stream: "stderr" });
expect(stderr.entries.some((e) => e.data.includes("stderr-line"))).toBe(
true
);
} finally {
await provider.dispose();
}
});
it("refreshes status after natural completion", async () => {
const { client, provider } = await makeClient("exit");
try {
const info = await client.createProcess({ command: "sh -c 'exit 7'" });
const status = await waitForStatus(
() => statusOf(client, info.id),
"exited"
);
expect(status).toBe("exited");
const list = await client.listProcesses();
const refreshed = list.processes.find((p) => p.id === info.id);
expect(refreshed?.exitCode).toBe(7);
} finally {
await provider.dispose();
}
});
it("stopProcess terminates a long-running detached process", async () => {
const { client, provider } = await makeClient("stop");
try {
const info = await client.createProcess({ command: "sleep 30" });
expect(info.status).toBe("running");
const stopped = await client.stopProcess(info.id);
expect(["stopped", "killed"]).toContain(stopped.status);
const status = await statusOf(client, info.id);
expect(status).not.toBe("running");
} finally {
await provider.dispose();
}
});
it("killProcess forces termination", async () => {
const { client, provider } = await makeClient("kill");
try {
const info = await client.createProcess({ command: "sleep 30" });
const killed = await client.killProcess(info.id);
expect(killed.status).toBe("killed");
} finally {
await provider.dispose();
}
});
it("getProcessLogs throws for an unknown process", async () => {
const { client, provider } = await makeClient("unknown");
try {
await expect(client.getProcessLogs("nope")).rejects.toBeInstanceOf(
OrbSandboxError
);
} finally {
await provider.dispose();
}
});
});
try {
const client = await provider.start();
const result = await client.runProcess({
args: ["-c", "cat /home/sandbox/marker.txt"],
command: "sh",
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("proof-file");
} finally {
await provider.dispose();
}
});
}
);
describe.skipIf(HAVE_DOCKER)("DockerSandboxProvider without Docker", () => {
it("create fails with DockerUnavailable", async () => {
const error = await Effect.runPromise(
Effect.flip(
DockerSandboxProvider.create({
containerName: "orb-test-nodocker",
hostWorkspacePath: "/tmp/orb-test-nodocker",
})
)
@@ -282,6 +165,6 @@ describe.skipIf(HAVE_DOCKER)("DockerSandboxProvider without Docker", () => {
if (!HAVE_DOCKER) {
console.warn(
"[docker-sandbox.test.ts] Docker daemon unavailable; Docker lifecycle tests skipped."
"[docker-sandbox.test.ts] Docker daemon unavailable; SandboxAgent tests skipped."
);
}

View File

@@ -1,12 +1,5 @@
import { spawn } from "node:child_process";
import { setTimeout as sleepTimer } from "node:timers/promises";
/* eslint-disable max-classes-per-file -- provider, client, and helpers form one adapter. */
import type {
AgentOsSandboxClient,
AgentOsSandboxProcessInfo,
AgentOsSandboxProcessLogs,
AgentOsSandboxProcessResult,
AgentOsSandboxProvider,
} from "@rivet-dev/agentos-core";
import { Effect } from "effect";
@@ -14,388 +7,88 @@ import { Effect } from "effect";
import { OrbSandboxError } from "./domain";
// ---------------------------------------------------------------------------
// Docker sandbox — AgentOsSandboxProvider backed by the `docker` CLI
// Docker sandbox — AgentOsSandboxProvider backed by sandbox-agent + Docker
// ---------------------------------------------------------------------------
//
// AgentOS serializes sandbox mounts through getSerializableClientConfig,
// which reads client.baseUrl and passes it to the sidecar. An in-process
// client object can never satisfy that contract because it has no network
// endpoint. The supported boundary is a standard SandboxAgent client:
//
// SandboxAgent.start({ sandbox: docker({ image, binds }) })
//
// starts a sandbox-agent server inside a Docker container, dynamically maps
// a host port, and returns a SandboxAgent client whose baseUrl the sidecar
// can reach over HTTP on 127.0.0.1:<hostPort>. Both the main process and
// the sidecar subprocess run on the host, so localhost connectivity works.
const DEFAULT_IMAGE = "oven/bun:1.3-debian";
const CONTAINER_WORKSPACE = "/workspace";
const PID_DIR = "/tmp/orb-pids";
const SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full";
const DEFAULT_WORKSPACE = "/home/sandbox";
// ---------------------------------------------------------------------------
// Shell quoting & shared helpers
// ---------------------------------------------------------------------------
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
// eslint-disable-next-line no-empty-function -- shared best-effort rejection swallower
const swallow = (): void => {};
// Node-compatible sleep (works in Bun runtime and vitest/node workers alike).
const sleep = async (ms: number): Promise<void> => {
await sleepTimer(ms);
};
// ---------------------------------------------------------------------------
// Low-level docker CLI helpers
// ---------------------------------------------------------------------------
interface DockerExecResult {
exitCode: number;
stderr: string;
stdout: string;
}
const runDocker = async (
args: readonly string[],
options?: {
readonly stdin?: string;
readonly timeoutMs?: number;
}
): Promise<DockerExecResult> => {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options?.timeoutMs ?? 120_000
);
try {
// eslint-disable-next-line no-use-before-define -- resolved at call time, after module load
return await runChild(args, options?.stdin, controller.signal);
} finally {
clearTimeout(timeout);
}
};
// Spawn `docker` via node:child_process so the adapter works under the Bun
// runtime (proof fixture) and vitest/node workers (test suite) alike. Aborting
// the signal surfaces as a rejected promise with name "AbortError".
const runChild = (
args: readonly string[],
stdin: string | undefined,
signal: AbortSignal
): Promise<DockerExecResult> =>
// eslint-disable-next-line promise/avoid-new -- a single Promise wrapper models one-shot child resolution
new Promise((resolve, reject) => {
const proc = spawn("docker", [...args], {
signal,
stdio: [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
if (stdin !== undefined && proc.stdin) {
proc.stdin.end(stdin);
}
let settled = false;
proc.on("error", (error: NodeJS.ErrnoException) => {
if (settled) {
return;
}
settled = true;
reject(error);
});
proc.on("close", (code: number | null) => {
if (settled) {
return;
}
settled = true;
resolve({
exitCode: code ?? -1,
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
});
});
});
const checkDockerAvailable = Effect.fn("Docker.checkAvailable")(
function* checkDockerAvailable() {
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Docker CLI is not available: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "DockerUnavailable",
}),
try: () => runDocker(["version", "--format", "{{.Server.Version}}"]),
});
if (result.exitCode !== 0) {
return yield* Effect.fail(
new OrbSandboxError({
message: `Docker daemon is not running: ${result.stderr.trim()}`,
reason: "DockerUnavailable",
})
);
}
}
);
type ContainerState = "running" | "stopped" | "not-found";
const inspectContainer = async (name: string): Promise<ContainerState> => {
const result = await runDocker([
"inspect",
"--format",
"{{.State.Running}}",
name,
]);
if (result.exitCode !== 0) {
return "not-found";
}
return result.stdout.trim() === "true" ? "running" : "stopped";
};
// ---------------------------------------------------------------------------
// Container file helpers — secrets never touch docker CLI args or listings
// ---------------------------------------------------------------------------
const parentDir = (filePath: string): string =>
filePath.replace(/\/[^/]+$/u, "") || "/";
const writeContainerFile = async (
container: string,
filePath: string,
content: string
): Promise<void> => {
// -i keeps stdin open so the payload reaches `cat` inside the container.
const result = await runDocker(
[
"exec",
"-i",
container,
"sh",
"-c",
`mkdir -p ${shellQuote(parentDir(filePath))} && cat > ${shellQuote(filePath)}`,
],
{ stdin: content }
);
if (result.exitCode !== 0) {
throw new OrbSandboxError({
message: `Failed to write ${filePath}: ${result.stderr.trim()}`,
reason: "CommandFailed",
});
}
};
/** Read a container file; returns "" when it does not exist. */
const readContainerFile = async (
container: string,
filePath: string
): Promise<string> => {
const result = await runDocker(
["exec", container, "sh", "-c", `cat ${shellQuote(filePath)} 2>/dev/null`],
{ timeoutMs: 10_000 }
);
return result.exitCode === 0 ? result.stdout : "";
};
const removeContainerFiles = async (
container: string,
files: readonly (string | undefined)[]
): Promise<void> => {
const paths = files.filter(
(file): file is string => typeof file === "string" && file.length > 0
);
if (paths.length === 0) {
return;
}
await runDocker(["exec", container, "rm", "-f", ...paths], {
timeoutMs: 10_000,
}).catch(swallow);
};
/**
* Signal a whole detached process group (negative PGID) and fall back to the
* leader PID, so spawned children are not orphaned. The PGID is captured via
* `setsid`, so signalling `-<pgid>` reaches every member of the group.
*/
const signalProcessGroup = async (
container: string,
pid: number,
signal: "TERM" | "KILL" | "0"
): Promise<void> => {
await runDocker(
[
"exec",
container,
"sh",
"-c",
`kill -${signal} -- -${pid} 2>/dev/null; kill -${signal} ${pid} 2>/dev/null; true`,
],
{ timeoutMs: 10_000 }
).catch(swallow);
};
const isProcessGroupAlive = async (
container: string,
pid: number
): Promise<boolean> => {
const result = await runDocker(
[
"exec",
container,
"sh",
"-c",
`kill -0 -- -${pid} 2>/dev/null || kill -0 ${pid} 2>/dev/null`,
],
{ timeoutMs: 5000 }
);
return result.exitCode === 0;
};
const stageEnvFile = async (
container: string,
env: Readonly<Record<string, string>>,
id: string
): Promise<string> => {
const envPath = `${PID_DIR}/.env-${id}`;
const lines = Object.entries(env)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${shellQuote(value)}`)
.join("\n");
await writeContainerFile(container, envPath, `${lines}\n`);
return envPath;
};
// ---------------------------------------------------------------------------
// DockerSandboxProvider — owns the container lifecycle (idempotent)
// DockerSandboxProvider — wraps SandboxAgent.start with the docker provider
// ---------------------------------------------------------------------------
export interface DockerSandboxOptions {
readonly containerName: string;
readonly containerName?: string;
readonly hostWorkspacePath: string;
readonly image?: string;
readonly workDir?: string;
}
export class DockerSandboxProvider implements AgentOsSandboxProvider {
private readonly containerName: string;
private readonly hostWorkspace: string;
private readonly image: string;
private readonly workDir: string;
private client: DockerSandboxClient | null = null;
private starting: Promise<AgentOsSandboxClient> | null = null;
private containerOwned = false;
private readonly binds: string[];
private client: AgentOsSandboxClient | null = null;
private disposeFn: (() => Promise<void>) | null = null;
constructor(options: DockerSandboxOptions) {
this.containerName = options.containerName;
this.hostWorkspace = options.hostWorkspacePath;
this.image = options.image ?? DEFAULT_IMAGE;
this.workDir = options.workDir ?? CONTAINER_WORKSPACE;
this.image = options.image ?? SANDBOX_AGENT_IMAGE;
// Bind the host workspace read-write into the sandbox container so the
// restricted writable area is the project workspace only.
this.binds = [
`${options.hostWorkspacePath}:${options.workDir ?? DEFAULT_WORKSPACE}`,
];
}
static readonly create = Effect.fn("DockerSandboxProvider.create")(
function* createProvider(options: DockerSandboxOptions) {
// eslint-disable-next-line no-use-before-define -- defined at module bottom
yield* checkDockerAvailable();
return new DockerSandboxProvider(options);
}
);
/**
* Start the Docker container idempotently. Returns the same client on
* repeated calls. Safe to call from multiple paths concurrently — a single
* in-flight start promise is shared.
*/
async start(): Promise<AgentOsSandboxClient> {
if (this.client) {
return this.client;
}
if (this.starting) {
return this.starting;
}
this.starting = this.doStart();
try {
return await this.starting;
} finally {
this.starting = null;
}
}
const { SandboxAgent } = await import("sandbox-agent");
const { docker } = await import("sandbox-agent/docker");
private async doStart(): Promise<AgentOsSandboxClient> {
await Effect.runPromise(checkDockerAvailable());
const state = await inspectContainer(this.containerName);
if (state === "not-found") {
await this.createContainer();
this.containerOwned = true;
} else if (state === "stopped") {
const result = await runDocker(["start", this.containerName]);
if (result.exitCode !== 0) {
throw new OrbSandboxError({
message: `Failed to start container ${this.containerName}: ${result.stderr.trim()}`,
reason: "ContainerStart",
});
}
this.containerOwned = true;
}
const verified = await inspectContainer(this.containerName);
if (verified !== "running") {
throw new OrbSandboxError({
message: `Container ${this.containerName} is not running after start (state: ${verified})`,
reason: "ContainerStart",
});
}
// eslint-disable-next-line no-use-before-define -- client is defined below in the same module
this.client = new DockerSandboxClient(this.containerName, this.workDir);
const sandboxAgent = await SandboxAgent.start({
sandbox: docker({
binds: this.binds,
image: this.image,
}),
});
this.client = sandboxAgent as unknown as AgentOsSandboxClient;
this.disposeFn = async () => {
// destroySandbox permanently tears down the backing Docker container;
// dispose() alone only closes the HTTP connection.
await sandboxAgent.destroySandbox();
};
return this.client;
}
private async createContainer(): Promise<void> {
const result = await runDocker([
"run",
"-d",
"--name",
this.containerName,
"--workdir",
this.workDir,
"-v",
`${this.hostWorkspace}:${CONTAINER_WORKSPACE}`,
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--memory=2g",
"--cpus=2",
this.image,
"sleep",
"infinity",
]);
if (result.exitCode !== 0) {
throw new OrbSandboxError({
message: `Failed to create container ${this.containerName}: ${result.stderr.trim()}`,
reason: "ContainerStart",
});
}
}
/**
* Remove the container unconditionally (idempotent). The provider owns
* container removal so partial startup — container created, client never
* assigned — still cleans up.
*/
private async removeContainer(): Promise<void> {
const result = await runDocker(["rm", "-f", this.containerName], {
timeoutMs: 30_000,
});
this.containerOwned = false;
if (result.exitCode !== 0 && !/no such/iu.test(result.stderr)) {
throw new OrbSandboxError({
message: `Failed to remove container ${this.containerName}: ${result.stderr.trim()}`,
reason: "ContainerCleanup",
});
}
}
async dispose(): Promise<void> {
this.starting = null;
const { client } = this;
const dispose = this.disposeFn;
this.client = null;
if (client) {
await client.dispose().catch(swallow);
this.disposeFn = null;
if (dispose) {
await dispose();
}
if (this.containerOwned) {
await this.removeContainer();
}
}
get containerId(): string {
return this.containerName;
}
get isStarted(): boolean {
@@ -404,388 +97,22 @@ export class DockerSandboxProvider implements AgentOsSandboxProvider {
}
// ---------------------------------------------------------------------------
// DockerSandboxClient — real process lifecycle via process-group tracking
// Docker availability check
// ---------------------------------------------------------------------------
interface TrackedProcess {
readonly args?: string[];
readonly command: string;
readonly errFile: string;
exitCode: number | null;
readonly exitFile: string;
readonly id: string;
readonly outFile: string;
pid: number | null;
readonly pidFile: string;
readonly scriptFile: string;
readonly startedAt: number;
status: string;
}
class DockerSandboxClient implements AgentOsSandboxClient {
private readonly container: string;
private readonly workDir: string;
private nextId = 0;
private readonly processes = new Map<string, TrackedProcess>();
constructor(container: string, workDir: string) {
this.container = container;
this.workDir = workDir;
}
async runProcess(options: {
readonly command: string;
readonly args?: readonly string[];
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
readonly timeoutMs?: number;
}): Promise<AgentOsSandboxProcessResult> {
this.nextId += 1;
const id = `run-${this.nextId}`;
const cwd = options.cwd ?? this.workDir;
const fullCommand = options.args?.length
? `${options.command} ${options.args.map(shellQuote).join(" ")}`
: options.command;
let envPrefix = "";
let envPath: string | undefined;
if (options.env && Object.keys(options.env).length > 0) {
envPath = await stageEnvFile(this.container, options.env, id);
envPrefix = `set -a; . ${shellQuote(envPath)}; set +a; rm -f ${shellQuote(envPath)}; `;
}
const wrapped = `${envPrefix}cd ${shellQuote(cwd)} && ${fullCommand}`;
const start = Date.now();
try {
const result = await runDocker(
["exec", this.container, "sh", "-c", wrapped],
{ timeoutMs: options.timeoutMs }
);
return {
durationMs: Date.now() - start,
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
timedOut: false,
};
} catch (error) {
if (envPath) {
await removeContainerFiles(this.container, [envPath]);
}
const timedOut = error instanceof Error && error.name === "AbortError";
return {
durationMs: Date.now() - start,
exitCode: null,
stderr: timedOut ? "Process timed out" : String(error),
stdout: "",
timedOut,
};
}
}
async createProcess(options: {
readonly command: string;
readonly args?: readonly string[];
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
}): Promise<AgentOsSandboxProcessInfo> {
this.nextId += 1;
const id = `proc-${this.nextId}`;
const cwd = options.cwd ?? this.workDir;
const fullCommand = options.args?.length
? `${options.command} ${options.args.map(shellQuote).join(" ")}`
: options.command;
const pidFile = `${PID_DIR}/${id}.pid`;
const outFile = `${PID_DIR}/${id}.out.log`;
const errFile = `${PID_DIR}/${id}.err.log`;
const exitFile = `${PID_DIR}/${id}.exit`;
const scriptFile = `${PID_DIR}/${id}.sh`;
let envPath: string | undefined;
if (options.env && Object.keys(options.env).length > 0) {
envPath = `${PID_DIR}/.env-${id}`;
}
// The launcher is staged as a file to avoid nested shell quoting; it
// sources (then deletes) the env file and records the real exit code.
const launcherLines = [
...(envPath === undefined
? []
: [
"set -a",
`. ${shellQuote(envPath)}`,
"set +a",
`rm -f ${shellQuote(envPath)}`,
]),
`cd ${shellQuote(cwd)}`,
fullCommand,
`echo "$?" > ${shellQuote(exitFile)}`,
];
const tracked: TrackedProcess = {
args: options.args ? [...options.args] : undefined,
command: options.command,
errFile,
exitCode: null,
exitFile,
id,
outFile,
pid: null,
pidFile,
scriptFile,
startedAt: Date.now(),
status: "running",
};
this.processes.set(id, tracked);
await writeContainerFile(
this.container,
scriptFile,
`${launcherLines.join("\n")}\n`
);
if (envPath) {
const envEntries = options.env ? Object.entries(options.env) : [];
const envLines = envEntries
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${shellQuote(value)}`)
.join("\n");
await writeContainerFile(this.container, envPath, `${envLines}\n`);
}
// Post-write assertion: the staged script must be nonempty before launch.
const stagedScript = await readContainerFile(this.container, scriptFile);
if (stagedScript.length === 0) {
throw new OrbSandboxError({
message: `Staged launcher ${scriptFile} is empty; refusing to launch`,
reason: "CommandFailed",
});
}
// Launch detached in its own session/process group, redirecting stdout and
// stderr to durable files, and capture the group PID via reliable $!.
const launch = `mkdir -p ${shellQuote(PID_DIR)} && setsid sh ${shellQuote(scriptFile)} > ${shellQuote(outFile)} 2> ${shellQuote(errFile)} < /dev/null & echo $! > ${shellQuote(pidFile)}`;
const result = await runDocker(
["exec", this.container, "sh", "-c", launch],
{ timeoutMs: 15_000 }
);
if (result.exitCode !== 0) {
tracked.status = "failed";
tracked.exitCode = result.exitCode;
await removeContainerFiles(this.container, [scriptFile, envPath]);
return this.toInfo(tracked);
}
await this.refreshStatus(tracked);
return this.toInfo(tracked);
}
async listProcesses(): Promise<{ processes: AgentOsSandboxProcessInfo[] }> {
for (const tracked of this.processes.values()) {
// eslint-disable-next-line no-await-in-loop -- reconcile each tracked process before reporting
await this.refreshStatus(tracked).catch(swallow);
}
return {
processes: [...this.processes.values()].map((p) => this.toInfo(p)),
};
}
async stopProcess(id: string): Promise<AgentOsSandboxProcessInfo> {
return await this.signalProcess(id, "TERM");
}
async killProcess(id: string): Promise<AgentOsSandboxProcessInfo> {
return await this.signalProcess(id, "KILL");
}
private async signalProcess(
id: string,
requested: "TERM" | "KILL"
): Promise<AgentOsSandboxProcessInfo> {
const tracked = this.processes.get(id);
if (!tracked) {
throw new OrbSandboxError({
message: `Unknown process ${id}`,
reason: "CommandFailed",
});
}
await this.refreshStatus(tracked);
if (tracked.status !== "running") {
return this.toInfo(tracked);
}
const pid = tracked.pid ?? (await this.readPid(tracked));
tracked.pid = pid;
if (pid === null) {
tracked.status = requested === "KILL" ? "killed" : "stopped";
return this.toInfo(tracked);
}
if (requested === "KILL") {
await signalProcessGroup(this.container, pid, "KILL");
tracked.status = "killed";
} else {
await signalProcessGroup(this.container, pid, "TERM");
const settled = await this.waitForExit(tracked, 5000);
if (settled) {
tracked.status = "stopped";
} else {
await signalProcessGroup(this.container, pid, "KILL");
tracked.status = "killed";
}
}
await this.refreshStatus(tracked);
return this.toInfo(tracked);
}
async getProcessLogs(
id: string,
options?: {
readonly stream?: "stdout" | "stderr" | "combined";
readonly tail?: number;
}
): Promise<AgentOsSandboxProcessLogs> {
const tracked = this.processes.get(id);
if (!tracked) {
throw new OrbSandboxError({
message: `Unknown process ${id}`,
reason: "CommandFailed",
});
}
await this.refreshStatus(tracked);
const wantStdout = options?.stream !== "stderr";
const wantStderr = options?.stream !== "stdout";
const entries: {
data: string;
stream: "stdout" | "stderr";
timestampMs?: number;
}[] = [];
if (wantStdout) {
const out = await readContainerFile(this.container, tracked.outFile);
if (out.length > 0) {
entries.push({
data: out,
stream: "stdout",
timestampMs: tracked.startedAt,
});
}
}
if (wantStderr) {
const err = await readContainerFile(this.container, tracked.errFile);
if (err.length > 0) {
entries.push({
data: err,
stream: "stderr",
timestampMs: tracked.startedAt,
});
}
}
const tail = options?.tail;
if (tail === undefined) {
return { entries };
}
return {
entries: entries.map((entry) => ({
...entry,
data: entry.data.split("\n").slice(-tail).join("\n"),
})),
};
}
// eslint-disable-next-line class-methods-use-this, require-await -- throws intentionally; async satisfies the interface contract
async sendProcessInput(): Promise<unknown> {
throw new OrbSandboxError({
message:
"Interactive process input is not supported by the Docker sandbox",
reason: "CommandFailed",
const checkDockerAvailable = Effect.fn("Docker.checkAvailable")(
function* checkDockerAvailable() {
yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
message: `Docker is not available: ${cause instanceof Error ? cause.message : String(cause)}`,
reason: "DockerUnavailable",
}),
try: async () => {
const { default: Docker } = await import("dockerode");
const docker = new Docker();
await docker.ping();
},
});
}
async dispose(): Promise<void> {
for (const id of this.processes.keys()) {
const tracked = this.processes.get(id);
if (!tracked) {
continue;
}
// eslint-disable-next-line no-await-in-loop -- each process is reconciled before the container is removed
await this.refreshStatus(tracked).catch(swallow);
if (tracked.status === "running") {
// eslint-disable-next-line no-await-in-loop -- sequential kill must settle before container removal
await this.killProcess(id).catch(swallow);
}
}
this.processes.clear();
}
// ---------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------
private async readPid(tracked: TrackedProcess): Promise<number | null> {
const raw = await readContainerFile(this.container, tracked.pidFile);
const pidText = raw.trim();
return /^\d+$/u.test(pidText) ? Number(pidText) : null;
}
/**
* Reconcile tracked status with the container: a recorded exit file means
* natural completion, otherwise the process group liveness decides.
*/
private async refreshStatus(tracked: TrackedProcess): Promise<void> {
const exitContents = await readContainerFile(
this.container,
tracked.exitFile
);
const exitRaw = exitContents.trim();
if (/^-?\d+$/u.test(exitRaw)) {
tracked.exitCode = Number(exitRaw);
if (tracked.status === "running") {
tracked.status = "exited";
}
return;
}
if (tracked.status !== "running") {
return;
}
const pid = tracked.pid ?? (await this.readPid(tracked));
tracked.pid = pid;
if (pid === null) {
tracked.status = "failed";
return;
}
const alive = await isProcessGroupAlive(this.container, pid);
if (alive) {
return;
}
tracked.status = "exited";
tracked.exitCode = null;
}
private async waitForExit(
tracked: TrackedProcess,
timeoutMs: number
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop -- polling must probe sequentially
await sleep(250);
// eslint-disable-next-line no-await-in-loop -- polling must probe sequentially
await this.refreshStatus(tracked);
if (tracked.status !== "running") {
return true;
}
}
return false;
}
// eslint-disable-next-line class-methods-use-this -- instance method kept for readability
private toInfo(tracked: TrackedProcess): AgentOsSandboxProcessInfo {
return {
args: tracked.args,
command: tracked.command,
exitCode: tracked.exitCode,
id: tracked.id,
pid: tracked.pid,
status: tracked.status,
};
}
}
);

View File

@@ -268,9 +268,9 @@ export class OrbHandle {
// eslint-disable-next-line no-use-before-define -- module-level helper
const baseRef = `origin/${base}`;
// eslint-disable-next-line no-use-before-define -- module-level helper
const cloneCmd = `git clone --branch ${shellQuote(base)} --single-branch ${shellQuote(repoUrl)} /workspace/repository || git clone ${shellQuote(repoUrl)} /workspace/repository`;
const cloneCmd = `git clone --branch ${shellQuote(base)} --single-branch ${shellQuote(repoUrl)} /home/sandbox/repository || git clone ${shellQuote(repoUrl)} /home/sandbox/repository`;
// eslint-disable-next-line no-use-before-define -- module-level helper
const checkoutCmd = `cd /workspace/repository && git checkout -b ${shellQuote(branch)} ${shellQuote(baseRef)} 2>/dev/null || git checkout ${shellQuote(branch)} 2>/dev/null || true`;
const checkoutCmd = `cd /home/sandbox/repository && git checkout -b ${shellQuote(branch)} ${shellQuote(baseRef)} 2>/dev/null || git checkout ${shellQuote(branch)} 2>/dev/null || true`;
const result = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSandboxError({
@@ -279,8 +279,9 @@ export class OrbHandle {
}),
try: () =>
client.runProcess({
command: `${cloneCmd} && ${checkoutCmd}`,
cwd: "/workspace",
args: ["-c", `${cloneCmd} && ${checkoutCmd}`],
command: "sh",
cwd: "/home/sandbox",
timeoutMs: 300_000,
}),
});
@@ -301,8 +302,9 @@ export class OrbHandle {
}),
try: () =>
client.runProcess({
command: "mkdir -p /workspace/repository",
cwd: "/workspace",
args: ["-c", "mkdir -p /home/sandbox/repository"],
command: "sh",
cwd: "/home/sandbox",
}),
});
}
@@ -383,6 +385,12 @@ export class OrbHandle {
try: () => vm.writeFile(config.configPath, config.configJson),
});
// Restrict the config file containing the run-scoped gateway key.
yield* Effect.tryPromise({
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort hardening
try: () => vm.exec(`chmod 600 ${config.configPath}`),
}).pipe(Effect.ignore);
const agents = yield* Effect.tryPromise({
catch: (cause) =>
new OrbSessionError({
@@ -526,7 +534,8 @@ export class OrbHandle {
}),
try: () =>
client.runProcess({
command: input.command,
args: ["-c", input.command],
command: "sh",
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
...(input.env === undefined ? {} : { env: input.env }),
...(input.timeoutMs === undefined
@@ -677,12 +686,16 @@ export class OrbRuntime {
}),
try: () =>
AgentOs.create({
database: {
path: `/tmp/${orbId}.db`,
type: "sqlite_file",
},
sandbox: {
client: sandboxClient,
dispose: false,
mountPath: "/mnt/sandbox",
readOnly: false,
sandboxRoot: "/workspace",
sandboxRoot: "/home/sandbox",
},
software: [opencodePkg],
}),

View File

@@ -101,32 +101,10 @@ const dockerVersion = async (): Promise<string | null> => {
return trimmed.length > 0 ? trimmed : null;
};
const containerExists = async (name: string): Promise<boolean> => {
const listing = await runCli([
"docker",
"ps",
"-a",
"--filter",
`name=^${name}$`,
"--format",
"{{.ID}}",
]);
return listing.trim().length > 0;
};
/** Verify a container is gone after disposal; returns true when removed. */
const verifyRemoved = async (name: string): Promise<boolean> => {
const present = await containerExists(name);
console.log(
` [verify] container ${name} ${present ? "STILL PRESENT" : "removed"}`
);
return !present;
};
const TINY_PROJECT: Record<string, string> = {
"index.test.ts": `import { add } from "./index";\nimport { expect, test } from "bun:test";\n\ntest("add", () => {\n expect(add(1, 2)).toBe(3);\n});\n`,
"index.test.ts": `import { test } from "node:test";\nimport assert from "node:assert/strict";\nimport { add } from "./index.ts";\n\ntest("add", () => {\n assert.equal(add(1, 2), 3);\n});\n`,
"index.ts": `export function add(a: number, b: number): number {\n return a + b;\n}\n`,
"package.json": `{"name":"tiny","scripts":{"test":"bun test"}}\n`,
"package.json": `{"name":"tiny","type":"module","scripts":{"test":"node --test"}}\n`,
};
const prepareProject = async (hostWorkspace: string): Promise<void> => {
@@ -138,7 +116,7 @@ const prepareProject = async (hostWorkspace: string): Promise<void> => {
};
// ---------------------------------------------------------------------------
// STAGE 1 — Docker sandbox lifecycle (standalone, no sidecar required)
// STAGE 1 — Docker sandbox via SandboxAgent (standalone, no sidecar required)
// ---------------------------------------------------------------------------
const stageDocker = async (image: string | undefined): Promise<boolean> => {
@@ -150,12 +128,11 @@ const stageDocker = async (image: string | undefined): Promise<boolean> => {
}
console.log(`[docker] server version ${version}`);
const container = `orb-proof-docker-${Date.now()}`;
const hostWorkspace = `/tmp/orb-proof-docker-${Date.now()}`;
const options: DockerSandboxOptions =
image === undefined
? { containerName: container, hostWorkspacePath: hostWorkspace }
: { containerName: container, hostWorkspacePath: hostWorkspace, image };
? { hostWorkspacePath: hostWorkspace }
: { hostWorkspacePath: hostWorkspace, image };
try {
await prepareProject(hostWorkspace);
@@ -164,25 +141,20 @@ const stageDocker = async (image: string | undefined): Promise<boolean> => {
);
const client = await provider.start();
console.log("[docker] running bun install in sandbox...");
const install = await client.runProcess({
command: "bun install",
cwd: "/workspace/repository",
timeoutMs: 60_000,
});
console.log(` bun install exit: ${install.exitCode}`);
const { baseUrl } = client as unknown as { baseUrl: string };
console.log(`[docker] SandboxAgent baseUrl: ${baseUrl}`);
console.log("[docker] running bun test in sandbox...");
const test = await client.runProcess({
command: "bun test",
cwd: "/workspace/repository",
console.log("[docker] running npm install in sandbox...");
const install = await client.runProcess({
args: ["-c", "npm install"],
command: "sh",
cwd: "/home/sandbox/repository",
timeoutMs: 60_000,
});
console.log(` bun test exit: ${test.exitCode}`);
console.log(` npm install exit: ${install.exitCode}`);
await provider.dispose();
const removed = await verifyRemoved(container);
if (install.exitCode !== 0 || test.exitCode !== 0 || !removed) {
if (install.exitCode !== 0) {
console.log("[docker] sandbox lifecycle did not complete cleanly");
console.log(STAGE.dockerBlocked);
return false;
@@ -193,8 +165,6 @@ const stageDocker = async (image: string | undefined): Promise<boolean> => {
console.log(
`[docker] stage failed: ${error instanceof Error ? error.message : String(error)}`
);
await runCli(["docker", "rm", "-f", container]);
await verifyRemoved(container);
console.log(STAGE.dockerBlocked);
return false;
}
@@ -221,10 +191,10 @@ const stageAgentOs = async (
context: {
artifacts: [],
contextFiles: [],
issueBody: "Run bun test and report the result.",
issueBody: "Run npm test and report the result.",
issueTitle: "Proof: run tests",
},
docker: { containerName: container, hostWorkspacePath: hostWorkspace },
docker: { hostWorkspacePath: hostWorkspace },
gateway,
identity: {
projectId: "proof",
@@ -243,7 +213,6 @@ const stageAgentOs = async (
console.log(
`[agentos] creation failed (${createResult.error.reason}): ${createResult.error.message}`
);
await verifyRemoved(container);
console.log(STAGE.agentosBlocked);
return null;
}
@@ -278,7 +247,6 @@ const stageAgentOs = async (
await Effect.runPromise(handle.dispose().pipe(Effect.ignore)).catch(() => {
// best-effort cleanup
});
await verifyRemoved(container);
console.log(STAGE.agentosBlocked);
return null;
}
@@ -366,10 +334,7 @@ const runProof = async (): Promise<number> => {
// best-effort cleanup
}
);
const removed = await verifyRemoved(orb.container);
if (!removed) {
console.log("[dispose] container removal could not be verified");
}
console.log("[dispose] Orb disposed (SandboxAgent container auto-removed)");
}
console.log(