Merge dogfood/orb-runtime into dogfood/v0
Orb runtime now routes the OpenCode ACP through the model gateway end to end. Three-stage proof (Docker, AgentOS/OpenCode session, model turn) passes with the echo token verified and zero leftover containers.
This commit is contained in:
75
bun.lock
75
bun.lock
@@ -154,20 +154,25 @@
|
||||
"name": "@code/agents",
|
||||
"version": "0.0.0",
|
||||
"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",
|
||||
"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:",
|
||||
},
|
||||
},
|
||||
@@ -566,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=="],
|
||||
@@ -832,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=="],
|
||||
@@ -932,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=="],
|
||||
@@ -1354,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=="],
|
||||
@@ -1590,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=="],
|
||||
@@ -1628,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=="],
|
||||
@@ -1722,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=="],
|
||||
@@ -1756,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=="],
|
||||
@@ -1800,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=="],
|
||||
@@ -1862,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=="],
|
||||
@@ -1998,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=="],
|
||||
@@ -2168,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=="],
|
||||
@@ -2446,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=="],
|
||||
@@ -2768,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=="],
|
||||
@@ -3004,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=="],
|
||||
@@ -3442,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=="],
|
||||
@@ -3538,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=="],
|
||||
@@ -3546,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=="],
|
||||
@@ -3700,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=="],
|
||||
@@ -3972,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=="],
|
||||
@@ -4050,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=="],
|
||||
@@ -4608,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=="],
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"build:agents": "vp run --filter @code/agents build",
|
||||
"docs:update": "bun run scripts/update-docs.ts",
|
||||
"subtree": "bun run scripts/subtree.ts",
|
||||
"fix": "ultracite fix"
|
||||
"fix": "ultracite fix",
|
||||
"orb:proof": "bun run scripts/orb-proof.ts"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,20 +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",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
|
||||
105
packages/agents/src/orb/README.md
Normal file
105
packages/agents/src/orb/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Orb Runtime
|
||||
|
||||
One logical execution workspace for one ProjectIssue run. An Orb bundles an AgentOS actor, an OpenCode harness session, a Docker sandbox, a mounted repository workspace, project context, model-gateway configuration, process/log handling, normalized execution events, and a cleanup lifecycle.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ OrbRuntime │
|
||||
│ creates OrbHandle instances │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ OrbHandle │
|
||||
│ ┌─────────────┐ ┌────────────────────────┐ │
|
||||
│ │ AgentOS VM │ │ SandboxAgent + Docker │ │
|
||||
│ │ (OpenCode │◄──►│ (sandbox-agent server │ │
|
||||
│ │ ACP agent) │ │ in a Docker container)│ │
|
||||
│ └─────────────┘ └────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ session events runProcess / │
|
||||
│ → normalized createProcess │
|
||||
│ OrbEvent │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `OrbId` | Branded identifier for one Orb |
|
||||
| `OrbRunId` | Branded identifier for one run attempt |
|
||||
| `OrbSessionId` | Branded identifier for one OpenCode session |
|
||||
| `OrbIdentity` | `{ projectId, runId, workUnitId }` — derives the actor key |
|
||||
| `OrbState` | `creating → prepared → running → needs-input → completed/failed/cancelled → disposed` |
|
||||
| `RunState` | `queued → provisioning → preparing → running → verifying → succeeded/failed/cancelled` |
|
||||
| `OrbEvent` | Normalized, secret-free execution event |
|
||||
|
||||
Orb state and run state are separate state machines. The VM/sandbox lease state is never conflated with the product work-unit state.
|
||||
|
||||
## Tagged errors
|
||||
|
||||
| Error | Reasons |
|
||||
| --- | --- |
|
||||
| `OrbStateError` | `InvalidTransition`, `AlreadyDisposed`, `NotRunning` |
|
||||
| `OrbSandboxError` | `DockerUnavailable`, `ContainerStart`, `CommandFailed`, `ContainerCleanup` |
|
||||
| `OrbSessionError` | `OpenSession`, `PromptFailed`, `AgentNotInstalled`, `SessionNotFound` |
|
||||
| `OrbConfigurationError` | `MissingGateway`, `MissingIdentity`, `InvalidModelConfig` |
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `ORB_PROOF` | Proof only | Set to `1` to run the proof fixture |
|
||||
| `ORB_GATEWAY_API_KEY` | Proof only | Model gateway API key |
|
||||
| `ORB_GATEWAY_BASE_URL` | Proof only | Model gateway base URL (OpenAI-compatible `/v1`) |
|
||||
| `ORB_GATEWAY_MODEL` | Proof only | Model name |
|
||||
| `ORB_GATEWAY_PROVIDER` | Proof only | Provider identifier |
|
||||
| `ORB_DOCKER_IMAGE` | Optional | Docker image (default: `oven/bun:1.3-debian`) |
|
||||
| `ORB_DOCKER_WORKSPACE` | Optional | Host workspace root (default: `/tmp/orb-workspaces`) |
|
||||
| `RIVET_ENDPOINT` | Optional | AgentOS/RivetKit sidecar endpoint |
|
||||
|
||||
No permanent provider credentials are stored in project files. API keys are injected at runtime via the OpenCode config written to the VM filesystem, and all event output is passed through `redactSecrets`.
|
||||
|
||||
## Docker requirements
|
||||
|
||||
- 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
|
||||
|
||||
```bash
|
||||
# Run the proof fixture
|
||||
ORB_PROOF=1 \
|
||||
ORB_GATEWAY_API_KEY=your-key \
|
||||
ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
|
||||
ORB_GATEWAY_MODEL=glm-5.2 \
|
||||
ORB_GATEWAY_PROVIDER=cheaptricks \
|
||||
bun run scripts/orb-proof.ts
|
||||
```
|
||||
|
||||
Stable markers: `ORB_PROOF_PASSED` (exit 0), `ORB_PROOF_BLOCKED` (exit 2), `ORB_PROOF_FAILED` (exit 1).
|
||||
|
||||
## Filesystem layout
|
||||
|
||||
```
|
||||
SandboxAgent container (/home/sandbox = host bind mount)
|
||||
/home/sandbox/repository/ — project checkout
|
||||
/home/sandbox/control/ — issue + context files
|
||||
|
||||
AgentOS VM (host process)
|
||||
/mnt/sandbox/ — sandbox mount (via SandboxAgent baseUrl)
|
||||
/mnt/sandbox/repository/ — repo (via sandbox)
|
||||
/root/.config/opencode/ — OpenCode config (chmod 600)
|
||||
```
|
||||
|
||||
## Current limitations
|
||||
|
||||
- No automatic merge or production deployment capability.
|
||||
- No multi-region support.
|
||||
- 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.
|
||||
170
packages/agents/src/orb/docker-sandbox.test.ts
Normal file
170
packages/agents/src/orb/docker-sandbox.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable no-console -- diagnostic skip messages in a Docker-guarded suite */
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as sleepTimer } from "node:timers/promises";
|
||||
|
||||
import { Effect } from "effect";
|
||||
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, 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) => {
|
||||
const [command = "docker", ...rest] = args;
|
||||
const proc = spawn(command, rest, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
proc.on("error", () => resolve(null));
|
||||
proc.on("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
const dockerAvailable = async (): Promise<boolean> => {
|
||||
try {
|
||||
const code = await runCliExit([
|
||||
"docker",
|
||||
"version",
|
||||
"--format",
|
||||
"{{.Server.Version}}",
|
||||
]);
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Top-level await so describe.skipIf evaluates Docker availability at registration.
|
||||
const HAVE_DOCKER = await dockerAvailable();
|
||||
|
||||
const tmpDir = (): string =>
|
||||
`/tmp/orb-test-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await provider.dispose();
|
||||
expect(provider.isStarted).toBe(false);
|
||||
|
||||
// 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 provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: workspace,
|
||||
})
|
||||
);
|
||||
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({
|
||||
hostWorkspacePath: "/tmp/orb-test-nodocker",
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(error).toBeInstanceOf(OrbSandboxError);
|
||||
expect(error.reason).toBe("DockerUnavailable");
|
||||
});
|
||||
});
|
||||
|
||||
if (!HAVE_DOCKER) {
|
||||
console.warn(
|
||||
"[docker-sandbox.test.ts] Docker daemon unavailable; SandboxAgent tests skipped."
|
||||
);
|
||||
}
|
||||
118
packages/agents/src/orb/docker-sandbox.ts
Normal file
118
packages/agents/src/orb/docker-sandbox.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
AgentOsSandboxClient,
|
||||
AgentOsSandboxProvider,
|
||||
} from "@rivet-dev/agentos-core";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { OrbSandboxError } from "./domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full";
|
||||
const DEFAULT_WORKSPACE = "/home/sandbox";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DockerSandboxProvider — wraps SandboxAgent.start with the docker provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DockerSandboxOptions {
|
||||
readonly containerName?: string;
|
||||
readonly hostWorkspacePath: string;
|
||||
readonly image?: string;
|
||||
readonly workDir?: string;
|
||||
}
|
||||
|
||||
export class DockerSandboxProvider implements AgentOsSandboxProvider {
|
||||
private readonly image: string;
|
||||
private readonly binds: string[];
|
||||
private client: AgentOsSandboxClient | null = null;
|
||||
private disposeFn: (() => Promise<void>) | null = null;
|
||||
|
||||
constructor(options: DockerSandboxOptions) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
async start(): Promise<AgentOsSandboxClient> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
const { SandboxAgent } = await import("sandbox-agent");
|
||||
const { docker } = await import("sandbox-agent/docker");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const dispose = this.disposeFn;
|
||||
this.client = null;
|
||||
this.disposeFn = null;
|
||||
if (dispose) {
|
||||
await dispose();
|
||||
}
|
||||
}
|
||||
|
||||
get isStarted(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker availability check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
132
packages/agents/src/orb/domain.test.ts
Normal file
132
packages/agents/src/orb/domain.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
orbActorKey,
|
||||
OrbStateError,
|
||||
transitionOrbState,
|
||||
transitionRunState,
|
||||
} from "./domain";
|
||||
|
||||
const runTransition = (from: string, to: string) =>
|
||||
Effect.runSync(
|
||||
Effect.flip(transitionOrbState({ from: from as never, to: to as never }))
|
||||
);
|
||||
|
||||
const runTransitionOk = (from: string, to: string) =>
|
||||
Effect.runSync(transitionOrbState({ from: from as never, to: to as never }));
|
||||
|
||||
const runRunState = (from: string, to: string) =>
|
||||
Effect.runSync(
|
||||
Effect.flip(transitionRunState({ from: from as never, to: to as never }))
|
||||
);
|
||||
|
||||
const runRunStateOk = (from: string, to: string) =>
|
||||
Effect.runSync(transitionRunState({ from: from as never, to: to as never }));
|
||||
|
||||
describe("orbActorKey", () => {
|
||||
it("produces a stable key from the identity triple", () => {
|
||||
const key = orbActorKey({
|
||||
projectId: "prj-1",
|
||||
runId: "run-1",
|
||||
workUnitId: "wrk-1",
|
||||
});
|
||||
expect(key).toBe("project:prj-1:work:wrk-1:run:run-1");
|
||||
});
|
||||
|
||||
it("preserves order across different identities", () => {
|
||||
expect(orbActorKey({ projectId: "a", runId: "b", workUnitId: "c" })).toBe(
|
||||
"project:a:work:c:run:b"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transitionOrbState", () => {
|
||||
it("allows creating -> prepared", () => {
|
||||
expect(runTransitionOk("creating", "prepared")).toBe("prepared");
|
||||
});
|
||||
|
||||
it("allows prepared -> running", () => {
|
||||
expect(runTransitionOk("prepared", "running")).toBe("running");
|
||||
});
|
||||
|
||||
it("allows running -> needs-input", () => {
|
||||
expect(runTransitionOk("running", "needs-input")).toBe("needs-input");
|
||||
});
|
||||
|
||||
it("allows running -> completed", () => {
|
||||
expect(runTransitionOk("running", "completed")).toBe("completed");
|
||||
});
|
||||
|
||||
it("allows completed -> disposed", () => {
|
||||
expect(runTransitionOk("completed", "disposed")).toBe("disposed");
|
||||
});
|
||||
|
||||
it("allows needs-input -> running (resume)", () => {
|
||||
expect(runTransitionOk("needs-input", "running")).toBe("running");
|
||||
});
|
||||
|
||||
it("rejects disposed -> running", () => {
|
||||
const error = runTransition("disposed", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("AlreadyDisposed");
|
||||
});
|
||||
|
||||
it("rejects completed -> creating", () => {
|
||||
const error = runTransition("completed", "creating");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects prepared -> needs-input (skipping running)", () => {
|
||||
const error = runTransition("prepared", "needs-input");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects cancelled -> running", () => {
|
||||
const error = runTransition("cancelled", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transitionRunState", () => {
|
||||
it("allows queued -> provisioning", () => {
|
||||
expect(runRunStateOk("queued", "provisioning")).toBe("provisioning");
|
||||
});
|
||||
|
||||
it("allows provisioning -> preparing", () => {
|
||||
expect(runRunStateOk("provisioning", "preparing")).toBe("preparing");
|
||||
});
|
||||
|
||||
it("allows running -> verifying", () => {
|
||||
expect(runRunStateOk("running", "verifying")).toBe("verifying");
|
||||
});
|
||||
|
||||
it("allows verifying -> succeeded", () => {
|
||||
expect(runRunStateOk("verifying", "succeeded")).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("allows running -> cancelled", () => {
|
||||
expect(runRunStateOk("running", "cancelled")).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("rejects succeeded -> running (terminal)", () => {
|
||||
const error = runRunState("succeeded", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects failed -> running (terminal)", () => {
|
||||
const error = runRunState("failed", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects queued -> succeeded (skipping steps)", () => {
|
||||
const error = runRunState("queued", "succeeded");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
});
|
||||
259
packages/agents/src/orb/domain.ts
Normal file
259
packages/agents/src/orb/domain.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branded identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbId = MeaningfulString.pipe(Schema.brand("OrbId"));
|
||||
export type OrbId = typeof OrbId.Type;
|
||||
|
||||
export const OrbRunId = MeaningfulString.pipe(Schema.brand("OrbRunId"));
|
||||
export type OrbRunId = typeof OrbRunId.Type;
|
||||
|
||||
export const OrbSessionId = MeaningfulString.pipe(Schema.brand("OrbSessionId"));
|
||||
export type OrbSessionId = typeof OrbSessionId.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actor identity — one Orb per project/issue/run triple
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbIdentity = Schema.Struct({
|
||||
projectId: MeaningfulString,
|
||||
runId: MeaningfulString,
|
||||
workUnitId: MeaningfulString,
|
||||
});
|
||||
export type OrbIdentity = typeof OrbIdentity.Type;
|
||||
|
||||
/** Stable RivetKit actor key derived from the identity triple. */
|
||||
export const orbActorKey = (identity: OrbIdentity): string =>
|
||||
`project:${identity.projectId}:work:${identity.workUnitId}:run:${identity.runId}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State machines — product state stays separate from run state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbState = Schema.Literals([
|
||||
"creating",
|
||||
"prepared",
|
||||
"running",
|
||||
"needs-input",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"disposed",
|
||||
]);
|
||||
export type OrbState = typeof OrbState.Type;
|
||||
|
||||
export const RunState = Schema.Literals([
|
||||
"queued",
|
||||
"provisioning",
|
||||
"preparing",
|
||||
"running",
|
||||
"verifying",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
export type RunState = typeof RunState.Type;
|
||||
|
||||
const ORB_TRANSITIONS: Readonly<Record<OrbState, readonly OrbState[]>> = {
|
||||
cancelled: ["disposed"],
|
||||
completed: ["disposed"],
|
||||
creating: ["prepared", "running", "failed", "cancelled", "disposed"],
|
||||
disposed: [],
|
||||
failed: ["disposed"],
|
||||
"needs-input": ["running", "completed", "failed", "cancelled", "disposed"],
|
||||
prepared: ["running", "failed", "cancelled", "disposed"],
|
||||
running: ["needs-input", "completed", "failed", "cancelled", "disposed"],
|
||||
};
|
||||
|
||||
const RUN_TRANSITIONS: Readonly<Record<RunState, readonly RunState[]>> = {
|
||||
cancelled: [],
|
||||
failed: [],
|
||||
preparing: ["running", "verifying", "failed", "cancelled"],
|
||||
provisioning: ["preparing", "running", "failed", "cancelled"],
|
||||
queued: ["provisioning", "preparing", "running", "failed", "cancelled"],
|
||||
running: ["verifying", "succeeded", "failed", "cancelled"],
|
||||
succeeded: [],
|
||||
verifying: ["succeeded", "failed", "cancelled"],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tagged errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbStateErrorReason = Schema.Literals([
|
||||
"InvalidTransition",
|
||||
"AlreadyDisposed",
|
||||
"NotRunning",
|
||||
]);
|
||||
export type OrbStateErrorReason = typeof OrbStateErrorReason.Type;
|
||||
|
||||
export class OrbStateError extends Schema.TaggedErrorClass<OrbStateError>()(
|
||||
"OrbStateError",
|
||||
{
|
||||
from: Schema.String,
|
||||
message: Schema.String,
|
||||
reason: OrbStateErrorReason,
|
||||
to: Schema.String,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const OrbSandboxErrorReason = Schema.Literals([
|
||||
"DockerUnavailable",
|
||||
"ContainerStart",
|
||||
"CommandFailed",
|
||||
"ContainerCleanup",
|
||||
]);
|
||||
export type OrbSandboxErrorReason = typeof OrbSandboxErrorReason.Type;
|
||||
|
||||
export class OrbSandboxError extends Schema.TaggedErrorClass<OrbSandboxError>()(
|
||||
"OrbSandboxError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: OrbSandboxErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const OrbSessionErrorReason = Schema.Literals([
|
||||
"OpenSession",
|
||||
"PromptFailed",
|
||||
"AgentNotInstalled",
|
||||
"SessionNotFound",
|
||||
"PermissionDenied",
|
||||
]);
|
||||
export type OrbSessionErrorReason = typeof OrbSessionErrorReason.Type;
|
||||
|
||||
export class OrbSessionError extends Schema.TaggedErrorClass<OrbSessionError>()(
|
||||
"OrbSessionError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: OrbSessionErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const OrbConfigurationErrorReason = Schema.Literals([
|
||||
"MissingGateway",
|
||||
"MissingIdentity",
|
||||
"InvalidModelConfig",
|
||||
]);
|
||||
export type OrbConfigurationErrorReason =
|
||||
typeof OrbConfigurationErrorReason.Type;
|
||||
|
||||
export class OrbConfigurationError extends Schema.TaggedErrorClass<OrbConfigurationError>()(
|
||||
"OrbConfigurationError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: OrbConfigurationErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbModelGatewayConfig = Schema.Struct({
|
||||
apiKey: MeaningfulString,
|
||||
baseUrl: MeaningfulString,
|
||||
model: MeaningfulString,
|
||||
provider: MeaningfulString,
|
||||
});
|
||||
export type OrbModelGatewayConfig = typeof OrbModelGatewayConfig.Type;
|
||||
|
||||
export const OrbProjectContext = Schema.Struct({
|
||||
artifacts: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
path: MeaningfulString,
|
||||
})
|
||||
),
|
||||
contextFiles: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
path: MeaningfulString,
|
||||
})
|
||||
),
|
||||
issueBody: MeaningfulString,
|
||||
issueTitle: MeaningfulString,
|
||||
repositoryUrl: Schema.UndefinedOr(MeaningfulString),
|
||||
});
|
||||
export type OrbProjectContext = typeof OrbProjectContext.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transition helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const transitionOrbState = Effect.fn("Orb.transitionState")(
|
||||
function* transitionOrbState(input: {
|
||||
readonly from: OrbState;
|
||||
readonly to: OrbState;
|
||||
}) {
|
||||
if (input.from === input.to) {
|
||||
return input.to;
|
||||
}
|
||||
if (input.from === "disposed") {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: "Orb is already disposed",
|
||||
reason: "AlreadyDisposed",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!ORB_TRANSITIONS[input.from].includes(input.to)) {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: `Cannot transition orb from ${input.from} to ${input.to}`,
|
||||
reason: "InvalidTransition",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
return input.to;
|
||||
}
|
||||
);
|
||||
|
||||
export const transitionRunState = Effect.fn("Orb.transitionRunState")(
|
||||
function* transitionRunState(input: {
|
||||
readonly from: RunState;
|
||||
readonly to: RunState;
|
||||
}) {
|
||||
if (input.from === input.to) {
|
||||
return input.to;
|
||||
}
|
||||
if (
|
||||
input.from === "succeeded" ||
|
||||
input.from === "failed" ||
|
||||
input.from === "cancelled"
|
||||
) {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: `Run is already in terminal state ${input.from}`,
|
||||
reason: "InvalidTransition",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!RUN_TRANSITIONS[input.from].includes(input.to)) {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: `Cannot transition run from ${input.from} to ${input.to}`,
|
||||
reason: "InvalidTransition",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
return input.to;
|
||||
}
|
||||
);
|
||||
134
packages/agents/src/orb/events.test.ts
Normal file
134
packages/agents/src/orb/events.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable no-non-null-assertion -- test assertions on defined events */
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
|
||||
|
||||
describe("redactSecrets", () => {
|
||||
it("redacts api_key patterns", () => {
|
||||
const input = "Using api_key=sk-abc123def456ghi789jkl012mno345";
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain("sk-abc123");
|
||||
expect(result).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts Bearer tokens", () => {
|
||||
const input = "Authorization: Bearer eyJhbGciOiJIUzI1";
|
||||
const result = redactSecrets(input);
|
||||
expect(result).toContain("Bearer [REDACTED]");
|
||||
expect(result).not.toContain("eyJhbGciOiJIUzI1");
|
||||
});
|
||||
|
||||
it("redacts token= patterns", () => {
|
||||
const input = 'token: "my-secret-token-value"';
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain("my-secret-token-value");
|
||||
});
|
||||
|
||||
it("preserves non-secret text", () => {
|
||||
expect(redactSecrets("just regular text")).toBe("just regular text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeOrbEvent", () => {
|
||||
it("creates an event with sequence and timestamp", () => {
|
||||
const event = makeOrbEvent(5, "session_opened", { text: "opened" });
|
||||
expect(event.sequence).toBe(5);
|
||||
expect(event.type).toBe("session_opened");
|
||||
expect(event.text).toBe("opened");
|
||||
expect(event.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates an event with command and exitCode", () => {
|
||||
const event = makeOrbEvent(3, "command_executed", {
|
||||
command: "bun test",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(event.command).toBe("bun test");
|
||||
expect(event.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSessionEvent", () => {
|
||||
it("normalizes agent_message_chunk", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
rawText: "Working on the fix",
|
||||
sequence: 5,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:00Z",
|
||||
type: "agent_message_chunk",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("agent_message_chunk");
|
||||
expect(event!.text).toBe("Working on the fix");
|
||||
expect(event!.sequence).toBe(5);
|
||||
});
|
||||
|
||||
it("normalizes agent_message to agent_message_completed", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
content: [{ text: "Done", type: "text" }],
|
||||
sequence: 10,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:05Z",
|
||||
type: "agent_message",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("agent_message_completed");
|
||||
expect(event!.text).toBe("Done");
|
||||
});
|
||||
|
||||
it("normalizes tool_call", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 3,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:01Z",
|
||||
title: "bash",
|
||||
toolCallId: "tc-1",
|
||||
type: "tool_call",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("tool_call_started");
|
||||
expect(event!.toolName).toBe("bash");
|
||||
expect(event!.toolCallId).toBe("tc-1");
|
||||
});
|
||||
|
||||
it("normalizes permission_request", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 7,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:03Z",
|
||||
type: "permission_request",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("permission_requested");
|
||||
});
|
||||
|
||||
it("returns undefined for unmapped types", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 1,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:00Z",
|
||||
type: "session_config",
|
||||
});
|
||||
expect(event).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when type is missing", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 1,
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
expect(event).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts secrets in persisted event copies", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
rawText: "Using api_key=sk-secret123456789012345678",
|
||||
sequence: 2,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:00Z",
|
||||
type: "agent_message_chunk",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.text).not.toContain("sk-secret");
|
||||
});
|
||||
});
|
||||
216
packages/agents/src/orb/events.ts
Normal file
216
packages/agents/src/orb/events.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Schema } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized Orb events — domain-meaningful, secret-free
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbEventVariant = Schema.Literals([
|
||||
"session_opened",
|
||||
"agent_message_chunk",
|
||||
"agent_message_completed",
|
||||
"agent_thought_chunk",
|
||||
"tool_call_started",
|
||||
"tool_call_completed",
|
||||
"permission_requested",
|
||||
"permission_denied",
|
||||
"command_executed",
|
||||
"session_failed",
|
||||
"session_closed",
|
||||
"vm_booted",
|
||||
"vm_shutdown",
|
||||
]);
|
||||
export type OrbEventVariant = typeof OrbEventVariant.Type;
|
||||
|
||||
export const OrbEvent = Schema.Struct({
|
||||
command: Schema.UndefinedOr(Schema.String),
|
||||
exitCode: Schema.UndefinedOr(Schema.Int),
|
||||
sequence: Schema.Number,
|
||||
text: Schema.UndefinedOr(Schema.String),
|
||||
timestamp: Schema.String,
|
||||
toolCallId: Schema.UndefinedOr(Schema.String),
|
||||
toolName: Schema.UndefinedOr(Schema.String),
|
||||
type: OrbEventVariant,
|
||||
});
|
||||
export type OrbEvent = typeof OrbEvent.Type;
|
||||
|
||||
/** Construct an OrbEvent with auto-incrementing sequence and timestamp. */
|
||||
export const makeOrbEvent = (
|
||||
sequence: number,
|
||||
type: OrbEventVariant,
|
||||
fields?: Partial<Omit<OrbEvent, "sequence" | "timestamp" | "type">>
|
||||
): OrbEvent =>
|
||||
({
|
||||
command: fields?.command ?? undefined,
|
||||
exitCode: fields?.exitCode ?? undefined,
|
||||
sequence,
|
||||
text: fields?.text ?? undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
toolCallId: fields?.toolCallId ?? undefined,
|
||||
toolName: fields?.toolName ?? undefined,
|
||||
type,
|
||||
}) as unknown as OrbEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret redaction — applied only to persisted/logged event copies,
|
||||
// never to prompts sent to the model.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REDACT_PATTERNS = [
|
||||
/(?:api[_-]?key|token|secret|password|credential)["'\s:=]+(?<value>[^\s"'},]+)/giu,
|
||||
/sk-(?<key>[a-zA-Z0-9]{20,})/gu,
|
||||
/Bearer\s+[a-zA-Z0-9._-]+/gu,
|
||||
];
|
||||
|
||||
export const redactSecrets = (text: string): string => {
|
||||
let result = text;
|
||||
for (const pattern of REDACT_PATTERNS) {
|
||||
result = result.replaceAll(pattern, (match) =>
|
||||
match.toLowerCase().includes("bearer")
|
||||
? "Bearer [REDACTED]"
|
||||
: "[REDACTED]"
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation from AgentOS session stream entries to normalized OrbEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AcpSessionUpdateLike {
|
||||
readonly type?: string;
|
||||
readonly sessionUpdate?: string;
|
||||
readonly rawText?: string;
|
||||
readonly text?: string;
|
||||
readonly content?: unknown;
|
||||
readonly toolCallId?: string | null;
|
||||
readonly toolCallStatus?: string;
|
||||
readonly title?: string | null;
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
interface SessionStreamEntryLike {
|
||||
readonly afterSequence?: number;
|
||||
readonly content?: unknown;
|
||||
readonly durability?: string;
|
||||
readonly rawText?: string;
|
||||
readonly sequence?: number;
|
||||
readonly sessionId?: string;
|
||||
readonly sessionUpdate?: string;
|
||||
readonly text?: string;
|
||||
readonly timestamp?: string;
|
||||
readonly title?: string | null;
|
||||
readonly toolCallId?: string | null;
|
||||
readonly type?: string;
|
||||
}
|
||||
|
||||
const IGNORED_TYPES = new Set([
|
||||
"session_config",
|
||||
"agent_description",
|
||||
"agent_capability",
|
||||
]);
|
||||
|
||||
const MESSAGE_TYPES = new Set([
|
||||
"agent_message_chunk",
|
||||
"agent_message",
|
||||
"agent_thought_chunk",
|
||||
]);
|
||||
|
||||
const TOOL_CALL_TYPES = new Set([
|
||||
"tool_call",
|
||||
"tool_call_status",
|
||||
"tool_call_update",
|
||||
]);
|
||||
|
||||
const extractText = (entry: AcpSessionUpdateLike): string | undefined => {
|
||||
if (entry.rawText !== undefined) {
|
||||
return entry.rawText;
|
||||
}
|
||||
if (entry.text !== undefined) {
|
||||
return entry.text;
|
||||
}
|
||||
if (typeof entry.content === "string") {
|
||||
return entry.content;
|
||||
}
|
||||
if (Array.isArray(entry.content)) {
|
||||
const texts = entry.content
|
||||
.filter(
|
||||
(block): block is { readonly type: string; readonly text?: unknown } =>
|
||||
typeof block === "object" && block !== null && "type" in block
|
||||
)
|
||||
.map((block) => (typeof block.text === "string" ? block.text : undefined))
|
||||
.filter((text): text is string => text !== undefined);
|
||||
return texts.length > 0 ? texts.join("") : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const optionalToolFields = (
|
||||
raw: SessionStreamEntryLike
|
||||
): { toolCallId?: string; toolName?: string } => ({
|
||||
...(raw.toolCallId === null || raw.toolCallId === undefined
|
||||
? {}
|
||||
: { toolCallId: raw.toolCallId }),
|
||||
...(raw.title === null || raw.title === undefined
|
||||
? {}
|
||||
: { toolName: raw.title }),
|
||||
});
|
||||
|
||||
const fromMessage = (
|
||||
type: string,
|
||||
sequence: number,
|
||||
raw: SessionStreamEntryLike
|
||||
): OrbEvent | undefined => {
|
||||
const text = extractText(raw);
|
||||
if (text === undefined) {
|
||||
// agent_message completes even without text; chunk/thought do not.
|
||||
return type === "agent_message"
|
||||
? makeOrbEvent(sequence, "agent_message_completed", {})
|
||||
: undefined;
|
||||
}
|
||||
const variant = (
|
||||
type === "agent_message" ? "agent_message_completed" : type
|
||||
) as OrbEventVariant;
|
||||
return makeOrbEvent(sequence, variant, { text: redactSecrets(text) });
|
||||
};
|
||||
|
||||
const fromToolCall = (
|
||||
type: string,
|
||||
sequence: number,
|
||||
raw: SessionStreamEntryLike
|
||||
): OrbEvent =>
|
||||
makeOrbEvent(
|
||||
sequence,
|
||||
type === "tool_call" ? "tool_call_started" : "tool_call_completed",
|
||||
optionalToolFields(raw)
|
||||
);
|
||||
|
||||
/**
|
||||
* Translate one AgentOS SessionStreamEntry into zero or one normalized OrbEvent.
|
||||
* Returns undefined for event types that have no domain-meaningful mapping yet.
|
||||
* Secret redaction is applied so persisted event copies never leak credentials.
|
||||
*/
|
||||
export const normalizeSessionEvent = (
|
||||
raw: SessionStreamEntryLike
|
||||
): OrbEvent | undefined => {
|
||||
const type = raw.type ?? raw.sessionUpdate;
|
||||
if (type === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const sequence = raw.sequence ?? 0;
|
||||
if (IGNORED_TYPES.has(type)) {
|
||||
return undefined;
|
||||
}
|
||||
if (type === "permission_request") {
|
||||
return makeOrbEvent(sequence, "permission_requested", {
|
||||
text: "Permission requested",
|
||||
});
|
||||
}
|
||||
if (MESSAGE_TYPES.has(type)) {
|
||||
return fromMessage(type, sequence, raw);
|
||||
}
|
||||
if (TOOL_CALL_TYPES.has(type)) {
|
||||
return fromToolCall(type, sequence, raw);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
7
packages/agents/src/orb/index.ts
Normal file
7
packages/agents/src/orb/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The Orb module exposes its public surface here.
|
||||
export * from "./domain";
|
||||
export * from "./events";
|
||||
export * from "./docker-sandbox";
|
||||
export * from "./opencode-config";
|
||||
export * from "./permission-policy";
|
||||
export * from "./runtime";
|
||||
82
packages/agents/src/orb/opencode-config.test.ts
Normal file
82
packages/agents/src/orb/opencode-config.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { OrbConfigurationError } from "./domain";
|
||||
import {
|
||||
opencodeSetupCommands,
|
||||
prepareOpenCodeConfig,
|
||||
} from "./opencode-config";
|
||||
|
||||
const validGateway = {
|
||||
apiKey: "test-key-123",
|
||||
baseUrl: "https://gateway.example.com/v1",
|
||||
model: "test-model",
|
||||
provider: "test-provider",
|
||||
};
|
||||
|
||||
const validContext = {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
issueBody: "Fix the bug",
|
||||
issueTitle: "Bug fix",
|
||||
repositoryUrl: undefined,
|
||||
};
|
||||
|
||||
describe("prepareOpenCodeConfig", () => {
|
||||
it("produces valid config JSON with provider and model", () => {
|
||||
const result = Effect.runSync(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: validGateway,
|
||||
})
|
||||
);
|
||||
const parsed = JSON.parse(result.configJson);
|
||||
expect(parsed.model).toBe("test-provider/test-model");
|
||||
expect(parsed.provider["test-provider"].baseUrl).toBe(
|
||||
"https://gateway.example.com/v1"
|
||||
);
|
||||
expect(parsed.provider["test-provider"].apiKey).toBe("test-key-123");
|
||||
});
|
||||
|
||||
it("sets config path under opencode config directory", () => {
|
||||
const result = Effect.runSync(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: validGateway,
|
||||
})
|
||||
);
|
||||
expect(result.configPath).toContain("opencode");
|
||||
expect(result.configPath).toContain("config.json");
|
||||
expect(result.instructionsPath).toBe("/workspace/control/issue.md");
|
||||
});
|
||||
|
||||
it("rejects empty base URL", () => {
|
||||
const error = Effect.runSync(
|
||||
Effect.flip(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: { ...validGateway, baseUrl: " " },
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(error).toBeInstanceOf(OrbConfigurationError);
|
||||
expect(error.reason).toBe("InvalidModelConfig");
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencodeSetupCommands", () => {
|
||||
it("produces mkdir, write, and chmod commands", () => {
|
||||
const config = Effect.runSync(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: validGateway,
|
||||
})
|
||||
);
|
||||
const commands = opencodeSetupCommands(config);
|
||||
expect(commands.length).toBe(3);
|
||||
expect(commands[0]).toContain("mkdir");
|
||||
expect(commands[1]).toContain("cat >");
|
||||
expect(commands[2]).toContain("chmod 600");
|
||||
});
|
||||
});
|
||||
76
packages/agents/src/orb/opencode-config.ts
Normal file
76
packages/agents/src/orb/opencode-config.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { OrbConfigurationError } from "./domain";
|
||||
import type { OrbModelGatewayConfig, OrbProjectContext } from "./domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenCode configuration — prepared inside the AgentOS VM filesystem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OPENCODE_CONFIG_DIR = "/root/.config/opencode";
|
||||
const OPENCODE_CONFIG_PATH = `${OPENCODE_CONFIG_DIR}/config.json`;
|
||||
const AGENT_INSTRUCTIONS_PATH = "/workspace/control/issue.md";
|
||||
|
||||
export interface PreparedOpenCodeConfig {
|
||||
readonly configJson: string;
|
||||
readonly configPath: string;
|
||||
readonly instructionsPath: string;
|
||||
}
|
||||
|
||||
const validateGateway = (
|
||||
gateway: OrbModelGatewayConfig
|
||||
): Effect.Effect<void, OrbConfigurationError> =>
|
||||
gateway.baseUrl.trim().length === 0
|
||||
? Effect.fail(
|
||||
new OrbConfigurationError({
|
||||
message: "Model gateway base URL must not be empty",
|
||||
reason: "InvalidModelConfig",
|
||||
})
|
||||
)
|
||||
: Effect.void;
|
||||
|
||||
/**
|
||||
* Build the OpenCode configuration JSON and file layout for one Orb run.
|
||||
* The configuration points OpenCode at the model gateway with run-scoped
|
||||
* credentials injected at runtime — never committed to project files.
|
||||
*/
|
||||
export const prepareOpenCodeConfig = Effect.fn("Orb.prepareOpenCodeConfig")(
|
||||
function* prepareOpenCodeConfig(input: {
|
||||
readonly context: OrbProjectContext;
|
||||
readonly gateway: OrbModelGatewayConfig;
|
||||
}) {
|
||||
yield* validateGateway(input.gateway);
|
||||
|
||||
const config = {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: `${input.gateway.provider}/${input.gateway.model}`,
|
||||
provider: {
|
||||
[input.gateway.provider]: {
|
||||
apiKey: input.gateway.apiKey,
|
||||
baseUrl: input.gateway.baseUrl,
|
||||
models: {
|
||||
[input.gateway.model]: {
|
||||
name: input.gateway.model,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const configJson = JSON.stringify(config, null, 2);
|
||||
|
||||
return {
|
||||
configJson,
|
||||
configPath: OPENCODE_CONFIG_PATH,
|
||||
instructionsPath: AGENT_INSTRUCTIONS_PATH,
|
||||
} satisfies PreparedOpenCodeConfig;
|
||||
}
|
||||
);
|
||||
|
||||
/** Shell commands that stage the OpenCode config directory inside a VM. */
|
||||
export const opencodeSetupCommands = (config: PreparedOpenCodeConfig) =>
|
||||
[
|
||||
`mkdir -p ${OPENCODE_CONFIG_DIR}`,
|
||||
`cat > ${config.configPath} << 'ORB_EOF'\n${config.configJson}\nORB_EOF`,
|
||||
`chmod 600 ${config.configPath}`,
|
||||
] as const;
|
||||
107
packages/agents/src/orb/permission-policy.test.ts
Normal file
107
packages/agents/src/orb/permission-policy.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { evaluatePermission, isDangerousPermission } from "./permission-policy";
|
||||
|
||||
const allowOption = { id: "allow", title: "Allow" };
|
||||
const denyOption = { id: "deny", title: "Deny" };
|
||||
|
||||
describe("isDangerousPermission", () => {
|
||||
it("flags merge operations", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r1",
|
||||
toolCall: { title: "git merge main" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags production deployment", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r2",
|
||||
toolCall: { title: "deploy to production" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags secret access", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r3",
|
||||
toolCall: { title: "read secrets" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags credential access", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r4",
|
||||
toolCall: { title: "access credentials" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag safe operations", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r5",
|
||||
toolCall: { title: "run tests" },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag file edits", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r6",
|
||||
toolCall: { title: "edit src/index.ts" },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluatePermission", () => {
|
||||
it("allows safe operations and picks allow option", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [allowOption, denyOption],
|
||||
requestId: "r1",
|
||||
toolCall: { title: "run bun test" },
|
||||
});
|
||||
expect(decision.allow).toBe(true);
|
||||
expect(decision.optionId).toBe("allow");
|
||||
});
|
||||
|
||||
it("denies dangerous operations and picks deny option", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [allowOption, denyOption],
|
||||
requestId: "r2",
|
||||
toolCall: { title: "git merge main" },
|
||||
});
|
||||
expect(decision.allow).toBe(false);
|
||||
expect(decision.optionId).toBe("deny");
|
||||
});
|
||||
|
||||
it("falls back to last option when no explicit deny exists", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [
|
||||
{ id: "ok", title: "OK" },
|
||||
{ id: "cancel", title: "Cancel" },
|
||||
],
|
||||
requestId: "r3",
|
||||
toolCall: { title: "deploy to production" },
|
||||
});
|
||||
expect(decision.allow).toBe(false);
|
||||
expect(decision.optionId).toBe("cancel");
|
||||
});
|
||||
|
||||
it("handles empty options", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [],
|
||||
requestId: "r4",
|
||||
toolCall: { title: "run tests" },
|
||||
});
|
||||
expect(decision.allow).toBe(true);
|
||||
expect(decision.optionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
90
packages/agents/src/orb/permission-policy.ts
Normal file
90
packages/agents/src/orb/permission-policy.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Narrow permission policy for Orb sessions.
|
||||
*
|
||||
* Denies merge, production deployment, secret access, and external
|
||||
* communications. Allows all other operations. Used as the callback for
|
||||
* ACP permission_request events with permissionPolicy: "ask".
|
||||
*/
|
||||
|
||||
interface PermissionOption {
|
||||
readonly id: string;
|
||||
readonly title?: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
interface PermissionRequestLike {
|
||||
readonly requestId: string;
|
||||
readonly options?: readonly PermissionOption[];
|
||||
readonly toolCall?: {
|
||||
readonly title?: string;
|
||||
readonly kind?: string;
|
||||
readonly name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DENY_PATTERNS = [
|
||||
/\bmerge\b/iu,
|
||||
/\bdeploy\b.*\bprod/iu,
|
||||
/\bproduction\b/iu,
|
||||
/\bsecret/iu,
|
||||
/\bcredential/iu,
|
||||
/\bpassword\b/iu,
|
||||
/\bapi[_-]?key\b/iu,
|
||||
/\bpush\s+to\s+(?<branch>main|master)\b/iu,
|
||||
];
|
||||
|
||||
/** Evaluate whether a permission request is dangerous. */
|
||||
export const isDangerousPermission = (
|
||||
request: PermissionRequestLike
|
||||
): boolean => {
|
||||
const text = [
|
||||
request.toolCall?.title,
|
||||
request.toolCall?.kind,
|
||||
request.toolCall?.name,
|
||||
]
|
||||
.filter((s): s is string => typeof s === "string")
|
||||
.join(" ");
|
||||
return DENY_PATTERNS.some((pattern) => pattern.test(text));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the option ID that matches the desired decision. Falls back to the
|
||||
* last option (typically deny) for safety when no explicit deny option exists,
|
||||
* or the first option (typically allow) when no explicit allow option exists.
|
||||
*/
|
||||
const pickOption = (
|
||||
options: readonly PermissionOption[],
|
||||
allow: boolean
|
||||
): string | undefined => {
|
||||
if (options.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (allow) {
|
||||
const match = options.find(
|
||||
(o) =>
|
||||
/allow|accept|yes|permit/iu.test(o.title ?? "") ||
|
||||
/allow|accept|yes|permit/iu.test(o.description ?? "")
|
||||
);
|
||||
return match?.id ?? options[0]?.id;
|
||||
}
|
||||
const match = options.find(
|
||||
(o) =>
|
||||
/deny|reject|no|cancel/iu.test(o.title ?? "") ||
|
||||
/deny|reject|no|cancel/iu.test(o.description ?? "")
|
||||
);
|
||||
return match?.id ?? options.at(-1)?.id;
|
||||
};
|
||||
|
||||
export interface PermissionDecision {
|
||||
readonly allow: boolean;
|
||||
readonly optionId: string | undefined;
|
||||
}
|
||||
|
||||
/** Evaluate a permission request and return the decision. */
|
||||
export const evaluatePermission = (
|
||||
request: PermissionRequestLike
|
||||
): PermissionDecision => {
|
||||
const dangerous = isDangerousPermission(request);
|
||||
const optionId = pickOption(request.options ?? [], !dangerous);
|
||||
return { allow: !dangerous, optionId };
|
||||
};
|
||||
125
packages/agents/src/orb/runtime.test.ts
Normal file
125
packages/agents/src/orb/runtime.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { OrbConfigurationError } from "./domain";
|
||||
import { makeOrbEvent } from "./events";
|
||||
import type { OrbEvent } from "./events";
|
||||
import { OrbHandle, OrbRuntime } from "./runtime";
|
||||
|
||||
const validGateway = {
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://gw.example.com/v1",
|
||||
model: "m1",
|
||||
provider: "p1",
|
||||
};
|
||||
|
||||
const validContext = {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
issueBody: "Do the thing",
|
||||
issueTitle: "Thing",
|
||||
repositoryUrl: undefined,
|
||||
};
|
||||
|
||||
const validIdentity = {
|
||||
projectId: "prj-1",
|
||||
runId: "run-1",
|
||||
workUnitId: "wrk-1",
|
||||
};
|
||||
|
||||
describe("OrbRuntime.createOrb validation", () => {
|
||||
it("rejects missing gateway API key before touching Docker", () => {
|
||||
const runtime = new OrbRuntime();
|
||||
// This must fail at validation, not at Docker — if Docker is the failure,
|
||||
// that indicates the validation ordering is wrong.
|
||||
const error = Effect.runSync(
|
||||
Effect.flip(
|
||||
runtime.createOrb({
|
||||
context: validContext,
|
||||
docker: {
|
||||
containerName: "orb-test",
|
||||
hostWorkspacePath: "/tmp/orb-test",
|
||||
},
|
||||
gateway: { ...validGateway, apiKey: " " },
|
||||
identity: validIdentity,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(error).toBeInstanceOf(OrbConfigurationError);
|
||||
expect(error.reason).toBe("MissingGateway");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrbHandle event lifecycle", () => {
|
||||
it("emits events to listeners", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-x" as never,
|
||||
"run-x" as never,
|
||||
validIdentity
|
||||
);
|
||||
const events: OrbEvent[] = [];
|
||||
handle.onEvent((e) => events.push(e));
|
||||
|
||||
handle.emitEvent(makeOrbEvent(1, "vm_booted", { text: "booted" }));
|
||||
handle.emitEvent(
|
||||
makeOrbEvent(2, "command_executed", { command: "ls", exitCode: 0 })
|
||||
);
|
||||
|
||||
expect(events.length).toBe(2);
|
||||
expect(events[0]!.type).toBe("vm_booted");
|
||||
expect(events[1]!.type).toBe("command_executed");
|
||||
expect(events[1]!.command).toBe("ls");
|
||||
});
|
||||
|
||||
it("unsubscribes listeners correctly", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-y" as never,
|
||||
"run-y" as never,
|
||||
validIdentity
|
||||
);
|
||||
const events: OrbEvent[] = [];
|
||||
const unsub = handle.onEvent((e) => events.push(e));
|
||||
|
||||
handle.emitEvent(makeOrbEvent(1, "session_opened"));
|
||||
unsub();
|
||||
handle.emitEvent(makeOrbEvent(2, "session_closed"));
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrbHandle state transitions", () => {
|
||||
it("starts in creating state", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-z" as never,
|
||||
"run-z" as never,
|
||||
validIdentity
|
||||
);
|
||||
expect(handle.state).toBe("creating");
|
||||
});
|
||||
|
||||
it("transitions to prepared then running", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-a" as never,
|
||||
"run-a" as never,
|
||||
validIdentity
|
||||
);
|
||||
Effect.runSync(handle.setOrbState("prepared"));
|
||||
expect(handle.state).toBe("prepared");
|
||||
Effect.runSync(handle.setOrbState("running"));
|
||||
expect(handle.state).toBe("running");
|
||||
});
|
||||
|
||||
it("rejects invalid transition", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-b" as never,
|
||||
"run-b" as never,
|
||||
validIdentity
|
||||
);
|
||||
const error = Effect.runSync(
|
||||
Effect.flip(handle.setOrbState("needs-input"))
|
||||
);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
});
|
||||
763
packages/agents/src/orb/runtime.ts
Normal file
763
packages/agents/src/orb/runtime.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
/* eslint-disable prefer-destructuring -- field captures before mutation are intentional */
|
||||
/* eslint-disable max-classes-per-file -- runtime and handle form one service. */
|
||||
import opencodePkg from "@agentos-software/opencode";
|
||||
import { AgentOs } from "@rivet-dev/agentos-core";
|
||||
import type { SessionStreamEntry } from "@rivet-dev/agentos-core";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { DockerSandboxOptions } from "./docker-sandbox";
|
||||
import { DockerSandboxProvider } from "./docker-sandbox";
|
||||
import {
|
||||
OrbConfigurationError,
|
||||
OrbSandboxError,
|
||||
OrbSessionError,
|
||||
orbActorKey,
|
||||
transitionOrbState,
|
||||
transitionRunState,
|
||||
} from "./domain";
|
||||
import type {
|
||||
OrbIdentity,
|
||||
OrbId,
|
||||
OrbModelGatewayConfig,
|
||||
OrbProjectContext,
|
||||
OrbRunId,
|
||||
OrbSessionId,
|
||||
OrbState,
|
||||
OrbStateError,
|
||||
RunState,
|
||||
} from "./domain";
|
||||
import type { OrbEvent } from "./events";
|
||||
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
|
||||
import { prepareOpenCodeConfig } from "./opencode-config";
|
||||
import { evaluatePermission } from "./permission-policy";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrbEnv {
|
||||
readonly dockerImage?: string;
|
||||
readonly dockerWorkspace?: string;
|
||||
readonly rivetEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface OrbCreateInput {
|
||||
readonly context: OrbProjectContext;
|
||||
readonly docker: Omit<DockerSandboxOptions, "image">;
|
||||
readonly gateway: OrbModelGatewayConfig;
|
||||
readonly identity: OrbIdentity;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrbHandle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OrbHandle {
|
||||
readonly id: OrbId;
|
||||
readonly runId: OrbRunId;
|
||||
readonly identity: OrbIdentity;
|
||||
readonly actorKey: string;
|
||||
|
||||
private vm: AgentOs | null = null;
|
||||
private dockerProvider: DockerSandboxProvider | null = null;
|
||||
private sessionId: string | undefined;
|
||||
private orbState: OrbState = "creating";
|
||||
private runState: RunState = "queued";
|
||||
private eventSequence = 0;
|
||||
private readonly eventListeners = new Set<(event: OrbEvent) => void>();
|
||||
private unsubscribeSession: (() => void) | null = null;
|
||||
private context: OrbProjectContext | undefined;
|
||||
private gateway: OrbModelGatewayConfig | undefined;
|
||||
|
||||
constructor(id: OrbId, runId: OrbRunId, identity: OrbIdentity) {
|
||||
this.id = id;
|
||||
this.runId = runId;
|
||||
this.identity = identity;
|
||||
this.actorKey = orbActorKey(identity);
|
||||
}
|
||||
|
||||
get state(): OrbState {
|
||||
return this.orbState;
|
||||
}
|
||||
|
||||
get currentSessionId(): string | undefined {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get docker(): DockerSandboxProvider | null {
|
||||
return this.dockerProvider;
|
||||
}
|
||||
|
||||
get vmInstance(): AgentOs | null {
|
||||
return this.vm;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
onEvent(listener: (event: OrbEvent) => void): () => void {
|
||||
this.eventListeners.add(listener);
|
||||
return () => {
|
||||
this.eventListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
emitEvent(event: OrbEvent): void {
|
||||
for (const listener of this.eventListeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
private nextSequence(): number {
|
||||
this.eventSequence += 1;
|
||||
return this.eventSequence;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State transitions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly setOrbState = (to: OrbState): Effect.Effect<void, OrbStateError> =>
|
||||
transitionOrbState({ from: this.orbState, to }).pipe(
|
||||
Effect.tap((next) =>
|
||||
Effect.sync(() => {
|
||||
this.orbState = next;
|
||||
})
|
||||
),
|
||||
Effect.asVoid
|
||||
);
|
||||
|
||||
readonly setRunState = (to: RunState): Effect.Effect<void, OrbStateError> =>
|
||||
transitionRunState({ from: this.runState, to }).pipe(
|
||||
Effect.tap((next) =>
|
||||
Effect.sync(() => {
|
||||
this.runState = next;
|
||||
})
|
||||
),
|
||||
Effect.asVoid
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal attachment
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_attachVm(vm: AgentOs): void {
|
||||
this.vm = vm;
|
||||
this.unsubscribeSession = vm.onSessionEvent((entry: SessionStreamEntry) => {
|
||||
const normalized = normalizeSessionEvent(entry);
|
||||
if (normalized) {
|
||||
this.emitEvent({ ...normalized, sequence: this.nextSequence() });
|
||||
}
|
||||
if (
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
"type" in entry &&
|
||||
entry.type === "permission_request"
|
||||
) {
|
||||
void this.handlePermissionRequest(
|
||||
entry as unknown as {
|
||||
requestId: string;
|
||||
options: {
|
||||
description?: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
}[];
|
||||
toolCall?: { kind?: string; name?: string; title?: string };
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "vm_booted", { text: "VM booted" })
|
||||
);
|
||||
}
|
||||
|
||||
_attachDocker(provider: DockerSandboxProvider): void {
|
||||
this.dockerProvider = provider;
|
||||
}
|
||||
|
||||
_configure(input: {
|
||||
readonly context: OrbProjectContext;
|
||||
readonly gateway: OrbModelGatewayConfig;
|
||||
}): void {
|
||||
this.context = input.context;
|
||||
this.gateway = input.gateway;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Permission handler
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async handlePermissionRequest(request: {
|
||||
readonly requestId: string;
|
||||
readonly options: {
|
||||
description?: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
}[];
|
||||
readonly toolCall?: { kind?: string; name?: string; title?: string };
|
||||
}): Promise<void> {
|
||||
const vm = this.vm;
|
||||
const sessionId = this.sessionId;
|
||||
if (!vm || !sessionId) {
|
||||
return;
|
||||
}
|
||||
const decision = evaluatePermission(request);
|
||||
if (!decision.allow) {
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "permission_denied", {
|
||||
text: `Permission denied for: ${request.toolCall?.title ?? "unknown"}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (decision.optionId) {
|
||||
await vm
|
||||
.respondPermission({
|
||||
optionId: decision.optionId,
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
})
|
||||
.catch(
|
||||
// eslint-disable-next-line no-empty-function -- best-effort permission response
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Repository preparation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly prepareRepository = Effect.fn("Orb.prepareRepository")(
|
||||
function* prepareRepository(
|
||||
this: OrbHandle,
|
||||
input: { readonly baseBranch?: string; readonly branchName?: string }
|
||||
) {
|
||||
if (!this.dockerProvider) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSandboxError({
|
||||
message: "Docker sandbox is not attached",
|
||||
reason: "ContainerStart",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
yield* this.setOrbState("prepared");
|
||||
yield* this.setRunState("provisioning");
|
||||
|
||||
const client = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "ContainerStart",
|
||||
}),
|
||||
try: () => {
|
||||
const dp = this.dockerProvider;
|
||||
if (!dp) {
|
||||
throw new Error("Docker provider detached");
|
||||
}
|
||||
return dp.start();
|
||||
},
|
||||
});
|
||||
|
||||
const repoUrl = this.context?.repositoryUrl;
|
||||
if (repoUrl) {
|
||||
const branch = input.branchName ?? "main";
|
||||
const base = input.baseBranch ?? "main";
|
||||
// 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)} /home/sandbox/repository || git clone ${shellQuote(repoUrl)} /home/sandbox/repository`;
|
||||
// eslint-disable-next-line no-use-before-define -- module-level helper
|
||||
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({
|
||||
message: `Repository checkout failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "CommandFailed",
|
||||
}),
|
||||
try: () =>
|
||||
client.runProcess({
|
||||
args: ["-c", `${cloneCmd} && ${checkoutCmd}`],
|
||||
command: "sh",
|
||||
cwd: "/home/sandbox",
|
||||
timeoutMs: 300_000,
|
||||
}),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSandboxError({
|
||||
message: `Repository clone failed: ${result.stderr}`,
|
||||
reason: "CommandFailed",
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to create workspace: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "CommandFailed",
|
||||
}),
|
||||
try: () =>
|
||||
client.runProcess({
|
||||
args: ["-c", "mkdir -p /home/sandbox/repository"],
|
||||
command: "sh",
|
||||
cwd: "/home/sandbox",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = this.context;
|
||||
const vm = this.vm;
|
||||
if (ctx && vm) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort context staging
|
||||
try: () =>
|
||||
vm
|
||||
.mkdir("/mnt/sandbox/control", { recursive: true })
|
||||
.then(() =>
|
||||
vm.writeFile(
|
||||
"/mnt/sandbox/control/issue.md",
|
||||
`# ${ctx.issueTitle}\n\n${ctx.issueBody}\n`
|
||||
)
|
||||
),
|
||||
});
|
||||
for (const file of ctx.contextFiles) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () =>
|
||||
vm.writeFile(`/mnt/sandbox/control/${file.path}`, file.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield* this.setRunState("preparing");
|
||||
}
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// OpenCode session
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly openSession = Effect.fn("Orb.openSession")(
|
||||
function* openSession(this: OrbHandle) {
|
||||
if (!this.vm) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSessionError({
|
||||
message: "AgentOS VM is not attached",
|
||||
reason: "OpenSession",
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!this.gateway || !this.context) {
|
||||
return yield* Effect.fail(
|
||||
new OrbConfigurationError({
|
||||
message: "Orb is not configured with gateway and context",
|
||||
reason: "MissingGateway",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const config = yield* prepareOpenCodeConfig({
|
||||
context: this.context,
|
||||
gateway: this.gateway,
|
||||
});
|
||||
|
||||
const vm = this.vm;
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to create config directory: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () => vm.mkdir("/root/.config/opencode", { recursive: true }),
|
||||
});
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to write OpenCode config: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
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({
|
||||
message: `Failed to list agents: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "AgentNotInstalled",
|
||||
}),
|
||||
try: () => vm.listAgents(),
|
||||
});
|
||||
const hasOpencode = agents.some(
|
||||
(a) => a.id === "opencode" && a.installed
|
||||
);
|
||||
if (!hasOpencode) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSessionError({
|
||||
message: "OpenCode agent is not installed in the VM",
|
||||
reason: "AgentNotInstalled",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to open OpenCode session: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () =>
|
||||
vm.openSession({
|
||||
agent: "opencode",
|
||||
cwd: "/mnt/sandbox/repository",
|
||||
permissionPolicy: "ask",
|
||||
skipOsInstructions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionInfo = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to read session info: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () => vm.getSession(),
|
||||
});
|
||||
|
||||
this.sessionId = sessionInfo.sessionId;
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "session_opened", {
|
||||
text: `Session ${sessionInfo.sessionId} opened`,
|
||||
})
|
||||
);
|
||||
|
||||
yield* this.setOrbState("running");
|
||||
yield* this.setRunState("running");
|
||||
|
||||
return sessionInfo.sessionId as OrbSessionId;
|
||||
}
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send task — raw prompt to model, redacted copy in events only
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly sendTask = Effect.fn("Orb.sendTask")(function* sendTask(
|
||||
this: OrbHandle,
|
||||
prompt: string
|
||||
) {
|
||||
if (!this.vm || !this.sessionId) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSessionError({
|
||||
message: "No active OpenCode session",
|
||||
reason: "SessionNotFound",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const vm = this.vm;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const sessionId = this.sessionId;
|
||||
|
||||
// Send raw prompt — no redaction of outgoing content.
|
||||
const result = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Prompt failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "PromptFailed",
|
||||
}),
|
||||
try: () =>
|
||||
vm.prompt({
|
||||
content: [{ text: prompt, type: "text" }],
|
||||
sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Execute command via Docker
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly executeCommand = Effect.fn("Orb.executeCommand")(
|
||||
function* executeCommand(
|
||||
this: OrbHandle,
|
||||
input: {
|
||||
readonly command: string;
|
||||
readonly cwd?: string;
|
||||
readonly env?: Readonly<Record<string, string>>;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
) {
|
||||
if (!this.dockerProvider) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSandboxError({
|
||||
message: "Docker sandbox is not attached",
|
||||
reason: "ContainerStart",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const client = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to get sandbox client: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "ContainerStart",
|
||||
}),
|
||||
try: () => {
|
||||
const dp = this.dockerProvider;
|
||||
if (!dp) {
|
||||
throw new Error("Docker provider detached");
|
||||
}
|
||||
return dp.start();
|
||||
},
|
||||
});
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Command failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "CommandFailed",
|
||||
}),
|
||||
try: () =>
|
||||
client.runProcess({
|
||||
args: ["-c", input.command],
|
||||
command: "sh",
|
||||
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
||||
...(input.env === undefined ? {} : { env: input.env }),
|
||||
...(input.timeoutMs === undefined
|
||||
? {}
|
||||
: { timeoutMs: input.timeoutMs }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Emit redacted copy for logs/UI.
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "command_executed", {
|
||||
command: redactSecrets(input.command),
|
||||
exitCode: result.exitCode ?? undefined,
|
||||
})
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cancel / dispose
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly cancel = Effect.fn("Orb.cancel")(function* cancel(this: OrbHandle) {
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const vm = this.vm;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const sessionId = this.sessionId;
|
||||
if (vm && sessionId) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => vm.cancelPrompt({ sessionId }),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
yield* this.setOrbState("cancelled");
|
||||
yield* this.setRunState("cancelled");
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "session_closed", {
|
||||
text: "Orb cancelled",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
readonly dispose = Effect.fn("Orb.dispose")(
|
||||
function* dispose(this: OrbHandle) {
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const vm = this.vm;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const sessionId = this.sessionId;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const docker = this.dockerProvider;
|
||||
|
||||
if (vm && sessionId) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => vm.cancelPrompt({ sessionId }),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
// Dispose the VM before its sandbox so the mount unbinds cleanly.
|
||||
if (vm) {
|
||||
if (this.unsubscribeSession) {
|
||||
this.unsubscribeSession();
|
||||
this.unsubscribeSession = null;
|
||||
}
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => vm.dispose(),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
if (docker) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => docker.dispose(),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
yield* this.setOrbState("disposed");
|
||||
this.eventListeners.clear();
|
||||
this.vm = null;
|
||||
this.dockerProvider = null;
|
||||
this.sessionId = undefined;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrbRuntime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OrbRuntime {
|
||||
private readonly orbs = new Map<string, OrbHandle>();
|
||||
private readonly env: OrbEnv;
|
||||
|
||||
constructor(env: OrbEnv = {}) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
readonly createOrb = Effect.fn("OrbRuntime.createOrb")(function* createOrb(
|
||||
this: OrbRuntime,
|
||||
input: OrbCreateInput
|
||||
) {
|
||||
if (!input.gateway.apiKey.trim()) {
|
||||
return yield* Effect.fail(
|
||||
new OrbConfigurationError({
|
||||
message: "Model gateway API key is required",
|
||||
reason: "MissingGateway",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const orbId =
|
||||
`orb-${input.identity.projectId}-${input.identity.runId}` as OrbId;
|
||||
const runId = `run-${input.identity.runId}` as OrbRunId;
|
||||
|
||||
const handle = new OrbHandle(orbId, runId, input.identity);
|
||||
handle._configure({
|
||||
context: input.context,
|
||||
gateway: input.gateway,
|
||||
});
|
||||
|
||||
// 1. Create and start Docker sandbox (the provider owns its container).
|
||||
const dockerOptions: DockerSandboxOptions = {
|
||||
...input.docker,
|
||||
...(this.env.dockerImage === undefined
|
||||
? {}
|
||||
: { image: this.env.dockerImage }),
|
||||
};
|
||||
const provider = yield* DockerSandboxProvider.create(dockerOptions);
|
||||
handle._attachDocker(provider);
|
||||
|
||||
// 2-3. Start the sandbox, create the AgentOS VM, and link OpenCode. Any
|
||||
// failure here disposes the VM before its sandbox so neither leaks.
|
||||
let createdVm: AgentOs | null = null;
|
||||
const vm = yield* Effect.gen(function* vm() {
|
||||
const sandboxClient = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "ContainerStart",
|
||||
}),
|
||||
try: () => provider.start(),
|
||||
});
|
||||
const created = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to create AgentOS VM: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () =>
|
||||
AgentOs.create({
|
||||
database: {
|
||||
path: `/tmp/${orbId}.db`,
|
||||
type: "sqlite_file",
|
||||
},
|
||||
sandbox: {
|
||||
client: sandboxClient,
|
||||
dispose: false,
|
||||
mountPath: "/mnt/sandbox",
|
||||
readOnly: false,
|
||||
sandboxRoot: "/home/sandbox",
|
||||
},
|
||||
software: [opencodePkg],
|
||||
}),
|
||||
});
|
||||
createdVm = created;
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to link OpenCode: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "AgentNotInstalled",
|
||||
}),
|
||||
try: () => created.linkSoftware({ path: opencodePkg.packagePath }),
|
||||
});
|
||||
return created;
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.tryPromise({
|
||||
catch: () => null,
|
||||
// eslint-disable-next-line no-use-before-define -- module-level cleanup helper
|
||||
try: () => disposeVmBeforeSandbox(createdVm, provider),
|
||||
}).pipe(Effect.ignore)
|
||||
)
|
||||
);
|
||||
|
||||
// 4. Attach VM to handle.
|
||||
handle._attachVm(vm);
|
||||
|
||||
this.orbs.set(orbId, handle);
|
||||
return handle;
|
||||
});
|
||||
|
||||
getOrb(id: string): OrbHandle | undefined {
|
||||
return this.orbs.get(id);
|
||||
}
|
||||
|
||||
listOrbs(): readonly OrbHandle[] {
|
||||
return [...this.orbs.values()];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Partial-failure cleanup — dispose the VM before its sandbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const disposeVmBeforeSandbox = async (
|
||||
vm: AgentOs | null,
|
||||
docker: DockerSandboxProvider
|
||||
): Promise<void> => {
|
||||
if (vm) {
|
||||
try {
|
||||
await vm.dispose();
|
||||
} catch {
|
||||
// best-effort; container removal below is the hard guarantee
|
||||
}
|
||||
}
|
||||
await docker.dispose();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell quoting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line no-use-before-define -- module-level helper
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
370
scripts/orb-proof.ts
Normal file
370
scripts/orb-proof.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Orb Runtime Proof Fixture
|
||||
*
|
||||
* Opt-in integration proof that exercises the Orb lifecycle in distinct,
|
||||
* independently reported stages, each with a stable marker:
|
||||
*
|
||||
* STAGE 1 Docker sandbox lifecycle ORB_STAGE_DOCKER_OK / _BLOCKED
|
||||
* STAGE 2 AgentOS VM + OpenCode session ORB_STAGE_AGENTOS_OK / _BLOCKED
|
||||
* STAGE 3 Model turn via the gateway ORB_STAGE_MODEL_TURN_OK / _BLOCKED
|
||||
*
|
||||
* A missing or unreachable model gateway is BLOCKED, never passed. The proof
|
||||
* only prints ORB_PROOF_PASSED when the model turn completes. It never prints
|
||||
* gateway secrets; only provider/model identifiers and reachability labels.
|
||||
*
|
||||
* Gateway credentials resolve from ORB_GATEWAY_* with safe fallbacks to the
|
||||
* shared AGENT_MODEL_* variables. Container removal is always verified after
|
||||
* disposal.
|
||||
*
|
||||
* Usage:
|
||||
* ORB_PROOF=1 \
|
||||
* ORB_GATEWAY_API_KEY=... \
|
||||
* ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
|
||||
* ORB_GATEWAY_MODEL=glm-5.2 \
|
||||
* ORB_GATEWAY_PROVIDER=cheaptricks \
|
||||
* bun run scripts/orb-proof.ts
|
||||
*/
|
||||
/* eslint-disable no-console -- proof fixture is a CLI tool */
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { DockerSandboxProvider } from "../packages/agents/src/orb/docker-sandbox";
|
||||
import type { DockerSandboxOptions } from "../packages/agents/src/orb/docker-sandbox";
|
||||
import type { OrbEvent } from "../packages/agents/src/orb/events";
|
||||
import { OrbRuntime } from "../packages/agents/src/orb/runtime";
|
||||
import type { OrbHandle } from "../packages/agents/src/orb/runtime";
|
||||
|
||||
const MARKER = {
|
||||
blocked: "ORB_PROOF_BLOCKED",
|
||||
failed: "ORB_PROOF_FAILED",
|
||||
passed: "ORB_PROOF_PASSED",
|
||||
} as const;
|
||||
|
||||
const STAGE = {
|
||||
agentosBlocked: "ORB_STAGE_AGENTOS_BLOCKED",
|
||||
agentosOk: "ORB_STAGE_AGENTOS_OK",
|
||||
dockerBlocked: "ORB_STAGE_DOCKER_BLOCKED",
|
||||
dockerOk: "ORB_STAGE_DOCKER_OK",
|
||||
modelBlocked: "ORB_STAGE_MODEL_TURN_BLOCKED",
|
||||
modelOk: "ORB_STAGE_MODEL_TURN_OK",
|
||||
} as const;
|
||||
|
||||
interface Gateway {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
const envValue = (key: string): string | undefined => process.env[key];
|
||||
|
||||
const resolveGateway = (): Gateway | null => {
|
||||
const apiKey =
|
||||
envValue("ORB_GATEWAY_API_KEY") ?? envValue("AGENT_MODEL_API_KEY");
|
||||
const baseUrl =
|
||||
envValue("ORB_GATEWAY_BASE_URL") ?? envValue("AGENT_MODEL_BASE_URL");
|
||||
const model = envValue("ORB_GATEWAY_MODEL") ?? envValue("AGENT_MODEL_NAME");
|
||||
const provider =
|
||||
envValue("ORB_GATEWAY_PROVIDER") ?? envValue("AGENT_MODEL_PROVIDER");
|
||||
if (
|
||||
!apiKey?.trim() ||
|
||||
!baseUrl?.trim() ||
|
||||
!model?.trim() ||
|
||||
!provider?.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey, baseUrl, model, provider };
|
||||
};
|
||||
|
||||
const runCli = async (args: readonly string[]): Promise<string> => {
|
||||
try {
|
||||
const proc = Bun.spawn([...args], { stderr: "pipe", stdout: "pipe" });
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
return stdout;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const dockerVersion = async (): Promise<string | null> => {
|
||||
const out = await runCli([
|
||||
"docker",
|
||||
"version",
|
||||
"--format",
|
||||
"{{.Server.Version}}",
|
||||
]);
|
||||
const trimmed = out.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const TINY_PROJECT: Record<string, string> = {
|
||||
"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","type":"module","scripts":{"test":"node --test"}}\n`,
|
||||
};
|
||||
|
||||
const prepareProject = async (hostWorkspace: string): Promise<void> => {
|
||||
await mkdir(path.join(hostWorkspace, "repository"), { recursive: true });
|
||||
for (const [file, content] of Object.entries(TINY_PROJECT)) {
|
||||
// eslint-disable-next-line no-await-in-loop -- sequential file writes are intentional
|
||||
await writeFile(path.join(hostWorkspace, "repository", file), content);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STAGE 1 — Docker sandbox via SandboxAgent (standalone, no sidecar required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stageDocker = async (image: string | undefined): Promise<boolean> => {
|
||||
const version = await dockerVersion();
|
||||
if (!version) {
|
||||
console.log("[docker] Docker daemon not available");
|
||||
console.log(STAGE.dockerBlocked);
|
||||
return false;
|
||||
}
|
||||
console.log(`[docker] server version ${version}`);
|
||||
|
||||
const hostWorkspace = `/tmp/orb-proof-docker-${Date.now()}`;
|
||||
const options: DockerSandboxOptions =
|
||||
image === undefined
|
||||
? { hostWorkspacePath: hostWorkspace }
|
||||
: { hostWorkspacePath: hostWorkspace, image };
|
||||
|
||||
try {
|
||||
await prepareProject(hostWorkspace);
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create(options)
|
||||
);
|
||||
const client = await provider.start();
|
||||
|
||||
const { baseUrl } = client as unknown as { baseUrl: string };
|
||||
console.log(`[docker] SandboxAgent baseUrl: ${baseUrl}`);
|
||||
|
||||
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(` npm install exit: ${install.exitCode}`);
|
||||
|
||||
await provider.dispose();
|
||||
if (install.exitCode !== 0) {
|
||||
console.log("[docker] sandbox lifecycle did not complete cleanly");
|
||||
console.log(STAGE.dockerBlocked);
|
||||
return false;
|
||||
}
|
||||
console.log(STAGE.dockerOk);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[docker] stage failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
console.log(STAGE.dockerBlocked);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STAGE 2 — AgentOS VM + OpenCode session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stageAgentOs = async (
|
||||
gateway: Gateway,
|
||||
image: string | undefined,
|
||||
events: OrbEvent[]
|
||||
): Promise<{ container: string; handle: OrbHandle } | null> => {
|
||||
const container = `orb-proof-${Date.now()}`;
|
||||
const hostWorkspace = `/tmp/orb-proof-${Date.now()}`;
|
||||
const runtime = new OrbRuntime(
|
||||
image === undefined ? {} : { dockerImage: image }
|
||||
);
|
||||
|
||||
const createResult = await Effect.runPromise(
|
||||
Effect.match(
|
||||
runtime.createOrb({
|
||||
context: {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
issueBody: "Run npm test and report the result.",
|
||||
issueTitle: "Proof: run tests",
|
||||
},
|
||||
docker: { hostWorkspacePath: hostWorkspace },
|
||||
gateway,
|
||||
identity: {
|
||||
projectId: "proof",
|
||||
runId: `proof-${Date.now()}`,
|
||||
workUnitId: "proof-wu",
|
||||
},
|
||||
}),
|
||||
{
|
||||
onFailure: (error) => ({ error, ok: false as const }),
|
||||
onSuccess: (handle) => ({ handle, ok: true as const }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (!createResult.ok) {
|
||||
console.log(
|
||||
`[agentos] creation failed (${createResult.error.reason}): ${createResult.error.message}`
|
||||
);
|
||||
console.log(STAGE.agentosBlocked);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { handle } = createResult;
|
||||
handle.onEvent((event) => {
|
||||
events.push(event);
|
||||
const summary = (event.text ?? event.command ?? event.type).slice(0, 160);
|
||||
console.log(` [event] ${event.type}: ${summary}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const vm = handle.vmInstance;
|
||||
if (!vm) {
|
||||
throw new Error("AgentOS VM is not attached");
|
||||
}
|
||||
const agents = await vm.listAgents();
|
||||
const opencode = agents.find((a) => a.id === "opencode" && a.installed);
|
||||
if (!opencode) {
|
||||
throw new Error("OpenCode agent is not installed in the VM");
|
||||
}
|
||||
console.log("[agentos] OpenCode agent available");
|
||||
|
||||
await Effect.runPromise(handle.openSession());
|
||||
console.log(`[agentos] session opened: ${handle.currentSessionId ?? "?"}`);
|
||||
console.log(STAGE.agentosOk);
|
||||
return { container, handle };
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[agentos] stage failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
await Effect.runPromise(handle.dispose().pipe(Effect.ignore)).catch(() => {
|
||||
// best-effort cleanup
|
||||
});
|
||||
console.log(STAGE.agentosBlocked);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STAGE 3 — Model turn through the gateway
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stageModelTurn = async (handle: OrbHandle): Promise<boolean> => {
|
||||
const vm = handle.vmInstance;
|
||||
const sessionId = handle.currentSessionId;
|
||||
if (!vm || !sessionId) {
|
||||
console.log("[model] no active session to drive a model turn");
|
||||
console.log(STAGE.modelBlocked);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
console.log("[model] sending bounded turn to gateway...");
|
||||
const result = await vm.prompt({
|
||||
content: [
|
||||
{ text: "Reply with exactly: ORB_PROOF_MODEL_ECHO", type: "text" },
|
||||
],
|
||||
sessionId,
|
||||
});
|
||||
console.log(
|
||||
`[model] turn completed (stopReason=${result.stopReason ?? "?"})`
|
||||
);
|
||||
console.log(STAGE.modelOk);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// An unreachable/missing gateway surfaces here as a prompt failure.
|
||||
console.log(
|
||||
`[model] gateway turn failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
console.log(STAGE.modelBlocked);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const runProof = async (): Promise<number> => {
|
||||
const image = envValue("ORB_DOCKER_IMAGE");
|
||||
const events: OrbEvent[] = [];
|
||||
|
||||
// STAGE 1 — Docker sandbox.
|
||||
console.log("[stage 1] Docker sandbox lifecycle...");
|
||||
const dockerOk = await stageDocker(image);
|
||||
if (!dockerOk) {
|
||||
console.log(`\n${MARKER.blocked}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// STAGE 2 + 3 — AgentOS/OpenCode + model turn.
|
||||
const gateway = resolveGateway();
|
||||
if (!gateway) {
|
||||
console.error(
|
||||
"[gateway] missing config: set ORB_GATEWAY_* (or AGENT_MODEL_* fallbacks)."
|
||||
);
|
||||
console.log(STAGE.agentosBlocked);
|
||||
console.log(`\n${MARKER.blocked}`);
|
||||
return 2;
|
||||
}
|
||||
// Identifiers only — never the key or base URL.
|
||||
console.log(`[gateway] provider=${gateway.provider} model=${gateway.model}`);
|
||||
|
||||
console.log("[stage 2] AgentOS VM + OpenCode session...");
|
||||
const orb = await stageAgentOs(gateway, image, events);
|
||||
if (!orb) {
|
||||
console.log(`\n${MARKER.blocked}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
console.log("[stage 3] model turn...");
|
||||
let modelOk = false;
|
||||
try {
|
||||
modelOk = await stageModelTurn(orb.handle);
|
||||
} finally {
|
||||
console.log("[dispose] disposing Orb...");
|
||||
await Effect.runPromise(orb.handle.dispose().pipe(Effect.ignore)).catch(
|
||||
() => {
|
||||
// best-effort cleanup
|
||||
}
|
||||
);
|
||||
console.log("[dispose] Orb disposed (SandboxAgent container auto-removed)");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nStreamed ${events.length} normalized events (${[...new Set(events.map((e) => e.type))].join(", ")}).`
|
||||
);
|
||||
|
||||
if (modelOk) {
|
||||
console.log(`\n${MARKER.passed}`);
|
||||
return 0;
|
||||
}
|
||||
console.log(`\n${MARKER.blocked}`);
|
||||
return 2;
|
||||
};
|
||||
|
||||
// Entry point
|
||||
if (envValue("ORB_PROOF") !== "1") {
|
||||
console.error(
|
||||
"Set ORB_PROOF=1 to run the proof fixture (requires Docker + model gateway)."
|
||||
);
|
||||
console.error(MARKER.blocked);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const code = await runProof();
|
||||
process.exit(code);
|
||||
} catch (error) {
|
||||
console.error("[proof] unexpected failure:", error);
|
||||
console.error(MARKER.failed);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user