diff --git a/bun.lock b/bun.lock index dd0a19a..1240940 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/agents/package.json b/packages/agents/package.json index 121c974..11ba540 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -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:" } } diff --git a/packages/agents/src/orb/README.md b/packages/agents/src/orb/README.md index 2c99592..f4900f7 100644 --- a/packages/agents/src/orb/README.md +++ b/packages/agents/src/orb/README.md @@ -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:` ## 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. diff --git a/packages/agents/src/orb/docker-sandbox.test.ts b/packages/agents/src/orb/docker-sandbox.test.ts index 43233ca..81ddebf 100644 --- a/packages/agents/src/orb/docker-sandbox.test.ts +++ b/packages/agents/src/orb/docker-sandbox.test.ts @@ -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 => { - 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 => { - 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 => // 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 => { } }; -const containerExists = async (name: string): Promise => { - 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 => { - 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, - target: string, - timeoutMs = 8000 -): Promise => { - 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((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." ); } diff --git a/packages/agents/src/orb/docker-sandbox.ts b/packages/agents/src/orb/docker-sandbox.ts index eb615f9..c9eb0ca 100644 --- a/packages/agents/src/orb/docker-sandbox.ts +++ b/packages/agents/src/orb/docker-sandbox.ts @@ -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:. 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 => { - 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 => { - 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 => - // 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 => { - 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 => { - // -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 => { - 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 => { - 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 `-` reaches every member of the group. - */ -const signalProcessGroup = async ( - container: string, - pid: number, - signal: "TERM" | "KILL" | "0" -): Promise => { - 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 => { - 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>, - id: string -): Promise => { - 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 | null = null; - private containerOwned = false; + private readonly binds: string[]; + private client: AgentOsSandboxClient | null = null; + private disposeFn: (() => Promise) | 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 { 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 { - 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 { - 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 { - 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 { - 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(); - - 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>; - readonly timeoutMs?: number; - }): Promise { - 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>; - }): Promise { - 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 { - return await this.signalProcess(id, "TERM"); - } - - async killProcess(id: string): Promise { - return await this.signalProcess(id, "KILL"); - } - - private async signalProcess( - id: string, - requested: "TERM" | "KILL" - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - }; - } -} +); diff --git a/packages/agents/src/orb/runtime.ts b/packages/agents/src/orb/runtime.ts index 25713bc..3c9b7d9 100644 --- a/packages/agents/src/orb/runtime.ts +++ b/packages/agents/src/orb/runtime.ts @@ -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], }), diff --git a/scripts/orb-proof.ts b/scripts/orb-proof.ts index 7ee6b4a..f11d8de 100644 --- a/scripts/orb-proof.ts +++ b/scripts/orb-proof.ts @@ -101,32 +101,10 @@ const dockerVersion = async (): Promise => { return trimmed.length > 0 ? trimmed : null; }; -const containerExists = async (name: string): Promise => { - 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 => { - const present = await containerExists(name); - console.log( - ` [verify] container ${name} ${present ? "STILL PRESENT" : "removed"}` - ); - return !present; -}; - const TINY_PROJECT: Record = { - "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 => { @@ -138,7 +116,7 @@ const prepareProject = async (hostWorkspace: string): Promise => { }; // --------------------------------------------------------------------------- -// 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 => { @@ -150,12 +128,11 @@ const stageDocker = async (image: string | undefined): Promise => { } 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 => { ); 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 => { 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 => { // 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(