diff --git a/bun.lock b/bun.lock index 8b519d1..1240940 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 0975630..bbafa16 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/agents/package.json b/packages/agents/package.json index a1e5390..11ba540 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -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:" } } diff --git a/packages/agents/src/orb/README.md b/packages/agents/src/orb/README.md new file mode 100644 index 0000000..f4900f7 --- /dev/null +++ b/packages/agents/src/orb/README.md @@ -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:` + +## 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. diff --git a/packages/agents/src/orb/docker-sandbox.test.ts b/packages/agents/src/orb/docker-sandbox.test.ts new file mode 100644 index 0000000..81ddebf --- /dev/null +++ b/packages/agents/src/orb/docker-sandbox.test.ts @@ -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 => { + vitestIt(name, fn, 60_000); +}; + +// Node-compatible Docker availability check. +const runCliExit = (args: readonly string[]): Promise => + // eslint-disable-next-line promise/avoid-new -- wrapping one-shot child close in a single promise + new Promise((resolve) => { + 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 => { + 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((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." + ); +} diff --git a/packages/agents/src/orb/docker-sandbox.ts b/packages/agents/src/orb/docker-sandbox.ts new file mode 100644 index 0000000..c9eb0ca --- /dev/null +++ b/packages/agents/src/orb/docker-sandbox.ts @@ -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:. 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) | 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 { + 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 { + 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(); + }, + }); + } +); diff --git a/packages/agents/src/orb/domain.test.ts b/packages/agents/src/orb/domain.test.ts new file mode 100644 index 0000000..0d2a2b1 --- /dev/null +++ b/packages/agents/src/orb/domain.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/domain.ts b/packages/agents/src/orb/domain.ts new file mode 100644 index 0000000..c08cdb1 --- /dev/null +++ b/packages/agents/src/orb/domain.ts @@ -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> = { + 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> = { + 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", + { + 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", + { + 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", + { + 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", + { + 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; + } +); diff --git a/packages/agents/src/orb/events.test.ts b/packages/agents/src/orb/events.test.ts new file mode 100644 index 0000000..e73ddaa --- /dev/null +++ b/packages/agents/src/orb/events.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/events.ts b/packages/agents/src/orb/events.ts new file mode 100644 index 0000000..7b6cf6d --- /dev/null +++ b/packages/agents/src/orb/events.ts @@ -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> +): 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:=]+(?[^\s"'},]+)/giu, + /sk-(?[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; +}; diff --git a/packages/agents/src/orb/index.ts b/packages/agents/src/orb/index.ts new file mode 100644 index 0000000..046a564 --- /dev/null +++ b/packages/agents/src/orb/index.ts @@ -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"; diff --git a/packages/agents/src/orb/opencode-config.test.ts b/packages/agents/src/orb/opencode-config.test.ts new file mode 100644 index 0000000..60c8cc3 --- /dev/null +++ b/packages/agents/src/orb/opencode-config.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/opencode-config.ts b/packages/agents/src/orb/opencode-config.ts new file mode 100644 index 0000000..4e3cf1a --- /dev/null +++ b/packages/agents/src/orb/opencode-config.ts @@ -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 => + 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; diff --git a/packages/agents/src/orb/permission-policy.test.ts b/packages/agents/src/orb/permission-policy.test.ts new file mode 100644 index 0000000..3d14bb6 --- /dev/null +++ b/packages/agents/src/orb/permission-policy.test.ts @@ -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(); + }); +}); diff --git a/packages/agents/src/orb/permission-policy.ts b/packages/agents/src/orb/permission-policy.ts new file mode 100644 index 0000000..bd11051 --- /dev/null +++ b/packages/agents/src/orb/permission-policy.ts @@ -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+(?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 }; +}; diff --git a/packages/agents/src/orb/runtime.test.ts b/packages/agents/src/orb/runtime.test.ts new file mode 100644 index 0000000..3624894 --- /dev/null +++ b/packages/agents/src/orb/runtime.test.ts @@ -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"); + }); +}); diff --git a/packages/agents/src/orb/runtime.ts b/packages/agents/src/orb/runtime.ts new file mode 100644 index 0000000..3c9b7d9 --- /dev/null +++ b/packages/agents/src/orb/runtime.ts @@ -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; + 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 => + transitionOrbState({ from: this.orbState, to }).pipe( + Effect.tap((next) => + Effect.sync(() => { + this.orbState = next; + }) + ), + Effect.asVoid + ); + + readonly setRunState = (to: RunState): Effect.Effect => + 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 { + 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>; + 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(); + 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 => { + 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("'", `'"'"'`)}'`; diff --git a/scripts/orb-proof.ts b/scripts/orb-proof.ts new file mode 100644 index 0000000..f11d8de --- /dev/null +++ b/scripts/orb-proof.ts @@ -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 => { + 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 => { + const out = await runCli([ + "docker", + "version", + "--format", + "{{.Server.Version}}", + ]); + const trimmed = out.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const TINY_PROJECT: Record = { + "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 => { + 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 => { + 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 => { + 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 => { + 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); + } +})();