From 739570c026175cd1931e3c3d691d4c40fdb0d9bc Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 09:21:10 -0700 Subject: [PATCH 01/16] Give each Bot a computer on Daytona when an API key is configured Signed-off-by: Muhammad Hashmi --- .env.example | 17 + README.md | 5 +- agent-computer/Dockerfile | 2 +- bun.lock | 279 ++++++++++++- server/package.json | 1 + server/src/computer/client.ts | 17 +- server/src/computer/daytona.ts | 570 ++++++++++++++++++++++++++ server/src/computer/routes.ts | 7 +- server/src/config.ts | 41 +- server/src/index.ts | 39 +- server/tests/computer-client.test.ts | 38 ++ server/tests/computer-daytona.test.ts | 565 +++++++++++++++++++++++++ server/tests/config.test.ts | 36 ++ 13 files changed, 1574 insertions(+), 43 deletions(-) create mode 100644 server/src/computer/daytona.ts create mode 100644 server/tests/computer-daytona.test.ts diff --git a/.env.example b/.env.example index 3205c401..0b994f26 100644 --- a/.env.example +++ b/.env.example @@ -192,3 +192,20 @@ SUPERVISOR_TOKEN= # Set to runsc to run every computer under gVisor, if the host has it. Unset, a computer is an # ordinary container and shares the host kernel, which is worth knowing when the Bot is not ours. COMPUTER_RUNTIME= + +# Remote computers on Daytona (https://daytona.io). Set an API key and every Bot gets a cloud +# sandbox of its own -- no local Docker needed for computers. Get a key at https://app.daytona.io +# under Dashboard -> Keys. Requires COMPUTER_TOKEN above, because a Daytona computer is reached +# over a public preview URL and the token is what refuses strangers ("/health" is the only +# unauthenticated route). Mutually exclusive with COMPUTER_SUPERVISOR_URL. AGENT_COMPUTER_URL is +# not needed in this mode. +# +# The first start builds a snapshot from agent-computer/ (a few minutes, streamed to the server +# log); later computers start from it in seconds. Idle computers stop after 15 minutes and wake +# on the next action. +# DAYTONA_API_KEY= +# Only for self-hosted or staging Daytona; the default is the hosted platform. +# DAYTONA_API_URL= +# DAYTONA_TARGET= +# Use a prebuilt snapshot by name instead of building from the local sources. +# DAYTONA_SNAPSHOT= diff --git a/README.md b/README.md index 244670cf..029626a9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), ## Requirements -- Docker, for PostgreSQL, browser computers, the supervisor, and the shipped Bots. +- Docker, for PostgreSQL and the shipped Bots. Bot computers can run locally on Docker or remotely on Daytona (set DAYTONA_API_KEY). - [Bun](https://bun.sh) 1.3+, for the app and API server. - A CopilotKit Intelligence project and license. - A model key. The proof-of-concept Bot uses OpenAI; the LangGraph Bot can use OpenAI, Anthropic, or Google. @@ -124,7 +124,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), ## Features -- **A computer per Bot**: the supervisor gives each Bot its own container, its own `/workspace` volume and its own browser profile. Set `COMPUTER_RUNTIME=runsc` to run them under gVisor where the host supports it. +- **A computer per Bot**: the supervisor gives each Bot its own container, its own `/workspace` volume and its own browser profile. Set `COMPUTER_RUNTIME=runsc` to run them under gVisor where the host supports it. Or set `DAYTONA_API_KEY` to run each computer in a remote Daytona sandbox instead of a local container. - **The gateway is the only way in**: it resolves the target from a server-held snapshot, evaluates the policy, writes the audit row, and only then calls the computer. There is no path that acts without the record existing first. - **CEL policy, fail closed**: rules can inspect `tool.name`, `intent`, `bot.id`, `actor.id`, `page.url`, `page.host`, `element.*`, `key`, `file.*` and `mcp.*`. Deny is evaluated before allow, a missing policy permits nothing, and a broken rule refuses rather than opens. - **Take the wheel**: a Bot that hits a login wall or a 2FA prompt asks for help. Control is handed over in the same panel and recorded as `computer.help_requested`, `computer.control_taken` and `computer.control_released`. While a person is driving, Bot actions are refused rather than queued. @@ -180,6 +180,7 @@ Settings worth knowing: | `COMPUTER_TOKEN` | Secret every Bot computer request must present. `start.sh` sets one. | | `SUPERVISOR_TOKEN` | Secret the supervisor requires. `start.sh` sets one. | | `COMPUTER_SUPERVISOR_URL` | Gives each Bot a computer of its own instead of one shared computer. | +| `DAYTONA_API_KEY` | Runs each Bot's computer in a remote Daytona sandbox instead of local Docker. | | `COMPUTER_RUNTIME` | Set to `runsc` to run computers under gVisor, where the host has it. | | `AGENT_COMPUTER_POLICY` | JSON action policy. Malformed JSON stops server startup. | | `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Lets a Bot reach this machine's own services. | diff --git a/agent-computer/Dockerfile b/agent-computer/Dockerfile index 594db89e..5ca85076 100644 --- a/agent-computer/Dockerfile +++ b/agent-computer/Dockerfile @@ -1,7 +1,7 @@ # The Bot's computer uses Playwright's image so Chromium and its system libraries stay matched. # # The image tag and Playwright dependency must be pinned to the same exact version. Bump both or -# neither. +# neither. Also keep aligned with the Daytona Image recipe in server/src/computer/daytona.ts. FROM mcr.microsoft.com/playwright:v1.62.1-noble # unzip is not in the Playwright image and bun's installer needs it. diff --git a/bun.lock b/bun.lock index f62f688e..e01c391d 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@ag-ui/client": "0.0.57", "@better-auth/drizzle-adapter": "^1.6.27", "@copilotkit/runtime": "1.67.1", + "@daytona/sdk": "^0.205.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.6.27", "cel-js": "^0.8.2", @@ -123,6 +124,44 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.28", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw=="], + + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1114.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.28", "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/middleware-sdk-s3": "^3.972.74", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ZeAgOtB+CXFaWXph98U7a/XrBlVx1lQ2rCJRWLxLmAaQ9k5lht6DWfwnEcVjdzduK/ySao1tCjq0fBAScGqjAg=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.977.8", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-env": "^3.972.69", "@aws-sdk/credential-provider-http": "^3.972.71", "@aws-sdk/credential-provider-login": "^3.972.76", "@aws-sdk/credential-provider-process": "^3.972.69", "@aws-sdk/credential-provider-sso": "^3.973.13", "@aws-sdk/credential-provider-web-identity": "^3.972.75", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.80", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.69", "@aws-sdk/credential-provider-http": "^3.972.71", "@aws-sdk/credential-provider-ini": "^3.973.14", "@aws-sdk/credential-provider-process": "^3.972.69", "@aws-sdk/credential-provider-sso": "^3.973.13", "@aws-sdk/credential-provider-web-identity": "^3.972.75", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.13", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/token-providers": "3.1111.0", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw=="], + + "@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.1114.0", "", { "dependencies": { "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.1114.0" } }, "sha512-eMLPc9M4SrPGiFQDhKXqlFUsT4Ry8eyP6wXvJW5tZAiOsZU7kI+LPoFo37MbZeTvnb2zthl1E35F4MIZ+5GS3Q=="], + + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.74", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1111.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/nested-clients": "^3.997.43", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.39", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + "@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="], "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], @@ -281,6 +320,14 @@ "@copilotkit/web-inspector": ["@copilotkit/web-inspector@1.67.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/core": "1.67.1", "@copilotkit/shared": "1.67.1", "lit": "^3.2.0", "lucide": "^0.525.0", "marked": "^12.0.2" } }, "sha512-DskVsDrUtZPt9Ymh6UxGElUIETBq8Lxa/vtl9eUdvJ+Ply1xUaGKwfNUpIaGiMWzTML4X7diSVdVrmemlnlYjQ=="], + "@daytona/analytics-api-client": ["@daytona/analytics-api-client@0.205.1", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-/kGNw6RGI7psf5OYqOPTfyN2tDW/206XBMXFQxmD3Ljuor/xk17WrONRah35OJy62lk+BzvCRtnkGBNEL4EjhQ=="], + + "@daytona/api-client": ["@daytona/api-client@0.205.1", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-Rmeny9+8jm+7mx/0B4FnfddqXSRuQmMTRxlbYaI8ekVidvXFA5YsNExBzzijPZW2piCDfUhlJDt6qKVsrhZe4Q=="], + + "@daytona/sdk": ["@daytona/sdk@0.205.1", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1106.0", "@aws-sdk/lib-storage": "^3.1106.0", "@daytona/analytics-api-client": "0.205.1", "@daytona/api-client": "0.205.1", "@daytona/toolbox-api-client": "0.205.1", "@iarna/toml": "^2.2.5", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", "@opentelemetry/instrumentation-http": "^0.221.0", "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-node": "^0.221.0", "@opentelemetry/sdk-trace-base": "^2.10.0", "@opentelemetry/semantic-conventions": "^1.43.0", "axios": "^1.19.0", "busboy": "^1.0.0", "dotenv": "^17.4.2", "expand-tilde": "^2.0.2", "fast-glob": "^3.3.0", "form-data": "^4.0.4", "isomorphic-ws": "^5.0.0", "pathe": "^2.0.3", "shell-quote": "^1.8.2", "socket.io-client": "^4.8.1", "tar": "^7.5.22", "tslib": "2.8.1", "ws": "^8.21.3" } }, "sha512-gSqWNvNXmFJEdmsB+Z/XbWagnt6xkf6AWYtfAmemvY56Ji5oMYQ3Fk6VyeQpArvYCLQTgQy/MifiUXMLlChwYA=="], + + "@daytona/toolbox-api-client": ["@daytona/toolbox-api-client@0.205.1", "", { "dependencies": { "axios": "^1.6.1" } }, "sha512-FmiNbaAdeOiV12No+cRqs7bwklu+q4iA1QW1gA+x235imHaJ5Y8nYOjMV20tOhytGhNm0W23nz7WqyVao6A2pw=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], @@ -379,14 +426,22 @@ "@graphql-yoga/typed-event-target": ["@graphql-yoga/typed-event-target@3.0.2", "", { "dependencies": { "@repeaterjs/repeater": "^3.0.4", "tslib": "^2.8.1" } }, "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.2" } }, "sha512-7fkpoXZWzyxaBkzRtlD0wRU5ckxbNT4j6BywzpSYh+SFPsGxEVmNR9KHaMwM2xkHB0pADQLl4Z8DSrztECJ7Ww=="], "@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="], + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jetbrains/websandbox": ["@jetbrains/websandbox@1.3.1", "", {}, "sha512-YTl3MJXbAkYDyuRhWqlQoo7eY2jaLGRqUkx4LVRvxr0VnK7s5bu0p2KAGOWZIBf6I3pmuuq+JzBDHw1b0fV0/Q=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -399,6 +454,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=="], + "@langchain/core": ["@langchain/core@1.2.7", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "zod": "^3.25.76 || ^4" } }, "sha512-NKEjQimC9IR7YKkfirH9Hq1BIFFKd4tjhWvbtjggS+5mE+bM4ZeFwTIUe9BDIz9zi3hzG4DLbRMi5E8k/PwVTA=="], "@langchain/langgraph": ["@langchain/langgraph@1.4.9", "", { "dependencies": { "@langchain/langgraph-checkpoint": "^1.1.3", "@langchain/langgraph-sdk": "~1.9.28", "@langchain/protocol": "^0.0.18", "@standard-schema/spec": "1.1.0" }, "peerDependencies": { "@langchain/core": "^1.1.48", "zod": "^3.25.32 || ^4.2.0" } }, "sha512-EvD9rS66Cya09y6rbMgD3Ir8miAkJQFo7FyJOPRPO736Kz3y5TeyeBDOS8ctff/jRc788bPijHx2NVFM79Qqig=="], @@ -445,6 +502,64 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ=="], + + "@opentelemetry/configuration": ["@opentelemetry/configuration@0.221.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "yaml": "^2.8.3" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-uE9y56Zdi9Gt/RdxYnVOo3YmFZkKJJMA0gqtBe8wh8gdtF5Asqe+Oh/TWiDtFb1s+31jNY4CWgnfIB1KOITfFA=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.10.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.221.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-grpc-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0", "@opentelemetry/sdk-logs": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-txG1G0IrYSsKKMeiWZfj/i5cQmWB+h+hf3HzPpF3RqZVwp+iQQEIsv8Vtmzy6RWVdHdJZfygmVrBI39YTBvWcw=="], + + "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.221.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0", "@opentelemetry/sdk-logs": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-nKXkr4Tomi6fjYVOf+ytcW3dZAVr4v4Bv5gsT6dr2gvpUPJpKgHB4XbMufMsPotRE3g0XH2GwVVCkN2w6SON+Q=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.221.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0", "@opentelemetry/sdk-logs": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-AH6EY+47gXFaWYgG3hfeOneGiE9xIZGtDBk+9g0sM8NZWzsQhhmqPbQQXJzS7pyCh5jRRr2nYNXVrkCmoojRvQ=="], + + "@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.221.0", "", { "dependencies": { "@opentelemetry/exporter-metrics-otlp-http": "0.221.0", "@opentelemetry/otlp-grpc-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KOgCtO15FC6C1T/xOqBcr7EyUs7B+7yomGNb5Y97d3s38rPbCCk5sewkmE2b0/itOkQ/PptX8CLlD+kn2mEtTg=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.221.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-metrics": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-sRfCKbOzgy8xZQV2as0RzIZlnCmCseCKZGLfRcrpo2CBngJDr+rPtX0zkG0+oUCV5kfQPUoW3W3C96Ag3Y/Clg=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.221.0", "", { "dependencies": { "@opentelemetry/exporter-metrics-otlp-http": "0.221.0", "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-YMF4LveY2I3yhw61rn6nmC9FE8U24IZHPeKU1Duc5+sbwjMd8FwZAwba318ImdThCg/HuVQvhm2y6bfgNPnfYg=="], + + "@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.221.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kW79a20qWESIuAdDrxzg9WKM98twV/NBWBFRAH57ap/+ssZhiCo0hckzKT0zpuwR/gSHrFAQhJL0bYDrnEM34g=="], + + "@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.221.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-grpc-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zXminlZedtq9LvOW64CnNkOqk15zV75k8JgtdTuWFge6+jk2m4GmAUm6L2eIiG1o2a2bZxXw2PDrszm+bps0IA=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.221.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-AySXiKoC+meiWm6zdVj5T2LnPDZuatveBby1cMOeQteIWsYXAUxs8Sru13G2pVSPrUXz6vF+og7QVBX6GdC/oQ=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.221.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Z9i2T7vgZbWe9rSLYxXVIbeW+XyzUq4rZanW3ZyVNwVDqCsh0EJKUgBWWQ0CZfeuUA+RQPzKgJQHMuWAUnKqXw=="], + + "@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-7gsvgf0UDoJ4l9ObrwBmz5G/ZogiPk+lq+g5GpLp24YQF/vPM/BSsnOfcLnfinast5ASUgLo78uSC/ObjlnXgg=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw=="], + + "@opentelemetry/instrumentation-http": ["@opentelemetry/instrumentation-http@0.221.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/instrumentation": "0.221.0", "@opentelemetry/semantic-conventions": "^1.29.0", "forwarded-parse": "2.1.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-oIP91CPIANuYr09tGFElPFKAh6JUar+awJf1kBRYlaeo9b0gDwZHEB2zBfFlvdNFHm0wAVutMZODVi5smKT30g=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.221.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/otlp-transformer": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA=="], + + "@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.221.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.10.0", "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-transformer": "0.221.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-rQDmNgyiGCTrescjnzH2ntVyUKVIq6I2UjuK8+stT/Xg0ZOT71FVJqwjFdspQl6Yol/Yqsut9bDo+ame8oTmDQ=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], + + "@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GnA5B24H+1w8BO21J0q+IWNB0z1v+AGbcquTdIt/dufibhnhgxaA8YKvz0I3akRZhB1jHT+/tlzK+qlAjEDybQ=="], + + "@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-yw/IX8DL470dSMZJoE82ScfYGp7JWZ/G8kFJo35ZILUVTB2jFPTOaioN+8s09pH0RHsWNhweVZb+ZnjJJpCChg=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="], + + "@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/configuration": "0.221.0", "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.221.0", "@opentelemetry/exporter-logs-otlp-http": "0.221.0", "@opentelemetry/exporter-logs-otlp-proto": "0.221.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.221.0", "@opentelemetry/exporter-metrics-otlp-http": "0.221.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.221.0", "@opentelemetry/exporter-prometheus": "0.221.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.221.0", "@opentelemetry/exporter-trace-otlp-http": "0.221.0", "@opentelemetry/exporter-trace-otlp-proto": "0.221.0", "@opentelemetry/exporter-zipkin": "2.10.0", "@opentelemetry/instrumentation": "0.221.0", "@opentelemetry/otlp-exporter-base": "0.221.0", "@opentelemetry/otlp-grpc-exporter-base": "0.221.0", "@opentelemetry/propagator-b3": "2.10.0", "@opentelemetry/propagator-jaeger": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0", "@opentelemetry/sdk-trace-node": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-UbYuvtBrQQB5Prsh9KOKy4kxzexFxfMs5MkteHeWMoswsEB7kiNhyUVkAOFW/qsEzNHtrkgyghrD2ilZJa+5YA=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.10.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -453,6 +568,24 @@ "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="], @@ -605,6 +738,20 @@ "@slack/web-api": ["@slack/web-api@7.19.0", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA=="], + "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.7.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.11.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tabler/icons": ["@tabler/icons@3.46.0", "", {}, "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A=="], @@ -851,7 +998,7 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ai": ["ai@6.0.253", "", { "dependencies": { "@ai-sdk/gateway": "3.0.172", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NNwp47xpD4c47NB0ad+MP8HL66f8g6l0JwZ2UdHR6PpBPk9WACEq1k7md3r0GVR6skqAU/Slt5Hk9mJYQwJkvA=="], @@ -907,6 +1054,8 @@ "boring-avatars": ["boring-avatars@2.0.4", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -925,6 +1074,8 @@ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -953,6 +1104,10 @@ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], + "clarinet": ["clarinet@0.12.6", "", {}, "sha512-0FR+TrvLbYHLjhzs9oeIbd3yfZmd4u2DzYQEjUTm2dNfh4Y/9RIRWPjsm3aBtrVEpjKI7+lWa4ouqEXoml84mQ=="], "class-transformer": ["class-transformer@0.5.1", "", {}, "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="], @@ -965,6 +1120,8 @@ "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], @@ -1155,6 +1312,10 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -1169,6 +1330,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -1201,6 +1364,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expand-tilde": ["expand-tilde@2.0.2", "", { "dependencies": { "homedir-polyfill": "^1.0.1" } }, "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw=="], + "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], "express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="], @@ -1241,6 +1406,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], + "framer-motion": ["framer-motion@13.1.0", "", { "dependencies": { "motion-dom": "^13.0.0", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -1259,6 +1426,8 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -1335,6 +1504,8 @@ "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "homedir-polyfill": ["homedir-polyfill@1.0.3", "", { "dependencies": { "parse-passwd": "^1.0.0" } }, "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA=="], + "hono": ["hono@4.13.2", "", {}, "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1345,7 +1516,7 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -1357,6 +1528,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -1385,6 +1558,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], @@ -1417,6 +1592,8 @@ "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="], @@ -1507,6 +1684,8 @@ "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.clonedeep": ["lodash.clonedeep@4.5.0", "", {}, "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="], "lodash.get": ["lodash.get@4.4.2", "", {}, "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="], @@ -1527,6 +1706,8 @@ "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -1663,9 +1844,9 @@ "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="], @@ -1675,6 +1856,12 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "motion": ["motion@13.1.0", "", { "dependencies": { "framer-motion": "^13.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA=="], "motion-dom": ["motion-dom@13.0.0", "", { "dependencies": { "motion-utils": "^13.0.0" } }, "sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng=="], @@ -1749,6 +1936,8 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -1815,6 +2004,8 @@ "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], @@ -1889,8 +2080,12 @@ "remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1955,6 +2150,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], @@ -1971,6 +2168,10 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.7", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg=="], + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -1989,8 +2190,12 @@ "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], + "streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="], + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -2023,6 +2228,8 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "thread-stream": ["thread-stream@3.2.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -2147,16 +2354,26 @@ "worker": ["worker@workspace:worker"], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], @@ -2189,6 +2406,8 @@ "@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@aws-sdk/lib-storage/buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@copilotkit/a2ui-renderer/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2269,11 +2488,9 @@ "@types/mdast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@whatwg-node/node-fetch/@fastify/busboy": ["@fastify/busboy@3.2.1", "", {}, "sha512-tgK4O+57iz5ycYNGXE5ZWj1ES03lD2XnnBYWSbU/3wYZRMQzCUq7Ycds/RdyBZQeL5MU4fxBt6lzbIWf/Bickw=="], - - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "@whatwg-node/node-fetch/@fastify/busboy": ["@fastify/busboy@3.2.1", "", {}, "sha512-tgK4O+57iz5ycYNGXE5ZWj1ES03lD2XnnBYWSbU/3wYZRMQzCUq7Ycds/RdyBZQeL5MU4fxBt6lzbIWf/Bickw=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -2285,6 +2502,10 @@ "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], @@ -2315,7 +2536,7 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -2375,6 +2596,8 @@ "hastscript/property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "jsonwebtoken/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -2463,14 +2686,14 @@ "shiki/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + "stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], "style-to-js/style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "type-graphql/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "unified/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "unified/vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -2499,8 +2722,16 @@ "vfile-message/unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@ag-ui/mcp-middleware/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@0.0.54", "", { "dependencies": { "zod": "^3.22.4" } }, "sha512-Ilx31OvRQaZfU7jSArGqz06JZKOsAt8zWiCPJljyp9zR6Tzl18oyfx8o6FsuGfAktGRe50GI9SCCxNXXysZwtA=="], "@ag-ui/mcp-middleware/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@0.0.54", "", { "dependencies": { "@ag-ui/core": "0.0.54", "@ag-ui/proto": "0.0.54" } }, "sha512-0dPuE/eAeBRBDj/OOj5AW8SoP1r0dufmoOdrtKgmf+dlbVXKSNkDDHGrrvIWFPxwvPTWhHeN6wnsVUayWpUsGg=="], @@ -2513,6 +2744,8 @@ "@ai-sdk/openai-compatible/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@copilotkit/react-core/streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], "@copilotkit/react-core/streamdown/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], @@ -2595,6 +2828,8 @@ "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "@modelcontextprotocol/sdk/express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "@modelcontextprotocol/sdk/express/range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -2621,6 +2856,8 @@ "@slack/bolt/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "@slack/bolt/express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "@slack/bolt/express/range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "@slack/bolt/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -2631,12 +2868,12 @@ "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@typespec/ts-http-runtime/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -2699,7 +2936,7 @@ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "hast-util-from-dom/@types/hast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -2765,12 +3002,18 @@ "style-to-js/style-to-object/inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@modelcontextprotocol/sdk/express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], @@ -2779,6 +3022,8 @@ "@slack/bolt/express/body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@slack/bolt/express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@slack/bolt/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@slack/bolt/express/type-is/media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], diff --git a/server/package.json b/server/package.json index 304377f7..104e4662 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@ag-ui/client": "0.0.57", "@better-auth/drizzle-adapter": "^1.6.27", "@copilotkit/runtime": "1.67.1", + "@daytona/sdk": "^0.205.1", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.6.27", "cel-js": "^0.8.2", diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index f019ef81..87146752 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -39,8 +39,13 @@ export type ComputerClientOptions = { * is the correct failure: a computer that answers an unauthenticated caller is the bug. */ token?: string; - /** Base URL of the Bot's computer, e.g. http://agent-computer:4100 */ - baseUrl: string; + /** + * Base URL of the Bot's computer, e.g. http://agent-computer:4100. + * + * Absent when each Bot is located dynamically (by a supervisor or Daytona). When absent, every call + * must name a Bot that `resolveBaseUrl` can locate. + */ + baseUrl?: string; /** * Where this Bot's computer is, when each Bot has one of its own. * @@ -138,7 +143,7 @@ export function createComputerClient(options: ComputerClientOptions) { */ const token = options.token; const timeoutMs = options.timeoutMs ?? 45_000; - const base = options.baseUrl.replace(/\/$/, ""); + const base = options.baseUrl?.replace(/\/$/, ""); /** * A view of the computer as one Bot. @@ -169,6 +174,12 @@ export function createComputerClient(options: ComputerClientOptions) { ? (await options.resolveBaseUrl(botId)).replace(/\/$/, "") : base; + if (!target) { + throw new ComputerUnavailableError( + "This deployment locates computers per Bot, and this call named no Bot.", + ); + } + // Already stopped before this left: do not dispatch at all. Relying on fetch to reject an // aborted signal makes "did the click happen" depend on how quickly the runtime notices, and // the answer to "the person pressed Stop first" should never be a race. diff --git a/server/src/computer/daytona.ts b/server/src/computer/daytona.ts new file mode 100644 index 00000000..771c81c4 --- /dev/null +++ b/server/src/computer/daytona.ts @@ -0,0 +1,570 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { + Daytona, + DaytonaConflictError, + DaytonaNotFoundError, + Image, +} from "@daytona/sdk"; +import { type ComputerLocation, SupervisorError } from "./supervisor"; + +/** + * Remote computers on Daytona for OpenBot. + * + * When configured with DAYTONA_API_KEY, each Bot gets its own cloud sandbox managed + * by Daytona rather than a local Docker container. The supervisor client is responsible + * for building and reusing an image snapshot, provisioning sandboxes on demand, + * checking health over Daytona's preview URLs, and mapping Bot IDs to active sandboxes. + */ + +export type SandboxHandle = { + id: string; + state?: string; + labels?: Record; + createdAt?: string; + start(timeout?: number): Promise; + stop(): Promise; + delete(): Promise; + getPreviewLink(port: number): Promise<{ url: string }>; +}; + +export type DaytonaSdk = { + create( + params: { + snapshot: string; + envVars: Record; + labels: Record; + public: boolean; + autoStopInterval: number; + }, + options?: { timeout?: number }, + ): Promise; + get(id: string): Promise; + list(query?: { + labels?: Record; + }): AsyncIterable; + snapshot: { + get(name: string): Promise<{ state: string }>; + create( + params: { name: string; image: unknown }, + options?: { onLogs?: (line: string) => void; timeout?: number }, + ): Promise; + }; +}; + +export type DaytonaSupervisorOptions = { + apiKey: string; + apiUrl?: string; + target?: string; + snapshot?: string; + computerToken: string; + environment?: Record; + agentComputerDir?: string; + sdk?: DaytonaSdk; + fetchImpl?: typeof fetch; + pollIntervalMs?: number; + healthTimeoutMs?: number; +}; + +const COMPUTER_PORT = 4100; +const OWNERSHIP_LABEL_KEY = "openbot/computer"; +const OWNERSHIP_LABEL_VALUE = "true"; +const BOT_ID_LABEL_KEY = "openbot/bot-id"; +const DEFAULT_AGENT_COMPUTER_DIR = join( + import.meta.dir, + "../../../agent-computer", +); +const DEFAULT_POLL_INTERVAL_MS = 2000; +const HEALTH_POLL_INTERVAL_MS = 500; +const DEFAULT_HEALTH_TIMEOUT_MS = 120_000; +const RECIPE_VERSION = "1"; + +// The Playwright image tag and dependency must stay aligned with +// agent-computer/package.json and agent-computer/Dockerfile. Bump both or neither. +function buildRecipe(dir: string): unknown { + return Image.base("mcr.microsoft.com/playwright:v1.62.1-noble") + .runCommands( + "apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*", + "curl -fsSL https://bun.sh/install | bash", + ) + .env({ PATH: "/root/.bun/bin:${PATH}" }) + .workdir("/app") + .addLocalFile(join(dir, "package.json"), "/app/package.json") + .runCommands("bun install") + .addLocalDir(join(dir, "src"), "/app/src") + .runCommands("mkdir -p /workspace /profiles") + .env({ + WORKSPACE_DIR: "/workspace", + PROFILES_DIR: "/profiles", + PORT: "4100", + }) + .entrypoint(["bun", "src/index.ts"]); +} + +function getAllFiles(dir: string): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...getAllFiles(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; +} + +function computeSnapshotName(agentComputerDir: string): string { + const hash = createHash("sha256"); + hash.update(RECIPE_VERSION); + hash.update("\0"); + + const pkgJsonPath = join(agentComputerDir, "package.json"); + const pkgBytes = readFileSync(pkgJsonPath); + hash.update(pkgBytes); + hash.update("\0"); + + const srcDir = join(agentComputerDir, "src"); + let fileList: string[] = []; + try { + fileList = getAllFiles(srcDir); + } catch { + fileList = []; + } + + const items = fileList.map((fullPath) => { + const rel = relative(srcDir, fullPath).split("\\").join("/"); + return { rel, fullPath }; + }); + items.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0)); + + for (const item of items) { + const contents = readFileSync(item.fullPath); + hash.update(item.rel); + hash.update("\0"); + hash.update(contents); + hash.update("\0"); + } + + const hex = hash.digest("hex").slice(0, 12); + return `openbot-agent-computer-${hex}`; +} + +function isNotFoundError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + if (err instanceof DaytonaNotFoundError) return true; + if ( + "name" in err && + (err as { name: string }).name === "DaytonaNotFoundError" + ) { + return true; + } + return false; +} + +function isConflictError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + if (err instanceof DaytonaConflictError) return true; + if ( + "name" in err && + (err as { name: string }).name === "DaytonaConflictError" + ) { + return true; + } + return false; +} + +function toErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} + +function wrapBotError( + botId: string, + action: string, + err: unknown, +): SupervisorError { + if (err instanceof SupervisorError) { + return err; + } + return new SupervisorError( + `Daytona could not ${action} the computer for ${botId}: ${toErrorMessage(err)}`, + ); +} + +function sleep(ms: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} + +export function createDaytonaSupervisorClient( + options: DaytonaSupervisorOptions, +) { + const sdk: DaytonaSdk = + options.sdk ?? + new Daytona({ + apiKey: options.apiKey, + ...(options.apiUrl ? { apiUrl: options.apiUrl } : {}), + ...(options.target ? { target: options.target } : {}), + }); + + const doFetch = options.fetchImpl ?? fetch; + const known = new Map(); + const locating = new Map>(); + + let snapshotPromise: Promise | undefined; + + function ensureSnapshot(): Promise { + if (options.snapshot) { + return Promise.resolve(options.snapshot); + } + + if (!snapshotPromise) { + snapshotPromise = (async () => { + const agentComputerDir = + options.agentComputerDir ?? DEFAULT_AGENT_COMPUTER_DIR; + const snapshotName = computeSnapshotName(agentComputerDir); + const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxWaitMs = 15 * 60 * 1000; + + async function pollUntilActive(name: string): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < maxWaitMs) { + let snap: { state: string }; + try { + snap = await sdk.snapshot.get(name); + } catch (err) { + throw new SupervisorError( + `Failed to inspect Daytona snapshot ${name} while waiting for active state: ${toErrorMessage(err)}`, + ); + } + if (snap.state === "active") { + return name; + } + if (snap.state === "error" || snap.state === "build_failed") { + throw new SupervisorError( + `Daytona snapshot ${name} failed with state "${snap.state}". Delete it in the Daytona dashboard or set DAYTONA_SNAPSHOT to override.`, + ); + } + await sleep(pollInterval); + } + throw new SupervisorError( + `Timed out waiting for Daytona snapshot ${name} to become active.`, + ); + } + + try { + const existing = await sdk.snapshot.get(snapshotName); + if (existing.state === "active") { + return snapshotName; + } + if (existing.state === "error" || existing.state === "build_failed") { + throw new SupervisorError( + `Daytona snapshot ${snapshotName} failed with state "${existing.state}". Delete it in the Daytona dashboard or set DAYTONA_SNAPSHOT to override.`, + ); + } + return await pollUntilActive(snapshotName); + } catch (err) { + if (isNotFoundError(err)) { + const recipe = buildRecipe(agentComputerDir); + try { + await sdk.snapshot.create( + { name: snapshotName, image: recipe }, + { + onLogs: (line: string) => console.info(`[daytona] ${line}`), + timeout: 900, + }, + ); + return snapshotName; + } catch (createErr) { + if (isConflictError(createErr)) { + return await pollUntilActive(snapshotName); + } + if (createErr instanceof SupervisorError) { + throw createErr; + } + throw new SupervisorError( + `Failed to create Daytona snapshot ${snapshotName}: ${toErrorMessage(createErr)}`, + ); + } + } + if (err instanceof SupervisorError) { + throw err; + } + throw new SupervisorError( + `Failed to inspect Daytona snapshot ${snapshotName}: ${toErrorMessage(err)}`, + ); + } + })().catch((err) => { + snapshotPromise = undefined; + throw err; + }); + } + + return snapshotPromise; + } + + async function resolveSandbox( + botId: string, + action: string, + ): Promise { + const knownEntry = known.get(botId); + if (knownEntry) { + try { + return await sdk.get(knownEntry.sandboxId); + } catch (err) { + if (isNotFoundError(err)) { + known.delete(botId); + } else { + throw wrapBotError(botId, action, err); + } + } + } + + try { + for await (const sb of sdk.list({ + labels: { + [BOT_ID_LABEL_KEY]: botId, + }, + })) { + const state = sb.state?.toLowerCase(); + if (state !== "destroyed" && state !== "destroying") { + return sb; + } + } + } catch (err) { + throw wrapBotError(botId, action, err); + } + + return undefined; + } + + return { + async locate(botId: string): Promise { + const existing = locating.get(botId); + if (existing) { + return existing; + } + + const promise = (async () => { + const snapshot = await ensureSnapshot(); + + let sandboxHandle = await resolveSandbox(botId, "locate"); + if (sandboxHandle) { + const state = sandboxHandle.state?.toLowerCase(); + if (state === "error" || state === "build_failed") { + try { + await sandboxHandle.delete(); + } catch { + // Best-effort cleanup of broken sandboxes + } + known.delete(botId); + sandboxHandle = undefined; + } + } + + if (!sandboxHandle) { + const passthroughEnv: Record = {}; + if (options.environment) { + for (const [key, value] of Object.entries(options.environment)) { + if ( + typeof value === "string" && + (key.startsWith("EGRESS_PROXY") || key === "ACTION_TIMEOUT_MS") + ) { + passthroughEnv[key] = value; + } + } + } + + const envVars: Record = { + COMPUTER_BOT_ID: botId, + COMPUTER_TOKEN: options.computerToken, + ...passthroughEnv, + }; + + const labels: Record = { + [OWNERSHIP_LABEL_KEY]: OWNERSHIP_LABEL_VALUE, + [BOT_ID_LABEL_KEY]: botId, + }; + + try { + sandboxHandle = await sdk.create( + { + snapshot, + envVars, + labels, + public: true, + autoStopInterval: 15, + }, + { timeout: 300 }, + ); + } catch (err) { + throw wrapBotError(botId, "create", err); + } + } else { + const state = sandboxHandle.state?.toLowerCase(); + if ( + state === "stopped" || + state === "archived" || + state === "paused" + ) { + try { + await sandboxHandle.start(300); + } catch (err) { + throw wrapBotError(botId, "start", err); + } + } else if (state !== "started") { + const pollInterval = + options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxWaitMs = 300 * 1000; + const startTime = Date.now(); + while (sandboxHandle.state?.toLowerCase() !== "started") { + if (Date.now() - startTime >= maxWaitMs) { + throw new SupervisorError( + `Timed out waiting for computer sandbox for ${botId} to start.`, + ); + } + await sleep(pollInterval); + try { + sandboxHandle = await sdk.get(sandboxHandle.id); + } catch (err) { + throw wrapBotError(botId, "locate", err); + } + const cur = sandboxHandle.state?.toLowerCase(); + if (cur === "error" || cur === "build_failed") { + throw new SupervisorError( + `Daytona sandbox for ${botId} failed with state "${cur}".`, + ); + } + } + } + } + + let previewUrl: string; + try { + const preview = await sandboxHandle.getPreviewLink(COMPUTER_PORT); + previewUrl = preview.url; + } catch (err) { + throw wrapBotError(botId, "locate", err); + } + + known.set(botId, { sandboxId: sandboxHandle.id, url: previewUrl }); + + const healthPollInterval = + options.pollIntervalMs ?? HEALTH_POLL_INTERVAL_MS; + const healthTimeout = + options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS; + const healthStart = Date.now(); + const healthUrl = `${previewUrl.replace(/\/$/, "")}/health`; + + let healthy = false; + while (Date.now() - healthStart < healthTimeout) { + try { + const res = await doFetch(healthUrl, { + headers: { + "X-Daytona-Skip-Preview-Warning": "true", + }, + }); + if (res.ok) { + healthy = true; + break; + } + } catch { + // Health endpoint not reachable yet; retry + } + await sleep(healthPollInterval); + } + + if (!healthy) { + throw new SupervisorError( + `The computer for ${botId} started but never answered /health at its preview URL.`, + ); + } + + return previewUrl; + })(); + + locating.set(botId, promise); + try { + return await promise; + } finally { + locating.delete(botId); + } + }, + + async stop(botId: string): Promise { + const sandboxHandle = await resolveSandbox(botId, "stop"); + if (!sandboxHandle) { + return; + } + const state = sandboxHandle.state?.toLowerCase(); + if (state === "destroyed" || state === "destroying") { + return; + } + try { + await sandboxHandle.stop(); + } catch (err) { + throw wrapBotError(botId, "stop", err); + } + }, + + async reset(botId: string): Promise { + const sandboxHandle = await resolveSandbox(botId, "reset"); + known.delete(botId); + if (!sandboxHandle) { + return; + } + try { + await sandboxHandle.delete(); + } catch (err) { + throw wrapBotError(botId, "reset", err); + } + }, + + async list(): Promise { + const result: ComputerLocation[] = []; + try { + for await (const sb of sdk.list({ + labels: { + [OWNERSHIP_LABEL_KEY]: OWNERSHIP_LABEL_VALUE, + }, + })) { + const state = sb.state?.toLowerCase(); + if (state === "destroyed" || state === "destroying") { + continue; + } + const botId = sb.labels?.[BOT_ID_LABEL_KEY]; + if (!botId) { + continue; + } + result.push({ + botId, + container: sb.id, + status: sb.state ?? "unknown", + startedAt: sb.createdAt, + }); + } + } catch (err) { + throw new SupervisorError( + `Daytona failed to list computers: ${toErrorMessage(err)}`, + ); + } + return result; + }, + + async warm(): Promise { + try { + await ensureSnapshot(); + } catch (err) { + console.error( + `[daytona] Warm failed to build snapshot: ${toErrorMessage(err)}`, + ); + } + }, + }; +} diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 3ce1c285..638c7913 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -37,9 +37,10 @@ export function createComputerRoutes( ) { const routes = new Hono<{ Variables: AppVariables }>(); - routes.get("/:botId/status", requireUser, async (context) => - context.json(await client.status(context.req.param("botId"))), - ); + routes.get("/:botId/status", requireUser, async (context) => { + const botId = context.req.param("botId"); + return context.json(await client.forBot(botId).status(botId)); + }); routes.get("/:botId/screenshot", requireUser, async (context) => { try { diff --git a/server/src/config.ts b/server/src/config.ts index 9e9c2c8c..5dc6f637 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -63,7 +63,7 @@ export type DeploymentConfig = { * mounted and failing: a capability that is not configured should be missing, not broken. */ computer?: { - baseUrl: string; + baseUrl?: string; /** The secret every computer requires of its caller. */ token?: string; /** @@ -72,6 +72,13 @@ export type DeploymentConfig = { * machine. */ supervisor?: { baseUrl: string; token?: string }; + /** Remote computers on Daytona, when an API key is configured. Mutually exclusive with supervisor. */ + daytona?: { + apiKey: string; + apiUrl?: string; + target?: string; + snapshot?: string; + }; /** True on a laptop, where browsing the deployment's own services is the point. */ allowPrivateHosts: boolean; /** @@ -266,22 +273,36 @@ function computerConfig( environment: Environment, ): DeploymentConfig["computer"] { const baseUrl = url(environment, "AGENT_COMPUTER_URL"); - if (!baseUrl) { + const daytonaKey = optional(environment, "DAYTONA_API_KEY"); + const supervisorUrl = url(environment, "COMPUTER_SUPERVISOR_URL"); + if (!baseUrl && !daytonaKey) { return undefined; } - const policy = actionPolicy(environment); + if (daytonaKey && supervisorUrl) { + throw new Error( + "Set either DAYTONA_API_KEY or COMPUTER_SUPERVISOR_URL, not both. They are two ways of giving each Bot its own computer.", + ); + } /* * The secret the computers require. Without it every call to a computer is refused, and that is the * intended failure: `agent-computer` drives a browser holding real logins and must not answer * unauthenticated callers that can reach its port. */ const computerToken = optional(environment, "COMPUTER_TOKEN"); - const supervisorUrl = url(environment, "COMPUTER_SUPERVISOR_URL"); + if (daytonaKey && !computerToken) { + throw new Error( + "DAYTONA_API_KEY is set but COMPUTER_TOKEN is not. Daytona computers are reached over a public preview URL, and the token is the only thing that refuses strangers. Generate one: openssl rand -base64 32", + ); + } + const policy = actionPolicy(environment); const supervisorToken = optional(environment, "SUPERVISOR_TOKEN"); + const daytonaUrl = url(environment, "DAYTONA_API_URL"); + const daytonaTarget = optional(environment, "DAYTONA_TARGET"); + const daytonaSnapshot = optional(environment, "DAYTONA_SNAPSHOT"); return { - baseUrl, allowPrivateHosts: optional(environment, "AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS") === "true", + ...(baseUrl ? { baseUrl } : {}), ...(policy ? { policy } : {}), ...(computerToken ? { token: computerToken } : {}), ...(supervisorUrl @@ -292,6 +313,16 @@ function computerConfig( }, } : {}), + ...(daytonaKey + ? { + daytona: { + apiKey: daytonaKey, + ...(daytonaUrl ? { apiUrl: daytonaUrl } : {}), + ...(daytonaTarget ? { target: daytonaTarget } : {}), + ...(daytonaSnapshot ? { snapshot: daytonaSnapshot } : {}), + }, + } + : {}), }; } diff --git a/server/src/index.ts b/server/src/index.ts index 3fc067a1..d6b37132 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,6 +23,7 @@ import { createPolicyStore, DEFAULT_ACTION_POLICY, } from "./computer/policy-store"; +import { createDaytonaSupervisorClient } from "./computer/daytona"; import { createSupervisorClient } from "./computer/supervisor"; import { loadConfig } from "./config"; import { createConnectorAdminService } from "./connectors"; @@ -150,14 +151,25 @@ const roleRepository = createRoleRepository(database); const loadAgentsForActor = createRuntimeAgentLoader(database, agentVault); await synchronizeTenantPackage(database, tenantPackage); const auth = config.auth ? createAuth(config, database) : undefined; -// One computer each, when a supervisor is configured to give them out. Without one every Bot shares -// the computer at `baseUrl`, which is what a laptop wants and is honest about being one machine. -const supervisor = config.computer?.supervisor - ? createSupervisorClient(config.computer.supervisor) - : undefined; +const daytonaSupervisor = + config.computer?.daytona && config.computer.token + ? createDaytonaSupervisorClient({ + ...config.computer.daytona, + computerToken: config.computer.token, + environment: process.env, + }) + : undefined; +// Kick the snapshot build off at boot so the first person to open a computer +// is not the one who waits minutes for an image build. +if (daytonaSupervisor) void daytonaSupervisor.warm(); +const supervisor = + daytonaSupervisor ?? + (config.computer?.supervisor + ? createSupervisorClient(config.computer.supervisor) + : undefined); const computerClient = config.computer ? createComputerClient({ - baseUrl: config.computer.baseUrl, + ...(config.computer.baseUrl ? { baseUrl: config.computer.baseUrl } : {}), allowPrivateHosts: config.computer.allowPrivateHosts, ...(config.computer.token ? { token: config.computer.token } : {}), ...(supervisor @@ -432,12 +444,15 @@ serve({ // Located per Bot when there is a supervisor, and the one shared computer when there is not. let upstream: string; try { - upstream = toStreamUrl( - supervisor - ? await supervisor.locate(streamBotId) - : config.computer.baseUrl, - streamBotId, - ); + const streamBase = supervisor + ? await supervisor.locate(streamBotId) + : config.computer.baseUrl; + if (!streamBase) { + return new Response("No computer address is configured.", { + status: 503, + }); + } + upstream = toStreamUrl(streamBase, streamBotId); } catch (error) { // Said out loud rather than falling back to another Bot's computer, which is the failure this // whole path exists to prevent. diff --git a/server/tests/computer-client.test.ts b/server/tests/computer-client.test.ts index 462a7b5c..ba3e1779 100644 --- a/server/tests/computer-client.test.ts +++ b/server/tests/computer-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + ComputerUnavailableError, createComputerClient, ElementNotFoundError, NavigationRefusedError, @@ -154,6 +155,43 @@ describe("computer client", () => { reason: "The assistant's computer did not respond in time.", }); }); + + test("refuses an unbound call when no baseUrl is configured", async () => { + const client = createComputerClient({ + fetchImpl: ((url: string, init?: RequestInit) => + Promise.resolve(ok({}))) as unknown as typeof fetch, + }); + + await expect(client.navigate("https://example.com/")).rejects.toThrow( + ComputerUnavailableError, + ); + await expect(client.navigate("https://example.com/")).rejects.toThrow( + "This deployment locates computers per Bot, and this call named no Bot.", + ); + }); + + test("routes to the resolved address when bound to a Bot without a shared baseUrl", async () => { + const seen: string[] = []; + const client = createComputerClient({ + resolveBaseUrl: async (botId) => `http://${botId}.daytona.internal:4100`, + fetchImpl: ((url: string, init?: RequestInit) => { + seen.push(url); + return Promise.resolve( + ok({ + url: "https://example.com/", + title: "Example", + elapsedMs: 10, + }), + ); + }) as unknown as typeof fetch, + }); + + const botClient = client.forBot("bot-123"); + await expect( + botClient.navigate("https://example.com/"), + ).resolves.toMatchObject({ title: "Example" }); + expect(seen).toEqual(["http://bot-123.daytona.internal:4100/navigate"]); + }); }); /** diff --git a/server/tests/computer-daytona.test.ts b/server/tests/computer-daytona.test.ts new file mode 100644 index 00000000..063d38aa --- /dev/null +++ b/server/tests/computer-daytona.test.ts @@ -0,0 +1,565 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createDaytonaSupervisorClient } from "../src/computer/daytona"; +import { SupervisorError } from "../src/computer/supervisor"; + +/** + * Daytona computer provider test suite. + * + * OpenBot gives each Bot its own computer in a remote Daytona sandbox when configured with + * DAYTONA_API_KEY. The supervisor client is responsible for creating, locating, stopping, + * resetting, and listing these sandboxes via the Daytona SDK and validating readiness + * over the sandbox preview URL before returning it to the caller. + */ + +class DaytonaNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "DaytonaNotFoundError"; + } +} + +type FakeSandbox = { + id: string; + state: string; + labels: Record; + envVars: Record; + public: boolean; + autoStopInterval: number; + createdAt: string; + previewUrl: string; + startCalls: number; + stopCalls: number; + deleteCalls: number; +}; + +type CreateParams = { + snapshot: string; + envVars: Record; + labels: Record; + public: boolean; + autoStopInterval: number; + options?: { timeout?: number }; +}; + +function makeSandboxHandle(sb: FakeSandbox) { + return { + id: sb.id, + state: sb.state, + labels: sb.labels, + createdAt: sb.createdAt, + start: async () => { + sb.startCalls++; + sb.state = "started"; + }, + stop: async () => { + sb.stopCalls++; + sb.state = "stopped"; + }, + delete: async () => { + sb.deleteCalls++; + sb.state = "destroyed"; + }, + getPreviewLink: async (_port: number) => ({ url: sb.previewUrl }), + }; +} + +function createFakeSdk(initialSandboxes: FakeSandbox[] = []) { + let idCounter = 1; + const sandboxes = new Map(); + const snapshots = new Map(); + const creates: CreateParams[] = []; + + for (const sb of initialSandboxes) { + sandboxes.set(sb.id, sb); + } + + const sdk = { + sandboxes, + snapshots, + creates, + create: async ( + params: { + snapshot: string; + envVars: Record; + labels: Record; + public: boolean; + autoStopInterval: number; + }, + options?: { timeout?: number }, + ) => { + creates.push({ ...params, options }); + const id = `sb-${idCounter++}`; + const sb: FakeSandbox = { + id, + state: "started", + labels: params.labels, + envVars: params.envVars, + public: params.public, + autoStopInterval: params.autoStopInterval, + createdAt: new Date().toISOString(), + previewUrl: `https://${id}.preview.daytona.app`, + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + }; + sandboxes.set(id, sb); + return makeSandboxHandle(sb); + }, + get: async (id: string) => { + const sb = sandboxes.get(id); + if (!sb) { + throw new DaytonaNotFoundError(`Sandbox ${id} not found`); + } + return makeSandboxHandle(sb); + }, + list: (query?: { labels?: Record }) => { + async function* generator() { + for (const sb of sandboxes.values()) { + if (query?.labels) { + const matches = Object.entries(query.labels).every( + ([k, v]) => sb.labels[k] === v, + ); + if (!matches) continue; + } + yield makeSandboxHandle(sb); + } + } + return generator(); + }, + snapshot: { + get: async (name: string) => { + const snap = snapshots.get(name); + if (!snap) { + throw new DaytonaNotFoundError(`Snapshot ${name} not found`); + } + return snap; + }, + create: async ( + params: { name: string; image: unknown }, + _options?: { onLogs?: (line: string) => void; timeout?: number }, + ) => { + snapshots.set(params.name, { state: "active" }); + return { name: params.name }; + }, + }, + }; + + return sdk; +} + +function fakeFetch( + handler?: (url: string, init?: RequestInit) => Response | Promise, +): typeof fetch { + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (handler) return handler(url, init); + if (url.endsWith("/health")) { + return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }) as unknown as typeof fetch; +} + +describe("Daytona computer supervisor", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // cleanup best-effort + } + } + tempDirs.length = 0; + }); + + test("create carries snapshot, both ownership labels, public:true, autoStopInterval:15, COMPUTER_TOKEN and COMPUTER_BOT_ID then returns preview URL after healthy /health", async () => { + const sdk = createFakeSdk(); + let healthCheckedUrl: string | undefined; + let healthHeaders: HeadersInit | undefined; + + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input); + if (url.endsWith("/health")) { + healthCheckedUrl = url; + healthHeaders = init?.headers; + return new Response("ok", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }) as unknown as typeof fetch; + + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "secret-token-123", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl, + }); + + const url = await client.locate("sales"); + + expect(sdk.creates).toHaveLength(1); + const createParams = sdk.creates[0]; + expect(createParams.snapshot).toBe("prebuilt"); + expect(createParams.public).toBe(true); + expect(createParams.autoStopInterval).toBe(15); + expect(createParams.labels).toEqual({ + "openbot/computer": "true", + "openbot/bot-id": "sales", + }); + expect(createParams.envVars.COMPUTER_TOKEN).toBe("secret-token-123"); + expect(createParams.envVars.COMPUTER_BOT_ID).toBe("sales"); + + expect(url).toBe("https://sb-1.preview.daytona.app"); + expect(healthCheckedUrl).toBe("https://sb-1.preview.daytona.app/health"); + expect( + new Headers(healthHeaders).get("X-Daytona-Skip-Preview-Warning"), + ).toBe("true"); + }); + + test("stopped labeled sandbox is reused and started", async () => { + const existing: FakeSandbox = { + id: "sb-stopped-1", + state: "stopped", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "support", + }, + envVars: { + COMPUTER_TOKEN: "tok", + COMPUTER_BOT_ID: "support", + }, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T10:00:00Z", + previewUrl: "https://sb-stopped-1.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + }; + + const sdk = createFakeSdk([existing]); + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + const url = await client.locate("support"); + + expect(url).toBe("https://sb-stopped-1.preview.daytona.app"); + expect(sdk.creates).toHaveLength(0); + expect(existing.startCalls).toBe(1); + expect(existing.state).toBe("started"); + }); + + test("concurrent locate calls create once", async () => { + const sdk = createFakeSdk(); + let createsCount = 0; + const { promise: gatePromise, resolve: openGate } = + Promise.withResolvers(); + const origCreate = sdk.create; + sdk.create = async (params, options) => { + createsCount++; + await gatePromise; + return origCreate(params, options); + }; + + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + const firstLocate = client.locate("marketing"); + const secondLocate = client.locate("marketing"); + openGate(); + + const [url1, url2] = await Promise.all([firstLocate, secondLocate]); + + expect(url1).toBe(url2); + expect(createsCount).toBe(1); + }); + + test("reset deletes and the next locate creates fresh", async () => { + const sdk = createFakeSdk(); + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + const firstUrl = await client.locate("finance"); + expect(sdk.creates).toHaveLength(1); + const firstSandbox = sdk.sandboxes.get("sb-1")!; + + await client.reset("finance"); + expect(firstSandbox.deleteCalls).toBe(1); + + const secondUrl = await client.locate("finance"); + expect(sdk.creates).toHaveLength(2); + expect(secondUrl).not.toBe(firstUrl); + }); + + test("stop with no sandbox is a no-op", async () => { + const sdk = createFakeSdk(); + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + await expect(client.stop("nonexistent")).resolves.toBeUndefined(); + expect(sdk.creates).toHaveLength(0); + }); + + test("SDK failure throws SupervisorError naming the Bot", async () => { + const sdk = createFakeSdk(); + sdk.create = async () => { + throw new Error("quota exceeded"); + }; + + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + await expect(client.locate("analytics")).rejects.toThrow(SupervisorError); + await expect(client.locate("analytics")).rejects.toThrow(/analytics/); + }); + + test("list maps openbot/bot-id and skips destroyed", async () => { + const activeBot: FakeSandbox = { + id: "sb-active", + state: "started", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "bot-active", + }, + envVars: {}, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T10:00:00Z", + previewUrl: "https://sb-active.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + }; + + const destroyedBot: FakeSandbox = { + id: "sb-destroyed", + state: "destroyed", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "bot-destroyed", + }, + envVars: {}, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T09:00:00Z", + previewUrl: "https://sb-destroyed.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + }; + + const stoppedBot: FakeSandbox = { + id: "sb-stopped", + state: "stopped", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "bot-stopped", + }, + envVars: {}, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T11:00:00Z", + previewUrl: "https://sb-stopped.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + }; + + const unrelatedSandbox: FakeSandbox = { + id: "sb-unrelated", + state: "started", + labels: { + "some-other-label": "true", + }, + envVars: {}, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T08:00:00Z", + previewUrl: "https://sb-unrelated.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + }; + + const sdk = createFakeSdk([ + activeBot, + destroyedBot, + stoppedBot, + unrelatedSandbox, + ]); + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + const list = await client.list(); + + expect(list).toHaveLength(2); + expect(list).toEqual( + expect.arrayContaining([ + { + botId: "bot-active", + container: "sb-active", + status: "started", + startedAt: "2026-08-20T10:00:00Z", + }, + { + botId: "bot-stopped", + container: "sb-stopped", + status: "stopped", + startedAt: "2026-08-20T11:00:00Z", + }, + ]), + ); + }); + + test("health timeout throws SupervisorError", async () => { + const sdk = createFakeSdk(); + const failingFetch = (async () => { + return new Response("service unavailable", { status: 503 }); + }) as unknown as typeof fetch; + + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 50, + sdk: sdk as never, + fetchImpl: failingFetch, + }); + + await expect(client.locate("unhealthy-bot")).rejects.toThrow( + SupervisorError, + ); + await expect(client.locate("unhealthy-bot")).rejects.toThrow( + /The computer for unhealthy-bot started but never answered \/health/, + ); + }); + + test("snapshot naming is stable for unchanged temp sources and changes when source contents change", async () => { + const fixtureDir = mkdtempSync( + join(tmpdir(), "openbot-agent-computer-test-"), + ); + tempDirs.push(fixtureDir); + + writeFileSync( + join(fixtureDir, "package.json"), + JSON.stringify({ name: "agent-computer", version: "1.0.0" }), + ); + mkdirSync(join(fixtureDir, "src"), { recursive: true }); + writeFileSync( + join(fixtureDir, "src", "index.ts"), + 'console.log("hello world");\n', + ); + + const sdk1 = createFakeSdk(); + const client1 = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + agentComputerDir: fixtureDir, + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk1 as never, + fetchImpl: fakeFetch(), + }); + + await client1.locate("bot-1"); + expect(sdk1.creates).toHaveLength(1); + const snapshotName1 = sdk1.creates[0].snapshot; + expect(snapshotName1).toMatch(/^openbot-agent-computer-[a-f0-9]{12}$/); + + const sdk2 = createFakeSdk(); + const client2 = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + agentComputerDir: fixtureDir, + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk2 as never, + fetchImpl: fakeFetch(), + }); + + await client2.locate("bot-2"); + expect(sdk2.creates).toHaveLength(1); + const snapshotName2 = sdk2.creates[0].snapshot; + expect(snapshotName2).toBe(snapshotName1); + + writeFileSync( + join(fixtureDir, "src", "index.ts"), + 'console.log("hello world updated");\n', + ); + + const sdk3 = createFakeSdk(); + const client3 = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + agentComputerDir: fixtureDir, + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk3 as never, + fetchImpl: fakeFetch(), + }); + + await client3.locate("bot-3"); + expect(sdk3.creates).toHaveLength(1); + const snapshotName3 = sdk3.creates[0].snapshot; + expect(snapshotName3).toMatch(/^openbot-agent-computer-[a-f0-9]{12}$/); + expect(snapshotName3).not.toBe(snapshotName1); + }); +}); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 326ad5cb..1877f9e8 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -186,4 +186,40 @@ describe("deployment configuration", () => { expect(attempt).toThrow("AGENT_STALL_TIMEOUT_MS"); }, ); + + test("enables Daytona remote computers without a shared base address", () => { + const config = loadConfig({ + ...baseEnvironment, + DAYTONA_API_KEY: "dtn_test_key", + COMPUTER_TOKEN: "secret-token", + }); + + expect(config.computer?.daytona?.apiKey).toBe("dtn_test_key"); + expect(config.computer?.baseUrl).toBeUndefined(); + expect(config.computer?.token).toBe("secret-token"); + }); + + test("refuses to configure Daytona computers without COMPUTER_TOKEN", () => { + expect(() => + loadConfig({ + ...baseEnvironment, + DAYTONA_API_KEY: "dtn_test_key", + }), + ).toThrow( + "DAYTONA_API_KEY is set but COMPUTER_TOKEN is not. Daytona computers are reached over a public preview URL, and the token is the only thing that refuses strangers. Generate one: openssl rand -base64 32", + ); + }); + + test("refuses to configure both Daytona and a container supervisor", () => { + expect(() => + loadConfig({ + ...baseEnvironment, + DAYTONA_API_KEY: "dtn_test_key", + COMPUTER_TOKEN: "secret-token", + COMPUTER_SUPERVISOR_URL: "http://localhost:4000", + }), + ).toThrow( + "Set either DAYTONA_API_KEY or COMPUTER_SUPERVISOR_URL, not both. They are two ways of giving each Bot its own computer.", + ); + }); }); From bc70631256999e3004298d936bd9da5207ce38f9 Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 09:37:15 -0700 Subject: [PATCH 02/16] Keep Daytona reset monotonic across delayed sandbox indexes Signed-off-by: Muhammad Hashmi --- server/src/computer/daytona.ts | 110 ++++++++++++---- server/tests/computer-daytona.test.ts | 173 +++++++++++++++++++++++++- 2 files changed, 254 insertions(+), 29 deletions(-) diff --git a/server/src/computer/daytona.ts b/server/src/computer/daytona.ts index 771c81c4..b61b9fd6 100644 --- a/server/src/computer/daytona.ts +++ b/server/src/computer/daytona.ts @@ -25,7 +25,7 @@ export type SandboxHandle = { createdAt?: string; start(timeout?: number): Promise; stop(): Promise; - delete(): Promise; + delete(timeout?: number, wait?: boolean): Promise; getPreviewLink(port: number): Promise<{ url: string }>; }; @@ -78,28 +78,33 @@ const DEFAULT_AGENT_COMPUTER_DIR = join( const DEFAULT_POLL_INTERVAL_MS = 2000; const HEALTH_POLL_INTERVAL_MS = 500; const DEFAULT_HEALTH_TIMEOUT_MS = 120_000; -const RECIPE_VERSION = "1"; +const RECIPE_VERSION = "2"; // The Playwright image tag and dependency must stay aligned with // agent-computer/package.json and agent-computer/Dockerfile. Bump both or neither. function buildRecipe(dir: string): unknown { - return Image.base("mcr.microsoft.com/playwright:v1.62.1-noble") - .runCommands( - "apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*", - "curl -fsSL https://bun.sh/install | bash", - ) - .env({ PATH: "/root/.bun/bin:${PATH}" }) - .workdir("/app") - .addLocalFile(join(dir, "package.json"), "/app/package.json") - .runCommands("bun install") - .addLocalDir(join(dir, "src"), "/app/src") - .runCommands("mkdir -p /workspace /profiles") - .env({ - WORKSPACE_DIR: "/workspace", - PROFILES_DIR: "/profiles", - PORT: "4100", - }) - .entrypoint(["bun", "src/index.ts"]); + return ( + Image.base("mcr.microsoft.com/playwright:v1.62.1-noble") + .runCommands( + "apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*", + "curl -fsSL https://bun.sh/install | bash", + ) + // Image.env shell-quotes variable references so ${PATH} cannot be used. + .env({ + PATH: "/root/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + }) + .workdir("/app") + .addLocalFile(join(dir, "package.json"), "/app/package.json") + .runCommands("bun install") + .addLocalDir(join(dir, "src"), "/app/src") + .runCommands("mkdir -p /workspace /profiles") + .env({ + WORKSPACE_DIR: "/workspace", + PROFILES_DIR: "/profiles", + PORT: "4100", + }) + .entrypoint(["bun", "src/index.ts"]) + ); } function getAllFiles(dir: string): string[] { @@ -216,6 +221,7 @@ export function createDaytonaSupervisorClient( const doFetch = options.fetchImpl ?? fetch; const known = new Map(); const locating = new Map>(); + const resetSandboxes = new Map(); let snapshotPromise: Promise | undefined; @@ -313,15 +319,20 @@ export function createDaytonaSupervisorClient( botId: string, action: string, ): Promise { + const deletedSandboxId = resetSandboxes.get(botId); const knownEntry = known.get(botId); if (knownEntry) { - try { - return await sdk.get(knownEntry.sandboxId); - } catch (err) { - if (isNotFoundError(err)) { - known.delete(botId); - } else { - throw wrapBotError(botId, action, err); + if (deletedSandboxId && knownEntry.sandboxId === deletedSandboxId) { + known.delete(botId); + } else { + try { + return await sdk.get(knownEntry.sandboxId); + } catch (err) { + if (isNotFoundError(err)) { + known.delete(botId); + } else { + throw wrapBotError(botId, action, err); + } } } } @@ -332,6 +343,9 @@ export function createDaytonaSupervisorClient( [BOT_ID_LABEL_KEY]: botId, }, })) { + if (deletedSandboxId && sb.id === deletedSandboxId) { + continue; + } const state = sb.state?.toLowerCase(); if (state !== "destroyed" && state !== "destroying") { return sb; @@ -403,6 +417,7 @@ export function createDaytonaSupervisorClient( }, { timeout: 300 }, ); + resetSandboxes.delete(botId); } catch (err) { throw wrapBotError(botId, "create", err); } @@ -519,11 +534,49 @@ export function createDaytonaSupervisorClient( if (!sandboxHandle) { return; } + resetSandboxes.set(botId, sandboxHandle.id); try { - await sandboxHandle.delete(); + await sandboxHandle.delete(60, true); } catch (err) { throw wrapBotError(botId, "reset", err); } + + const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxWaitMs = 300 * 1000; + const startTime = Date.now(); + + while (true) { + let stillPresent = false; + try { + for await (const sb of sdk.list({ + labels: { + [BOT_ID_LABEL_KEY]: botId, + }, + })) { + if (sb.id === sandboxHandle.id) { + const state = sb.state?.toLowerCase(); + if (state !== "destroyed" && state !== "destroying") { + stillPresent = true; + break; + } + } + } + } catch (err) { + throw wrapBotError(botId, "reset", err); + } + + if (!stillPresent) { + break; + } + + if (Date.now() - startTime >= maxWaitMs) { + throw new SupervisorError( + `Timed out waiting for deleted computer sandbox ${sandboxHandle.id} for ${botId} to be removed.`, + ); + } + + await sleep(pollInterval); + } }, async list(): Promise { @@ -542,6 +595,9 @@ export function createDaytonaSupervisorClient( if (!botId) { continue; } + if (resetSandboxes.get(botId) === sb.id) { + continue; + } result.push({ botId, container: sb.id, diff --git a/server/tests/computer-daytona.test.ts b/server/tests/computer-daytona.test.ts index 063d38aa..c04d4207 100644 --- a/server/tests/computer-daytona.test.ts +++ b/server/tests/computer-daytona.test.ts @@ -33,6 +33,7 @@ type FakeSandbox = { startCalls: number; stopCalls: number; deleteCalls: number; + deleteHandler?: (timeout?: number, wait?: boolean) => void | Promise; }; type CreateParams = { @@ -58,9 +59,13 @@ function makeSandboxHandle(sb: FakeSandbox) { sb.stopCalls++; sb.state = "stopped"; }, - delete: async () => { + delete: async (timeout?: number, wait?: boolean) => { sb.deleteCalls++; - sb.state = "destroyed"; + if (sb.deleteHandler) { + await sb.deleteHandler(timeout, wait); + } else { + sb.state = "destroyed"; + } }, getPreviewLink: async (_port: number) => ({ url: sb.previewUrl }), }; @@ -562,4 +567,168 @@ describe("Daytona computer supervisor", () => { expect(snapshotName3).toMatch(/^openbot-agent-computer-[a-f0-9]{12}$/); expect(snapshotName3).not.toBe(snapshotName1); }); + + test("snapshot image recipe configures PATH with bun and standard system binaries without literal variable substitution", async () => { + const fixtureDir = mkdtempSync( + join(tmpdir(), "openbot-agent-computer-test-"), + ); + tempDirs.push(fixtureDir); + + writeFileSync( + join(fixtureDir, "package.json"), + JSON.stringify({ name: "agent-computer", version: "1.0.0" }), + ); + mkdirSync(join(fixtureDir, "src"), { recursive: true }); + writeFileSync( + join(fixtureDir, "src", "index.ts"), + 'console.log("hello world");\n', + ); + + let capturedImage: { dockerfile: string } | undefined; + const sdk = createFakeSdk(); + const originalSnapshotCreate = sdk.snapshot.create; + sdk.snapshot.create = async (params, options) => { + capturedImage = params.image as { dockerfile: string }; + return originalSnapshotCreate(params, options); + }; + + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + agentComputerDir: fixtureDir, + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + await client.locate("recipe-test-bot"); + + expect(capturedImage).toBeDefined(); + const dockerfile = capturedImage!.dockerfile; + + const envPathLine = dockerfile + .split("\n") + .find((line) => line.startsWith("ENV PATH=")); + + expect(envPathLine).toBeDefined(); + expect(envPathLine).toContain("/root/.bun/bin"); + expect(envPathLine).toContain("/usr/bin"); + expect(envPathLine).toContain("/bin"); + expect(envPathLine).not.toContain("${PATH}"); + expect(dockerfile).not.toContain("${PATH}"); + }); + + test("reset waits for sandbox deletion so list does not return the reset bot", async () => { + const existing: FakeSandbox = { + id: "sb-reset-wait-1", + state: "started", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "reset-wait-bot", + }, + envVars: {}, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T10:00:00Z", + previewUrl: "https://sb-reset-wait-1.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + deleteHandler: (_timeout, wait) => { + if (wait) { + existing.state = "destroyed"; + } + }, + }; + + const sdk = createFakeSdk([existing]); + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + await client.reset("reset-wait-bot"); + + const remaining = await client.list(); + expect( + remaining.find((bot) => bot.botId === "reset-wait-bot"), + ).toBeUndefined(); + }); + + test("reset waits for Daytona list convergence across eventual consistency stale started responses", async () => { + const existing: FakeSandbox = { + id: "sb-reset-convergence-1", + state: "started", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "reset-convergence-bot", + }, + envVars: {}, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T10:00:00Z", + previewUrl: "https://sb-reset-convergence-1.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + }; + + const sdk = createFakeSdk([existing]); + let postDeleteListCalls = 0; + const origList = sdk.list; + + sdk.list = (query?: { labels?: Record }) => { + if (existing.deleteCalls > 0) { + postDeleteListCalls++; + if (postDeleteListCalls === 2) { + async function* staleGenerator() { + yield { + id: existing.id, + state: "started", + labels: existing.labels, + createdAt: existing.createdAt, + start: async () => {}, + stop: async () => {}, + delete: async () => {}, + getPreviewLink: async () => ({ url: existing.previewUrl }), + }; + } + return staleGenerator(); + } + async function* emptyGenerator() {} + return emptyGenerator(); + } + return origList(query); + }; + + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + await client.reset("reset-convergence-bot"); + + const immediate = await client.list(); + expect( + immediate.find((bot) => bot.botId === "reset-convergence-bot"), + ).toBeUndefined(); + + const later = await client.list(); + expect( + later.find((bot) => bot.botId === "reset-convergence-bot"), + ).toBeUndefined(); + + expect(postDeleteListCalls).toBeGreaterThanOrEqual(3); + }); }); From d74049c894194d92f87466408963d7968a7d7fb5 Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 09:55:42 -0700 Subject: [PATCH 03/16] Retry Daytona resets after transient deletion failures Signed-off-by: Muhammad Hashmi --- server/src/computer/daytona.ts | 39 +----------------- server/tests/computer-daytona.test.ts | 59 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/server/src/computer/daytona.ts b/server/src/computer/daytona.ts index b61b9fd6..4fdfc8ec 100644 --- a/server/src/computer/daytona.ts +++ b/server/src/computer/daytona.ts @@ -534,49 +534,12 @@ export function createDaytonaSupervisorClient( if (!sandboxHandle) { return; } - resetSandboxes.set(botId, sandboxHandle.id); try { await sandboxHandle.delete(60, true); } catch (err) { throw wrapBotError(botId, "reset", err); } - - const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const maxWaitMs = 300 * 1000; - const startTime = Date.now(); - - while (true) { - let stillPresent = false; - try { - for await (const sb of sdk.list({ - labels: { - [BOT_ID_LABEL_KEY]: botId, - }, - })) { - if (sb.id === sandboxHandle.id) { - const state = sb.state?.toLowerCase(); - if (state !== "destroyed" && state !== "destroying") { - stillPresent = true; - break; - } - } - } - } catch (err) { - throw wrapBotError(botId, "reset", err); - } - - if (!stillPresent) { - break; - } - - if (Date.now() - startTime >= maxWaitMs) { - throw new SupervisorError( - `Timed out waiting for deleted computer sandbox ${sandboxHandle.id} for ${botId} to be removed.`, - ); - } - - await sleep(pollInterval); - } + resetSandboxes.set(botId, sandboxHandle.id); }, async list(): Promise { diff --git a/server/tests/computer-daytona.test.ts b/server/tests/computer-daytona.test.ts index c04d4207..63e23594 100644 --- a/server/tests/computer-daytona.test.ts +++ b/server/tests/computer-daytona.test.ts @@ -686,7 +686,7 @@ describe("Daytona computer supervisor", () => { sdk.list = (query?: { labels?: Record }) => { if (existing.deleteCalls > 0) { postDeleteListCalls++; - if (postDeleteListCalls === 2) { + if (postDeleteListCalls === 1) { async function* staleGenerator() { yield { id: existing.id, @@ -728,7 +728,62 @@ describe("Daytona computer supervisor", () => { expect( later.find((bot) => bot.botId === "reset-convergence-bot"), ).toBeUndefined(); + }); + + test("failed reset deletion does not tombstone sandbox so retry deletes it and removes it from list", async () => { + let deleteAttempts = 0; + const existing: FakeSandbox = { + id: "sb-failed-delete-1", + state: "started", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "retry-delete-bot", + }, + envVars: {}, + public: true, + autoStopInterval: 15, + createdAt: "2026-08-20T10:00:00Z", + previewUrl: "https://sb-failed-delete-1.preview.daytona.app", + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + deleteHandler: (_timeout, _wait) => { + deleteAttempts++; + if (deleteAttempts === 1) { + throw new Error("transient deletion error"); + } + existing.state = "destroyed"; + }, + }; - expect(postDeleteListCalls).toBeGreaterThanOrEqual(3); + const sdk = createFakeSdk([existing]); + const client = createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk: sdk as never, + fetchImpl: fakeFetch(), + }); + + let resetError: unknown; + try { + await client.reset("retry-delete-bot"); + } catch (err) { + resetError = err; + } + + expect(resetError).toBeInstanceOf(SupervisorError); + expect((resetError as Error)?.message).toContain("retry-delete-bot"); + expect(existing.deleteCalls).toBe(1); + + await client.reset("retry-delete-bot"); + + expect(existing.deleteCalls).toBe(2); + const remaining = await client.list(); + expect( + remaining.find((bot) => bot.botId === "retry-delete-bot"), + ).toBeUndefined(); }); }); From a10ad1e6cd2e97c731b7a5ceeacfcbae9602b031 Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 10:08:55 -0700 Subject: [PATCH 04/16] Make Daytona computer lifecycle reads non-provisioning and bounded Signed-off-by: Muhammad Hashmi --- server/src/computer/daytona.ts | 283 +++++++++--- server/src/computer/gateway.ts | 51 +++ server/src/computer/routes.ts | 2 +- server/src/index.ts | 19 +- server/tests/computer-daytona.test.ts | 606 ++++++++++++++------------ server/tests/computer-gateway.test.ts | 175 +++++++- 6 files changed, 788 insertions(+), 348 deletions(-) diff --git a/server/src/computer/daytona.ts b/server/src/computer/daytona.ts index 4fdfc8ec..0a455f15 100644 --- a/server/src/computer/daytona.ts +++ b/server/src/computer/daytona.ts @@ -65,6 +65,7 @@ export type DaytonaSupervisorOptions = { fetchImpl?: typeof fetch; pollIntervalMs?: number; healthTimeoutMs?: number; + snapshotTimeoutMs?: number; }; const COMPUTER_PORT = 4100; @@ -78,6 +79,7 @@ const DEFAULT_AGENT_COMPUTER_DIR = join( const DEFAULT_POLL_INTERVAL_MS = 2000; const HEALTH_POLL_INTERVAL_MS = 500; const DEFAULT_HEALTH_TIMEOUT_MS = 120_000; +const DEFAULT_SNAPSHOT_TIMEOUT_MS = 15 * 60 * 1000; const RECIPE_VERSION = "2"; // The Playwright image tag and dependency must stay aligned with @@ -132,13 +134,7 @@ function computeSnapshotName(agentComputerDir: string): string { hash.update("\0"); const srcDir = join(agentComputerDir, "src"); - let fileList: string[] = []; - try { - fileList = getAllFiles(srcDir); - } catch { - fileList = []; - } - + const fileList = getAllFiles(srcDir); const items = fileList.map((fullPath) => { const rel = relative(srcDir, fullPath).split("\\").join("/"); return { rel, fullPath }; @@ -200,6 +196,15 @@ function wrapBotError( `Daytona could not ${action} the computer for ${botId}: ${toErrorMessage(err)}`, ); } +function toSupervisorStatus(state?: string): string { + if (!state) return "unknown"; + const lower = state.toLowerCase(); + if (lower === "started") return "running"; + if (lower === "stopped" || lower === "archived" || lower === "paused") { + return "exited"; + } + return state; +} function sleep(ms: number): Promise { const { promise, resolve } = Promise.withResolvers(); @@ -207,6 +212,49 @@ function sleep(ms: number): Promise { return promise; } +function withTimeout( + promise: Promise, + timeoutMs: number, + errorMessage = "Operation timed out.", +): Promise { + if (timeoutMs <= 0) { + return Promise.reject(new Error(errorMessage)); + } + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(errorMessage)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timer); + }); +} + +type PollDecision = { done: true; value: T } | { done: false }; + +async function pollUntil( + step: () => Promise>, + options: { + timeoutMs: number; + intervalMs: number; + timeoutError: () => Error; + }, +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < options.timeoutMs) { + const decision = await step(); + if (decision.done) { + return decision.value; + } + const elapsed = Date.now() - startTime; + const remaining = options.timeoutMs - elapsed; + if (remaining <= 0) { + break; + } + await sleep(Math.min(options.intervalMs, remaining)); + } + throw options.timeoutError(); +} + export function createDaytonaSupervisorClient( options: DaytonaSupervisorOptions, ) { @@ -219,6 +267,15 @@ export function createDaytonaSupervisorClient( }); const doFetch = options.fetchImpl ?? fetch; + /** + * In-memory supervisor state: + * - `known`: maps botId to { sandboxId, url } for fast locate cache hits. Entries are removed + * on reset, deletion, terminal states (destroyed/destroying/error/build_failed), or 404s. + * - `locating`: maps botId to active locate promises to deduplicate concurrent calls. Note: + * this deduplication is local to this single process only (no cross-instance synchronization). + * - `resetSandboxes`: maps botId to the deleted sandboxId to mask Daytona list eventual-consistency + * staleness where a deleted sandbox temporarily reappears in list() queries. Cleared on fresh creation. + */ const known = new Map(); const locating = new Map>(); const resetSandboxes = new Map(); @@ -234,33 +291,49 @@ export function createDaytonaSupervisorClient( snapshotPromise = (async () => { const agentComputerDir = options.agentComputerDir ?? DEFAULT_AGENT_COMPUTER_DIR; - const snapshotName = computeSnapshotName(agentComputerDir); + let snapshotName: string; + try { + snapshotName = computeSnapshotName(agentComputerDir); + } catch (err) { + if (err instanceof SupervisorError) throw err; + throw new SupervisorError( + `Failed to prepare agent-computer sources from "${agentComputerDir}": ${toErrorMessage(err)}. Set DAYTONA_SNAPSHOT to use a prebuilt snapshot.`, + ); + } + const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const maxWaitMs = 15 * 60 * 1000; + const snapshotTimeout = + options.snapshotTimeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS; async function pollUntilActive(name: string): Promise { - const startTime = Date.now(); - while (Date.now() - startTime < maxWaitMs) { - let snap: { state: string }; - try { - snap = await sdk.snapshot.get(name); - } catch (err) { - throw new SupervisorError( - `Failed to inspect Daytona snapshot ${name} while waiting for active state: ${toErrorMessage(err)}`, - ); - } - if (snap.state === "active") { - return name; - } - if (snap.state === "error" || snap.state === "build_failed") { - throw new SupervisorError( - `Daytona snapshot ${name} failed with state "${snap.state}". Delete it in the Daytona dashboard or set DAYTONA_SNAPSHOT to override.`, - ); - } - await sleep(pollInterval); - } - throw new SupervisorError( - `Timed out waiting for Daytona snapshot ${name} to become active.`, + return await pollUntil( + async () => { + let snap: { state: string }; + try { + snap = await sdk.snapshot.get(name); + } catch (err) { + throw new SupervisorError( + `Failed to inspect Daytona snapshot ${name} while waiting for active state: ${toErrorMessage(err)}`, + ); + } + if (snap.state === "active") { + return { done: true, value: name }; + } + if (snap.state === "error" || snap.state === "build_failed") { + throw new SupervisorError( + `Daytona snapshot ${name} failed with state "${snap.state}". Delete it in the Daytona dashboard or set DAYTONA_SNAPSHOT to override.`, + ); + } + return { done: false }; + }, + { + timeoutMs: snapshotTimeout, + intervalMs: pollInterval, + timeoutError: () => + new SupervisorError( + `Timed out waiting for Daytona snapshot ${name} to become active.`, + ), + }, ); } @@ -277,14 +350,27 @@ export function createDaytonaSupervisorClient( return await pollUntilActive(snapshotName); } catch (err) { if (isNotFoundError(err)) { - const recipe = buildRecipe(agentComputerDir); + let recipe: unknown; try { - await sdk.snapshot.create( - { name: snapshotName, image: recipe }, - { - onLogs: (line: string) => console.info(`[daytona] ${line}`), - timeout: 900, - }, + recipe = buildRecipe(agentComputerDir); + } catch (recipeErr) { + if (recipeErr instanceof SupervisorError) throw recipeErr; + throw new SupervisorError( + `Failed to build agent-computer recipe from "${agentComputerDir}": ${toErrorMessage(recipeErr)}. Set DAYTONA_SNAPSHOT to use a prebuilt snapshot.`, + ); + } + + try { + await withTimeout( + sdk.snapshot.create( + { name: snapshotName, image: recipe }, + { + onLogs: (line: string) => console.info(`[daytona] ${line}`), + timeout: Math.ceil(snapshotTimeout / 1000), + }, + ), + snapshotTimeout, + `Timed out waiting for Daytona snapshot ${snapshotName} to be created.`, ); return snapshotName; } catch (createErr) { @@ -326,13 +412,19 @@ export function createDaytonaSupervisorClient( known.delete(botId); } else { try { - return await sdk.get(knownEntry.sandboxId); + const sb = await sdk.get(knownEntry.sandboxId); + const state = sb.state?.toLowerCase(); + if (state === "destroyed" || state === "destroying") { + known.delete(botId); + return undefined; + } + return sb; } catch (err) { if (isNotFoundError(err)) { known.delete(botId); - } else { - throw wrapBotError(botId, action, err); + return undefined; } + throw wrapBotError(botId, action, err); } } } @@ -382,7 +474,7 @@ export function createDaytonaSupervisorClient( } } - if (!sandboxHandle) { + async function createFreshSandbox(): Promise { const passthroughEnv: Record = {}; if (options.environment) { for (const [key, value] of Object.entries(options.environment)) { @@ -407,7 +499,7 @@ export function createDaytonaSupervisorClient( }; try { - sandboxHandle = await sdk.create( + const handle = await sdk.create( { snapshot, envVars, @@ -418,9 +510,14 @@ export function createDaytonaSupervisorClient( { timeout: 300 }, ); resetSandboxes.delete(botId); + return handle; } catch (err) { throw wrapBotError(botId, "create", err); } + } + + if (!sandboxHandle) { + sandboxHandle = await createFreshSandbox(); } else { const state = sandboxHandle.state?.toLowerCase(); if ( @@ -433,31 +530,69 @@ export function createDaytonaSupervisorClient( } catch (err) { throw wrapBotError(botId, "start", err); } - } else if (state !== "started") { - const pollInterval = - options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const maxWaitMs = 300 * 1000; - const startTime = Date.now(); - while (sandboxHandle.state?.toLowerCase() !== "started") { - if (Date.now() - startTime >= maxWaitMs) { - throw new SupervisorError( - `Timed out waiting for computer sandbox for ${botId} to start.`, - ); - } - await sleep(pollInterval); + } + } + + if (sandboxHandle.state?.toLowerCase() !== "started") { + const pollInterval = + options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxWaitMs = 300 * 1000; + + sandboxHandle = await pollUntil( + async () => { try { - sandboxHandle = await sdk.get(sandboxHandle.id); + sandboxHandle = await sdk.get(sandboxHandle!.id); } catch (err) { + if (isNotFoundError(err)) { + known.delete(botId); + sandboxHandle = await createFreshSandbox(); + if (sandboxHandle.state?.toLowerCase() === "started") { + return { done: true, value: sandboxHandle }; + } + return { done: false }; + } throw wrapBotError(botId, "locate", err); } + const cur = sandboxHandle.state?.toLowerCase(); + if (cur === "started") { + return { done: true, value: sandboxHandle }; + } + if (cur === "stopped" || cur === "archived" || cur === "paused") { + try { + await sandboxHandle.start(300); + } catch (err) { + throw wrapBotError(botId, "start", err); + } + if (sandboxHandle.state?.toLowerCase() === "started") { + return { done: true, value: sandboxHandle }; + } + return { done: false }; + } + if (cur === "destroyed" || cur === "destroying") { + known.delete(botId); + sandboxHandle = await createFreshSandbox(); + if (sandboxHandle.state?.toLowerCase() === "started") { + return { done: true, value: sandboxHandle }; + } + return { done: false }; + } if (cur === "error" || cur === "build_failed") { throw new SupervisorError( `Daytona sandbox for ${botId} failed with state "${cur}".`, ); } - } - } + return { done: false }; + }, + { + timeoutMs: maxWaitMs, + intervalMs: pollInterval, + timeoutError: () => + new SupervisorError( + `Timed out waiting for computer sandbox for ${botId} to start.`, + ), + }, + ); } let previewUrl: string; @@ -475,24 +610,36 @@ export function createDaytonaSupervisorClient( const healthTimeout = options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS; const healthStart = Date.now(); + const healthDeadline = healthStart + healthTimeout; const healthUrl = `${previewUrl.replace(/\/$/, "")}/health`; let healthy = false; - while (Date.now() - healthStart < healthTimeout) { + while (Date.now() < healthDeadline) { + const remainingMs = Math.max(1, healthDeadline - Date.now()); try { - const res = await doFetch(healthUrl, { - headers: { - "X-Daytona-Skip-Preview-Warning": "true", - }, - }); + const res = await withTimeout( + doFetch(healthUrl, { + headers: { + "X-Daytona-Skip-Preview-Warning": "true", + }, + signal: AbortSignal.timeout(remainingMs), + }), + remainingMs, + ); if (res.ok) { healthy = true; break; } } catch { - // Health endpoint not reachable yet; retry + // Health endpoint not reachable yet or timed out; retry + } + const sleepMs = Math.min( + healthPollInterval, + Math.max(0, healthDeadline - Date.now()), + ); + if (sleepMs > 0) { + await sleep(sleepMs); } - await sleep(healthPollInterval); } if (!healthy) { @@ -518,7 +665,7 @@ export function createDaytonaSupervisorClient( return; } const state = sandboxHandle.state?.toLowerCase(); - if (state === "destroyed" || state === "destroying") { + if (state !== "started" && state !== "paused") { return; } try { @@ -564,7 +711,7 @@ export function createDaytonaSupervisorClient( result.push({ botId, container: sb.id, - status: sb.state ?? "unknown", + status: toSupervisorStatus(sb.state), startedAt: sb.createdAt, }); } diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index b2337be9..c95c12a4 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -27,6 +27,7 @@ import { } from "./policy"; import type { ClickInput, + ComputerStatus, KeyInput, ListFilesInput, ReadFileInput, @@ -247,6 +248,56 @@ export function createComputerGateway(options: ComputerGatewayOptions) { snapshot, read, + /** + * Inspect a Bot's computer status without provisioning or waking it. + * + * With a supervisor, this reads current inventory from list() and maps the container/sandbox + * state to a ComputerStatus. Without a supervisor, it delegates to client.status(botId). + */ + async status(botId: string): Promise { + if (supervisor?.list) { + const list = await supervisor.list(); + const entry = list.find((item) => item.botId === botId); + if (!entry) { + return { botId, state: "absent" }; + } + const rawStatus = entry.status ?? ""; + const status = rawStatus.toLowerCase(); + switch (status) { + case "running": + case "started": + return { botId, state: "ready" }; + case "creating": + case "starting": + case "restoring": + case "pulling_snapshot": + case "resuming": + case "stopping": + return { botId, state: "starting" }; + case "stopped": + case "paused": + case "archived": + case "exited": + case "destroyed": + return { botId, state: "absent" }; + case "error": + case "build_failed": + return { + botId, + state: "unreachable", + reason: `The computer reported state "${rawStatus}".`, + }; + default: + return { + botId, + state: "unreachable", + reason: `The computer reported unknown state "${rawStatus}".`, + }; + } + } + return as(botId).status(botId); + }, + /** * Handovers, recorded but not policy-gated. * diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 638c7913..ddcae9a7 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -39,7 +39,7 @@ export function createComputerRoutes( routes.get("/:botId/status", requireUser, async (context) => { const botId = context.req.param("botId"); - return context.json(await client.forBot(botId).status(botId)); + return context.json(await gateway.status(botId)); }); routes.get("/:botId/screenshot", requireUser, async (context) => { diff --git a/server/src/index.ts b/server/src/index.ts index d6b37132..e3165d87 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -243,15 +243,20 @@ void recordAuditEvent(bootAuditStore, { void recordAuditEvent(bootAuditStore, { eventType: "computer.isolation_loaded", targetType: "computer", - payload: supervisor + payload: daytonaSupervisor ? { isolation: "one computer per Bot", - note: "Each Bot gets its own container, its own /workspace and its own browser profile.", + note: "Each Bot gets a remote Daytona sandbox with its own /workspace and its own browser profile.", } - : { - isolation: "one shared computer", - note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL to give each Bot its own.", - }, + : supervisor + ? { + isolation: "one computer per Bot", + note: "Each Bot gets its own container, its own /workspace and its own browser profile.", + } + : { + isolation: "one shared computer", + note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", + }, }).catch(() => undefined); console.info( @@ -262,7 +267,7 @@ console.info( ? {} : { warning: - "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL for a computer each.", + "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", }), }), ); diff --git a/server/tests/computer-daytona.test.ts b/server/tests/computer-daytona.test.ts index 63e23594..069b9dc3 100644 --- a/server/tests/computer-daytona.test.ts +++ b/server/tests/computer-daytona.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createDaytonaSupervisorClient } from "../src/computer/daytona"; +import { + createDaytonaSupervisorClient, + type DaytonaSdk, + type DaytonaSupervisorOptions, + type SandboxHandle, +} from "../src/computer/daytona"; import { SupervisorError } from "../src/computer/supervisor"; /** @@ -45,7 +50,45 @@ type CreateParams = { options?: { timeout?: number }; }; -function makeSandboxHandle(sb: FakeSandbox) { +function makeSandbox(options: { + id: string; + botId?: string; + state?: string; + labels?: Record; + envVars?: Record; + public?: boolean; + autoStopInterval?: number; + createdAt?: string; + previewUrl?: string; + deleteHandler?: (timeout?: number, wait?: boolean) => void | Promise; +}): FakeSandbox { + const botId = options.botId; + const defaultLabels = botId + ? { "openbot/computer": "true", "openbot/bot-id": botId } + : { "openbot/computer": "true" }; + const labels = options.labels ?? defaultLabels; + const envVars = + options.envVars ?? + (botId ? { COMPUTER_TOKEN: "tok", COMPUTER_BOT_ID: botId } : {}); + + return { + id: options.id, + state: options.state ?? "started", + labels, + envVars, + public: options.public ?? true, + autoStopInterval: options.autoStopInterval ?? 15, + createdAt: options.createdAt ?? "2026-08-20T10:00:00Z", + previewUrl: + options.previewUrl ?? `https://${options.id}.preview.daytona.app`, + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + ...(options.deleteHandler ? { deleteHandler: options.deleteHandler } : {}), + }; +} + +function makeSandboxHandle(sb: FakeSandbox): SandboxHandle { return { id: sb.id, state: sb.state, @@ -71,7 +114,13 @@ function makeSandboxHandle(sb: FakeSandbox) { }; } -function createFakeSdk(initialSandboxes: FakeSandbox[] = []) { +type FakeSdk = DaytonaSdk & { + sandboxes: Map; + snapshots: Map; + creates: CreateParams[]; +}; + +function createFakeSdk(initialSandboxes: FakeSandbox[] = []): FakeSdk { let idCounter = 1; const sandboxes = new Map(); const snapshots = new Map(); @@ -81,20 +130,11 @@ function createFakeSdk(initialSandboxes: FakeSandbox[] = []) { sandboxes.set(sb.id, sb); } - const sdk = { + const sdk: FakeSdk = { sandboxes, snapshots, creates, - create: async ( - params: { - snapshot: string; - envVars: Record; - labels: Record; - public: boolean; - autoStopInterval: number; - }, - options?: { timeout?: number }, - ) => { + create: async (params, options) => { creates.push({ ...params, options }); const id = `sb-${idCounter++}`; const sb: FakeSandbox = { @@ -142,10 +182,7 @@ function createFakeSdk(initialSandboxes: FakeSandbox[] = []) { } return snap; }, - create: async ( - params: { name: string; image: unknown }, - _options?: { onLogs?: (line: string) => void; timeout?: number }, - ) => { + create: async (params, _options) => { snapshots.set(params.name, { state: "active" }); return { name: params.name }; }, @@ -173,6 +210,30 @@ function fakeFetch( }) as unknown as typeof fetch; } +type ClientOverrides = Partial & { + snapshotTimeoutMs?: number; +}; + +function makeClient( + sdk: FakeSdk = createFakeSdk(), + overrides: ClientOverrides = {}, +) { + const defaultSnapshot = + !overrides.agentComputerDir && overrides.snapshot === undefined + ? { snapshot: "prebuilt" } + : {}; + return createDaytonaSupervisorClient({ + apiKey: "test-api-key", + computerToken: "tok", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk, + fetchImpl: fakeFetch(), + ...defaultSnapshot, + ...overrides, + } as DaytonaSupervisorOptions); +} + describe("Daytona computer supervisor", () => { const tempDirs: string[] = []; @@ -192,26 +253,17 @@ describe("Daytona computer supervisor", () => { let healthCheckedUrl: string | undefined; let healthHeaders: HeadersInit | undefined; - const fetchImpl = (async ( - input: string | URL | Request, - init?: RequestInit, - ) => { - const url = String(input); + const fetchImpl = fakeFetch((url, init) => { if (url.endsWith("/health")) { healthCheckedUrl = url; healthHeaders = init?.headers; return new Response("ok", { status: 200 }); } return new Response("not found", { status: 404 }); - }) as unknown as typeof fetch; + }); - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", + const client = makeClient(sdk, { computerToken: "secret-token-123", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, fetchImpl, }); @@ -237,36 +289,14 @@ describe("Daytona computer supervisor", () => { }); test("stopped labeled sandbox is reused and started", async () => { - const existing: FakeSandbox = { + const existing = makeSandbox({ id: "sb-stopped-1", + botId: "support", state: "stopped", - labels: { - "openbot/computer": "true", - "openbot/bot-id": "support", - }, - envVars: { - COMPUTER_TOKEN: "tok", - COMPUTER_BOT_ID: "support", - }, - public: true, - autoStopInterval: 15, - createdAt: "2026-08-20T10:00:00Z", - previewUrl: "https://sb-stopped-1.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - }; + }); const sdk = createFakeSdk([existing]); - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); const url = await client.locate("support"); @@ -288,15 +318,7 @@ describe("Daytona computer supervisor", () => { return origCreate(params, options); }; - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); const firstLocate = client.locate("marketing"); const secondLocate = client.locate("marketing"); @@ -310,15 +332,7 @@ describe("Daytona computer supervisor", () => { test("reset deletes and the next locate creates fresh", async () => { const sdk = createFakeSdk(); - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); const firstUrl = await client.locate("finance"); expect(sdk.creates).toHaveLength(1); @@ -334,15 +348,7 @@ describe("Daytona computer supervisor", () => { test("stop with no sandbox is a no-op", async () => { const sdk = createFakeSdk(); - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); await expect(client.stop("nonexistent")).resolves.toBeUndefined(); expect(sdk.creates).toHaveLength(0); @@ -354,87 +360,41 @@ describe("Daytona computer supervisor", () => { throw new Error("quota exceeded"); }; - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); await expect(client.locate("analytics")).rejects.toThrow(SupervisorError); await expect(client.locate("analytics")).rejects.toThrow(/analytics/); }); test("list maps openbot/bot-id and skips destroyed", async () => { - const activeBot: FakeSandbox = { + const activeBot = makeSandbox({ id: "sb-active", + botId: "bot-active", state: "started", - labels: { - "openbot/computer": "true", - "openbot/bot-id": "bot-active", - }, - envVars: {}, - public: true, - autoStopInterval: 15, createdAt: "2026-08-20T10:00:00Z", - previewUrl: "https://sb-active.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - }; + }); - const destroyedBot: FakeSandbox = { + const destroyedBot = makeSandbox({ id: "sb-destroyed", + botId: "bot-destroyed", state: "destroyed", - labels: { - "openbot/computer": "true", - "openbot/bot-id": "bot-destroyed", - }, - envVars: {}, - public: true, - autoStopInterval: 15, createdAt: "2026-08-20T09:00:00Z", - previewUrl: "https://sb-destroyed.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - }; + }); - const stoppedBot: FakeSandbox = { + const stoppedBot = makeSandbox({ id: "sb-stopped", + botId: "bot-stopped", state: "stopped", - labels: { - "openbot/computer": "true", - "openbot/bot-id": "bot-stopped", - }, - envVars: {}, - public: true, - autoStopInterval: 15, createdAt: "2026-08-20T11:00:00Z", - previewUrl: "https://sb-stopped.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - }; + }); - const unrelatedSandbox: FakeSandbox = { + const unrelatedSandbox = makeSandbox({ id: "sb-unrelated", - state: "started", labels: { "some-other-label": "true", }, - envVars: {}, - public: true, - autoStopInterval: 15, createdAt: "2026-08-20T08:00:00Z", - previewUrl: "https://sb-unrelated.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - }; + }); const sdk = createFakeSdk([ activeBot, @@ -442,15 +402,7 @@ describe("Daytona computer supervisor", () => { stoppedBot, unrelatedSandbox, ]); - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); const list = await client.list(); @@ -460,13 +412,13 @@ describe("Daytona computer supervisor", () => { { botId: "bot-active", container: "sb-active", - status: "started", + status: "running", startedAt: "2026-08-20T10:00:00Z", }, { botId: "bot-stopped", container: "sb-stopped", - status: "stopped", + status: "exited", startedAt: "2026-08-20T11:00:00Z", }, ]), @@ -475,17 +427,12 @@ describe("Daytona computer supervisor", () => { test("health timeout throws SupervisorError", async () => { const sdk = createFakeSdk(); - const failingFetch = (async () => { - return new Response("service unavailable", { status: 503 }); - }) as unknown as typeof fetch; - - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, + const failingFetch = fakeFetch( + () => new Response("service unavailable", { status: 503 }), + ); + + const client = makeClient(sdk, { healthTimeoutMs: 50, - sdk: sdk as never, fetchImpl: failingFetch, }); @@ -497,6 +444,121 @@ describe("Daytona computer supervisor", () => { ); }); + test("health fetch that never resolves is bounded by healthTimeoutMs and locate rejects with SupervisorError", async () => { + const sdk = createFakeSdk(); + const hangingFetch = fakeFetch(() => { + const { promise } = Promise.withResolvers(); + return promise; + }); + + const client = makeClient(sdk, { + healthTimeoutMs: 30, + fetchImpl: hangingFetch, + }); + + let locateError: unknown; + try { + await client.locate("hanging-health-bot"); + } catch (err) { + locateError = err; + } + + expect(locateError).toBeInstanceOf(SupervisorError); + expect((locateError as Error)?.message).toContain( + "The computer for hanging-health-bot started but never answered /health at its preview URL.", + ); + }); + + test("snapshot.create that never resolves is bounded by snapshotTimeoutMs and locate rejects with SupervisorError", async () => { + const fixtureDir = mkdtempSync( + join(tmpdir(), "openbot-agent-computer-test-"), + ); + tempDirs.push(fixtureDir); + + writeFileSync( + join(fixtureDir, "package.json"), + JSON.stringify({ name: "agent-computer", version: "1.0.0" }), + ); + mkdirSync(join(fixtureDir, "src"), { recursive: true }); + writeFileSync( + join(fixtureDir, "src", "index.ts"), + 'console.log("agent-computer");\n', + ); + + const sdk = createFakeSdk(); + sdk.snapshot.create = () => { + const { promise } = Promise.withResolvers(); + return promise; + }; + + const client = makeClient(sdk, { + agentComputerDir: fixtureDir, + snapshotTimeoutMs: 30, + }); + + let locateError: unknown; + try { + await client.locate("hanging-snapshot-bot"); + } catch (err) { + locateError = err; + } + + expect(locateError).toBeInstanceOf(SupervisorError); + }); + + test("missing agentComputerDir package.json or src directory rejects locate with SupervisorError naming the directory and advising DAYTONA_SNAPSHOT", async () => { + const missingPkgDir = mkdtempSync( + join(tmpdir(), "openbot-missing-pkg-test-"), + ); + tempDirs.push(missingPkgDir); + mkdirSync(join(missingPkgDir, "src"), { recursive: true }); + writeFileSync( + join(missingPkgDir, "src", "index.ts"), + 'console.log("agent-computer");\n', + ); + + const clientMissingPkg = makeClient(createFakeSdk(), { + agentComputerDir: missingPkgDir, + healthTimeoutMs: 50, + }); + + let pkgError: unknown; + try { + await clientMissingPkg.locate("missing-pkg-bot"); + } catch (err) { + pkgError = err; + } + + expect(pkgError).toBeInstanceOf(SupervisorError); + expect((pkgError as Error)?.message).toContain(missingPkgDir); + expect((pkgError as Error)?.message).toContain("DAYTONA_SNAPSHOT"); + + const missingSrcDir = mkdtempSync( + join(tmpdir(), "openbot-missing-src-test-"), + ); + tempDirs.push(missingSrcDir); + writeFileSync( + join(missingSrcDir, "package.json"), + JSON.stringify({ name: "agent-computer", version: "1.0.0" }), + ); + + const clientMissingSrc = makeClient(createFakeSdk(), { + agentComputerDir: missingSrcDir, + healthTimeoutMs: 50, + }); + + let srcError: unknown; + try { + await clientMissingSrc.locate("missing-src-bot"); + } catch (err) { + srcError = err; + } + + expect(srcError).toBeInstanceOf(SupervisorError); + expect((srcError as Error)?.message).toContain(missingSrcDir); + expect((srcError as Error)?.message).toContain("DAYTONA_SNAPSHOT"); + }); + test("snapshot naming is stable for unchanged temp sources and changes when source contents change", async () => { const fixtureDir = mkdtempSync( join(tmpdir(), "openbot-agent-computer-test-"), @@ -514,15 +576,7 @@ describe("Daytona computer supervisor", () => { ); const sdk1 = createFakeSdk(); - const client1 = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - agentComputerDir: fixtureDir, - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk1 as never, - fetchImpl: fakeFetch(), - }); + const client1 = makeClient(sdk1, { agentComputerDir: fixtureDir }); await client1.locate("bot-1"); expect(sdk1.creates).toHaveLength(1); @@ -530,15 +584,7 @@ describe("Daytona computer supervisor", () => { expect(snapshotName1).toMatch(/^openbot-agent-computer-[a-f0-9]{12}$/); const sdk2 = createFakeSdk(); - const client2 = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - agentComputerDir: fixtureDir, - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk2 as never, - fetchImpl: fakeFetch(), - }); + const client2 = makeClient(sdk2, { agentComputerDir: fixtureDir }); await client2.locate("bot-2"); expect(sdk2.creates).toHaveLength(1); @@ -551,15 +597,7 @@ describe("Daytona computer supervisor", () => { ); const sdk3 = createFakeSdk(); - const client3 = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - agentComputerDir: fixtureDir, - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk3 as never, - fetchImpl: fakeFetch(), - }); + const client3 = makeClient(sdk3, { agentComputerDir: fixtureDir }); await client3.locate("bot-3"); expect(sdk3.creates).toHaveLength(1); @@ -592,15 +630,7 @@ describe("Daytona computer supervisor", () => { return originalSnapshotCreate(params, options); }; - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - agentComputerDir: fixtureDir, - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk, { agentComputerDir: fixtureDir }); await client.locate("recipe-test-bot"); @@ -620,38 +650,18 @@ describe("Daytona computer supervisor", () => { }); test("reset waits for sandbox deletion so list does not return the reset bot", async () => { - const existing: FakeSandbox = { + const existing = makeSandbox({ id: "sb-reset-wait-1", - state: "started", - labels: { - "openbot/computer": "true", - "openbot/bot-id": "reset-wait-bot", - }, - envVars: {}, - public: true, - autoStopInterval: 15, - createdAt: "2026-08-20T10:00:00Z", - previewUrl: "https://sb-reset-wait-1.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, + botId: "reset-wait-bot", deleteHandler: (_timeout, wait) => { if (wait) { existing.state = "destroyed"; } }, - }; + }); const sdk = createFakeSdk([existing]); - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); await client.reset("reset-wait-bot"); @@ -662,22 +672,10 @@ describe("Daytona computer supervisor", () => { }); test("reset waits for Daytona list convergence across eventual consistency stale started responses", async () => { - const existing: FakeSandbox = { + const existing = makeSandbox({ id: "sb-reset-convergence-1", - state: "started", - labels: { - "openbot/computer": "true", - "openbot/bot-id": "reset-convergence-bot", - }, - envVars: {}, - public: true, - autoStopInterval: 15, - createdAt: "2026-08-20T10:00:00Z", - previewUrl: "https://sb-reset-convergence-1.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - }; + botId: "reset-convergence-bot", + }); const sdk = createFakeSdk([existing]); let postDeleteListCalls = 0; @@ -707,15 +705,7 @@ describe("Daytona computer supervisor", () => { return origList(query); }; - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); await client.reset("reset-convergence-bot"); @@ -732,21 +722,9 @@ describe("Daytona computer supervisor", () => { test("failed reset deletion does not tombstone sandbox so retry deletes it and removes it from list", async () => { let deleteAttempts = 0; - const existing: FakeSandbox = { + const existing = makeSandbox({ id: "sb-failed-delete-1", - state: "started", - labels: { - "openbot/computer": "true", - "openbot/bot-id": "retry-delete-bot", - }, - envVars: {}, - public: true, - autoStopInterval: 15, - createdAt: "2026-08-20T10:00:00Z", - previewUrl: "https://sb-failed-delete-1.preview.daytona.app", - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, + botId: "retry-delete-bot", deleteHandler: (_timeout, _wait) => { deleteAttempts++; if (deleteAttempts === 1) { @@ -754,18 +732,10 @@ describe("Daytona computer supervisor", () => { } existing.state = "destroyed"; }, - }; + }); const sdk = createFakeSdk([existing]); - const client = createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - snapshot: "prebuilt", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk: sdk as never, - fetchImpl: fakeFetch(), - }); + const client = makeClient(sdk); let resetError: unknown; try { @@ -786,4 +756,100 @@ describe("Daytona computer supervisor", () => { remaining.find((bot) => bot.botId === "retry-delete-bot"), ).toBeUndefined(); }); + + test("locate polls stopping sandbox until stopped, calls start(300), and returns preview URL after healthy /health", async () => { + const stoppingSandbox = makeSandbox({ + id: "sb-stopping-1", + botId: "stopping-bot", + state: "stopping", + }); + + const sdk = createFakeSdk([stoppingSandbox]); + let getCalls = 0; + const origGet = sdk.get; + sdk.get = async (id: string) => { + if (id === stoppingSandbox.id && ++getCalls === 1) { + stoppingSandbox.state = "stopped"; + } + return origGet(id); + }; + + const client = makeClient(sdk); + + const url = await client.locate("stopping-bot"); + + expect(url).toBe("https://sb-stopping-1.preview.daytona.app"); + expect(stoppingSandbox.startCalls).toBe(1); + expect(stoppingSandbox.state).toBe("started"); + }); + + test("locate creates a fresh sandbox when a cached sandbox is destroying or not found", async () => { + const sdk = createFakeSdk(); + const client = makeClient(sdk); + + const firstUrl = await client.locate("destroying-bot"); + expect(sdk.creates).toHaveLength(1); + expect(firstUrl).toBe("https://sb-1.preview.daytona.app"); + + const firstSandbox = sdk.sandboxes.get("sb-1")!; + firstSandbox.state = "destroying"; + + const secondUrl = await client.locate("destroying-bot"); + expect(sdk.creates).toHaveLength(2); + expect(secondUrl).toBe("https://sb-2.preview.daytona.app"); + expect(secondUrl).not.toBe(firstUrl); + }); + + test("stop on an already stopped sandbox resolves without calling sandbox.stop", async () => { + const stoppedSandbox = makeSandbox({ + id: "sb-already-stopped", + botId: "already-stopped-bot", + state: "stopped", + }); + + const sdk = createFakeSdk([stoppedSandbox]); + const client = makeClient(sdk); + + await client.stop("already-stopped-bot"); + + expect(stoppedSandbox.stopCalls).toBe(0); + }); + + test("list maps Daytona started to running and stopped to exited in shared supervisor vocabulary", async () => { + const startedSandbox = makeSandbox({ + id: "sb-started-voc", + botId: "started-voc-bot", + state: "started", + createdAt: "2026-08-20T10:00:00Z", + }); + + const stoppedSandbox = makeSandbox({ + id: "sb-stopped-voc", + botId: "stopped-voc-bot", + state: "stopped", + createdAt: "2026-08-20T11:00:00Z", + }); + + const sdk = createFakeSdk([startedSandbox, stoppedSandbox]); + const client = makeClient(sdk); + + const list = await client.list(); + + expect(list).toEqual( + expect.arrayContaining([ + { + botId: "started-voc-bot", + container: "sb-started-voc", + status: "running", + startedAt: "2026-08-20T10:00:00Z", + }, + { + botId: "stopped-voc-bot", + container: "sb-stopped-voc", + status: "exited", + startedAt: "2026-08-20T11:00:00Z", + }, + ]), + ); + }); }); diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index eff68c07..6f6e3274 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -6,7 +6,7 @@ import { createComputerGateway, } from "../src/computer/gateway"; import type { ActionPolicy } from "../src/computer/policy"; -import type { SnapshotResult } from "../src/computer/schema"; +import type { ComputerStatus, SnapshotResult } from "../src/computer/schema"; /** * What the gateway must guarantee, tested as properties rather than as call sequences. @@ -76,7 +76,10 @@ function fakeClient() { calls.push("writeFile"); return { path: "notes.md", bytes: 4, appended: false } as never; }, - status: async () => ({ botId: "b", state: "ready" as const }), + status: async (botId?: string) => { + calls.push("status"); + return { botId: botId ?? "b", state: "ready" as const }; + }, navigate: async () => { calls.push("navigate"); return { url: "https://example.com/", title: "Example" } as never; @@ -396,4 +399,172 @@ describe("the computer gateway", () => { // could not identify what was touched, rather than omitting the field. expect(rows[0]?.payload.element).toBe("not in the current snapshot"); }); + + describe("status", () => { + test("with supervisor.list returning no Bot it returns absent without locating or checking health", async () => { + // Checking status is an inspection, not an action: it must not create or wake a container. + // An absent Bot is simply absent, determined from the supervisor's current inventory. + const { client, calls } = fakeClient(); + const { store } = fakeAudit(); + const locateCalls: string[] = []; + const supervisor = { + locate: async (botId: string) => { + locateCalls.push(botId); + return "http://localhost:4100"; + }, + list: async () => [{ botId: "other-bot", status: "running" }], + stop: async () => {}, + reset: async () => {}, + }; + const gateway = createComputerGateway({ + client, + supervisor, + auditStore: store, + policy: () => PERMISSIVE, + }) as unknown as { status(botId: string): Promise }; + + const status = await gateway.status("sales-bot"); + + expect(status).toEqual({ botId: "sales-bot", state: "absent" }); + expect(calls).toEqual([]); + expect(locateCalls).toEqual([]); + }); + + test("maps started and running supervisor statuses to ready", async () => { + const { client, calls } = fakeClient(); + const { store } = fakeAudit(); + const locateCalls: string[] = []; + const supervisor = { + locate: async (botId: string) => { + locateCalls.push(botId); + return "http://localhost:4100"; + }, + list: async () => [ + { botId: "started-bot", status: "started" }, + { botId: "running-bot", status: "running" }, + ], + stop: async () => {}, + reset: async () => {}, + }; + const gateway = createComputerGateway({ + client, + supervisor, + auditStore: store, + policy: () => PERMISSIVE, + }) as unknown as { status(botId: string): Promise }; + + expect(await gateway.status("started-bot")).toEqual({ + botId: "started-bot", + state: "ready", + }); + expect(await gateway.status("running-bot")).toEqual({ + botId: "running-bot", + state: "ready", + }); + expect(calls).toEqual([]); + expect(locateCalls).toEqual([]); + }); + + test("maps creating and starting supervisor statuses to starting", async () => { + const { client, calls } = fakeClient(); + const { store } = fakeAudit(); + const supervisor = { + list: async () => [ + { botId: "creating-bot", status: "creating" }, + { botId: "starting-bot", status: "starting" }, + ], + stop: async () => {}, + reset: async () => {}, + }; + const gateway = createComputerGateway({ + client, + supervisor, + auditStore: store, + policy: () => PERMISSIVE, + }) as unknown as { status(botId: string): Promise }; + + expect(await gateway.status("creating-bot")).toEqual({ + botId: "creating-bot", + state: "starting", + }); + expect(await gateway.status("starting-bot")).toEqual({ + botId: "starting-bot", + state: "starting", + }); + expect(calls).toEqual([]); + }); + + test("maps stopped supervisor status to absent", async () => { + const { client, calls } = fakeClient(); + const { store } = fakeAudit(); + const supervisor = { + list: async () => [{ botId: "stopped-bot", status: "stopped" }], + stop: async () => {}, + reset: async () => {}, + }; + const gateway = createComputerGateway({ + client, + supervisor, + auditStore: store, + policy: () => PERMISSIVE, + }) as unknown as { status(botId: string): Promise }; + + expect(await gateway.status("stopped-bot")).toEqual({ + botId: "stopped-bot", + state: "absent", + }); + expect(calls).toEqual([]); + }); + + test("maps error and build_failed supervisor statuses to unreachable with a useful reason", async () => { + const { client, calls } = fakeClient(); + const { store } = fakeAudit(); + const supervisor = { + list: async () => [ + { botId: "error-bot", status: "error" }, + { botId: "failed-bot", status: "build_failed" }, + ], + stop: async () => {}, + reset: async () => {}, + }; + const gateway = createComputerGateway({ + client, + supervisor, + auditStore: store, + policy: () => PERMISSIVE, + }) as unknown as { status(botId: string): Promise }; + + const errorStatus = await gateway.status("error-bot"); + expect(errorStatus.botId).toBe("error-bot"); + expect(errorStatus.state).toBe("unreachable"); + expect(errorStatus.reason).toBeDefined(); + expect(typeof errorStatus.reason).toBe("string"); + expect(errorStatus.reason!.length).toBeGreaterThan(0); + + const failedStatus = await gateway.status("failed-bot"); + expect(failedStatus.botId).toBe("failed-bot"); + expect(failedStatus.state).toBe("unreachable"); + expect(failedStatus.reason).toBeDefined(); + expect(typeof failedStatus.reason).toBe("string"); + expect(failedStatus.reason!.length).toBeGreaterThan(0); + + expect(calls).toEqual([]); + }); + + test("without a supervisor, delegates once to client.status(botId)", async () => { + const { client, calls, addressedAs } = fakeClient(); + const { store } = fakeAudit(); + const gateway = createComputerGateway({ + client, + auditStore: store, + policy: () => PERMISSIVE, + }) as unknown as { status(botId: string): Promise }; + + const status = await gateway.status("sales-bot"); + + expect(status).toEqual({ botId: "sales-bot", state: "ready" }); + expect(calls).toEqual(["status"]); + expect(addressedAs).toContain("sales-bot"); + }); + }); }); From 6fa57d82b14c6e1249b3a686c21fa77fac682038 Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 10:19:54 -0700 Subject: [PATCH 05/16] Keep computer status reads renderable during supervisor failures Signed-off-by: Muhammad Hashmi --- server/src/computer/gateway.ts | 88 ++++++++++++++++----------- server/src/index.ts | 5 ++ server/tests/computer-daytona.test.ts | 11 ++++ server/tests/computer-gateway.test.ts | 64 ++++++++++++++++++- 4 files changed, 129 insertions(+), 39 deletions(-) diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index c95c12a4..248d8c51 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -256,43 +256,59 @@ export function createComputerGateway(options: ComputerGatewayOptions) { */ async status(botId: string): Promise { if (supervisor?.list) { - const list = await supervisor.list(); - const entry = list.find((item) => item.botId === botId); - if (!entry) { - return { botId, state: "absent" }; - } - const rawStatus = entry.status ?? ""; - const status = rawStatus.toLowerCase(); - switch (status) { - case "running": - case "started": - return { botId, state: "ready" }; - case "creating": - case "starting": - case "restoring": - case "pulling_snapshot": - case "resuming": - case "stopping": - return { botId, state: "starting" }; - case "stopped": - case "paused": - case "archived": - case "exited": - case "destroyed": + try { + const list = await supervisor.list(); + const entry = list.find((item) => item.botId === botId); + if (!entry) { return { botId, state: "absent" }; - case "error": - case "build_failed": - return { - botId, - state: "unreachable", - reason: `The computer reported state "${rawStatus}".`, - }; - default: - return { - botId, - state: "unreachable", - reason: `The computer reported unknown state "${rawStatus}".`, - }; + } + const rawStatus = entry.status ?? ""; + const status = rawStatus.toLowerCase(); + switch (status) { + case "running": + case "started": + return { botId, state: "ready" }; + case "created": + case "restarting": + case "creating": + case "starting": + case "restoring": + case "pulling_snapshot": + case "resuming": + return { botId, state: "starting" }; + case "stopped": + case "paused": + case "archived": + case "exited": + case "destroyed": + case "removing": + case "archiving": + case "pausing": + case "stopping": + return { botId, state: "absent" }; + case "error": + case "build_failed": + return { + botId, + state: "unreachable", + reason: `The computer reported state "${rawStatus}".`, + }; + default: + return { + botId, + state: "unreachable", + reason: `The computer reported unknown state "${rawStatus}".`, + }; + } + } catch (error) { + return { + botId, + state: "unreachable", + reason: + error instanceof Error && error.message.length > 0 + ? error.message + : "Unknown failure.", + }; } } return as(botId).status(botId); diff --git a/server/src/index.ts b/server/src/index.ts index e3165d87..4472dbbe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -262,6 +262,11 @@ void recordAuditEvent(bootAuditStore, { console.info( JSON.stringify({ type: "computer-isolation", + provider: daytonaSupervisor + ? "Daytona" + : supervisor + ? "Docker supervisor" + : "shared", isolation: supervisor ? "one computer per Bot" : "one shared computer", ...(supervisor ? {} diff --git a/server/tests/computer-daytona.test.ts b/server/tests/computer-daytona.test.ts index 069b9dc3..d5b64449 100644 --- a/server/tests/computer-daytona.test.ts +++ b/server/tests/computer-daytona.test.ts @@ -26,6 +26,11 @@ class DaytonaNotFoundError extends Error { } } +type DeleteCall = { + timeout?: number; + wait?: boolean; +}; + type FakeSandbox = { id: string; state: string; @@ -38,6 +43,7 @@ type FakeSandbox = { startCalls: number; stopCalls: number; deleteCalls: number; + deleteArgs: DeleteCall[]; deleteHandler?: (timeout?: number, wait?: boolean) => void | Promise; }; @@ -84,6 +90,7 @@ function makeSandbox(options: { startCalls: 0, stopCalls: 0, deleteCalls: 0, + deleteArgs: [], ...(options.deleteHandler ? { deleteHandler: options.deleteHandler } : {}), }; } @@ -104,6 +111,7 @@ function makeSandboxHandle(sb: FakeSandbox): SandboxHandle { }, delete: async (timeout?: number, wait?: boolean) => { sb.deleteCalls++; + sb.deleteArgs.push({ timeout, wait }); if (sb.deleteHandler) { await sb.deleteHandler(timeout, wait); } else { @@ -149,6 +157,7 @@ function createFakeSdk(initialSandboxes: FakeSandbox[] = []): FakeSdk { startCalls: 0, stopCalls: 0, deleteCalls: 0, + deleteArgs: [], }; sandboxes.set(id, sb); return makeSandboxHandle(sb); @@ -665,6 +674,8 @@ describe("Daytona computer supervisor", () => { await client.reset("reset-wait-bot"); + expect(existing.deleteCalls).toBe(1); + expect(existing.deleteArgs).toEqual([{ timeout: 60, wait: true }]); const remaining = await client.list(); expect( remaining.find((bot) => bot.botId === "reset-wait-bot"), diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index 6f6e3274..ab14d502 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -465,13 +465,15 @@ describe("the computer gateway", () => { expect(locateCalls).toEqual([]); }); - test("maps creating and starting supervisor statuses to starting", async () => { + test("maps creating, starting, created, and restarting supervisor statuses to starting", async () => { const { client, calls } = fakeClient(); const { store } = fakeAudit(); const supervisor = { list: async () => [ { botId: "creating-bot", status: "creating" }, { botId: "starting-bot", status: "starting" }, + { botId: "created-bot", status: "created" }, + { botId: "restarting-bot", status: "restarting" }, ], stop: async () => {}, reset: async () => {}, @@ -491,14 +493,28 @@ describe("the computer gateway", () => { botId: "starting-bot", state: "starting", }); + expect(await gateway.status("created-bot")).toEqual({ + botId: "created-bot", + state: "starting", + }); + expect(await gateway.status("restarting-bot")).toEqual({ + botId: "restarting-bot", + state: "starting", + }); expect(calls).toEqual([]); }); - test("maps stopped supervisor status to absent", async () => { + test("maps stopped, removing, archiving, pausing, and stopping supervisor statuses to absent", async () => { const { client, calls } = fakeClient(); const { store } = fakeAudit(); const supervisor = { - list: async () => [{ botId: "stopped-bot", status: "stopped" }], + list: async () => [ + { botId: "stopped-bot", status: "stopped" }, + { botId: "removing-bot", status: "removing" }, + { botId: "archiving-bot", status: "archiving" }, + { botId: "pausing-bot", status: "pausing" }, + { botId: "stopping-bot", status: "stopping" }, + ], stop: async () => {}, reset: async () => {}, }; @@ -513,6 +529,48 @@ describe("the computer gateway", () => { botId: "stopped-bot", state: "absent", }); + expect(await gateway.status("removing-bot")).toEqual({ + botId: "removing-bot", + state: "absent", + }); + expect(await gateway.status("archiving-bot")).toEqual({ + botId: "archiving-bot", + state: "absent", + }); + expect(await gateway.status("pausing-bot")).toEqual({ + botId: "pausing-bot", + state: "absent", + }); + expect(await gateway.status("stopping-bot")).toEqual({ + botId: "stopping-bot", + state: "absent", + }); + expect(calls).toEqual([]); + }); + + test("when supervisor.list throws, returns unreachable with the error message in reason", async () => { + const { client, calls } = fakeClient(); + const { store } = fakeAudit(); + const supervisor = { + list: async () => { + throw new Error("supervisor unavailable"); + }, + stop: async () => {}, + reset: async () => {}, + }; + const gateway = createComputerGateway({ + client, + supervisor, + auditStore: store, + policy: () => PERMISSIVE, + }) as unknown as { status(botId: string): Promise }; + + const status = await gateway.status("sales-bot"); + + expect(status.botId).toBe("sales-bot"); + expect(status.state).toBe("unreachable"); + expect(status.reason).toBeDefined(); + expect(status.reason).toContain("supervisor unavailable"); expect(calls).toEqual([]); }); From 0d24f4da491dc63fbefd540fea5cba1607eeb6e0 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 10:44:46 -0700 Subject: [PATCH 06/16] Check the address a supervisor hands back before calling it `resolveBaseUrl` returns a URL and the next line puts it into `fetch` with this deployment's computer token on it. That was fine while the only supervisor was ours, answering on loopback. It stops being fine with a hosted provider, where the address arrives from a third party's API and we call whatever it says. Deliberately not the navigation check. That one refuses private hosts, which is correct for a Bot browsing and exactly wrong here: our own supervisor answers `http://127.0.0.1:` for a container on this machine, so reusing it would refuse the ordinary case. What survives is what holds however the address was produced. The scheme has to be one a computer speaks, and the cloud metadata addresses are refused whatever anything says, because that is how a container's credentials leave it and no supervisor has a reason to name one. Groundwork for A10, where the supervisor becomes somebody else's API. --- server/src/computer/client.ts | 24 ++++++++++--- server/src/computer/target.ts | 44 +++++++++++++++++++++++ server/tests/computer-target.test.ts | 53 +++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index f019ef81..26bd83e8 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -22,7 +22,7 @@ import type { WriteFileInput, WriteFileResult, } from "./schema"; -import { checkNavigationTarget } from "./target"; +import { checkComputerAddress, checkNavigationTarget } from "./target"; /** * How the server talks to a Bot's computer. @@ -164,10 +164,24 @@ export function createComputerClient(options: ComputerClientOptions) { // Outside the try below on purpose: that catch reports "the computer is not running", which is // true of a computer that will not answer and misleading about a supervisor that could not be // reached or refused. Those are different operator-facing problems. - const target = - botId && options.resolveBaseUrl - ? (await options.resolveBaseUrl(botId)).replace(/\/$/, "") - : base; + let target: string; + if (botId && options.resolveBaseUrl) { + const located = (await options.resolveBaseUrl(botId)).replace( + /\/$/, + "", + ); + // Checked because this address is not necessarily ours. A hosted provider answers from its + // own API, and whatever comes back is about to be called with this deployment's computer + // token. Our own supervisor answers with a private address, which is fine and why this is + // not the navigation check. + const verdict = checkComputerAddress(located); + if (!verdict.allowed) { + throw new ComputerUnavailableError(verdict.reason); + } + target = located; + } else { + target = base; + } // Already stopped before this left: do not dispatch at all. Relying on fetch to reject an // aborted signal makes "did the click happen" depend on how quickly the runtime notices, and diff --git a/server/src/computer/target.ts b/server/src/computer/target.ts index 99d7719f..937879e3 100644 --- a/server/src/computer/target.ts +++ b/server/src/computer/target.ts @@ -56,6 +56,50 @@ function isPrivateIpv4(hostname: string): boolean { return false; } +/** + * Decide whether an address a supervisor handed back may be called at all. + * + * Deliberately not {@link checkNavigationTarget}. That one judges where a Bot may browse, and its + * private-host rule is exactly wrong here: our own supervisor answers with `http://127.0.0.1:` + * for a container on this machine, so applying it would refuse the normal case. + * + * What survives is what holds however the address was produced. The scheme must be one we speak, and + * the cloud metadata addresses are refused whatever anything says, because that is how a container's + * credentials leave it and no supervisor has a reason to name one. + * + * This matters because the address stops being ours. With a hosted provider (A10) it arrives from a + * third party's API and goes straight into `fetch` carrying this deployment's computer token, so it + * is worth one check that it is an address rather than a surprise. + */ +export function checkComputerAddress(raw: string): TargetVerdict { + let url: URL; + try { + url = new URL(raw); + } catch { + return { + allowed: false, + reason: `The computer's address is not a URL: ${raw}`, + }; + } + + if (!ALLOWED_PROTOCOLS.has(url.protocol)) { + return { + allowed: false, + reason: `A computer must be reached over http or https, not ${url.protocol.replace(":", "")}.`, + }; + } + + if (NEVER_ALLOWED_HOSTNAMES.has(url.hostname.toLowerCase())) { + return { + allowed: false, + reason: + "That address holds this deployment's own cloud credentials, so it is never called as a computer.", + }; + } + + return { allowed: true, url: url.toString() }; +} + /** * Decide whether a Bot may navigate here. * diff --git a/server/tests/computer-target.test.ts b/server/tests/computer-target.test.ts index 36456b31..c3acadf6 100644 --- a/server/tests/computer-target.test.ts +++ b/server/tests/computer-target.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { checkNavigationTarget } from "../src/computer/target"; +import { + checkComputerAddress, + checkNavigationTarget, +} from "../src/computer/target"; describe("navigation targets", () => { test("allows an ordinary public address", () => { @@ -77,3 +80,51 @@ describe("navigation targets", () => { expect(checkNavigationTarget("http://172.31.255.255/").allowed).toBe(false); }); }); + +/** + * The address a supervisor hands back, before anything is called on it. + * + * Not the navigation check. Our own supervisor answers with a loopback address for a container on + * this machine, so refusing private hosts here would refuse the ordinary case. What is worth + * checking is that the thing is an address at all, and that it is not the one place a token must + * never be sent, because with a hosted provider it arrives from somebody else's API. + */ +describe("checkComputerAddress", () => { + test("allows the private address our own supervisor returns", () => { + expect(checkComputerAddress("http://127.0.0.1:49213")).toEqual({ + allowed: true, + url: "http://127.0.0.1:49213/", + }); + }); + + test("allows a hosted provider's public address", () => { + const verdict = checkComputerAddress("https://sandbox-abc123.daytona.app"); + expect(verdict.allowed).toBe(true); + }); + + test.each(["169.254.169.254", "metadata.google.internal", "metadata.goog"])( + "refuses the cloud metadata address %s however it arrived", + (host) => { + const verdict = checkComputerAddress(`http://${host}/latest/meta-data/`); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toContain("cloud credentials"); + } + }, + ); + + test.each(["file:///etc/passwd", "ftp://example.com", "gopher://x"])( + "refuses %s, which is not a scheme a computer speaks", + (raw) => { + expect(checkComputerAddress(raw).allowed).toBe(false); + }, + ); + + test("refuses something that is not a URL", () => { + const verdict = checkComputerAddress("not-an-address"); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toContain("not a URL"); + } + }); +}); From 3e02bfb9562bd7f68afaf63a743b8e6229161cee Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 10:10:35 -0700 Subject: [PATCH 07/16] Tell OpenBot traffic apart in the analytics that already go The CopilotKit runtime reports a handful of events about itself and every deployment built on it looks the same in them, so there is no way to tell which of that traffic came through OpenBot. One field on those events answers it by filtering. No new events and no new pipeline. Carried on `global_properties`, which the sink treats as the pass-through bag for `oss.runtime.*` and spreads into the analytics event. `OPENBOT_ACCESSIBILITY_DISABLED=true` switches it off. The SDK's own `COPILOTKIT_TELEMETRY_DISABLED` and `DO_NOT_TRACK` still stop the events entirely, this field with them. Anything other than `true` or `1` leaves it on. A deployment that typed something else has not opted out, and reading a typo as consent to stop would be a setting that appears to work and does not. --- app/package.json | 2 +- bun.lock | 45 +++++++++++++++++-------------------- package.json | 2 +- server/package.json | 5 ++--- server/src/config.ts | 8 +++++++ server/src/copilot.ts | 5 +++++ server/tests/config.test.ts | 33 +++++++++++++++++++++++++++ 7 files changed, 71 insertions(+), 29 deletions(-) diff --git a/app/package.json b/app/package.json index 60e4f996..8fd0e461 100644 --- a/app/package.json +++ b/app/package.json @@ -15,7 +15,7 @@ "dependencies": { "@ag-ui/core": "0.0.57", "@base-ui/react": "^1.6.0", - "@copilotkit/react-core": "1.67.1", + "@copilotkit/react-core": "1.68.3", "@fontsource-variable/inter": "^5.3.0", "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", diff --git a/bun.lock b/bun.lock index b98eb296..cc2cb295 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "openbot", "devDependencies": { "@biomejs/biome": "^2.3.8", - "@copilotkit/aimock": "^1.38.0", + "@copilotkit/aimock": "1.39.0", "@types/bun": "^1.3.3", "roughjs": "^4.6.6", "typescript": "^5.9.3", @@ -19,7 +19,7 @@ "dependencies": { "@ag-ui/core": "0.0.57", "@base-ui/react": "^1.6.0", - "@copilotkit/react-core": "1.67.1", + "@copilotkit/react-core": "1.68.3", "@fontsource-variable/inter": "^5.3.0", "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", @@ -61,8 +61,7 @@ "dependencies": { "@ag-ui/client": "0.0.57", "@better-auth/drizzle-adapter": "^1.6.27", - "@copilotkit/runtime": "1.67.1", - "@copilotkit/shared": "1.67.1", + "@copilotkit/runtime": "1.68.3", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.6.27", "cel-js": "^0.8.2", @@ -73,7 +72,7 @@ "zod": "^4.4.3", }, "devDependencies": { - "@copilotkit/aimock": "^1.38.0", + "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7", }, @@ -254,35 +253,35 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], - "@copilotkit/a2ui-renderer": ["@copilotkit/a2ui-renderer@1.67.1", "", { "dependencies": { "@a2ui/web_core": "0.10.4", "clsx": "^2.1.1", "lit": "^3.3.2", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["react", "react-dom"] }, "sha512-/fELhn0gvC+LCurKwdxrF1BRiPbpYUU5UmZjwy1bu1q8brQbXs8UjCcz7oCiCRfhXirzAwEH2LwdPS9ElHJPNw=="], + "@copilotkit/a2ui-renderer": ["@copilotkit/a2ui-renderer@1.68.3", "", { "dependencies": { "@a2ui/web_core": "0.10.4", "clsx": "^2.1.1", "lit": "^3.3.2", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["react", "react-dom"] }, "sha512-rnD94CIJtLAGDmrJCjwJAFtAUwgNXRt+Ril+rf4hUdUxcYgCNvV43RYOenahbYAF+M9GBrBe+9zWlkLfjtUIig=="], - "@copilotkit/aimock": ["@copilotkit/aimock@1.38.0", "", { "peerDependencies": { "jest": ">=29", "vitest": ">=3" }, "optionalPeers": ["jest", "vitest"], "bin": { "aimock": "dist/aimock-cli.js", "llmock": "dist/cli.js" } }, "sha512-zrGXeSu0/LkHQgrM9Z68uU/k7qpYbnZ25i9djpNlYyDCDCUgKaQPbG1iIYOc+4vuigJmD91ujcK36mBKWz+JVw=="], + "@copilotkit/aimock": ["@copilotkit/aimock@1.39.0", "", { "peerDependencies": { "jest": ">=29", "vitest": ">=3" }, "optionalPeers": ["jest", "vitest"], "bin": { "aimock": "dist/aimock-cli.js", "llmock": "dist/cli.js" } }, "sha512-AWw4vmW2hBchHoggh0G4McWGmGZD6wtXAehL6K5ncWF5lVIjlv++bPmxmRwrpQCi/K4/xK10N9Zp9srJYipEJw=="], - "@copilotkit/channels-core": ["@copilotkit/channels-core@0.8.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-ui": "~0.8.1", "@copilotkit/core": "^1.67.0", "@copilotkit/shared": "^1.67.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "vitest": "^4.0.0" }, "optionalPeers": ["vitest"] }, "sha512-FOopmGLUncVCZ7g2s6opvJZoiAFaU49cgu2+T6TGtVfTVZLTaO5OzDNEOafIqh7xMm6TfMWCjr1o1oZ3njKKpw=="], + "@copilotkit/channels-core": ["@copilotkit/channels-core@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-ui": "~0.9.0", "@copilotkit/core": "^1.68.0", "@copilotkit/shared": "^1.68.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "vitest": "^4.0.0" }, "optionalPeers": ["vitest"] }, "sha512-bCWLb/jb9j8O+JvOvf/jkJ/Nb8B09U/tAdTCQ221mE2AEHS2dn5PMlQih9gqyF6kbJFO0Nmf+IDTritWswmfaA=="], - "@copilotkit/channels-intelligence": ["@copilotkit/channels-intelligence@0.8.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/channels-core": "^0.8.1", "@copilotkit/channels-slack": "^0.8.1", "@copilotkit/channels-teams": "^0.8.1", "@copilotkit/channels-ui": "^0.8.1", "phoenix": "^1.8.4" } }, "sha512-b9KtG81v52JhyguyVMyUm4VgARAV6WDfQBB2hLHMI89GwQlAA1LhWYkrPUoG6fk29P3SO8I/LYJjonRes3km5Q=="], + "@copilotkit/channels-intelligence": ["@copilotkit/channels-intelligence@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-slack": "^0.9.0", "@copilotkit/channels-teams": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "phoenix": "^1.8.4" } }, "sha512-w+ARYm+i30buMGJB+E3QFBze41s464MKkXGikgLYg3k9WEFHE3BgTRKz6MbHxv0yN9L6mOAjwMhR8LkTSufMcw=="], - "@copilotkit/channels-slack": ["@copilotkit/channels-slack@0.8.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-core": "^0.8.1", "@copilotkit/channels-ui": "^0.8.1", "@copilotkit/core": "^1.67.0", "@copilotkit/shared": "^1.67.0", "@slack/bolt": "^4.2.0", "@slack/types": "^2.21.1", "@slack/web-api": "^7.16.0", "rxjs": "^7.8.1", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1" } }, "sha512-rrXmX7x/FnJWyhwOlvesz3lMcFXqXMqnF6yeHHMUuCyuYsH54dxZLj0nb3MYMFX40LLuNlUbQtEIHlX2HkKh7A=="], + "@copilotkit/channels-slack": ["@copilotkit/channels-slack@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "@copilotkit/core": "^1.68.0", "@copilotkit/shared": "^1.68.0", "@slack/bolt": "^4.2.0", "@slack/types": "^2.21.1", "@slack/web-api": "^7.16.0", "rxjs": "^7.8.1", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1" } }, "sha512-3QkCInbQmruSP875x02YG1Ez59jPzQMDUlWRlNKPvzcAvnPfOmHxaXVVmBnS+fwcWccd4AAaeRvslTWBwU9s6g=="], - "@copilotkit/channels-teams": ["@copilotkit/channels-teams@0.8.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-core": "^0.8.1", "@copilotkit/channels-ui": "^0.8.1", "@copilotkit/core": "^1.67.0", "@copilotkit/shared": "^1.67.0", "@microsoft/agents-activity": "^1.5.3", "@microsoft/agents-hosting": "^1.5.3", "express": "^4.21.2", "rxjs": "^7.8.1", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1" } }, "sha512-tvFkNBwPSGTYR//uJ5WIrLLFtp047XO2I8OJkllhTcASRvCLcQkQokhOfukxiS8iEwGiuOxwbCuVlOi6ibhkLw=="], + "@copilotkit/channels-teams": ["@copilotkit/channels-teams@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "@copilotkit/core": "^1.68.0", "@copilotkit/shared": "^1.68.0", "@microsoft/agents-activity": "^1.5.3", "@microsoft/agents-hosting": "^1.5.3", "express": "^4.21.2", "rxjs": "^7.8.1", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1" } }, "sha512-OI1xaIg9Ho7vMUgPpxM2h4LZQRvQKRt+1yy2Er8nMBy1IJXQaNP4of4XI5pQA/WnskL/07xcGPrFiXrTRs4Hyg=="], - "@copilotkit/channels-ui": ["@copilotkit/channels-ui@0.8.1", "", { "dependencies": { "@copilotkit/shared": "^1.67.0" } }, "sha512-ZHhfpPP5UYs8Fjl9bDBVFfuJUcV/Kq8vxkksTJuFsfEZTpExALTOq9EPEHV+Pr/flVzIdrlJilnsL7fC4tOsJQ=="], + "@copilotkit/channels-ui": ["@copilotkit/channels-ui@0.9.0", "", { "dependencies": { "@copilotkit/shared": "^1.68.0" } }, "sha512-6nhmIuexyW+fOBp3BE3ZWk4Mco5NOG4sr//BZ46RynSYYD36klTQQbTKA0ntSR6nIGJ/luAGc8WbVdPH8srYOA=="], - "@copilotkit/core": ["@copilotkit/core@1.67.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/shared": "1.67.1", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-QRMsznHFzia+ZF5G8/cn9+91IOZzztUTZi0nXYw/3nENP6Vb8SLCnoH8hUiwGTo8TeoJMhfasGVG8Kt3MpNWNw=="], + "@copilotkit/core": ["@copilotkit/core@1.68.3", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/shared": "1.68.3", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-HcMX5/QwYGsEBLqZUxCeTqhCPaihtCq1Fjm6xUygX6WVrdbqtalKWuRQNFCwvzB0YMYvnNNZLRYcp37PprJq9A=="], "@copilotkit/license-verifier": ["@copilotkit/license-verifier@0.5.0", "", {}, "sha512-vrwKtIpYwF0FT9ZoYASH8owa2cGV0dhDvJGaCRaRMStwDxpc6DRdydKkhx8cWZXyBRxEYcq/Vygv4JvevhQQdQ=="], - "@copilotkit/react-core": ["@copilotkit/react-core@1.67.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/a2ui-renderer": "1.67.1", "@copilotkit/core": "1.67.1", "@copilotkit/runtime-client-gql": "1.67.1", "@copilotkit/shared": "1.67.1", "@copilotkit/web-components": "1.67.1", "@copilotkit/web-inspector": "1.67.1", "@jetbrains/websandbox": "^1.1.3", "@lit-labs/react": "^2.0.2", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "@scarf/scarf": "^1.3.0", "@tanstack/react-virtual": "^3.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "katex": "^0.16.22", "lit": "^3.3.2", "lucide-react": "^0.525.0", "react-markdown": "^8.0.7", "rxjs": "7.8.1", "streamdown": "^1.3.0", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.3.5", "untruncate-json": "^0.0.1", "use-stick-to-bottom": "^1.1.1", "zod-to-json-schema": "^3.24.5" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc", "zod": ">=3.0.0" } }, "sha512-daW5zMR8ujruq42NGJoqCBgHgrwAejRn6iK7+FJEkCmLoV8OonS0/fDsuLdWsuWfhHxmRP+CGjRwfwqfaE5kGg=="], + "@copilotkit/react-core": ["@copilotkit/react-core@1.68.3", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/a2ui-renderer": "1.68.3", "@copilotkit/core": "1.68.3", "@copilotkit/runtime-client-gql": "1.68.3", "@copilotkit/shared": "1.68.3", "@copilotkit/web-components": "1.68.3", "@copilotkit/web-inspector": "1.68.3", "@jetbrains/websandbox": "^1.1.3", "@lit-labs/react": "^2.0.2", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "@scarf/scarf": "^1.3.0", "@tanstack/react-virtual": "^3.13.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "katex": "^0.16.22", "lit": "^3.3.2", "lucide-react": "^0.525.0", "react-markdown": "^8.0.7", "rxjs": "7.8.1", "streamdown": "^1.3.0", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.3.5", "untruncate-json": "^0.0.1", "use-stick-to-bottom": "^1.1.1", "zod-to-json-schema": "^3.24.5" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc", "zod": ">=3.0.0" } }, "sha512-CRlwG7VeDmUofAvlOzw+RI/3+vCPR6NJD66MxUtBTMmHhPaEEahXSBSBqwLJ9MwLSCO+83J1dTLeTuGmWivDjg=="], - "@copilotkit/runtime": ["@copilotkit/runtime@1.67.1", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@ag-ui/encoder": "0.0.57", "@ag-ui/langgraph": "0.0.42", "@ag-ui/mcp-apps-middleware": "0.0.3", "@ag-ui/mcp-middleware": "0.0.1", "@ai-sdk/anthropic": "^3.0.49", "@ai-sdk/google": "^3.0.33", "@ai-sdk/google-vertex": "^3.0.97", "@ai-sdk/mcp": "^1.0.21", "@ai-sdk/openai": "^3.0.36", "@copilotkit/channels-core": "^0.8.1", "@copilotkit/channels-intelligence": "0.8.1", "@copilotkit/license-verifier": "~0.5.0", "@copilotkit/shared": "1.67.1", "@graphql-yoga/plugin-defer-stream": "^3.3.1", "@hono/node-server": "^1.13.5", "@modelcontextprotocol/sdk": "^1.18.2", "@remix-run/node-fetch-server": "^0.13.0", "@scarf/scarf": "^1.3.0", "@segment/analytics-node": "^2.1.2", "ai": "^6.0.104", "clarinet": "^0.12.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cors": "^2.8.5", "express": "^4.21.2", "graphql": "^16.8.1", "graphql-scalars": "^1.23.0", "graphql-yoga": "^5.3.1", "hono": "^4.11.4", "openai": "^4.85.1 || >=5.0.0", "partial-json": "^0.1.7", "phoenix": "^1.8.4", "pino": "^9.2.0", "pino-pretty": "^11.2.1", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "type-graphql": "2.0.0-rc.1", "uuid": "^10.0.0", "ws": "^8.18.0", "zod": "^3.23.3" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.57.0", "@langchain/aws": ">=0.1.9", "@langchain/community": ">=0.3.58", "@langchain/core": ">=0.3.66", "@langchain/google-gauth": ">=0.1.0", "@langchain/langgraph-sdk": ">=0.1.2", "@langchain/openai": ">=0.4.2", "groq-sdk": ">=0.3.0 <1.0.0", "langchain": ">=0.3.3" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/aws", "@langchain/community", "@langchain/google-gauth", "@langchain/langgraph-sdk", "@langchain/openai", "groq-sdk", "langchain"] }, "sha512-q3WT/YhzXhRc/K82rGfSyb5K8jSuYV8On51WTQ8dRlx8pm7H4+z/uFuIPA8uO5RKy8Blm1GPatyPozqFuNbh0w=="], + "@copilotkit/runtime": ["@copilotkit/runtime@1.68.3", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@ag-ui/encoder": "0.0.57", "@ag-ui/langgraph": "0.0.42", "@ag-ui/mcp-apps-middleware": "0.0.3", "@ag-ui/mcp-middleware": "0.0.1", "@ai-sdk/anthropic": "^3.0.49", "@ai-sdk/google": "^3.0.33", "@ai-sdk/google-vertex": "^3.0.97", "@ai-sdk/mcp": "^1.0.21", "@ai-sdk/openai": "^3.0.36", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-intelligence": "0.9.0", "@copilotkit/license-verifier": "~0.5.0", "@copilotkit/shared": "1.68.3", "@graphql-yoga/plugin-defer-stream": "^3.3.1", "@hono/node-server": "^1.13.5", "@modelcontextprotocol/sdk": "^1.18.2", "@remix-run/node-fetch-server": "^0.13.0", "@scarf/scarf": "^1.3.0", "@segment/analytics-node": "^2.1.2", "ai": "^6.0.104", "clarinet": "^0.12.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cors": "^2.8.5", "express": "^4.21.2", "graphql": "^16.8.1", "graphql-scalars": "^1.23.0", "graphql-yoga": "^5.3.1", "hono": "^4.11.4", "openai": "^4.85.1 || >=5.0.0", "partial-json": "^0.1.7", "phoenix": "^1.8.4", "pino": "^9.2.0", "pino-pretty": "^11.2.1", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "type-graphql": "2.0.0-rc.1", "uuid": "^11.1.0", "ws": "^8.18.0", "zod": "^3.23.3" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.57.0", "@langchain/aws": ">=0.1.9", "@langchain/community": ">=0.3.58", "@langchain/core": ">=0.3.66", "@langchain/google-gauth": ">=0.1.0", "@langchain/langgraph-sdk": ">=0.1.2", "@langchain/openai": ">=0.4.2", "groq-sdk": ">=0.3.0 <1.0.0", "langchain": ">=0.3.3" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/aws", "@langchain/community", "@langchain/google-gauth", "@langchain/langgraph-sdk", "@langchain/openai", "groq-sdk", "langchain"] }, "sha512-BOgYbRIXOXLcgiW+OISuMrzxXyXN0Ghk1xuHbd8XPTtgTl82NGBnv8XHHfxsMolta3SVAUaIkcphaUlw2439MQ=="], - "@copilotkit/runtime-client-gql": ["@copilotkit/runtime-client-gql@1.67.1", "", { "dependencies": { "@copilotkit/shared": "1.67.1", "@urql/core": "^5.0.3", "untruncate-json": "^0.0.1", "urql": "^4.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-ir2OUeA0iNhLzTVC9peee7qRiLYhje9tLKWdFSVpcFFGpRUAQcz23larOy6r+4hEOp56hV+DQS5wBrS//AqvyA=="], + "@copilotkit/runtime-client-gql": ["@copilotkit/runtime-client-gql@1.68.3", "", { "dependencies": { "@copilotkit/shared": "1.68.3", "@urql/core": "^5.0.3", "untruncate-json": "^0.0.1", "urql": "^4.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-GJRdcVU7Z3RDd8N6RDgMFi9M0QidU8UxWnns4d40UhriZxXN51jeGnw8E21P5DLtF41wfyn39gfK2SRvnm/jCQ=="], - "@copilotkit/shared": ["@copilotkit/shared@1.67.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "graphql": "^16.8.1", "partial-json": "^0.1.7", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-hGruBM3EDNg+wM76ECfUEj5xpnj7rOkzv/n/k5brEpdiIIs13v4/wQZf8nw2Rv6uyym7LO3yNRfbgKtcHsQvyQ=="], + "@copilotkit/shared": ["@copilotkit/shared@1.68.3", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "graphql": "^16.8.1", "partial-json": "^0.1.7", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-y8d7zrz4T81cpZwYHjVa4SOMbEhPzdlxJN5I9Q1JiasIuwcMEbuxXlk5H0Jc5xhZlb0vmaOsJ1ednatTe4gPdg=="], - "@copilotkit/web-components": ["@copilotkit/web-components@1.67.1", "", { "peerDependencies": { "lit": "^3.3.2" } }, "sha512-ypuXZqA/Vlk7xhvpYPI+u4DnIDV0ZhzHzjo7V0/By6EL7Wy4XAjpLI+1d9FxEWC8JEL0QJVkn17UJMpfnmeEoQ=="], + "@copilotkit/web-components": ["@copilotkit/web-components@1.68.3", "", { "peerDependencies": { "lit": "^3.3.2" } }, "sha512-7pj1HXhk2DG/1jdvpJmJIQxmxWsl3RRdme9ganf7HWKn5COLlTp7k+mkgk+1s3iKaAno2snGuWdyGa7xNQDR1g=="], - "@copilotkit/web-inspector": ["@copilotkit/web-inspector@1.67.1", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/core": "1.67.1", "@copilotkit/shared": "1.67.1", "lit": "^3.2.0", "lucide": "^0.525.0", "marked": "^12.0.2" } }, "sha512-DskVsDrUtZPt9Ymh6UxGElUIETBq8Lxa/vtl9eUdvJ+Ply1xUaGKwfNUpIaGiMWzTML4X7diSVdVrmemlnlYjQ=="], + "@copilotkit/web-inspector": ["@copilotkit/web-inspector@1.68.3", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/core": "1.68.3", "@copilotkit/shared": "1.68.3", "lit": "^3.2.0", "lucide": "^0.525.0", "marked": "^12.0.2" } }, "sha512-VJtKeNEA86FV1jrwVeXvoV3F8Iors9BclV5t7rfGduCw67rxVgl4wWydeOLqiJ/Jlm9F+nDywN3zPSJjjspjaQ=="], "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], @@ -866,7 +865,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], @@ -2202,8 +2201,6 @@ "@copilotkit/react-core/streamdown": ["streamdown@1.6.11", "", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="], - "@copilotkit/runtime/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], - "@copilotkit/runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@copilotkit/shared/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2286,6 +2283,8 @@ "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], @@ -2426,8 +2425,6 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], diff --git a/package.json b/package.json index 72e93762..1b28ec55 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", - "@copilotkit/aimock": "^1.38.0", + "@copilotkit/aimock": "1.39.0", "@types/bun": "^1.3.3", "roughjs": "^4.6.6", "typescript": "^5.9.3", diff --git a/server/package.json b/server/package.json index e51e7847..43b1c354 100644 --- a/server/package.json +++ b/server/package.json @@ -14,8 +14,7 @@ "dependencies": { "@ag-ui/client": "0.0.57", "@better-auth/drizzle-adapter": "^1.6.27", - "@copilotkit/runtime": "1.67.1", - "@copilotkit/shared": "1.67.1", + "@copilotkit/runtime": "1.68.3", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.6.27", "cel-js": "^0.8.2", @@ -26,7 +25,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@copilotkit/aimock": "^1.38.0", + "@copilotkit/aimock": "1.39.0", "drizzle-kit": "^0.31.10", "eventsource": "3.0.7" } diff --git a/server/src/config.ts b/server/src/config.ts index a85e8f01..d3bb3eed 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -58,6 +58,8 @@ export type DeploymentConfig = { * See auth/dev-actor.ts for the two locks that stop this reaching a deployment. */ devNoAuth: boolean; + /** Names OpenBot on the analytics the runtime already sends. Off with OPENBOT_ACCESSIBILITY_DISABLED. */ + accessibility: boolean; /** * The Bot computer. Absent means the feature is off and its routes are not mounted, rather than * mounted and failing: a capability that is not configured should be missing, not broken. @@ -345,6 +347,11 @@ function actionPolicy(environment: Environment): ActionPolicy | undefined { * * Zero is a legitimate value and means off. It is not the same as a malformed one. */ +function accessibilityEnabled(environment: Environment): boolean { + const off = optional(environment, "OPENBOT_ACCESSIBILITY_DISABLED"); + return off !== "true" && off !== "1"; +} + function agentStallTimeoutMs(environment: Environment): number { const raw = optional(environment, "AGENT_STALL_TIMEOUT_MS"); if (!raw) { @@ -380,6 +387,7 @@ export function loadConfig( oauth: { google }, auth: authConfig(environment, google), devNoAuth: devAuthEnabled(environment), + accessibility: accessibilityEnabled(environment), computer: computerConfig(environment), ...(optional(environment, "AGENT_TOOL_TOKEN") ? { agentToolToken: optional(environment, "AGENT_TOOL_TOKEN") as string } diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 7a44a239..02297684 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -498,6 +498,11 @@ export function mountCopilotRuntime( apiKey: intelligence.apiKey, }), licenseToken: intelligence.licenseToken, + // Carried on the events the runtime already sends, so OpenBot's traffic is separable from any + // other deployment's. Adds no events of its own. + ...(config.accessibility + ? { telemetryProperties: { accessibility_title: "OpenBot" } } + : {}), // `identifyUser` is the Intelligence projection of the same person `identifyActor` returns: // one resolver decides both whose threads these are and whose coworkers exist. agents: createRequestAgents( diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 326ad5cb..9583308f 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -187,3 +187,36 @@ describe("deployment configuration", () => { }, ); }); + +describe("accessibility", () => { + test("is on when nothing is set", () => { + expect(loadConfig(baseEnvironment).accessibility).toBe(true); + }); + + test.each(["true", "1"])( + "is off on OPENBOT_ACCESSIBILITY_DISABLED=%p", + (value) => { + expect( + loadConfig({ + ...baseEnvironment, + OPENBOT_ACCESSIBILITY_DISABLED: value, + }).accessibility, + ).toBe(false); + }, + ); + + // Anything else is not a way of saying off. A deployment that typed something + // else has not opted out, and silently treating it as opt-out would be a + // setting that appears to work and does not. + test.each(["false", "no", "", "yes"])( + "stays on for OPENBOT_ACCESSIBILITY_DISABLED=%p", + (value) => { + expect( + loadConfig({ + ...baseEnvironment, + OPENBOT_ACCESSIBILITY_DISABLED: value, + }).accessibility, + ).toBe(true); + }, + ); +}); From e8b291b46cd9a6e4d2b291455fde6a319e931a04 Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 11:12:34 -0700 Subject: [PATCH 08/16] Unify computer backends behind a deep provider and single gateway module Signed-off-by: Muhammad Hashmi --- server/src/app.ts | 22 +- server/src/computer/client.ts | 533 +++++------------- server/src/computer/daytona.ts | 143 +++-- server/src/computer/gateway.ts | 428 ++++++++------ server/src/computer/provider.ts | 201 +++++++ server/src/computer/routes.ts | 24 +- server/src/computer/supervisor.ts | 117 +++- server/src/config.ts | 144 +++-- server/src/index.ts | 112 ++-- server/tests/agent-routes.test.ts | 5 +- server/tests/channel-routes.test.ts | 5 +- server/tests/computer-client.test.ts | 62 +- server/tests/computer-daytona.test.ts | 149 ++++- .../tests/computer-gateway-provider.test.ts | 139 +++++ server/tests/computer-gateway.test.ts | 394 ++++--------- server/tests/computer-provider.test.ts | 174 ++++++ server/tests/computer-routes.test.ts | 35 ++ server/tests/computer-supervisor.test.ts | 92 +++ server/tests/config.test.ts | 77 ++- 19 files changed, 1717 insertions(+), 1139 deletions(-) create mode 100644 server/src/computer/provider.ts create mode 100644 server/tests/computer-gateway-provider.test.ts create mode 100644 server/tests/computer-provider.test.ts create mode 100644 server/tests/computer-routes.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 7675d476..001f710a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,7 +19,6 @@ import { createComponentRoutes } from "./components/routes"; import type { SandboxedStore } from "./components/sandboxed"; import { createSandboxedRoutes } from "./components/sandboxed-routes"; import type { ComponentStore } from "./components/store"; -import type { ComputerClient } from "./computer/client"; import type { ComputerGateway } from "./computer/gateway"; import type { PolicyStore } from "./computer/policy-store"; import { createComputerRoutes } from "./computer/routes"; @@ -46,9 +45,7 @@ export function createApp( * scope broke every server test that touches createApp even though none of them use CopilotKit. */ copilotHandler?: HonoApp, - /** Absent when no computer is configured, and the routes are then not mounted at all. */ - computerClient?: ComputerClient, - /** The only path to an acting call: policy decision, then audit row, then the action. */ + /** The single governed computer module: policy, audit trail, transport, and provider lifecycle. */ computerGateway?: ComputerGateway, /** What the gateway enforces, and what an administrator can change while running. */ computerPolicy?: PolicyStore, @@ -289,24 +286,19 @@ export function createApp( if (copilotHandler) { // Mounted at the ROOT with the handler carrying its own basePath. Mounting it at // "/api/copilotkit" as well double-prefixes it: Hono strips the prefix before the handler sees - // the path, so every route lands at /api/copilotkit/api/copilotkit/* and /info 404s. The client + // the path, so every route lands at /api/copilotkit/api/copilotkit/* and /info 404s. The browser // reports that as "Runtime info request failed with status 404" and every run fails before it // starts, with nothing at all in the server log. app.route("/", copilotHandler); } - // The Bot computer. Acting on a page needs the gateway and the policy it enforces, so all - // three arrive together or the routes are not mounted: a computer whose actions were ungoverned is - // not a reduced feature, it is the one shape of this feature that must not exist. - if (computerClient && computerGateway && computerPolicy) { + // The Bot computer. Acting on a page needs the gateway and the policy it enforces, so both arrive + // together or the routes are not mounted. An ungoverned computer is not a reduced feature. It is + // the one shape of this feature that must not exist. + if (computerGateway && computerPolicy) { app.route( "/api/computers", - createComputerRoutes( - computerClient, - computerGateway, - computerPolicy, - requireUser, - ), + createComputerRoutes(computerGateway, computerPolicy, requireUser), ); } diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index 87146752..02e43e2a 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -1,65 +1,7 @@ -import type { - ActionResult, - ClickInput, - ComputerProfile, - ComputerStatus, - ControlState, - HumanInput, - HumanInputResult, - KeyInput, - ListFilesInput, - ListFilesResult, - NavigateResult, - ReadFileInput, - ReadFileResult, - ReadResult, - ScreenshotResult, - ScrollInput, - SecretRequest, - SecretResult, - SnapshotResult, - TypeInput, - WriteFileInput, - WriteFileResult, -} from "./schema"; +import type { NavigateResult } from "./schema"; import { checkNavigationTarget } from "./target"; -/** - * How the server talks to a Bot's computer. - * - * The computer has no authentication of its own and trusts whatever reaches it, so this module is - * the boundary: it decides whether a navigation is permitted before the request leaves, and it is - * the only place that knows the computer's address. Nothing downstream of here should be handed a - * raw URL from a model. - */ - -export type ComputerClientOptions = { - /** - * The secret this deployment's computers require. Absent means every call is refused by them, which - * is the correct failure: a computer that answers an unauthenticated caller is the bug. - */ - token?: string; - /** - * Base URL of the Bot's computer, e.g. http://agent-computer:4100. - * - * Absent when each Bot is located dynamically (by a supervisor or Daytona). When absent, every call - * must name a Bot that `resolveBaseUrl` can locate. - */ - baseUrl?: string; - /** - * Where this Bot's computer is, when each Bot has one of its own. - * - * A supervisor gives every Bot its own container, so the address stops being one fixed URL and becomes - * whatever the supervisor published for that Bot, which also changes when its computer is reset. - * Left unset, `baseUrl` answers for everyone as one shared computer. - */ - resolveBaseUrl?: (botId: string) => Promise; - /** True on a laptop, where browsing the deployment's own services is the point. */ - allowPrivateHosts?: boolean; - timeoutMs?: number; - fetchImpl?: typeof fetch; -}; - +/** The computer did not accept or answer a request. */ export class ComputerUnavailableError extends Error { constructor(reason: string) { super(reason); @@ -67,16 +9,7 @@ export class ComputerUnavailableError extends Error { } } -/** - * The Bot acted on something that is not on the page. - * - * Its own error because it is its own condition, and the one the Bot can fix by taking a fresh - * snapshot. - * - * The message a locator failure carries is a Playwright call log, several lines of `waiting for - * locator('aria-ref=e5')`, which is noise to a model and to a person. It is replaced with the thing - * to do next. - */ +/** The requested element is not on the current page. */ export class ElementNotFoundError extends Error { constructor(reason: string) { super(reason); @@ -84,6 +17,7 @@ export class ElementNotFoundError extends Error { } } +/** The navigation target is not permitted. */ export class NavigationRefusedError extends Error { constructor(reason: string) { super(reason); @@ -91,14 +25,7 @@ export class NavigationRefusedError extends Error { } } -/** - * The file request itself was refused by the computer: outside the workspace, missing, or too large. - * - * Distinct from a policy refusal, which happens in the gateway before the request is ever made. Both - * reach the browser as a 403 but they mean different things: this one says the path is not a thing a - * Bot may name at all, the other says this Bot may not touch an otherwise perfectly valid path. Only - * the second has a rule an administrator can go and edit. - */ +/** The computer refused access to a path outside its workspace. */ export class WorkspaceRefusedError extends Error { constructor(reason: string) { super(reason); @@ -106,14 +33,7 @@ export class WorkspaceRefusedError extends Error { } } -/** - * The request asked for something that is not there, or not usable: no such file, a folder where a - * file was wanted, a write that is too big. - * - * Not a refusal. Nothing declined to let the Bot do this; the thing it named does not fit the request. - * Kept separate from {@link WorkspaceRefusedError} because a Bot's next move differs completely: here - * it should look at what IS there and try again, whereas a refusal is final and should be reported. - */ +/** The workspace request names a path or value that cannot be used. */ export class WorkspaceRequestError extends Error { constructor(reason: string) { super(reason); @@ -121,12 +41,7 @@ export class WorkspaceRequestError extends Error { } } -/** - * The refs the caller is using were taken before the page changed. - * - * Its own type because it is the one failure here that the model can fix without a person: take a new - * snapshot and try again. Collapsed into a generic failure, the Bot apologises to the person instead. - */ +/** The page changed after the caller received its element references. */ export class StaleSnapshotError extends Error { constructor(reason: string) { super(reason); @@ -134,312 +49,160 @@ export class StaleSnapshotError extends Error { } } -export function createComputerClient(options: ComputerClientOptions) { - const doFetch = options.fetchImpl ?? fetch; - /* - * The secret the computer demands. Without it this process is just another caller, which is the - * point: a computer that answers without this token bypasses the policy gateway, audit trail, and - * sign-in boundary. - */ - const token = options.token; - const timeoutMs = options.timeoutMs ?? 45_000; - const base = options.baseUrl?.replace(/\/$/, ""); - - /** - * A view of the computer as one Bot. - * - * Which Bot is asking has to reach the computer, or nothing on the far side can be per-Bot: its - * profile, its logins, the proxy its traffic leaves through and who holds its wheel all key off this - * one string. If the id is omitted, every Bot resolves the same fixed default and per-Bot settings - * such as `EGRESS_PROXY_` cannot apply. - * - * A bound view rather than a parameter on twenty methods: the gateway already knows the Bot at the - * point it acts, and threading it through every signature would put the same argument in every call - * site for a value that never changes within a request. - */ - function build(botId?: string) { - async function call( - path: string, - init?: RequestInit, - caller?: AbortSignal, - ): Promise { - // Resolved per call rather than held, because a computer that was reset comes back on a - // different port and a cached address would point at nothing. - // - // Outside the try below on purpose: that catch reports "the computer is not running", which is - // true of a computer that will not answer and misleading about a supervisor that could not be - // reached or refused. Those are different operator-facing problems. - const target = - botId && options.resolveBaseUrl - ? (await options.resolveBaseUrl(botId)).replace(/\/$/, "") - : base; - - if (!target) { - throw new ComputerUnavailableError( - "This deployment locates computers per Bot, and this call named no Bot.", - ); - } - - // Already stopped before this left: do not dispatch at all. Relying on fetch to reject an - // aborted signal makes "did the click happen" depend on how quickly the runtime notices, and - // the answer to "the person pressed Stop first" should never be a race. - if (caller?.aborted) { - throw new ComputerUnavailableError("The action was stopped."); - } +/** + * Transport options used inside the computer gateway. + * + * This is an internal seam. Application code uses ComputerGateway and does not + * use this interface directly. + */ +export type ComputerTransportOptions = { + token?: string; + allowPrivateHosts?: boolean; + timeoutMs?: number; + fetchImpl?: typeof fetch; +}; - let response: Response; - try { - response = await doFetch(`${target}${path}`, { - ...init, - // The Bot's identity, as a header rather than in the path, so the computer's published routes - // are unchanged and a caller that does not know which Bot it is still works. - headers: { - ...(init?.headers as Record | undefined), - ...(botId ? { "x-openbot-bot-id": botId } : {}), - ...(token ? { "x-openbot-computer-token": token } : {}), - }, - /* - * Both reasons to give up. The timeout protects the server from a computer that - * has stopped answering; `caller` is the person pressing Stop, and it has to reach the - * browser or the click they were stopping still lands. Combined rather than chosen between: - * whichever fires first ends the request. - */ - signal: caller - ? AbortSignal.any([caller, AbortSignal.timeout(timeoutMs)]) - : AbortSignal.timeout(timeoutMs), - }); - } catch (error) { - // Distinguished from a failed page load on purpose: this one means the computer itself is not - // there, which is an operator problem, not something the person asking can fix by rephrasing. - throw new ComputerUnavailableError( - error instanceof Error && error.name === "TimeoutError" - ? "The assistant's computer did not respond in time." - : "The assistant's computer is not running.", - ); - } +/** Internal HTTP interface used only by ComputerGateway. */ +export interface ComputerTransport { + call( + baseUrl: string, + botId: string, + path: string, + init?: RequestInit, + caller?: AbortSignal, + ): Promise; + post( + baseUrl: string, + botId: string, + path: string, + payload: unknown, + caller?: AbortSignal, + ): Promise; + navigate( + baseUrl: string, + botId: string, + url: string, + ): Promise; +} - const body = (await response.json().catch(() => null)) as Record< - string, - unknown - > | null; +/** + * Send authenticated HTTP requests to one located agent-computer process. + * + * Lifecycle and location are deliberately absent. ComputerGateway owns those + * operations through ComputerProvider. + */ +export function createComputerTransport( + options: ComputerTransportOptions, +): ComputerTransport { + const doFetch = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? 45_000; - if (!response.ok) { - const detail = - typeof body?.error === "string" - ? body.error - : `HTTP ${response.status}`; - // A stale ref is fixed by taking a new snapshot, so it is not reported as the computer being - // unavailable. - if (response.status === 409) { - throw new StaleSnapshotError(detail); - } - // These two must not be collapsed: path confinement and ordinary bad requests lead to - // different next actions. - // 403 is the path confinement: a boundary, and the answer will never change. - if (response.status === 403) { - throw new WorkspaceRefusedError(detail); - } - // 400 is an ordinary bad request: no such file, a folder where a file was wanted, too large. A - // different request would succeed, which is exactly what the Bot needs to understand. - if (response.status === 400) { - throw new WorkspaceRequestError(detail); - } - /* - * A locator that never resolved is not an outage. Playwright reports it as a timeout whose - * message is a call log naming the selector, which is how "that button is not there" ended up - * indistinguishable from "the computer is down". - */ - if (/waiting for locator|Timeout .* exceeded/i.test(detail)) { - const ref = detail.match(/aria-ref=([A-Za-z0-9_-]+)/)?.[1]; - throw new ElementNotFoundError( - `${ref ? `Element ${ref} is` : "That element is"} not on the page any more. Take a fresh snapshot and use the refs from it.`, - ); - } - throw new ComputerUnavailableError(detail); - } - return body; + async function call( + baseUrl: string, + botId: string, + path: string, + init?: RequestInit, + caller?: AbortSignal, + ): Promise { + if (caller?.aborted) { + throw new ComputerUnavailableError("The action was stopped."); } - async function post( - path: string, - payload: unknown, - caller?: AbortSignal, - ): Promise { - return call( - path, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload), + const target = baseUrl.replace(/\/$/, ""); + let response: Response; + try { + response = await doFetch(`${target}${path}`, { + ...init, + headers: { + ...(init?.headers as Record | undefined), + "x-openbot-bot-id": botId, + ...(options.token + ? { "x-openbot-computer-token": options.token } + : {}), }, - caller, + signal: caller + ? AbortSignal.any([caller, AbortSignal.timeout(timeoutMs)]) + : AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + throw new ComputerUnavailableError( + error instanceof Error && error.name === "TimeoutError" + ? "The assistant's computer did not respond in time." + : "The assistant's computer is not running.", ); } - return { - async status(botId: string): Promise { - try { - await call("/health"); - return { botId, state: "ready" }; - } catch (error) { - return { - botId, - state: "unreachable", - reason: error instanceof Error ? error.message : "Unknown failure.", - }; - } - }, - - /** Open a page. Refuses before the request leaves if the target is not permitted. */ - async navigate(url: string): Promise { - const verdict = checkNavigationTarget(url, { - allowPrivateHosts: options.allowPrivateHosts, - }); - if (!verdict.allowed) { - throw new NavigationRefusedError(verdict.reason); - } - - return (await call("/navigate", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ url: verdict.url }), - })) as NavigateResult; - }, - - async screenshot(): Promise { - return (await call("/screenshot")) as ScreenshotResult; - }, - - /** The current page as text. No navigation, so no target check applies. */ - async read(): Promise { - return (await call("/read")) as ReadResult; - }, - - async snapshot(): Promise { - return (await call("/snapshot", { method: "POST" })) as SnapshotResult; - }, - - /** - * The acting calls. - * - * Deliberately unguarded here. Unlike `navigate`, which checks its target in this module, these - * carry no policy of their own: the gateway in front of them is the only thing that knows which - * Bot is asking and what the deployment allows, and putting a second half-check here would create - * two places to keep in agreement. Never call these directly from a route. - */ - async click( - input: ClickInput, - caller?: AbortSignal, - ): Promise { - return (await post("/click", input, caller)) as ActionResult; - }, - - async type( - input: TypeInput, - caller?: AbortSignal, - ): Promise { - return (await post("/type", input, caller)) as ActionResult; - }, - - async key(input: KeyInput, caller?: AbortSignal): Promise { - return (await post("/key", input, caller)) as ActionResult; - }, - - async scroll( - input: ScrollInput, - caller?: AbortSignal, - ): Promise { - return (await post("/scroll", input, caller)) as ActionResult; - }, - - /** - * The workspace files. Also unguarded here: the computer confines the path to the workspace, and - * the gateway decides whether this Bot may touch it. Two questions, neither answered in this file. - */ - async readFile(input: ReadFileInput): Promise { - return (await post("/files/read", input)) as ReadFileResult; - }, - - async writeFile(input: WriteFileInput): Promise { - return (await post("/files/write", input)) as WriteFileResult; - }, - - async listFiles(input: ListFilesInput): Promise { - return (await post("/files/list", input)) as ListFilesResult; - }, - - /** Who has the wheel, and whether the Bot is waiting for a person. */ - async control(): Promise { - return (await call("/control")) as ControlState; - }, - - async requestControl(reason: string): Promise { - return (await post("/control/request", { reason })) as ControlState; - }, - - async takeControl(): Promise { - return (await post("/control/take", {})) as ControlState; - }, - - async releaseControl(): Promise { - return (await post("/control/release", {})) as ControlState; - }, - - /** - * A person's own mouse and keyboard, straight through. - * - * Deliberately NOT governed by the policy gateway. The policy exists to constrain what a BOT may - * do; a person taking the wheel is the escape hatch that makes a governed Bot usable at all, and - * a rule that could lock somebody out of their own browser mid-login would be a worse failure - * than anything it prevented. The takeover itself is audited as an event; the keystrokes are not. - */ - /** Ask for a secret. Carries the label and the field, never a value. */ - async requestSecret(input: SecretRequest): Promise { - return (await post("/control/secret", input)) as ControlState; - }, - - /** - * Supply one. The value passes through this call and is kept nowhere: not returned upward, not - * logged here, and not written to the audit trail by the gateway. - */ - /** The computers this process holds, running or not. */ - async computers(): Promise<{ computers: ComputerProfile[] }> { - return (await call("/computers")) as { computers: ComputerProfile[] }; - }, - - /** Stop the browser and keep what it knows. */ - async stopComputer(): Promise<{ stopped: boolean; wasRunning: boolean }> { - return (await post("/computers/stop", {})) as { - stopped: boolean; - wasRunning: boolean; - }; - }, + const body = (await response.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!response.ok) { + throwMappedError(response.status, body); + } + return body as T; + } - /** Delete the profile. Every login the Bot had goes with it. */ - async resetComputer(): Promise<{ reset: boolean; botId: string }> { - return (await post("/computers/reset", {})) as { - reset: boolean; - botId: string; - }; - }, + function post( + baseUrl: string, + botId: string, + path: string, + payload: unknown, + caller?: AbortSignal, + ): Promise { + return call( + baseUrl, + botId, + path, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }, + caller, + ); + } - async supplySecret(text: string): Promise { - return (await post("/human/secret", { text })) as SecretResult; - }, + async function navigate( + baseUrl: string, + botId: string, + url: string, + ): Promise { + const verdict = checkNavigationTarget(url, { + allowPrivateHosts: options.allowPrivateHosts, + }); + if (!verdict.allowed) { + throw new NavigationRefusedError(verdict.reason); + } + return post(baseUrl, botId, "/navigate", { + url: verdict.url, + }); + } - async humanInput(input: HumanInput): Promise { - const { kind, ...rest } = input; - return (await post(`/human/${kind}`, rest)) as HumanInputResult; - }, + return { call, post, navigate }; +} - /** The same computer, addressed as a particular Bot. */ - forBot(id: string) { - return build(id); - }, - }; +/** Map agent-computer responses to errors that a caller can act on. */ +function throwMappedError( + status: number, + body: Record | null, +): never { + const detail = + typeof body?.error === "string" ? body.error : `HTTP ${status}`; + if (status === 409) { + throw new StaleSnapshotError(detail); } - - return build(); + if (status === 403) { + throw new WorkspaceRefusedError(detail); + } + if (status === 400) { + throw new WorkspaceRequestError(detail); + } + if (/waiting for locator|Timeout .* exceeded/i.test(detail)) { + const ref = detail.match(/aria-ref=([A-Za-z0-9_-]+)/)?.[1]; + throw new ElementNotFoundError( + `${ref ? `Element ${ref} is` : "That element is"} not on the page any more. Take a fresh snapshot and use the refs from it.`, + ); + } + throw new ComputerUnavailableError(detail); } -export type ComputerClient = ReturnType; diff --git a/server/src/computer/daytona.ts b/server/src/computer/daytona.ts index 0a455f15..80bfb109 100644 --- a/server/src/computer/daytona.ts +++ b/server/src/computer/daytona.ts @@ -7,15 +7,21 @@ import { DaytonaNotFoundError, Image, } from "@daytona/sdk"; -import { type ComputerLocation, SupervisorError } from "./supervisor"; +import { + type ComputerLocation, + type ComputerProvider, + type IsolationDescription, + ProviderError, +} from "./provider"; +import type { ComputerStatus } from "./schema"; /** * Remote computers on Daytona for OpenBot. * - * When configured with DAYTONA_API_KEY, each Bot gets its own cloud sandbox managed - * by Daytona rather than a local Docker container. The supervisor client is responsible - * for building and reusing an image snapshot, provisioning sandboxes on demand, - * checking health over Daytona's preview URLs, and mapping Bot IDs to active sandboxes. + * When DAYTONA_API_KEY is configured, each Bot gets a Daytona cloud sandbox + * instead of a local Docker container. The provider builds and reuses an image + * snapshot, provisions sandboxes on demand, checks health over Daytona preview + * URLs, and maps Bot IDs to active sandboxes. */ export type SandboxHandle = { @@ -188,24 +194,51 @@ function wrapBotError( botId: string, action: string, err: unknown, -): SupervisorError { - if (err instanceof SupervisorError) { +): ProviderError { + if (err instanceof ProviderError) { return err; } - return new SupervisorError( + return new ProviderError( `Daytona could not ${action} the computer for ${botId}: ${toErrorMessage(err)}`, ); } -function toSupervisorStatus(state?: string): string { - if (!state) return "unknown"; - const lower = state.toLowerCase(); - if (lower === "started") return "running"; - if (lower === "stopped" || lower === "archived" || lower === "paused") { - return "exited"; + +function toComputerStatus(botId: string, state?: string): ComputerStatus { + const normalized = state?.toLowerCase(); + if (normalized === "started" || normalized === "running") { + return { botId, state: "ready" }; + } + if ( + normalized === "created" || + normalized === "restarting" || + normalized === "creating" || + normalized === "starting" || + normalized === "restoring" || + normalized === "pulling_snapshot" || + normalized === "resuming" + ) { + return { botId, state: "starting" }; } - return state; + if ( + normalized === "stopped" || + normalized === "paused" || + normalized === "archived" || + normalized === "exited" || + normalized === "destroyed" || + normalized === "removing" || + normalized === "archiving" || + normalized === "pausing" || + normalized === "stopping" + ) { + return { botId, state: "absent" }; + } + const reason = normalized + ? `Daytona reported the computer state "${normalized}".` + : "Daytona did not report a state for this computer."; + return { botId, state: "unreachable", reason }; } + function sleep(ms: number): Promise { const { promise, resolve } = Promise.withResolvers(); setTimeout(resolve, ms); @@ -255,9 +288,9 @@ async function pollUntil( throw options.timeoutError(); } -export function createDaytonaSupervisorClient( +export function createDaytonaComputerProvider( options: DaytonaSupervisorOptions, -) { +): ComputerProvider { const sdk: DaytonaSdk = options.sdk ?? new Daytona({ @@ -295,8 +328,8 @@ export function createDaytonaSupervisorClient( try { snapshotName = computeSnapshotName(agentComputerDir); } catch (err) { - if (err instanceof SupervisorError) throw err; - throw new SupervisorError( + if (err instanceof ProviderError) throw err; + throw new ProviderError( `Failed to prepare agent-computer sources from "${agentComputerDir}": ${toErrorMessage(err)}. Set DAYTONA_SNAPSHOT to use a prebuilt snapshot.`, ); } @@ -312,7 +345,7 @@ export function createDaytonaSupervisorClient( try { snap = await sdk.snapshot.get(name); } catch (err) { - throw new SupervisorError( + throw new ProviderError( `Failed to inspect Daytona snapshot ${name} while waiting for active state: ${toErrorMessage(err)}`, ); } @@ -320,7 +353,7 @@ export function createDaytonaSupervisorClient( return { done: true, value: name }; } if (snap.state === "error" || snap.state === "build_failed") { - throw new SupervisorError( + throw new ProviderError( `Daytona snapshot ${name} failed with state "${snap.state}". Delete it in the Daytona dashboard or set DAYTONA_SNAPSHOT to override.`, ); } @@ -330,7 +363,7 @@ export function createDaytonaSupervisorClient( timeoutMs: snapshotTimeout, intervalMs: pollInterval, timeoutError: () => - new SupervisorError( + new ProviderError( `Timed out waiting for Daytona snapshot ${name} to become active.`, ), }, @@ -343,7 +376,7 @@ export function createDaytonaSupervisorClient( return snapshotName; } if (existing.state === "error" || existing.state === "build_failed") { - throw new SupervisorError( + throw new ProviderError( `Daytona snapshot ${snapshotName} failed with state "${existing.state}". Delete it in the Daytona dashboard or set DAYTONA_SNAPSHOT to override.`, ); } @@ -354,8 +387,8 @@ export function createDaytonaSupervisorClient( try { recipe = buildRecipe(agentComputerDir); } catch (recipeErr) { - if (recipeErr instanceof SupervisorError) throw recipeErr; - throw new SupervisorError( + if (recipeErr instanceof ProviderError) throw recipeErr; + throw new ProviderError( `Failed to build agent-computer recipe from "${agentComputerDir}": ${toErrorMessage(recipeErr)}. Set DAYTONA_SNAPSHOT to use a prebuilt snapshot.`, ); } @@ -377,18 +410,18 @@ export function createDaytonaSupervisorClient( if (isConflictError(createErr)) { return await pollUntilActive(snapshotName); } - if (createErr instanceof SupervisorError) { + if (createErr instanceof ProviderError) { throw createErr; } - throw new SupervisorError( + throw new ProviderError( `Failed to create Daytona snapshot ${snapshotName}: ${toErrorMessage(createErr)}`, ); } } - if (err instanceof SupervisorError) { + if (err instanceof ProviderError) { throw err; } - throw new SupervisorError( + throw new ProviderError( `Failed to inspect Daytona snapshot ${snapshotName}: ${toErrorMessage(err)}`, ); } @@ -404,6 +437,7 @@ export function createDaytonaSupervisorClient( async function resolveSandbox( botId: string, action: string, + includeTerminal = false, ): Promise { const deletedSandboxId = resetSandboxes.get(botId); const knownEntry = known.get(botId); @@ -414,7 +448,10 @@ export function createDaytonaSupervisorClient( try { const sb = await sdk.get(knownEntry.sandboxId); const state = sb.state?.toLowerCase(); - if (state === "destroyed" || state === "destroying") { + if ( + !includeTerminal && + (state === "destroyed" || state === "destroying") + ) { known.delete(botId); return undefined; } @@ -439,7 +476,10 @@ export function createDaytonaSupervisorClient( continue; } const state = sb.state?.toLowerCase(); - if (state !== "destroyed" && state !== "destroying") { + if ( + includeTerminal || + (state !== "destroyed" && state !== "destroying") + ) { return sb; } } @@ -451,6 +491,16 @@ export function createDaytonaSupervisorClient( } return { + name: "Daytona", + isolation: "per-bot", + + describeIsolation(): IsolationDescription { + return { + isolation: "one computer per Bot", + note: "Each Bot gets a remote Daytona sandbox with its own /workspace and its own browser profile.", + }; + }, + async locate(botId: string): Promise { const existing = locating.get(botId); if (existing) { @@ -578,7 +628,7 @@ export function createDaytonaSupervisorClient( return { done: false }; } if (cur === "error" || cur === "build_failed") { - throw new SupervisorError( + throw new ProviderError( `Daytona sandbox for ${botId} failed with state "${cur}".`, ); } @@ -588,7 +638,7 @@ export function createDaytonaSupervisorClient( timeoutMs: maxWaitMs, intervalMs: pollInterval, timeoutError: () => - new SupervisorError( + new ProviderError( `Timed out waiting for computer sandbox for ${botId} to start.`, ), }, @@ -643,7 +693,7 @@ export function createDaytonaSupervisorClient( } if (!healthy) { - throw new SupervisorError( + throw new ProviderError( `The computer for ${botId} started but never answered /health at its preview URL.`, ); } @@ -659,6 +709,14 @@ export function createDaytonaSupervisorClient( } }, + async status(botId: string): Promise { + const sandboxHandle = await resolveSandbox(botId, "inspect", true); + if (!sandboxHandle) { + return { botId, state: "absent" }; + } + return toComputerStatus(botId, sandboxHandle.state); + }, + async stop(botId: string): Promise { const sandboxHandle = await resolveSandbox(botId, "stop"); if (!sandboxHandle) { @@ -702,21 +760,22 @@ export function createDaytonaSupervisorClient( continue; } const botId = sb.labels?.[BOT_ID_LABEL_KEY]; - if (!botId) { - continue; - } - if (resetSandboxes.get(botId) === sb.id) { + if (!botId || resetSandboxes.get(botId) === sb.id) { continue; } + const preview = await sb.getPreviewLink(COMPUTER_PORT); result.push({ botId, - container: sb.id, - status: toSupervisorStatus(sb.state), + status: + state === "started" || state === "running" + ? "running" + : "exited", + url: preview.url, startedAt: sb.createdAt, }); } } catch (err) { - throw new SupervisorError( + throw new ProviderError( `Daytona failed to list computers: ${toErrorMessage(err)}`, ); } @@ -734,3 +793,5 @@ export function createDaytonaSupervisorClient( }, }; } + +export const createDaytonaSupervisorClient = createDaytonaComputerProvider; diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index 248d8c51..68020157 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -18,26 +18,45 @@ * The refs are opaque to the caller precisely so that the server holds the mapping. */ import { type AuditStore, recordAuditEvent } from "../audit"; -import type { ComputerClient } from "./client"; +import { createComputerTransport } from "./client"; +export { + ComputerUnavailableError, + ElementNotFoundError, + NavigationRefusedError, + StaleSnapshotError, + WorkspaceRefusedError, + WorkspaceRequestError, +} from "./client"; import { type ActionPolicy, evaluateActionPolicy, type PolicyContext, type PolicyDecision, } from "./policy"; +import type { ComputerProvider } from "./provider"; import type { + ActionResult, ClickInput, ComputerStatus, + ControlState, + HumanInput, + HumanInputResult, KeyInput, ListFilesInput, + ListFilesResult, + NavigateResult, ReadFileInput, + ReadFileResult, ReadResult, + ScreenshotResult, ScrollInput, SecretRequest, + SecretResult, SnapshotElement, SnapshotResult, TypeInput, WriteFileInput, + WriteFileResult, } from "./schema"; export class ActionRefusedError extends Error { @@ -60,25 +79,127 @@ export type ActionActor = { }; export type ComputerGatewayOptions = { - /** - * The container supervisor, when each Bot has a computer of its own. - * - * Stop and reset prefer it: a computer that is wedged cannot be asked to stop itself, and that is - * exactly the state where a person reaches for the button. Without a supervisor these stay profile - * operations performed by the computer itself, which fits the single-computer deployment where - * nothing else holds the Docker socket. - */ - supervisor?: { - stop(botId: string): Promise; - reset(botId: string): Promise; - list?(): Promise<{ botId: string; status: string; startedAt?: string }[]>; - }; - client: ComputerClient; + provider: ComputerProvider; auditStore: AuditStore; /** Absent denies everything. See evaluateActionPolicy. */ policy: () => ActionPolicy | undefined; + /** True on a laptop, where browsing private network addresses is required. */ + allowPrivateHosts?: boolean; + /** The secret that agent-computer requires on each request. */ + token?: string; + /** An injectable fetch implementation for focused gateway tests. */ + fetchImpl?: typeof fetch; }; +export interface ComputerGateway { + readonly provider: ComputerProvider; + locate(botId: string): Promise; + status(botId: string): Promise; + screenshot(botId: string): Promise; + snapshot(botId: string): Promise; + read(botId: string): Promise; + navigate( + computerId: string, + botId: string, + actor: ActionActor, + url: string, + ): Promise; + click( + computerId: string, + botId: string, + actor: ActionActor, + input: ClickInput, + signal?: AbortSignal, + ): Promise; + type( + computerId: string, + botId: string, + actor: ActionActor, + input: TypeInput, + signal?: AbortSignal, + ): Promise; + key( + computerId: string, + botId: string, + actor: ActionActor, + input: KeyInput, + signal?: AbortSignal, + ): Promise; + scroll( + computerId: string, + botId: string, + actor: ActionActor, + input: ScrollInput, + ): Promise; + readFile( + computerId: string, + botId: string, + actor: ActionActor, + input: ReadFileInput, + ): Promise; + listFiles( + computerId: string, + botId: string, + actor: ActionActor, + input: ListFilesInput, + ): Promise; + writeFile( + computerId: string, + botId: string, + actor: ActionActor, + input: WriteFileInput, + ): Promise; + control(botId: string): Promise; + requestHelp( + computerId: string, + botId: string, + actor: ActionActor, + reason: string, + ): Promise; + takeControl( + computerId: string, + botId: string, + actor: ActionActor, + ): Promise; + releaseControl( + computerId: string, + botId: string, + actor: ActionActor, + ): Promise; + requestSecret( + computerId: string, + botId: string, + actor: ActionActor, + input: SecretRequest, + ): Promise; + supplySecret( + computerId: string, + botId: string, + actor: ActionActor, + text: string, + ): Promise; + humanInput(botId: string, input: HumanInput): Promise; + computers(): Promise<{ + isolation: "per-bot" | "shared"; + computers: { + botId: string; + running: boolean; + startedAt: string | null; + egress: null; + }[]; + }>; + stopComputer( + computerId: string, + botId: string, + actor: ActionActor, + ): Promise<{ wasRunning: boolean }>; + resetComputer( + computerId: string, + botId: string, + actor: ActionActor, + ): Promise<{ cleared: boolean }>; +} + /** * The last snapshot the server took, per computer. * @@ -93,23 +214,65 @@ type CachedSnapshot = { url: string; }; -export function createComputerGateway(options: ComputerGatewayOptions) { - const { client, auditStore, supervisor } = options; +export function createComputerGateway( + options: ComputerGatewayOptions, +): ComputerGateway { + const { provider, auditStore } = options; + const transport = createComputerTransport({ + ...(options.token ? { token: options.token } : {}), + ...(options.allowPrivateHosts !== undefined + ? { allowPrivateHosts: options.allowPrivateHosts } + : {}), + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + }); const snapshots = new Map(); - /** - * The computer, addressed as the Bot that is asking. - * - * Every call goes through this. The Bot's browser, its logins and the proxy its traffic leaves - * through are all keyed on this id at the far end, so a call that forgets it lands on the wrong - * computer, because there is always a computer to answer. - */ - const as = (botId: string) => client.forBot(botId); + function locate(botId: string): Promise { + return provider.locate(botId); + } + + async function get( + botId: string, + path: string, + signal?: AbortSignal, + ): Promise { + return transport.call( + await locate(botId), + botId, + path, + undefined, + signal, + ); + } + + async function post( + botId: string, + path: string, + payload: unknown, + signal?: AbortSignal, + ): Promise { + return transport.post( + await locate(botId), + botId, + path, + payload, + signal, + ); + } /** Read-only, so it passes straight through. Nothing has changed and there is nothing to decide. */ - async function snapshot(computerId: string): Promise { - const result = await as(computerId).snapshot(); - snapshots.set(computerId, { + async function screenshot(botId: string): Promise { + return get(botId, "/screenshot"); + } + + async function snapshot(botId: string): Promise { + const result = await transport.call( + await locate(botId), + botId, + "/snapshot", + { method: "POST" }, + ); + snapshots.set(botId, { snapshotId: result.snapshotId, url: result.url, elements: new Map( @@ -120,7 +283,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { } async function read(botId: string): Promise { - return as(botId).read(); + return get(botId, "/read"); } /** @@ -131,11 +294,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { * A deny rule written against a page a Bot has not snapshotted should still refuse it. */ function resolve( - computerId: string, + botId: string, ref: string | undefined, ): SnapshotElement | undefined { if (!ref) return undefined; - return snapshots.get(computerId)?.elements.get(ref); + return snapshots.get(botId)?.elements.get(ref); } /** @@ -161,8 +324,8 @@ export function createComputerGateway(options: ComputerGatewayOptions) { run: () => Promise, ): Promise { const { ref, filePath } = subject; - const element = resolve(computerId, ref); - const cached = snapshots.get(computerId); + const element = resolve(botId, ref); + const cached = snapshots.get(botId); // For a navigation the relevant page is the one being opened, not the one already loaded. Using // the cached URL would mean `page.host == "..."` could never match the destination, which is the // only thing a rule about navigation would ever want to say. @@ -245,73 +408,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { } return { + provider, + locate, + screenshot, snapshot, read, - /** - * Inspect a Bot's computer status without provisioning or waking it. - * - * With a supervisor, this reads current inventory from list() and maps the container/sandbox - * state to a ComputerStatus. Without a supervisor, it delegates to client.status(botId). - */ - async status(botId: string): Promise { - if (supervisor?.list) { - try { - const list = await supervisor.list(); - const entry = list.find((item) => item.botId === botId); - if (!entry) { - return { botId, state: "absent" }; - } - const rawStatus = entry.status ?? ""; - const status = rawStatus.toLowerCase(); - switch (status) { - case "running": - case "started": - return { botId, state: "ready" }; - case "created": - case "restarting": - case "creating": - case "starting": - case "restoring": - case "pulling_snapshot": - case "resuming": - return { botId, state: "starting" }; - case "stopped": - case "paused": - case "archived": - case "exited": - case "destroyed": - case "removing": - case "archiving": - case "pausing": - case "stopping": - return { botId, state: "absent" }; - case "error": - case "build_failed": - return { - botId, - state: "unreachable", - reason: `The computer reported state "${rawStatus}".`, - }; - default: - return { - botId, - state: "unreachable", - reason: `The computer reported unknown state "${rawStatus}".`, - }; - } - } catch (error) { - return { - botId, - state: "unreachable", - reason: - error instanceof Error && error.message.length > 0 - ? error.message - : "Unknown failure.", - }; - } - } - return as(botId).status(botId); + status(botId: string): Promise { + return provider.status(botId); }, /** @@ -329,7 +433,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, reason: string, ) { - const state = await as(botId).requestControl(reason); + const state = await post( + botId, + "/control/request", + { reason }, + ); await writeControlEvent(auditStore, "computer.help_requested", { botId, actor, @@ -340,7 +448,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { }, async takeControl(computerId: string, botId: string, actor: ActionActor) { - const state = await as(botId).takeControl(); + const state = await post(botId, "/control/take", {}); await writeControlEvent(auditStore, "computer.control_taken", { botId, actor, @@ -357,7 +465,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId: string, actor: ActionActor, ) { - const state = await as(botId).releaseControl(); + const state = await post(botId, "/control/release", {}); await writeControlEvent(auditStore, "computer.control_released", { botId, actor, @@ -366,35 +474,24 @@ export function createComputerGateway(options: ComputerGatewayOptions) { return state; }, - control(botId: string) { - return as(botId).control(); + control(botId: string): Promise { + return get(botId, "/control"); }, - /** - * The computers, for the admin surface. A read, so no audit row. - * - * With a supervisor the list is the containers, because that is what a computer is: one per - * Bot, each with its own storage, and the page's Stop and Reset act on those. Asking a single - * computer for its profiles would answer for the one shared browser instead, which is the older - * arrangement and no longer what an administrator is looking at. - */ + /** Return every computer that the configured provider owns. */ async computers() { - if (supervisor?.list) { - const running = await supervisor.list(); - return { - // Said, not inferred. Without a supervisor every Bot shares one browser, which looks - // identical on every screen to each having its own, same cards, same trail, same - // screenshots. A reader has to be told which deployment they are looking at. - isolation: "per-bot" as const, - computers: running.map((computer) => ({ - botId: computer.botId, - running: computer.status === "running", - startedAt: computer.startedAt ?? null, - egress: null, - })), - }; - } - return { isolation: "shared" as const, ...(await client.computers()) }; + const computers = await provider.list(); + return { + isolation: provider.isolation, + computers: computers.map((computer) => ({ + botId: computer.botId, + running: ["running", "started"].includes( + computer.status.toLowerCase(), + ), + startedAt: computer.startedAt ?? null, + egress: null, + })), + }; }, /** @@ -406,26 +503,14 @@ export function createComputerGateway(options: ComputerGatewayOptions) { * tried. */ async stopComputer(computerId: string, botId: string, actor: ActionActor) { - if (supervisor) { - await supervisor.stop(botId); - await writeControlEvent(auditStore, "computer.stopped", { - botId, - actor, - computerId, - reason: "the container was stopped", - }); - return { wasRunning: true }; - } - const result = await as(botId).stopComputer(); + await provider.stop(botId); await writeControlEvent(auditStore, "computer.stopped", { botId, actor, computerId, - reason: result.wasRunning - ? "browser was running" - : "no browser was running", + reason: "the computer was stopped", }); - return result; + return { wasRunning: true }; }, /** @@ -435,24 +520,15 @@ export function createComputerGateway(options: ComputerGatewayOptions) { * row is written whatever happens next. */ async resetComputer(computerId: string, botId: string, actor: ActionActor) { - if (supervisor) { - await supervisor.reset(botId); - await writeControlEvent(auditStore, "computer.reset", { - botId, - actor, - computerId, - reason: "the container and its profile were deleted", - }); - return { cleared: true }; - } - const result = await as(botId).resetComputer(); + await provider.reset(botId); + snapshots.delete(botId); await writeControlEvent(auditStore, "computer.reset", { botId, actor, computerId, - reason: "every saved login on this computer was deleted", + reason: "the computer and its saved state were deleted", }); - return result; + return { cleared: true }; }, /** @@ -469,7 +545,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, input: SecretRequest, ) { - const state = await as(botId).requestSecret(input); + const state = await post( + botId, + "/control/secret", + input, + ); await writeControlEvent(auditStore, "computer.secret_requested", { botId, actor, @@ -485,7 +565,11 @@ export function createComputerGateway(options: ComputerGatewayOptions) { actor: ActionActor, text: string, ) { - const result = await as(botId).supplySecret(text); + const result = await post( + botId, + "/human/secret", + { text }, + ); await writeControlEvent(auditStore, "computer.secret_supplied", { botId, actor, @@ -496,19 +580,19 @@ export function createComputerGateway(options: ComputerGatewayOptions) { return result; }, - humanInput( + async humanInput( botId: string, - input: Parameters[0], - ) { - return as(botId).humanInput(input); + input: HumanInput, + ): Promise { + const { kind, ...payload } = input; + return post(botId, `/human/${kind}`, payload); }, /** * Opening a page, through the gateway so it lands in the audit trail. * - * The client still applies its target guard, which is the floor that holds under every policy, - * including one that permits everything. This adds the record and the per-Bot decision on top: a - * refusal by either produces a row, so navigation denials are visible in the audit trail. + * The transport applies its target guard before it sends a request. This is + * the minimum rule that applies even when the action policy permits the URL. */ navigate( computerId: string, @@ -522,7 +606,8 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId, actor, { targetUrl: url }, - () => as(botId).navigate(url), + async () => + transport.navigate(await locate(botId), botId, url), ); }, @@ -539,7 +624,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId, actor, { ref: input.ref, ...(signal ? { signal } : {}) }, - () => as(botId).click(input, signal), + () => post(botId, "/click", input, signal), ); }, @@ -556,7 +641,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId, actor, { ref: input.ref, ...(signal ? { signal } : {}) }, - () => as(botId).type(input, signal), + () => post(botId, "/type", input, signal), ); }, @@ -575,7 +660,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { // The key is part of the subject, so a rule can tell Enter from a letter. Form submission can // happen through a keypress as well as a click, so the policy context carries the key. { ref: input.ref, key: input.key, ...(signal ? { signal } : {}) }, - () => as(botId).key(input, signal), + () => post(botId, "/key", input, signal), ); }, @@ -586,7 +671,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { input: ScrollInput, ) { return govern(computerId, "computer_scroll", botId, actor, {}, () => - as(botId).scroll(input), + post(botId, "/scroll", input), ); }, @@ -609,7 +694,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId, actor, { filePath: input.path }, - () => as(botId).readFile(input), + () => post(botId, "/files/read", input), ); }, @@ -630,7 +715,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId, actor, { filePath: input.path ?? "." }, - () => as(botId).listFiles(input), + () => post(botId, "/files/list", input), ); }, @@ -646,7 +731,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { botId, actor, { filePath: input.path }, - () => as(botId).writeFile(input), + () => post(botId, "/files/write", input), ); }, }; @@ -674,7 +759,6 @@ function describeFile(path: string): { }; } -export type ComputerGateway = ReturnType; /** * One audit row for one decision. diff --git a/server/src/computer/provider.ts b/server/src/computer/provider.ts new file mode 100644 index 00000000..40bb585f --- /dev/null +++ b/server/src/computer/provider.ts @@ -0,0 +1,201 @@ +import type { ComputerConfig } from "../config"; +import { createDaytonaComputerProvider } from "./daytona"; +import { + createDockerSupervisorProvider, + type SupervisorOptions, +} from "./supervisor"; + +import type { ComputerStatus } from "./schema"; + +/** The address and lifecycle details for one Bot's computer. */ +export type ComputerLocation = { + botId: string; + status: string; + url?: string; + startedAt?: string; +}; + +/** A description of how a provider separates one Bot's computer from another. */ +export type IsolationDescription = { + isolation: "one computer per Bot" | "one shared computer"; + note: string; + warning?: string; +}; + +/** An error from a computer provider. */ +export class ProviderError extends Error { + constructor(message: string) { + super(message); + this.name = "ProviderError"; + } +} + +/** + * A backend that gives Bots access to a computer. + * + * Implementations can use a computer for each Bot or one computer for all Bots. + * Callers use this interface and do not need to know which backend is active. + */ +export interface ComputerProvider { + /** The provider name for logs and status output. */ + readonly name: string; + /** How the provider separates computers between Bots. */ + readonly isolation: "per-bot" | "shared"; + /** Describe the isolation that this provider gives to Bots. */ + describeIsolation(): IsolationDescription; + /** Return the base address of the computer for this Bot. */ + locate(botId: string): Promise; + /** Return the lifecycle state of the computer for this Bot. */ + status(botId: string): Promise; + /** Stop the computer for this Bot if it exists. */ + stop(botId: string): Promise; + /** Remove the computer state for this Bot if it exists. */ + reset(botId: string): Promise; + /** List the computers that this provider owns. */ + list(): Promise; + /** Prepare provider resources before the first computer request. */ + warm?(): Promise; +} + +type SharedComputerProviderOptions = { + baseUrl: string; + token?: string; +}; + +type SharedComputerEntry = { + botId: string; + running?: boolean; + status?: string; + url?: string; + startedAt?: string | null; +}; + +/** + * Give every Bot the same computer. + * + * This adapter keeps shared deployments behind the same provider seam as the + * Docker supervisor and Daytona. + */ +export function createSharedComputerProvider( + options: SharedComputerProviderOptions, +): ComputerProvider { + const base = options.baseUrl.replace(/\/$/, ""); + + function headers(botId?: string): Record { + return { + ...(botId ? { "x-openbot-bot-id": botId } : {}), + ...(options.token + ? { "x-openbot-computer-token": options.token } + : {}), + }; + } + + async function call( + path: string, + method: "GET" | "POST", + botId?: string, + ): Promise { + let response: Response; + try { + response = await fetch(`${base}${path}`, { + method, + headers: headers(botId), + }); + } catch (error) { + throw new ProviderError( + `The shared computer at ${base} could not be reached (${error instanceof Error ? error.message : String(error)}).`, + ); + } + + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + if (!response.ok) { + throw new ProviderError( + body?.error ?? `The shared computer answered ${response.status}.`, + ); + } + return body; + } + + return { + name: "shared", + isolation: "shared", + describeIsolation(): IsolationDescription { + return { + isolation: "one shared computer", + note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", + warning: + "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", + }; + }, + + async locate(_botId: string): Promise { + return options.baseUrl; + }, + + async status(botId: string): Promise { + try { + await call("/health", "GET", botId); + return { botId, state: "ready" }; + } catch (error) { + return { + botId, + state: "unreachable", + reason: + error instanceof Error && error.message.length > 0 + ? error.message + : "Unknown failure.", + }; + } + }, + + async stop(botId: string): Promise { + await call("/stop", "POST", botId); + }, + + async reset(botId: string): Promise { + await call("/reset", "POST", botId); + }, + + async list(): Promise { + const body = (await call("/computers", "GET")) as { + computers?: SharedComputerEntry[]; + }; + return (body?.computers ?? []).map((computer) => ({ + botId: computer.botId, + status: + computer.status ?? (computer.running === true ? "running" : "stopped"), + url: computer.url ?? base, + ...(computer.startedAt ? { startedAt: computer.startedAt } : {}), + })); + }, + }; +} + +/** Build the one computer provider selected by deployment configuration. */ +export function createComputerProvider(config: ComputerConfig): ComputerProvider { + switch (config.provider) { + case "daytona": + return createDaytonaComputerProvider({ + apiKey: config.apiKey, + computerToken: config.token, + environment: process.env, + ...(config.apiUrl ? { apiUrl: config.apiUrl } : {}), + ...(config.target ? { target: config.target } : {}), + ...(config.snapshot ? { snapshot: config.snapshot } : {}), + }); + case "docker": { + const options: SupervisorOptions = { + baseUrl: config.baseUrl, + ...(config.supervisorToken ? { token: config.supervisorToken } : {}), + }; + return createDockerSupervisorProvider(options); + } + case "shared": + return createSharedComputerProvider({ + baseUrl: config.baseUrl, + ...(config.token ? { token: config.token } : {}), + }); + } +} diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index ddcae9a7..0a7b736f 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -3,18 +3,15 @@ import { Hono } from "hono"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; import { - type ComputerClient, + type ActionActor, + ActionRefusedError, + type ComputerGateway, ComputerUnavailableError, ElementNotFoundError, NavigationRefusedError, StaleSnapshotError, WorkspaceRefusedError, WorkspaceRequestError, -} from "./client"; -import { - type ActionActor, - ActionRefusedError, - type ComputerGateway, } from "./gateway"; import { type PolicyStore, parseActionPolicy } from "./policy-store"; @@ -25,12 +22,10 @@ import { type PolicyStore, parseActionPolicy } from "./policy-store"; * session guard because `COMPUTER_TOKEN` proves the caller is an internal service, not which user is * asking to drive the browser. * - * Read-only calls go to the client; acting calls go to the gateway. That split is the governance - * boundary: every acting route in this file passes through a policy decision and audit row before it - * reaches the computer. + * Every computer call goes through the gateway. That is the governance seam: each acting route in + * this file passes through a policy decision and audit row before it reaches the computer. */ export function createComputerRoutes( - client: ComputerClient, gateway: ComputerGateway, policyStore: PolicyStore, requireUser: MiddlewareHandler<{ Variables: AppVariables }>, @@ -45,7 +40,7 @@ export function createComputerRoutes( routes.get("/:botId/screenshot", requireUser, async (context) => { try { return context.json( - await client.forBot(context.req.param("botId")).screenshot(), + await gateway.screenshot(context.req.param("botId")), ); } catch (error) { return context.json({ error: describe(error) }, statusFor(error)); @@ -266,10 +261,9 @@ export function createComputerRoutes( /** * A person's own mouse and keyboard. * - * Not through the policy gateway, and not audited per keystroke, see the note on `humanInput` in - * client.ts. The takeover is the audited event; what the person typed during it is deliberately - * unrecorded, because the reason a takeover exists is to let them enter the thing nothing else - * should keep. + * Not through the policy decision, and not audited per keystroke. See `ComputerGateway.humanInput`. + * The takeover is the audited event; what the person typed during it is deliberately unrecorded, + * because the reason a takeover exists is to let them enter the thing nothing else should keep. */ routes.post("/:botId/human/:kind", requireUser, async (context) => { const kind = context.req.param("kind"); diff --git a/server/src/computer/supervisor.ts b/server/src/computer/supervisor.ts index 6212973d..7613a513 100644 --- a/server/src/computer/supervisor.ts +++ b/server/src/computer/supervisor.ts @@ -13,9 +13,16 @@ * honest about being one shared computer. */ -export type ComputerLocation = { +import type { ComputerStatus } from "./schema"; +import type { + ComputerLocation, + ComputerProvider, + IsolationDescription, +} from "./provider"; + +type SupervisorComputerLocation = { botId: string; - container: string; + container?: string; status: string; port?: number; /** Where to reach it, decided by the supervisor rather than assembled here. */ @@ -39,7 +46,9 @@ export class SupervisorError extends Error { } } -export function createSupervisorClient(options: SupervisorOptions) { +export function createDockerSupervisorProvider( + options: SupervisorOptions, +): ComputerProvider { const doFetch = options.fetchImpl ?? fetch; const base = options.baseUrl.replace(/\/$/, ""); const timeoutMs = options.timeoutMs ?? 120_000; @@ -73,7 +82,77 @@ export function createSupervisorClient(options: SupervisorOptions) { return body; } + async function list(): Promise { + const body = (await call("/computers", "GET")) as { + computers?: SupervisorComputerLocation[]; + }; + return (body?.computers ?? []).map((computer) => ({ + botId: computer.botId, + status: computer.status, + ...(computer.url + ? { url: computer.url } + : computer.port + ? { url: hostForPort(computer.port) } + : {}), + ...(computer.startedAt ? { startedAt: computer.startedAt } : {}), + })); + } + + function statusFromLocation( + botId: string, + location: ComputerLocation | undefined, + ): ComputerStatus { + if (!location) return { botId, state: "absent" }; + + const rawStatus = location.status; + switch (rawStatus.toLowerCase()) { + case "running": + case "started": + return { botId, state: "ready" }; + case "created": + case "restarting": + case "creating": + case "starting": + case "restoring": + case "pulling_snapshot": + case "resuming": + return { botId, state: "starting" }; + case "stopped": + case "paused": + case "archived": + case "exited": + case "destroyed": + case "removing": + case "archiving": + case "pausing": + case "stopping": + return { botId, state: "absent" }; + case "error": + case "build_failed": + return { + botId, + state: "unreachable", + reason: `The computer reported state "${rawStatus}".`, + }; + default: + return { + botId, + state: "unreachable", + reason: `The computer reported unknown state "${rawStatus}".`, + }; + } + } + + const isolation: IsolationDescription = { + isolation: "one computer per Bot", + note: "Each Bot gets its own container, its own /workspace and its own browser profile.", + }; + return { + name: "Docker supervisor", + isolation: "per-bot", + describeIsolation: () => isolation, + /** * The URL of this Bot's computer, starting it if it is not already up. * @@ -84,7 +163,7 @@ export function createSupervisorClient(options: SupervisorOptions) { async locate(botId: string): Promise { const state = (await call( `/computers/${encodeURIComponent(botId)}/ensure`, - )) as ComputerLocation; + )) as SupervisorComputerLocation; // The supervisor says where it is, because only it knows whether these computers sit on a // shared network or answer on a published port. if (state?.url) return state.url; @@ -94,6 +173,25 @@ export function createSupervisorClient(options: SupervisorOptions) { ); }, + async status(botId: string): Promise { + try { + const computers = await list(); + return statusFromLocation( + botId, + computers.find((computer) => computer.botId === botId), + ); + } catch (error) { + return { + botId, + state: "unreachable", + reason: + error instanceof Error && error.message.length > 0 + ? error.message + : "Unknown failure.", + }; + } + }, + async stop(botId: string): Promise { await call(`/computers/${encodeURIComponent(botId)}/stop`); }, @@ -102,13 +200,10 @@ export function createSupervisorClient(options: SupervisorOptions) { await call(`/computers/${encodeURIComponent(botId)}/reset`); }, - async list(): Promise { - const body = (await call("/computers", "GET")) as { - computers?: ComputerLocation[]; - }; - return body?.computers ?? []; - }, + list, }; } -export type SupervisorClient = ReturnType; +export const createSupervisorClient = createDockerSupervisorProvider; + +export type SupervisorClient = ComputerProvider; diff --git a/server/src/config.ts b/server/src/config.ts index 5dc6f637..cc8d02b8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -21,6 +21,39 @@ export type IntelligenceSettings = { licenseToken: string; }; +export type DaytonaComputerConfig = { + provider: "daytona"; + apiKey: string; + apiUrl?: string; + target?: string; + snapshot?: string; + token: string; + allowPrivateHosts: boolean; + policy?: ActionPolicy; +}; + +export type DockerComputerConfig = { + provider: "docker"; + baseUrl: string; + supervisorToken?: string; + token?: string; + allowPrivateHosts: boolean; + policy?: ActionPolicy; +}; + +export type SharedComputerConfig = { + provider: "shared"; + baseUrl: string; + token?: string; + allowPrivateHosts: boolean; + policy?: ActionPolicy; +}; + +export type ComputerConfig = + | DaytonaComputerConfig + | DockerComputerConfig + | SharedComputerConfig; + export type DeploymentConfig = { databaseUrl: string; keyEncryptionKey: string; @@ -62,34 +95,7 @@ export type DeploymentConfig = { * The Bot computer. Absent means the feature is off and its routes are not mounted, rather than * mounted and failing: a capability that is not configured should be missing, not broken. */ - computer?: { - baseUrl?: string; - /** The secret every computer requires of its caller. */ - token?: string; - /** - * The container supervisor, when each Bot is to get a computer of its own. Absent means one - * shared computer at `baseUrl`, which is what a laptop wants and is honest about being one - * machine. - */ - supervisor?: { baseUrl: string; token?: string }; - /** Remote computers on Daytona, when an API key is configured. Mutually exclusive with supervisor. */ - daytona?: { - apiKey: string; - apiUrl?: string; - target?: string; - snapshot?: string; - }; - /** True on a laptop, where browsing the deployment's own services is the point. */ - allowPrivateHosts: boolean; - /** - * What Bots may do on their computers. Absent means the built-in default applies. - * - * A whole policy in one variable rather than a variable per rule, because the rules are an - * ordered pair of lists and splitting them across `AGENT_COMPUTER_DENY_1`-style names makes their - * precedence, which is the only subtle thing about them, impossible to see. - */ - policy?: ActionPolicy; - }; + computer?: ComputerConfig; }; type Environment = Record; @@ -269,20 +275,20 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { }; } -function computerConfig( - environment: Environment, -): DeploymentConfig["computer"] { - const baseUrl = url(environment, "AGENT_COMPUTER_URL"); +function computerConfig(environment: Environment): ComputerConfig | undefined { const daytonaKey = optional(environment, "DAYTONA_API_KEY"); - const supervisorUrl = url(environment, "COMPUTER_SUPERVISOR_URL"); - if (!baseUrl && !daytonaKey) { + const supervisorAddress = optional(environment, "COMPUTER_SUPERVISOR_URL"); + const sharedAddress = optional(environment, "AGENT_COMPUTER_URL"); + if (!daytonaKey && !supervisorAddress && !sharedAddress) { return undefined; } - if (daytonaKey && supervisorUrl) { + + if (daytonaKey && supervisorAddress) { throw new Error( "Set either DAYTONA_API_KEY or COMPUTER_SUPERVISOR_URL, not both. They are two ways of giving each Bot its own computer.", ); } + /* * The secret the computers require. Without it every call to a computer is refused, and that is the * intended failure: `agent-computer` drives a browser holding real logins and must not answer @@ -294,35 +300,51 @@ function computerConfig( "DAYTONA_API_KEY is set but COMPUTER_TOKEN is not. Daytona computers are reached over a public preview URL, and the token is the only thing that refuses strangers. Generate one: openssl rand -base64 32", ); } + + const allowPrivateHosts = + optional(environment, "AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS") === "true"; const policy = actionPolicy(environment); - const supervisorToken = optional(environment, "SUPERVISOR_TOKEN"); - const daytonaUrl = url(environment, "DAYTONA_API_URL"); - const daytonaTarget = optional(environment, "DAYTONA_TARGET"); - const daytonaSnapshot = optional(environment, "DAYTONA_SNAPSHOT"); + + if (daytonaKey && computerToken) { + const apiUrl = url(environment, "DAYTONA_API_URL"); + const target = optional(environment, "DAYTONA_TARGET"); + const snapshot = optional(environment, "DAYTONA_SNAPSHOT"); + return { + provider: "daytona", + apiKey: daytonaKey, + token: computerToken, + allowPrivateHosts, + ...(apiUrl ? { apiUrl } : {}), + ...(target ? { target } : {}), + ...(snapshot ? { snapshot } : {}), + ...(policy ? { policy } : {}), + }; + } + + const supervisorUrl = url(environment, "COMPUTER_SUPERVISOR_URL"); + if (supervisorUrl) { + const supervisorToken = optional(environment, "SUPERVISOR_TOKEN"); + return { + provider: "docker", + baseUrl: supervisorUrl, + allowPrivateHosts, + ...(supervisorToken ? { supervisorToken } : {}), + ...(computerToken ? { token: computerToken } : {}), + ...(policy ? { policy } : {}), + }; + } + + const baseUrl = url(environment, "AGENT_COMPUTER_URL"); + if (!baseUrl) { + return undefined; + } + return { - allowPrivateHosts: - optional(environment, "AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS") === "true", - ...(baseUrl ? { baseUrl } : {}), - ...(policy ? { policy } : {}), + provider: "shared", + baseUrl, + allowPrivateHosts, ...(computerToken ? { token: computerToken } : {}), - ...(supervisorUrl - ? { - supervisor: { - baseUrl: supervisorUrl, - ...(supervisorToken ? { token: supervisorToken } : {}), - }, - } - : {}), - ...(daytonaKey - ? { - daytona: { - apiKey: daytonaKey, - ...(daytonaUrl ? { apiUrl: daytonaUrl } : {}), - ...(daytonaTarget ? { target: daytonaTarget } : {}), - ...(daytonaSnapshot ? { snapshot: daytonaSnapshot } : {}), - }, - } - : {}), + ...(policy ? { policy } : {}), }; } diff --git a/server/src/index.ts b/server/src/index.ts index 4472dbbe..19094ee2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,14 +17,12 @@ import { createThreadIdentity } from "./channels/thread-identity"; import { websocket as channelSocket } from "./channels/socket"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; -import { createComputerClient } from "./computer/client"; import { createComputerGateway } from "./computer/gateway"; import { createPolicyStore, DEFAULT_ACTION_POLICY, } from "./computer/policy-store"; -import { createDaytonaSupervisorClient } from "./computer/daytona"; -import { createSupervisorClient } from "./computer/supervisor"; +import { createComputerProvider } from "./computer/provider"; import { loadConfig } from "./config"; import { createConnectorAdminService } from "./connectors"; import { @@ -151,32 +149,13 @@ const roleRepository = createRoleRepository(database); const loadAgentsForActor = createRuntimeAgentLoader(database, agentVault); await synchronizeTenantPackage(database, tenantPackage); const auth = config.auth ? createAuth(config, database) : undefined; -const daytonaSupervisor = - config.computer?.daytona && config.computer.token - ? createDaytonaSupervisorClient({ - ...config.computer.daytona, - computerToken: config.computer.token, - environment: process.env, - }) - : undefined; -// Kick the snapshot build off at boot so the first person to open a computer -// is not the one who waits minutes for an image build. -if (daytonaSupervisor) void daytonaSupervisor.warm(); -const supervisor = - daytonaSupervisor ?? - (config.computer?.supervisor - ? createSupervisorClient(config.computer.supervisor) - : undefined); -const computerClient = config.computer - ? createComputerClient({ - ...(config.computer.baseUrl ? { baseUrl: config.computer.baseUrl } : {}), - allowPrivateHosts: config.computer.allowPrivateHosts, - ...(config.computer.token ? { token: config.computer.token } : {}), - ...(supervisor - ? { resolveBaseUrl: (botId: string) => supervisor.locate(botId) } - : {}), - }) +const computerProvider = config.computer + ? createComputerProvider(config.computer) : undefined; + +if (computerProvider?.warm) { + void computerProvider.warm(); +} // What Bots may do on their computers. Configuration supplies the deployment's default; an // administrator can change it while running, and a restart returns to the configured one. const policyStore = createPolicyStore( @@ -197,6 +176,16 @@ const policySource = await policyStore.load(); * unavailable, and the row is a note for a reader rather than something the server depends on. */ const bootAuditStore = createAuditStore(database); +const computerGateway = computerProvider + ? createComputerGateway({ + provider: computerProvider, + auditStore: bootAuditStore, + policy: () => policyStore.get(), + allowPrivateHosts: config.computer?.allowPrivateHosts, + token: config.computer?.token, + }) + : undefined; + /** * What a Bot can reach beyond its own computer. @@ -237,43 +226,33 @@ void recordAuditEvent(bootAuditStore, { /* * Record whether each Bot has a computer of its own. * - * Without a supervisor every Bot shares the browser at `AGENT_COMPUTER_URL`. That is a fine way to - * run on a laptop, but the shared isolation state must be visible rather than inferred. + * A shared provider is a fine way to run on a laptop, but the shared isolation state must be visible + * rather than inferred. */ +const isolation = computerProvider + ? computerProvider.describeIsolation() + : { + isolation: "one shared computer" as const, + note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", + warning: + "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", + }; + void recordAuditEvent(bootAuditStore, { eventType: "computer.isolation_loaded", targetType: "computer", - payload: daytonaSupervisor - ? { - isolation: "one computer per Bot", - note: "Each Bot gets a remote Daytona sandbox with its own /workspace and its own browser profile.", - } - : supervisor - ? { - isolation: "one computer per Bot", - note: "Each Bot gets its own container, its own /workspace and its own browser profile.", - } - : { - isolation: "one shared computer", - note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", - }, + payload: { + isolation: isolation.isolation, + note: isolation.note, + }, }).catch(() => undefined); console.info( JSON.stringify({ type: "computer-isolation", - provider: daytonaSupervisor - ? "Daytona" - : supervisor - ? "Docker supervisor" - : "shared", - isolation: supervisor ? "one computer per Bot" : "one shared computer", - ...(supervisor - ? {} - : { - warning: - "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", - }), + provider: computerProvider ? computerProvider.name : "shared", + isolation: isolation.isolation, + ...(isolation.warning ? { warning: isolation.warning } : {}), }), ); /** @@ -356,19 +335,8 @@ const app = createApp( identifyActor, stallGuard, ), - computerClient, // The only path to an acting call. - computerClient - ? createComputerGateway({ - client: computerClient, - auditStore: bootAuditStore, - // Read on every decision rather than captured once, so a rule an administrator adds while the - // server is running applies to the very next action instead of after a restart. - policy: () => policyStore.get(), - // Stop, reset and the listing act on containers when there are containers to act on. - ...(supervisor ? { supervisor } : {}), - }) - : undefined, + computerGateway, policyStore, // Bots as durable objects, and the channels they run in. agentProfileStore, @@ -451,12 +419,12 @@ serve({ if (!actor) { return new Response("Sign in first.", { status: 401 }); } - // Located per Bot when there is a supervisor, and the one shared computer when there is not. + // Located through the configured provider so every stream follows the same isolation rules. let upstream: string; try { - const streamBase = supervisor - ? await supervisor.locate(streamBotId) - : config.computer.baseUrl; + const streamBase = computerProvider + ? await computerProvider.locate(streamBotId) + : undefined; if (!streamBase) { return new Response("No computer address is configured.", { status: 503, diff --git a/server/tests/agent-routes.test.ts b/server/tests/agent-routes.test.ts index b05774ea..68ff108e 100644 --- a/server/tests/agent-routes.test.ts +++ b/server/tests/agent-routes.test.ts @@ -551,9 +551,8 @@ describe("agent route composition", () => { api: { getSession: async () => session }, }, { rolesForUser: async () => ["user"] }, - // Positions 4-11: auditReader, credentialService, packageStatusReader, connectorService, - // copilotHandler, computerClient, computerGateway, computerPolicy. - undefined, + // Positions 4-10: auditReader, credentialService, packageStatusReader, connectorService, + // copilotHandler, computerGateway, computerPolicy. undefined, undefined, undefined, diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index baaf7cf1..58120272 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -305,9 +305,8 @@ describe("channel route composition", () => { api: { getSession: async () => session }, }, { rolesForUser: async () => ["user"] }, - // Positions 4-12, ending at agentProfileStore. the computer gateway and policy store were added - // ahead of these, so the run of placeholders grew with them. - undefined, + // Positions 4-11, ending at agentProfileStore. The computer gateway and policy store come + // before these placeholders. undefined, undefined, undefined, diff --git a/server/tests/computer-client.test.ts b/server/tests/computer-client.test.ts index ba3e1779..23e80fd0 100644 --- a/server/tests/computer-client.test.ts +++ b/server/tests/computer-client.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - ComputerUnavailableError, - createComputerClient, + createComputerTransport, ElementNotFoundError, NavigationRefusedError, } from "../src/computer/client"; @@ -10,12 +9,31 @@ function clientWith( handler: (url: string, init?: RequestInit) => Promise | Response, allowPrivateHosts = false, ) { - return createComputerClient({ - baseUrl: "http://agent-computer:4100", + const transport = createComputerTransport({ allowPrivateHosts, fetchImpl: ((url: string, init?: RequestInit) => Promise.resolve(handler(url, init))) as unknown as typeof fetch, }); + const baseUrl = "http://agent-computer:4100"; + const botId = "bot-1"; + return { + navigate: (url: string) => transport.navigate(baseUrl, botId, url), + async status(requestedBotId: string) { + try { + await transport.call(baseUrl, requestedBotId, "/health"); + return { botId: requestedBotId, state: "ready" as const }; + } catch (error) { + return { + botId: requestedBotId, + state: "unreachable" as const, + reason: error instanceof Error ? error.message : "Unknown failure.", + }; + } + }, + screenshot: () => transport.call(baseUrl, botId, "/screenshot"), + click: (input: unknown, signal?: AbortSignal) => + transport.post(baseUrl, botId, "/click", input, signal), + }; } const ok = (body: unknown) => @@ -156,42 +174,6 @@ describe("computer client", () => { }); }); - test("refuses an unbound call when no baseUrl is configured", async () => { - const client = createComputerClient({ - fetchImpl: ((url: string, init?: RequestInit) => - Promise.resolve(ok({}))) as unknown as typeof fetch, - }); - - await expect(client.navigate("https://example.com/")).rejects.toThrow( - ComputerUnavailableError, - ); - await expect(client.navigate("https://example.com/")).rejects.toThrow( - "This deployment locates computers per Bot, and this call named no Bot.", - ); - }); - - test("routes to the resolved address when bound to a Bot without a shared baseUrl", async () => { - const seen: string[] = []; - const client = createComputerClient({ - resolveBaseUrl: async (botId) => `http://${botId}.daytona.internal:4100`, - fetchImpl: ((url: string, init?: RequestInit) => { - seen.push(url); - return Promise.resolve( - ok({ - url: "https://example.com/", - title: "Example", - elapsedMs: 10, - }), - ); - }) as unknown as typeof fetch, - }); - - const botClient = client.forBot("bot-123"); - await expect( - botClient.navigate("https://example.com/"), - ).resolves.toMatchObject({ title: "Example" }); - expect(seen).toEqual(["http://bot-123.daytona.internal:4100/navigate"]); - }); }); /** diff --git a/server/tests/computer-daytona.test.ts b/server/tests/computer-daytona.test.ts index d5b64449..756fe863 100644 --- a/server/tests/computer-daytona.test.ts +++ b/server/tests/computer-daytona.test.ts @@ -3,20 +3,20 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + createDaytonaComputerProvider, createDaytonaSupervisorClient, type DaytonaSdk, type DaytonaSupervisorOptions, type SandboxHandle, } from "../src/computer/daytona"; -import { SupervisorError } from "../src/computer/supervisor"; +import { ProviderError } from "../src/computer/provider"; /** * Daytona computer provider test suite. * - * OpenBot gives each Bot its own computer in a remote Daytona sandbox when configured with - * DAYTONA_API_KEY. The supervisor client is responsible for creating, locating, stopping, - * resetting, and listing these sandboxes via the Daytona SDK and validating readiness - * over the sandbox preview URL before returning it to the caller. + * OpenBot gives each Bot a remote Daytona sandbox when DAYTONA_API_KEY is set. + * The provider creates, locates, stops, resets, and lists these sandboxes through + * the Daytona SDK. It checks the preview URL before it returns a computer address. */ class DaytonaNotFoundError extends Error { @@ -246,6 +246,113 @@ function makeClient( describe("Daytona computer supervisor", () => { const tempDirs: string[] = []; + test("exposes the Daytona per-Bot provider contract", () => { + const provider = createDaytonaComputerProvider({ + apiKey: "test-api-key", + computerToken: "tok", + snapshot: "prebuilt", + sdk: createFakeSdk(), + fetchImpl: fakeFetch(), + }); + + expect(provider.name).toBe("Daytona"); + expect(provider.isolation).toBe("per-bot"); + expect(provider.describeIsolation()).toEqual({ + isolation: "one computer per Bot", + note: "Each Bot gets a remote Daytona sandbox with its own /workspace and its own browser profile.", + }); + }); + + test("status maps Daytona lifecycle states without starting or creating a sandbox", async () => { + const states = { + readyStarted: "started", + readyRunning: "running", + startingCreated: "created", + startingRestarting: "restarting", + startingCreating: "creating", + startingStarting: "starting", + startingRestoring: "restoring", + startingPulling: "pulling_snapshot", + startingResuming: "resuming", + absentStopped: "stopped", + absentPaused: "paused", + absentArchived: "archived", + absentExited: "exited", + absentDestroyed: "destroyed", + absentRemoving: "removing", + absentArchiving: "archiving", + absentPausing: "pausing", + absentStopping: "stopping", + unreachableError: "error", + unreachableBuildFailed: "build_failed", + unreachableUnknown: "new_state", + } as const; + const sandboxes = Object.entries(states).map(([botId, state], index) => + makeSandbox({ id: `status-${index}`, botId, state }), + ); + const sdk = createFakeSdk(sandboxes); + const provider = makeClient(sdk); + + for (const [botId, state] of Object.entries(states)) { + const status = await provider.status(botId); + if (state === "started" || state === "running") { + expect(status).toEqual({ botId, state: "ready" }); + } else if ( + [ + "created", + "restarting", + "creating", + "starting", + "restoring", + "pulling_snapshot", + "resuming", + ].includes(state) + ) { + expect(status).toEqual({ botId, state: "starting" }); + } else if ( + [ + "stopped", + "paused", + "archived", + "exited", + "destroyed", + "removing", + "archiving", + "pausing", + "stopping", + ].includes(state) + ) { + expect(status).toEqual({ botId, state: "absent" }); + } else { + expect(status).toMatchObject({ + botId, + state: "unreachable", + reason: expect.any(String), + }); + } + } + expect(sdk.creates).toHaveLength(0); + expect(sandboxes.every((sandbox) => sandbox.startCalls === 0)).toBe(true); + }); + + test("status reports an unknown Bot as absent without preparing the snapshot", async () => { + const sdk = createFakeSdk(); + sdk.snapshot.get = async () => { + throw new Error("status must not inspect the snapshot"); + }; + const provider = createDaytonaComputerProvider({ + apiKey: "test-api-key", + computerToken: "tok", + sdk, + fetchImpl: fakeFetch(), + }); + + await expect(provider.status("missing-bot")).resolves.toEqual({ + botId: "missing-bot", + state: "absent", + }); + expect(sdk.creates).toHaveLength(0); + }); afterEach(() => { for (const dir of tempDirs) { try { @@ -363,7 +470,7 @@ describe("Daytona computer supervisor", () => { expect(sdk.creates).toHaveLength(0); }); - test("SDK failure throws SupervisorError naming the Bot", async () => { + test("SDK failure throws ProviderError naming the Bot", async () => { const sdk = createFakeSdk(); sdk.create = async () => { throw new Error("quota exceeded"); @@ -371,7 +478,7 @@ describe("Daytona computer supervisor", () => { const client = makeClient(sdk); - await expect(client.locate("analytics")).rejects.toThrow(SupervisorError); + await expect(client.locate("analytics")).rejects.toThrow(ProviderError); await expect(client.locate("analytics")).rejects.toThrow(/analytics/); }); @@ -420,21 +527,21 @@ describe("Daytona computer supervisor", () => { expect.arrayContaining([ { botId: "bot-active", - container: "sb-active", status: "running", + url: "https://sb-active.preview.daytona.app", startedAt: "2026-08-20T10:00:00Z", }, { botId: "bot-stopped", - container: "sb-stopped", status: "exited", + url: "https://sb-stopped.preview.daytona.app", startedAt: "2026-08-20T11:00:00Z", }, ]), ); }); - test("health timeout throws SupervisorError", async () => { + test("health timeout throws ProviderError", async () => { const sdk = createFakeSdk(); const failingFetch = fakeFetch( () => new Response("service unavailable", { status: 503 }), @@ -446,14 +553,14 @@ describe("Daytona computer supervisor", () => { }); await expect(client.locate("unhealthy-bot")).rejects.toThrow( - SupervisorError, + ProviderError, ); await expect(client.locate("unhealthy-bot")).rejects.toThrow( /The computer for unhealthy-bot started but never answered \/health/, ); }); - test("health fetch that never resolves is bounded by healthTimeoutMs and locate rejects with SupervisorError", async () => { + test("health fetch that never resolves is bounded by healthTimeoutMs and locate rejects with ProviderError", async () => { const sdk = createFakeSdk(); const hangingFetch = fakeFetch(() => { const { promise } = Promise.withResolvers(); @@ -472,13 +579,13 @@ describe("Daytona computer supervisor", () => { locateError = err; } - expect(locateError).toBeInstanceOf(SupervisorError); + expect(locateError).toBeInstanceOf(ProviderError); expect((locateError as Error)?.message).toContain( "The computer for hanging-health-bot started but never answered /health at its preview URL.", ); }); - test("snapshot.create that never resolves is bounded by snapshotTimeoutMs and locate rejects with SupervisorError", async () => { + test("snapshot.create that never resolves is bounded by snapshotTimeoutMs and locate rejects with ProviderError", async () => { const fixtureDir = mkdtempSync( join(tmpdir(), "openbot-agent-computer-test-"), ); @@ -512,10 +619,10 @@ describe("Daytona computer supervisor", () => { locateError = err; } - expect(locateError).toBeInstanceOf(SupervisorError); + expect(locateError).toBeInstanceOf(ProviderError); }); - test("missing agentComputerDir package.json or src directory rejects locate with SupervisorError naming the directory and advising DAYTONA_SNAPSHOT", async () => { + test("missing agentComputerDir package.json or src directory rejects locate with ProviderError naming the directory and advising DAYTONA_SNAPSHOT", async () => { const missingPkgDir = mkdtempSync( join(tmpdir(), "openbot-missing-pkg-test-"), ); @@ -538,7 +645,7 @@ describe("Daytona computer supervisor", () => { pkgError = err; } - expect(pkgError).toBeInstanceOf(SupervisorError); + expect(pkgError).toBeInstanceOf(ProviderError); expect((pkgError as Error)?.message).toContain(missingPkgDir); expect((pkgError as Error)?.message).toContain("DAYTONA_SNAPSHOT"); @@ -563,7 +670,7 @@ describe("Daytona computer supervisor", () => { srcError = err; } - expect(srcError).toBeInstanceOf(SupervisorError); + expect(srcError).toBeInstanceOf(ProviderError); expect((srcError as Error)?.message).toContain(missingSrcDir); expect((srcError as Error)?.message).toContain("DAYTONA_SNAPSHOT"); }); @@ -755,7 +862,7 @@ describe("Daytona computer supervisor", () => { resetError = err; } - expect(resetError).toBeInstanceOf(SupervisorError); + expect(resetError).toBeInstanceOf(ProviderError); expect((resetError as Error)?.message).toContain("retry-delete-bot"); expect(existing.deleteCalls).toBe(1); @@ -850,14 +957,14 @@ describe("Daytona computer supervisor", () => { expect.arrayContaining([ { botId: "started-voc-bot", - container: "sb-started-voc", status: "running", + url: "https://sb-started-voc.preview.daytona.app", startedAt: "2026-08-20T10:00:00Z", }, { botId: "stopped-voc-bot", - container: "sb-stopped-voc", status: "exited", + url: "https://sb-stopped-voc.preview.daytona.app", startedAt: "2026-08-20T11:00:00Z", }, ]), diff --git a/server/tests/computer-gateway-provider.test.ts b/server/tests/computer-gateway-provider.test.ts new file mode 100644 index 00000000..a6d9330c --- /dev/null +++ b/server/tests/computer-gateway-provider.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import { createComputerGateway } from "../src/computer/gateway"; +import type { ComputerProvider } from "../src/computer/provider"; + +function setup() { + const calls: string[] = []; + const rows: AuditEventInput[] = []; + const provider: ComputerProvider = { + name: "test", + isolation: "per-bot", + describeIsolation: () => ({ + isolation: "one computer per Bot", + note: "Test provider.", + }), + locate: async (botId) => { + calls.push(`locate:${botId}`); + return `http://${botId}.computer`; + }, + status: async (botId) => { + calls.push(`status:${botId}`); + return { botId, state: "ready" }; + }, + stop: async (botId) => void calls.push(`stop:${botId}`), + reset: async (botId) => void calls.push(`reset:${botId}`), + list: async () => [ + { + botId: "bot-1", + status: "started", + startedAt: "2026-08-20T12:00:00.000Z", + }, + ], + }; + const auditStore: AuditStore = { + insert: async (event) => void rows.push(event), + }; + const requests: Array<{ url: string; init?: RequestInit }> = []; + const gateway = createComputerGateway({ + provider, + auditStore, + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + token: "computer-secret", + fetchImpl: (async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + return Response.json({ image: "aGVsbG8=", mimeType: "image/png" }); + }) as unknown as typeof fetch, + }); + return { gateway, provider, calls, rows, requests }; +} + +const ACTOR = { id: "dev-local-user" }; + +describe("the provider-backed computer gateway", () => { + test("exposes the provider and delegates address and status lookup", async () => { + const { gateway, provider, calls } = setup(); + + expect(gateway.provider).toBe(provider); + expect(await gateway.locate("bot-1")).toBe("http://bot-1.computer"); + expect(await gateway.status("bot-1")).toEqual({ + botId: "bot-1", + state: "ready", + }); + expect(calls).toEqual(["locate:bot-1", "status:bot-1"]); + }); + + test("takes a screenshot through the located computer with its identity and token", async () => { + const { gateway, requests } = setup(); + + expect(await gateway.screenshot("bot-1")).toEqual({ + image: "aGVsbG8=", + mimeType: "image/png", + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("http://bot-1.computer/screenshot"); + expect(requests[0]?.init?.headers).toMatchObject({ + "x-openbot-bot-id": "bot-1", + "x-openbot-computer-token": "computer-secret", + }); + }); + + test("uses the provider for inventory and audited lifecycle actions", async () => { + const { gateway, calls, rows } = setup(); + + expect(await gateway.computers()).toEqual({ + isolation: "per-bot", + computers: [ + { + botId: "bot-1", + running: true, + startedAt: "2026-08-20T12:00:00.000Z", + egress: null, + }, + ], + }); + expect(await gateway.stopComputer("computer-1", "bot-1", ACTOR)).toEqual({ + wasRunning: true, + }); + expect(await gateway.resetComputer("computer-1", "bot-1", ACTOR)).toEqual({ + cleared: true, + }); + expect(calls).toEqual(["stop:bot-1", "reset:bot-1"]); + expect(rows.map((row) => row.eventType)).toEqual([ + "computer.stopped", + "computer.reset", + ]); + }); + + test("routes acting, control, file, secret, and human input calls through the gateway", async () => { + const { gateway, requests } = setup(); + + await gateway.key("bot-1", "bot-1", ACTOR, { key: "Tab" }); + await gateway.scroll("bot-1", "bot-1", ACTOR, { deltaY: 400 }); + await gateway.listFiles("bot-1", "bot-1", ACTOR, { path: "notes" }); + await gateway.control("bot-1"); + await gateway.requestHelp("bot-1", "bot-1", ACTOR, "Sign in"); + await gateway.takeControl("bot-1", "bot-1", ACTOR); + await gateway.releaseControl("bot-1", "bot-1", ACTOR); + await gateway.requestSecret("bot-1", "bot-1", ACTOR, { + label: "Password", + ref: "e1", + snapshotId: 3, + }); + await gateway.supplySecret("bot-1", "bot-1", ACTOR, "secret"); + await gateway.humanInput("bot-1", { kind: "click", x: 10, y: 20 }); + + expect(requests.map(({ url }) => new URL(url).pathname)).toEqual([ + "/key", + "/scroll", + "/files/list", + "/control", + "/control/request", + "/control/take", + "/control/release", + "/control/secret", + "/human/secret", + "/human/click", + ]); + }); +}); diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index ab14d502..e6b2f22c 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; import type { AuditEventInput, AuditStore } from "../src/audit"; -import type { ComputerClient } from "../src/computer/client"; import { ActionRefusedError, createComputerGateway, } from "../src/computer/gateway"; import type { ActionPolicy } from "../src/computer/policy"; -import type { ComputerStatus, SnapshotResult } from "../src/computer/schema"; +import type { SnapshotResult } from "../src/computer/schema"; +import type { ComputerProvider } from "../src/computer/provider"; /** * What the gateway must guarantee, tested as properties rather than as call sequences. @@ -29,72 +29,73 @@ const SNAPSHOT: SnapshotResult = { ], }; -/** A client that records what reached it, so "did not reach the computer" is checkable. */ -function fakeClient() { +/** A computer that records which HTTP actions reached it. */ +function fakeComputer() { const calls: string[] = []; - /** Which Bot the gateway addressed the computer as, per call. */ const addressedAs: string[] = []; const result = (action: string) => ({ action, url: SNAPSHOT.url, elapsedMs: 1, }); - const client = { - snapshot: async () => SNAPSHOT, - read: async () => ({ - url: SNAPSHOT.url, - title: "Order", - text: "", - truncated: false, + const provider: ComputerProvider = { + name: "test", + isolation: "per-bot", + describeIsolation: () => ({ + isolation: "one computer per Bot", + note: "Test provider.", }), - click: async () => { - calls.push("click"); - return result("click") as never; - }, - type: async () => { - calls.push("type"); - return result("type") as never; - }, - key: async () => { - calls.push("key"); - return result("key") as never; - }, - scroll: async () => { - calls.push("scroll"); - return result("scroll") as never; - }, - readFile: async () => { - calls.push("readFile"); - return { - path: "notes.md", - text: "kept", - truncated: false, - bytes: 4, - } as never; - }, - writeFile: async () => { - calls.push("writeFile"); - return { path: "notes.md", bytes: 4, appended: false } as never; - }, - status: async (botId?: string) => { - calls.push("status"); - return { botId: botId ?? "b", state: "ready" as const }; - }, - navigate: async () => { - calls.push("navigate"); - return { url: "https://example.com/", title: "Example" } as never; - }, - screenshot: async () => ({}) as never, - /** - * The per-Bot view. Recorded rather than ignored, so a test can prove the gateway told the - * computer which Bot is asking, the thing every per-Bot behaviour on the far side keys off. - */ - forBot(botId: string) { + locate: async (botId) => { addressedAs.push(botId); - return client; + return "http://agent-computer:4100"; }, - } as unknown as ComputerClient; - return { client, calls, addressedAs }; + status: async (botId) => ({ botId, state: "ready" }), + stop: async () => {}, + reset: async () => {}, + list: async () => [], + }; + const fetchImpl = (async (url: string) => { + const path = new URL(url).pathname; + switch (path) { + case "/snapshot": + return Response.json(SNAPSHOT); + case "/read": + return Response.json({ + url: SNAPSHOT.url, + title: "Order", + text: "", + truncated: false, + }); + case "/files/read": + calls.push("readFile"); + return Response.json({ + path: "notes.md", + text: "kept", + truncated: false, + bytes: 4, + }); + case "/files/write": + calls.push("writeFile"); + return Response.json({ + path: "notes.md", + bytes: 4, + appended: false, + }); + case "/navigate": + calls.push("navigate"); + return Response.json({ + url: "https://example.com/", + title: "Example", + elapsedMs: 1, + }); + default: { + const action = path.slice(1); + calls.push(action); + return Response.json(result(action)); + } + } + }) as unknown as typeof fetch; + return { provider, fetchImpl, calls, addressedAs }; } function fakeAudit() { @@ -107,15 +108,16 @@ const ACTOR = { id: "dev-local-user" }; const PERMISSIVE: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; async function gatewayWith(policy: ActionPolicy | undefined) { - const { client, calls } = fakeClient(); + const { provider, fetchImpl, calls } = fakeComputer(); const { store, rows } = fakeAudit(); const gateway = createComputerGateway({ - client, + provider, + fetchImpl, auditStore: store, policy: () => policy, }); // Every test acts on refs, so the server must hold a snapshot first, exactly as the real flow does. - await gateway.snapshot("default"); + await gateway.snapshot("bot-1"); return { gateway, calls, rows }; } @@ -303,16 +305,20 @@ describe("the computer gateway", () => { test("the computer is told WHICH Bot is asking", async () => { // Every per-Bot behaviour on the computer keys off this id: the profile it opens, the logins it // has, the proxy its traffic leaves through, and who holds its wheel. - const { client, addressedAs } = fakeClient(); + const { provider, fetchImpl, addressedAs } = fakeComputer(); const { store } = fakeAudit(); const gateway = createComputerGateway({ - client, + provider, + fetchImpl, auditStore: store, policy: () => PERMISSIVE, }); await gateway.snapshot("sales-bot"); - await gateway.click("sales-bot", "sales-bot", ACTOR, "e1"); + await gateway.click("sales-bot", "sales-bot", ACTOR, { + ref: "e1", + snapshotId: 7, + }); await gateway.read("research-bot"); expect(addressedAs).toContain("sales-bot"); @@ -326,18 +332,21 @@ describe("the computer gateway", () => { test("a permitted action that FAILS gets its own row, not an allowed one", async () => { // A permitted read that later fails path confinement records both the decision and the failed // outcome, so the trail does not imply the Bot received the file. - const { client, calls } = fakeClient(); + const { provider, fetchImpl, calls } = fakeComputer(); const { store, rows } = fakeAudit(); - const failing: ComputerClient = { - ...client, - readFile: async () => { + const failingFetch = (async (url: string, init?: RequestInit) => { + if (new URL(url).pathname === "/files/read") { calls.push("readFile"); - throw new Error("that path is outside your workspace"); - }, - forBot: () => failing, - } as unknown as ComputerClient; + return Response.json( + { error: "that path is outside your workspace" }, + { status: 403 }, + ); + } + return fetchImpl(url, init); + }) as typeof fetch; const gateway = createComputerGateway({ - client: failing, + provider, + fetchImpl: failingFetch, auditStore: store, policy: () => PERMISSIVE, }); @@ -355,9 +364,7 @@ describe("the computer gateway", () => { }); test("opening a page is recorded, with the address it was opening", async () => { - // The target guard refuses a forbidden address inside the - // client and nothing was written, so an attempt on the cloud metadata endpoint left no row while - // ticking a radio button left one. + // The target guard refuses forbidden addresses before the request leaves. const { gateway, calls, rows } = await gatewayWith(PERMISSIVE); await gateway.navigate("default", "bot-1", ACTOR, "https://example.com/"); @@ -400,229 +407,26 @@ describe("the computer gateway", () => { expect(rows[0]?.payload.element).toBe("not in the current snapshot"); }); - describe("status", () => { - test("with supervisor.list returning no Bot it returns absent without locating or checking health", async () => { - // Checking status is an inspection, not an action: it must not create or wake a container. - // An absent Bot is simply absent, determined from the supervisor's current inventory. - const { client, calls } = fakeClient(); - const { store } = fakeAudit(); - const locateCalls: string[] = []; - const supervisor = { - locate: async (botId: string) => { - locateCalls.push(botId); - return "http://localhost:4100"; - }, - list: async () => [{ botId: "other-bot", status: "running" }], - stop: async () => {}, - reset: async () => {}, - }; - const gateway = createComputerGateway({ - client, - supervisor, - auditStore: store, - policy: () => PERMISSIVE, - }) as unknown as { status(botId: string): Promise }; - - const status = await gateway.status("sales-bot"); - - expect(status).toEqual({ botId: "sales-bot", state: "absent" }); - expect(calls).toEqual([]); - expect(locateCalls).toEqual([]); - }); - - test("maps started and running supervisor statuses to ready", async () => { - const { client, calls } = fakeClient(); - const { store } = fakeAudit(); - const locateCalls: string[] = []; - const supervisor = { - locate: async (botId: string) => { - locateCalls.push(botId); - return "http://localhost:4100"; - }, - list: async () => [ - { botId: "started-bot", status: "started" }, - { botId: "running-bot", status: "running" }, - ], - stop: async () => {}, - reset: async () => {}, - }; - const gateway = createComputerGateway({ - client, - supervisor, - auditStore: store, - policy: () => PERMISSIVE, - }) as unknown as { status(botId: string): Promise }; - - expect(await gateway.status("started-bot")).toEqual({ - botId: "started-bot", - state: "ready", - }); - expect(await gateway.status("running-bot")).toEqual({ - botId: "running-bot", - state: "ready", - }); - expect(calls).toEqual([]); - expect(locateCalls).toEqual([]); - }); - - test("maps creating, starting, created, and restarting supervisor statuses to starting", async () => { - const { client, calls } = fakeClient(); - const { store } = fakeAudit(); - const supervisor = { - list: async () => [ - { botId: "creating-bot", status: "creating" }, - { botId: "starting-bot", status: "starting" }, - { botId: "created-bot", status: "created" }, - { botId: "restarting-bot", status: "restarting" }, - ], - stop: async () => {}, - reset: async () => {}, - }; - const gateway = createComputerGateway({ - client, - supervisor, - auditStore: store, - policy: () => PERMISSIVE, - }) as unknown as { status(botId: string): Promise }; - - expect(await gateway.status("creating-bot")).toEqual({ - botId: "creating-bot", - state: "starting", - }); - expect(await gateway.status("starting-bot")).toEqual({ - botId: "starting-bot", - state: "starting", - }); - expect(await gateway.status("created-bot")).toEqual({ - botId: "created-bot", - state: "starting", - }); - expect(await gateway.status("restarting-bot")).toEqual({ - botId: "restarting-bot", - state: "starting", - }); - expect(calls).toEqual([]); - }); - - test("maps stopped, removing, archiving, pausing, and stopping supervisor statuses to absent", async () => { - const { client, calls } = fakeClient(); - const { store } = fakeAudit(); - const supervisor = { - list: async () => [ - { botId: "stopped-bot", status: "stopped" }, - { botId: "removing-bot", status: "removing" }, - { botId: "archiving-bot", status: "archiving" }, - { botId: "pausing-bot", status: "pausing" }, - { botId: "stopping-bot", status: "stopping" }, - ], - stop: async () => {}, - reset: async () => {}, - }; - const gateway = createComputerGateway({ - client, - supervisor, - auditStore: store, - policy: () => PERMISSIVE, - }) as unknown as { status(botId: string): Promise }; - - expect(await gateway.status("stopped-bot")).toEqual({ - botId: "stopped-bot", - state: "absent", - }); - expect(await gateway.status("removing-bot")).toEqual({ - botId: "removing-bot", - state: "absent", - }); - expect(await gateway.status("archiving-bot")).toEqual({ - botId: "archiving-bot", - state: "absent", - }); - expect(await gateway.status("pausing-bot")).toEqual({ - botId: "pausing-bot", - state: "absent", - }); - expect(await gateway.status("stopping-bot")).toEqual({ - botId: "stopping-bot", - state: "absent", - }); - expect(calls).toEqual([]); - }); - - test("when supervisor.list throws, returns unreachable with the error message in reason", async () => { - const { client, calls } = fakeClient(); - const { store } = fakeAudit(); - const supervisor = { - list: async () => { - throw new Error("supervisor unavailable"); - }, - stop: async () => {}, - reset: async () => {}, - }; - const gateway = createComputerGateway({ - client, - supervisor, - auditStore: store, - policy: () => PERMISSIVE, - }) as unknown as { status(botId: string): Promise }; - - const status = await gateway.status("sales-bot"); - - expect(status.botId).toBe("sales-bot"); - expect(status.state).toBe("unreachable"); - expect(status.reason).toBeDefined(); - expect(status.reason).toContain("supervisor unavailable"); - expect(calls).toEqual([]); + test("resolves an element from the Bot snapshot when the audit computer id differs", async () => { + const { provider, fetchImpl } = fakeComputer(); + const { store, rows } = fakeAudit(); + const gateway = createComputerGateway({ + provider, + fetchImpl, + auditStore: store, + policy: () => PERMISSIVE, }); + await gateway.snapshot("bot-1"); - test("maps error and build_failed supervisor statuses to unreachable with a useful reason", async () => { - const { client, calls } = fakeClient(); - const { store } = fakeAudit(); - const supervisor = { - list: async () => [ - { botId: "error-bot", status: "error" }, - { botId: "failed-bot", status: "build_failed" }, - ], - stop: async () => {}, - reset: async () => {}, - }; - const gateway = createComputerGateway({ - client, - supervisor, - auditStore: store, - policy: () => PERMISSIVE, - }) as unknown as { status(botId: string): Promise }; - - const errorStatus = await gateway.status("error-bot"); - expect(errorStatus.botId).toBe("error-bot"); - expect(errorStatus.state).toBe("unreachable"); - expect(errorStatus.reason).toBeDefined(); - expect(typeof errorStatus.reason).toBe("string"); - expect(errorStatus.reason!.length).toBeGreaterThan(0); - - const failedStatus = await gateway.status("failed-bot"); - expect(failedStatus.botId).toBe("failed-bot"); - expect(failedStatus.state).toBe("unreachable"); - expect(failedStatus.reason).toBeDefined(); - expect(typeof failedStatus.reason).toBe("string"); - expect(failedStatus.reason!.length).toBeGreaterThan(0); - - expect(calls).toEqual([]); + await gateway.click("audit-computer-7", "bot-1", ACTOR, { + ref: "e9", + snapshotId: 7, }); - test("without a supervisor, delegates once to client.status(botId)", async () => { - const { client, calls, addressedAs } = fakeClient(); - const { store } = fakeAudit(); - const gateway = createComputerGateway({ - client, - auditStore: store, - policy: () => PERMISSIVE, - }) as unknown as { status(botId: string): Promise }; - - const status = await gateway.status("sales-bot"); - - expect(status).toEqual({ botId: "sales-bot", state: "ready" }); - expect(calls).toEqual(["status"]); - expect(addressedAs).toContain("sales-bot"); + expect(rows[0]?.payload.element).toEqual({ + role: "button", + name: "Submit order", }); }); + }); diff --git a/server/tests/computer-provider.test.ts b/server/tests/computer-provider.test.ts new file mode 100644 index 00000000..f779e911 --- /dev/null +++ b/server/tests/computer-provider.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { ComputerConfig } from "../src/config"; +import { + createComputerProvider, + createSharedComputerProvider, +} from "../src/computer/provider"; + +const servers: { stop(closeActiveConnections?: boolean): void }[] = []; + +afterEach(() => { + for (const server of servers.splice(0)) server.stop(true); +}); + +function serve(handler: (request: Request) => Response | Promise) { + const server = Bun.serve({ port: 0, fetch: handler }); + servers.push(server); + return `http://127.0.0.1:${server.port}`; +} + +describe("shared computer provider", () => { + test("describes the shared browser and how to isolate Bots", () => { + const provider = createSharedComputerProvider({ + baseUrl: "http://computer:4100/", + }); + + expect(provider.name).toBe("shared"); + expect(provider.isolation).toBe("shared"); + expect(provider.describeIsolation()).toEqual({ + isolation: "one shared computer", + note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", + warning: "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", + }); + expect(provider.locate("sales")).resolves.toBe("http://computer:4100/"); + }); + + test("reports a healthy shared computer as ready", async () => { + const paths: string[] = []; + const baseUrl = serve((request) => { + paths.push(new URL(request.url).pathname); + return Response.json({ status: "ok" }); + }); + const provider = createSharedComputerProvider({ baseUrl }); + + expect(await provider.status("sales")).toEqual({ + botId: "sales", + state: "ready", + }); + expect(paths).toEqual(["/health"]); + }); + + test("reports the HTTP failure when the shared computer is not healthy", async () => { + const baseUrl = serve(() => new Response("not ready", { status: 503 })); + const provider = createSharedComputerProvider({ baseUrl }); + + expect(await provider.status("sales")).toEqual({ + botId: "sales", + state: "unreachable", + reason: "The shared computer answered 503.", + }); + }); + + test("sends lifecycle requests with the Bot identity and computer token", async () => { + const requests: { + path: string; + method: string; + botId: string | null; + token: string | null; + }[] = []; + const baseUrl = serve((request) => { + requests.push({ + path: new URL(request.url).pathname, + method: request.method, + botId: request.headers.get("x-openbot-bot-id"), + token: request.headers.get("x-openbot-computer-token"), + }); + return Response.json({ ok: true }); + }); + const provider = createSharedComputerProvider({ + baseUrl, + token: "computer-secret", + }); + + await provider.stop("sales"); + await provider.reset("sales"); + + expect(requests).toEqual([ + { + path: "/stop", + method: "POST", + botId: "sales", + token: "computer-secret", + }, + { + path: "/reset", + method: "POST", + botId: "sales", + token: "computer-secret", + }, + ]); + }); + + test("maps the shared computer inventory to provider locations", async () => { + const baseUrl = serve(() => + Response.json({ + computers: [ + { + botId: "sales", + running: true, + startedAt: "2026-08-20T12:00:00.000Z", + egress: null, + }, + { + botId: "support", + running: false, + startedAt: null, + egress: null, + }, + ], + }), + ); + const provider = createSharedComputerProvider({ baseUrl }); + + expect(await provider.list()).toEqual([ + { + botId: "sales", + status: "running", + url: baseUrl, + startedAt: "2026-08-20T12:00:00.000Z", + }, + { + botId: "support", + status: "stopped", + url: baseUrl, + }, + ]); + }); +}); + +describe("computer provider factory", () => { + test("selects the Docker supervisor adapter", () => { + const config: ComputerConfig = { + provider: "docker", + baseUrl: "http://supervisor:4300", + supervisorToken: "supervisor-secret", + token: "computer-secret", + allowPrivateHosts: false, + }; + + expect(createComputerProvider(config).name).toBe("Docker supervisor"); + }); + + test("selects the shared computer adapter", () => { + const config: ComputerConfig = { + provider: "shared", + baseUrl: "http://computer:4100", + token: "computer-secret", + allowPrivateHosts: false, + }; + + expect(createComputerProvider(config).name).toBe("shared"); + }); + + test("selects the Daytona adapter", () => { + const config: ComputerConfig = { + provider: "daytona", + apiKey: "daytona-key", + token: "computer-secret", + allowPrivateHosts: false, + snapshot: "prebuilt", + }; + + expect(createComputerProvider(config).name).toBe("Daytona"); + }); +}); diff --git a/server/tests/computer-routes.test.ts b/server/tests/computer-routes.test.ts new file mode 100644 index 00000000..641abe87 --- /dev/null +++ b/server/tests/computer-routes.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { ComputerGateway } from "../src/computer/gateway"; +import type { PolicyStore } from "../src/computer/policy-store"; +import { createComputerRoutes } from "../src/computer/routes"; + +describe("computer routes", () => { + test("gets a screenshot through the governed computer gateway", async () => { + const requestedBotIds: string[] = []; + const gateway = { + screenshot: async (botId: string) => { + requestedBotIds.push(botId); + return { image: "aGVsbG8=", mimeType: "image/png" as const }; + }, + } as unknown as ComputerGateway; + const policyStore = {} as PolicyStore; + const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = async ( + _context, + next, + ) => next(); + const routes = createComputerRoutes(gateway, policyStore, requireUser); + + const response = await routes.request( + "http://openbot.test/bot-17/screenshot", + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + image: "aGVsbG8=", + mimeType: "image/png", + }); + expect(requestedBotIds).toEqual(["bot-17"]); + }); +}); diff --git a/server/tests/computer-supervisor.test.ts b/server/tests/computer-supervisor.test.ts index f591a479..8680cdb8 100644 --- a/server/tests/computer-supervisor.test.ts +++ b/server/tests/computer-supervisor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + createDockerSupervisorProvider, createSupervisorClient, SupervisorError, } from "../src/computer/supervisor"; @@ -98,3 +99,94 @@ describe("locating a Bot's computer", () => { expect(seen).toBe("/computers/a%2Fb/ensure"); }); }); + +describe("Docker supervisor provider", () => { + test("describes one container and browser profile for each Bot", () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ computers: [] })) as unknown as typeof fetch, + }); + + expect(provider.name).toBe("Docker supervisor"); + expect(provider.isolation).toBe("per-bot"); + expect(provider.describeIsolation()).toEqual({ + isolation: "one computer per Bot", + note: "Each Bot gets its own container, its own /workspace and its own browser profile.", + }); + }); + + test("maps supervisor lifecycle states without starting the computer", async () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ + computers: [ + { + botId: "ready-bot", + container: "computer-ready", + status: "running", + url: "http://computer-ready:4100", + startedAt: "2026-08-20T12:00:00.000Z", + }, + { + botId: "starting-bot", + container: "computer-starting", + status: "creating", + }, + { + botId: "broken-bot", + container: "computer-broken", + status: "error", + }, + ], + })) as unknown as typeof fetch, + }); + + expect(await provider.status("ready-bot")).toEqual({ + botId: "ready-bot", + state: "ready", + }); + expect(await provider.status("starting-bot")).toEqual({ + botId: "starting-bot", + state: "starting", + }); + expect(await provider.status("missing-bot")).toEqual({ + botId: "missing-bot", + state: "absent", + }); + expect(await provider.status("broken-bot")).toEqual({ + botId: "broken-bot", + state: "unreachable", + reason: 'The computer reported state "error".', + }); + }); + + test("lists only the provider location fields", async () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ + computers: [ + { + botId: "sales", + container: "computer-sales", + status: "running", + port: 49152, + url: "http://computer-sales:4100", + startedAt: "2026-08-20T12:00:00.000Z", + }, + ], + })) as unknown as typeof fetch, + }); + + expect(await provider.list()).toEqual([ + { + botId: "sales", + status: "running", + url: "http://computer-sales:4100", + startedAt: "2026-08-20T12:00:00.000Z", + }, + ]); + }); +}); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 1877f9e8..cbcacac4 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -187,16 +187,65 @@ describe("deployment configuration", () => { }, ); - test("enables Daytona remote computers without a shared base address", () => { + test("configures Daytona as the remote computer provider", () => { const config = loadConfig({ ...baseEnvironment, DAYTONA_API_KEY: "dtn_test_key", + DAYTONA_API_URL: "https://daytona.example.com", + DAYTONA_TARGET: "us", + DAYTONA_SNAPSHOT: "agent-computer", COMPUTER_TOKEN: "secret-token", + AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS: "true", }); - expect(config.computer?.daytona?.apiKey).toBe("dtn_test_key"); - expect(config.computer?.baseUrl).toBeUndefined(); - expect(config.computer?.token).toBe("secret-token"); + expect(config.computer?.provider).toBe("daytona"); + expect(config.computer).toEqual({ + provider: "daytona", + apiKey: "dtn_test_key", + apiUrl: "https://daytona.example.com", + target: "us", + snapshot: "agent-computer", + token: "secret-token", + allowPrivateHosts: true, + }); + }); + + test("configures Docker as the per-Bot computer provider", () => { + const config = loadConfig({ + ...baseEnvironment, + COMPUTER_SUPERVISOR_URL: "http://localhost:4000", + SUPERVISOR_TOKEN: "supervisor-token", + COMPUTER_TOKEN: "computer-token", + }); + + expect(config.computer?.provider).toBe("docker"); + expect(config.computer).toEqual({ + provider: "docker", + baseUrl: "http://localhost:4000", + supervisorToken: "supervisor-token", + token: "computer-token", + allowPrivateHosts: false, + }); + }); + + test("configures one shared computer", () => { + const config = loadConfig({ + ...baseEnvironment, + AGENT_COMPUTER_URL: "http://localhost:4100", + COMPUTER_TOKEN: "computer-token", + }); + + expect(config.computer?.provider).toBe("shared"); + expect(config.computer).toEqual({ + provider: "shared", + baseUrl: "http://localhost:4100", + token: "computer-token", + allowPrivateHosts: false, + }); + }); + + test("leaves computers off when no provider address is configured", () => { + expect(loadConfig(baseEnvironment).computer).toBeUndefined(); }); test("refuses to configure Daytona computers without COMPUTER_TOKEN", () => { @@ -210,7 +259,7 @@ describe("deployment configuration", () => { ); }); - test("refuses to configure both Daytona and a container supervisor", () => { + test("refuses to configure both Daytona and Docker computer providers", () => { expect(() => loadConfig({ ...baseEnvironment, @@ -222,4 +271,22 @@ describe("deployment configuration", () => { "Set either DAYTONA_API_KEY or COMPUTER_SUPERVISOR_URL, not both. They are two ways of giving each Bot its own computer.", ); }); + + test.each([ + ["Daytona", "DAYTONA_API_URL", "DAYTONA_API_KEY"], + ["Docker", "COMPUTER_SUPERVISOR_URL", undefined], + ["shared", "AGENT_COMPUTER_URL", undefined], + ] as const)( + "refuses an invalid %s computer provider URL", + (_, urlName, daytonaKeyName) => { + expect(() => + loadConfig({ + ...baseEnvironment, + ...(daytonaKeyName ? { [daytonaKeyName]: "dtn_test_key" } : {}), + ...(daytonaKeyName ? { COMPUTER_TOKEN: "secret-token" } : {}), + [urlName]: "not a URL", + }), + ).toThrow(`${urlName} must be a valid URL`); + }, + ); }); From 5124bfd94b797b81d189d424072578c6cd8a35c8 Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 12:52:59 -0700 Subject: [PATCH 09/16] Make computer providers honest and race-safe Signed-off-by: Muhammad Hashmi --- app/src/routes/_authed/admin/computers.tsx | 10 +- server/src/computer/client.ts | 1 - server/src/computer/daytona.ts | 616 +++++++------ server/src/computer/gateway.ts | 172 +--- server/src/computer/provider.ts | 90 +- server/src/computer/routes.ts | 32 +- server/src/computer/supervisor.ts | 76 +- server/src/index.ts | 17 +- server/tests/computer-client.test.ts | 47 +- server/tests/computer-daytona-fixture.ts | 258 ++++++ .../tests/computer-daytona-lifecycle.test.ts | 561 +++++++++++ server/tests/computer-daytona.test.ts | 869 ++++-------------- .../tests/computer-gateway-provider.test.ts | 139 --- server/tests/computer-gateway.test.ts | 355 +++++-- server/tests/computer-provider.test.ts | 232 ++++- server/tests/computer-supervisor.test.ts | 136 ++- server/tests/config.test.ts | 13 +- 17 files changed, 2061 insertions(+), 1563 deletions(-) create mode 100644 server/tests/computer-daytona-fixture.ts create mode 100644 server/tests/computer-daytona-lifecycle.test.ts delete mode 100644 server/tests/computer-gateway-provider.test.ts diff --git a/app/src/routes/_authed/admin/computers.tsx b/app/src/routes/_authed/admin/computers.tsx index 3676ea93..b1e7d965 100644 --- a/app/src/routes/_authed/admin/computers.tsx +++ b/app/src/routes/_authed/admin/computers.tsx @@ -30,7 +30,7 @@ type ComputerProfile = { botId: string; running: boolean; startedAt: string | null; - egress: string | null; + egress?: string | null; }; /** API placeholder id; the list endpoint returns all computers. */ @@ -159,9 +159,11 @@ function ComputersPage() { ? `Browser running since ${new Date(computer.startedAt ?? "").toLocaleTimeString()}` : "No browser running. It starts when the Bot next needs it."} {" · "} - {computer.egress - ? `Leaves through ${computer.egress}` - : "Leaves directly"} + {computer.egress === undefined + ? "Egress not reported" + : computer.egress === null + ? "Leaves directly" + : `Leaves through ${computer.egress}`} diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index 02e43e2a..8d0a83c6 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -205,4 +205,3 @@ function throwMappedError( } throw new ComputerUnavailableError(detail); } - diff --git a/server/src/computer/daytona.ts b/server/src/computer/daytona.ts index 80bfb109..0278aa12 100644 --- a/server/src/computer/daytona.ts +++ b/server/src/computer/daytona.ts @@ -6,11 +6,11 @@ import { DaytonaConflictError, DaytonaNotFoundError, Image, + SandboxState, } from "@daytona/sdk"; import { type ComputerLocation, type ComputerProvider, - type IsolationDescription, ProviderError, } from "./provider"; import type { ComputerStatus } from "./schema"; @@ -26,12 +26,13 @@ import type { ComputerStatus } from "./schema"; export type SandboxHandle = { id: string; - state?: string; + state?: SandboxState; labels?: Record; createdAt?: string; start(timeout?: number): Promise; stop(): Promise; delete(timeout?: number, wait?: boolean): Promise; + refreshActivity(): Promise; getPreviewLink(port: number): Promise<{ url: string }>; }; @@ -59,7 +60,7 @@ export type DaytonaSdk = { }; }; -export type DaytonaSupervisorOptions = { +export type DaytonaProviderOptions = { apiKey: string; apiUrl?: string; target?: string; @@ -78,6 +79,7 @@ const COMPUTER_PORT = 4100; const OWNERSHIP_LABEL_KEY = "openbot/computer"; const OWNERSHIP_LABEL_VALUE = "true"; const BOT_ID_LABEL_KEY = "openbot/bot-id"; +const TOKEN_HASH_LABEL_KEY = "openbot/computer-token-hash"; const DEFAULT_AGENT_COMPUTER_DIR = join( import.meta.dir, "../../../agent-computer", @@ -159,13 +161,14 @@ function computeSnapshotName(agentComputerDir: string): string { return `openbot-agent-computer-${hex}`; } +function computeTokenHash(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + function isNotFoundError(err: unknown): boolean { if (!err || typeof err !== "object") return false; if (err instanceof DaytonaNotFoundError) return true; - if ( - "name" in err && - (err as { name: string }).name === "DaytonaNotFoundError" - ) { + if ("name" in err && err.name === "DaytonaNotFoundError") { return true; } return false; @@ -174,10 +177,7 @@ function isNotFoundError(err: unknown): boolean { function isConflictError(err: unknown): boolean { if (!err || typeof err !== "object") return false; if (err instanceof DaytonaConflictError) return true; - if ( - "name" in err && - (err as { name: string }).name === "DaytonaConflictError" - ) { + if ("name" in err && err.name === "DaytonaConflictError") { return true; } return false; @@ -203,42 +203,57 @@ function wrapBotError( ); } -function toComputerStatus(botId: string, state?: string): ComputerStatus { - const normalized = state?.toLowerCase(); - if (normalized === "started" || normalized === "running") { - return { botId, state: "ready" }; - } - if ( - normalized === "created" || - normalized === "restarting" || - normalized === "creating" || - normalized === "starting" || - normalized === "restoring" || - normalized === "pulling_snapshot" || - normalized === "resuming" - ) { - return { botId, state: "starting" }; +function toComputerStatus(botId: string, state?: SandboxState): ComputerStatus { + if (!state) { + return { + botId, + state: "unreachable", + reason: "Daytona did not report a state for this computer.", + }; } - if ( - normalized === "stopped" || - normalized === "paused" || - normalized === "archived" || - normalized === "exited" || - normalized === "destroyed" || - normalized === "removing" || - normalized === "archiving" || - normalized === "pausing" || - normalized === "stopping" - ) { - return { botId, state: "absent" }; + switch (state) { + case SandboxState.STARTED: + return { botId, state: "ready" }; + case SandboxState.CREATING: + case SandboxState.RESTORING: + case SandboxState.STARTING: + case SandboxState.PULLING_SNAPSHOT: + case SandboxState.RESUMING: + case SandboxState.PENDING_BUILD: + case SandboxState.BUILDING_SNAPSHOT: + case SandboxState.RESIZING: + case SandboxState.SNAPSHOTTING: + case SandboxState.FORKING: + return { botId, state: "starting" }; + case SandboxState.STOPPED: + case SandboxState.PAUSED: + case SandboxState.ARCHIVED: + case SandboxState.STOPPING: + case SandboxState.PAUSING: + case SandboxState.ARCHIVING: + case SandboxState.DESTROYED: + case SandboxState.DESTROYING: + return { botId, state: "absent" }; + case SandboxState.ERROR: + case SandboxState.BUILD_FAILED: + case SandboxState.UNKNOWN: + case SandboxState.UNKNOWN_DEFAULT_OPEN_API: + return { + botId, + state: "unreachable", + reason: `Daytona reported the computer state "${state}".`, + }; + default: { + const exhaustiveCheck: never = state; + return { + botId, + state: "unreachable", + reason: `Daytona reported the computer state "${String(exhaustiveCheck)}".`, + }; + } } - const reason = normalized - ? `Daytona reported the computer state "${normalized}".` - : "Daytona did not report a state for this computer."; - return { botId, state: "unreachable", reason }; } - function sleep(ms: number): Promise { const { promise, resolve } = Promise.withResolvers(); setTimeout(resolve, ms); @@ -253,7 +268,7 @@ function withTimeout( if (timeoutMs <= 0) { return Promise.reject(new Error(errorMessage)); } - let timer: ReturnType | undefined; + let timer: Timer | undefined; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(errorMessage)), timeoutMs); }); @@ -289,7 +304,7 @@ async function pollUntil( } export function createDaytonaComputerProvider( - options: DaytonaSupervisorOptions, + options: DaytonaProviderOptions, ): ComputerProvider { const sdk: DaytonaSdk = options.sdk ?? @@ -300,18 +315,30 @@ export function createDaytonaComputerProvider( }); const doFetch = options.fetchImpl ?? fetch; + /** - * In-memory supervisor state: - * - `known`: maps botId to { sandboxId, url } for fast locate cache hits. Entries are removed - * on reset, deletion, terminal states (destroyed/destroying/error/build_failed), or 404s. - * - `locating`: maps botId to active locate promises to deduplicate concurrent calls. Note: - * this deduplication is local to this single process only (no cross-instance synchronization). + * In-memory provider state: + * - `known`: maps botId to sandboxId for fast locate lookups. Preview URLs are never cached. + * Entries are cleared on reset, deletion, terminal states, stale token hashes, or 404s. * - `resetSandboxes`: maps botId to the deleted sandboxId to mask Daytona list eventual-consistency * staleness where a deleted sandbox temporarily reappears in list() queries. Cleared on fresh creation. + * - `queues`: serializes lifecycle operations (locate, stop, reset) per Bot to prevent race conditions. + * Note: synchronization is process-local only (no cross-instance synchronization). */ - const known = new Map(); - const locating = new Map>(); + const known = new Map(); const resetSandboxes = new Map(); + const queues = new Map>(); + + function runLifecycle(botId: string, op: () => Promise): Promise { + const prev = queues.get(botId) ?? Promise.resolve(); + const next = prev.catch(() => {}).then(op); + queues.set(botId, next); + return next.finally(() => { + if (queues.get(botId) === next) { + queues.delete(botId); + } + }); + } let snapshotPromise: Promise | undefined; @@ -440,17 +467,17 @@ export function createDaytonaComputerProvider( includeTerminal = false, ): Promise { const deletedSandboxId = resetSandboxes.get(botId); - const knownEntry = known.get(botId); - if (knownEntry) { - if (deletedSandboxId && knownEntry.sandboxId === deletedSandboxId) { + const knownSandboxId = known.get(botId); + if (knownSandboxId) { + if (deletedSandboxId && knownSandboxId === deletedSandboxId) { known.delete(botId); } else { try { - const sb = await sdk.get(knownEntry.sandboxId); - const state = sb.state?.toLowerCase(); + const sb = await sdk.get(knownSandboxId); if ( !includeTerminal && - (state === "destroyed" || state === "destroying") + (sb.state === SandboxState.DESTROYED || + sb.state === SandboxState.DESTROYING) ) { known.delete(botId); return undefined; @@ -475,10 +502,10 @@ export function createDaytonaComputerProvider( if (deletedSandboxId && sb.id === deletedSandboxId) { continue; } - const state = sb.state?.toLowerCase(); if ( includeTerminal || - (state !== "destroyed" && state !== "destroying") + (sb.state !== SandboxState.DESTROYED && + sb.state !== SandboxState.DESTROYING) ) { return sb; } @@ -490,161 +517,217 @@ export function createDaytonaComputerProvider( return undefined; } - return { - name: "Daytona", - isolation: "per-bot", + async function createFreshSandbox( + snapshot: string, + botId: string, + ): Promise { + const passthroughEnv: Record = {}; + if (options.environment) { + for (const [key, value] of Object.entries(options.environment)) { + if ( + typeof value === "string" && + (key.startsWith("EGRESS_PROXY") || key === "ACTION_TIMEOUT_MS") + ) { + passthroughEnv[key] = value; + } + } + } - describeIsolation(): IsolationDescription { - return { - isolation: "one computer per Bot", - note: "Each Bot gets a remote Daytona sandbox with its own /workspace and its own browser profile.", - }; - }, + const envVars: Record = { + COMPUTER_BOT_ID: botId, + COMPUTER_TOKEN: options.computerToken, + ...passthroughEnv, + }; - async locate(botId: string): Promise { - const existing = locating.get(botId); - if (existing) { - return existing; + const labels: Record = { + [OWNERSHIP_LABEL_KEY]: OWNERSHIP_LABEL_VALUE, + [BOT_ID_LABEL_KEY]: botId, + [TOKEN_HASH_LABEL_KEY]: computeTokenHash(options.computerToken), + }; + + try { + const handle = await sdk.create( + { + snapshot, + envVars, + labels, + public: true, + autoStopInterval: 15, + }, + { timeout: 300 }, + ); + resetSandboxes.delete(botId); + known.set(botId, handle.id); + return handle; + } catch (err) { + throw wrapBotError(botId, "create", err); + } + } + + async function ensureSandboxStarted( + initialHandle: SandboxHandle, + botId: string, + snapshot: string, + ): Promise { + let handle = initialHandle; + if (handle.state === SandboxState.STARTED) { + return handle; + } + + if ( + handle.state === SandboxState.STOPPED || + handle.state === SandboxState.PAUSED || + handle.state === SandboxState.ARCHIVED + ) { + try { + await handle.start(300); + return handle; + } catch (err) { + throw wrapBotError(botId, "start", err); } + } - const promise = (async () => { - const snapshot = await ensureSnapshot(); + const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const maxWaitMs = 300 * 1000; - let sandboxHandle = await resolveSandbox(botId, "locate"); - if (sandboxHandle) { - const state = sandboxHandle.state?.toLowerCase(); - if (state === "error" || state === "build_failed") { - try { - await sandboxHandle.delete(); - } catch { - // Best-effort cleanup of broken sandboxes - } + return await pollUntil( + async () => { + try { + handle = await sdk.get(handle.id); + } catch (err) { + if (isNotFoundError(err)) { known.delete(botId); - sandboxHandle = undefined; + const fresh = await createFreshSandbox(snapshot, botId); + return { done: true, value: fresh }; } + throw wrapBotError(botId, "locate", err); } - async function createFreshSandbox(): Promise { - const passthroughEnv: Record = {}; - if (options.environment) { - for (const [key, value] of Object.entries(options.environment)) { - if ( - typeof value === "string" && - (key.startsWith("EGRESS_PROXY") || key === "ACTION_TIMEOUT_MS") - ) { - passthroughEnv[key] = value; - } - } - } - - const envVars: Record = { - COMPUTER_BOT_ID: botId, - COMPUTER_TOKEN: options.computerToken, - ...passthroughEnv, - }; - - const labels: Record = { - [OWNERSHIP_LABEL_KEY]: OWNERSHIP_LABEL_VALUE, - [BOT_ID_LABEL_KEY]: botId, - }; - + if (handle.state === SandboxState.STARTED) { + return { done: true, value: handle }; + } + if ( + handle.state === SandboxState.STOPPED || + handle.state === SandboxState.PAUSED || + handle.state === SandboxState.ARCHIVED + ) { try { - const handle = await sdk.create( - { - snapshot, - envVars, - labels, - public: true, - autoStopInterval: 15, - }, - { timeout: 300 }, - ); - resetSandboxes.delete(botId); - return handle; + await handle.start(300); } catch (err) { - throw wrapBotError(botId, "create", err); + throw wrapBotError(botId, "start", err); } + return { done: true, value: handle }; + } + if ( + handle.state === SandboxState.DESTROYED || + handle.state === SandboxState.DESTROYING + ) { + known.delete(botId); + const fresh = await createFreshSandbox(snapshot, botId); + return { done: true, value: fresh }; } + if ( + handle.state === SandboxState.ERROR || + handle.state === SandboxState.BUILD_FAILED + ) { + throw new ProviderError( + `Daytona sandbox for ${botId} failed with state "${handle.state}".`, + ); + } + return { done: false }; + }, + { + timeoutMs: maxWaitMs, + intervalMs: pollInterval, + timeoutError: () => + new ProviderError( + `Timed out waiting for computer sandbox for ${botId} to start.`, + ), + }, + ); + } + + async function pollHealth(previewUrl: string, botId: string): Promise { + const healthPollInterval = + options.pollIntervalMs ?? HEALTH_POLL_INTERVAL_MS; + const healthTimeout = options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS; + const healthDeadline = Date.now() + healthTimeout; + const healthUrl = `${previewUrl.replace(/\/$/, "")}/health`; + + while (Date.now() < healthDeadline) { + const remainingMs = Math.max(1, healthDeadline - Date.now()); + try { + const res = await withTimeout( + doFetch(healthUrl, { + headers: { + "X-Daytona-Skip-Preview-Warning": "true", + }, + signal: AbortSignal.timeout(remainingMs), + }), + remainingMs, + ); + if (res.ok) { + return; + } + } catch { + // Health endpoint not reachable yet or timed out; retry + } + const sleepMs = Math.min( + healthPollInterval, + Math.max(0, healthDeadline - Date.now()), + ); + if (sleepMs > 0) { + await sleep(sleepMs); + } + } + + throw new ProviderError( + `The computer for ${botId} started but never answered /health at its preview URL.`, + ); + } + + return { + name: "Daytona", + isolation: "per-bot", + + async locate(botId: string): Promise { + return runLifecycle(botId, async () => { + const snapshot = await ensureSnapshot(); + + let sandboxHandle = await resolveSandbox(botId, "locate"); + const expectedTokenHash = computeTokenHash(options.computerToken); + + if (sandboxHandle) { + const tokenHash = sandboxHandle.labels?.[TOKEN_HASH_LABEL_KEY]; - if (!sandboxHandle) { - sandboxHandle = await createFreshSandbox(); - } else { - const state = sandboxHandle.state?.toLowerCase(); if ( - state === "stopped" || - state === "archived" || - state === "paused" + tokenHash !== expectedTokenHash || + sandboxHandle.state === SandboxState.ERROR || + sandboxHandle.state === SandboxState.BUILD_FAILED ) { try { - await sandboxHandle.start(300); - } catch (err) { - throw wrapBotError(botId, "start", err); + await sandboxHandle.delete(60, true); + } catch { + // Best-effort cleanup of stale or broken sandboxes } + known.delete(botId); + resetSandboxes.set(botId, sandboxHandle.id); + sandboxHandle = undefined; } } - if (sandboxHandle.state?.toLowerCase() !== "started") { - const pollInterval = - options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const maxWaitMs = 300 * 1000; - - sandboxHandle = await pollUntil( - async () => { - try { - sandboxHandle = await sdk.get(sandboxHandle!.id); - } catch (err) { - if (isNotFoundError(err)) { - known.delete(botId); - sandboxHandle = await createFreshSandbox(); - if (sandboxHandle.state?.toLowerCase() === "started") { - return { done: true, value: sandboxHandle }; - } - return { done: false }; - } - throw wrapBotError(botId, "locate", err); - } - - const cur = sandboxHandle.state?.toLowerCase(); - if (cur === "started") { - return { done: true, value: sandboxHandle }; - } - if (cur === "stopped" || cur === "archived" || cur === "paused") { - try { - await sandboxHandle.start(300); - } catch (err) { - throw wrapBotError(botId, "start", err); - } - if (sandboxHandle.state?.toLowerCase() === "started") { - return { done: true, value: sandboxHandle }; - } - return { done: false }; - } - if (cur === "destroyed" || cur === "destroying") { - known.delete(botId); - sandboxHandle = await createFreshSandbox(); - if (sandboxHandle.state?.toLowerCase() === "started") { - return { done: true, value: sandboxHandle }; - } - return { done: false }; - } - if (cur === "error" || cur === "build_failed") { - throw new ProviderError( - `Daytona sandbox for ${botId} failed with state "${cur}".`, - ); - } - return { done: false }; - }, - { - timeoutMs: maxWaitMs, - intervalMs: pollInterval, - timeoutError: () => - new ProviderError( - `Timed out waiting for computer sandbox for ${botId} to start.`, - ), - }, + if (!sandboxHandle) { + sandboxHandle = await createFreshSandbox(snapshot, botId); + } else { + sandboxHandle = await ensureSandboxStarted( + sandboxHandle, + botId, + snapshot, ); } + known.set(botId, sandboxHandle.id); + let previewUrl: string; try { const preview = await sandboxHandle.getPreviewLink(COMPUTER_PORT); @@ -653,60 +736,16 @@ export function createDaytonaComputerProvider( throw wrapBotError(botId, "locate", err); } - known.set(botId, { sandboxId: sandboxHandle.id, url: previewUrl }); + await pollHealth(previewUrl, botId); - const healthPollInterval = - options.pollIntervalMs ?? HEALTH_POLL_INTERVAL_MS; - const healthTimeout = - options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS; - const healthStart = Date.now(); - const healthDeadline = healthStart + healthTimeout; - const healthUrl = `${previewUrl.replace(/\/$/, "")}/health`; - - let healthy = false; - while (Date.now() < healthDeadline) { - const remainingMs = Math.max(1, healthDeadline - Date.now()); - try { - const res = await withTimeout( - doFetch(healthUrl, { - headers: { - "X-Daytona-Skip-Preview-Warning": "true", - }, - signal: AbortSignal.timeout(remainingMs), - }), - remainingMs, - ); - if (res.ok) { - healthy = true; - break; - } - } catch { - // Health endpoint not reachable yet or timed out; retry - } - const sleepMs = Math.min( - healthPollInterval, - Math.max(0, healthDeadline - Date.now()), - ); - if (sleepMs > 0) { - await sleep(sleepMs); - } - } - - if (!healthy) { - throw new ProviderError( - `The computer for ${botId} started but never answered /health at its preview URL.`, - ); + try { + await sandboxHandle.refreshActivity(); + } catch (err) { + throw wrapBotError(botId, "refresh activity on", err); } return previewUrl; - })(); - - locating.set(botId, promise); - try { - return await promise; - } finally { - locating.delete(botId); - } + }); }, async status(botId: string): Promise { @@ -717,69 +756,82 @@ export function createDaytonaComputerProvider( return toComputerStatus(botId, sandboxHandle.state); }, - async stop(botId: string): Promise { - const sandboxHandle = await resolveSandbox(botId, "stop"); - if (!sandboxHandle) { - return; - } - const state = sandboxHandle.state?.toLowerCase(); - if (state !== "started" && state !== "paused") { - return; - } - try { - await sandboxHandle.stop(); - } catch (err) { - throw wrapBotError(botId, "stop", err); - } + async stop(botId: string): Promise<{ wasRunning: boolean }> { + return runLifecycle(botId, async () => { + const sandboxHandle = await resolveSandbox(botId, "stop"); + if (!sandboxHandle) { + return { wasRunning: false }; + } + if (sandboxHandle.state !== SandboxState.STARTED) { + return { wasRunning: false }; + } + try { + await sandboxHandle.stop(); + return { wasRunning: true }; + } catch (err) { + throw wrapBotError(botId, "stop", err); + } + }); }, - async reset(botId: string): Promise { - const sandboxHandle = await resolveSandbox(botId, "reset"); - known.delete(botId); - if (!sandboxHandle) { - return; - } - try { - await sandboxHandle.delete(60, true); - } catch (err) { - throw wrapBotError(botId, "reset", err); - } - resetSandboxes.set(botId, sandboxHandle.id); + async reset(botId: string): Promise<{ cleared: boolean }> { + return runLifecycle(botId, async () => { + const sandboxHandle = await resolveSandbox(botId, "reset"); + known.delete(botId); + if (!sandboxHandle) { + return { cleared: false }; + } + try { + await sandboxHandle.delete(60, true); + } catch (err) { + throw wrapBotError(botId, "reset", err); + } + resetSandboxes.set(botId, sandboxHandle.id); + return { cleared: true }; + }); }, async list(): Promise { - const result: ComputerLocation[] = []; try { + const matching: { botId: string; sb: SandboxHandle }[] = []; for await (const sb of sdk.list({ labels: { [OWNERSHIP_LABEL_KEY]: OWNERSHIP_LABEL_VALUE, }, })) { - const state = sb.state?.toLowerCase(); - if (state === "destroyed" || state === "destroying") { + if ( + sb.state === SandboxState.DESTROYED || + sb.state === SandboxState.DESTROYING + ) { continue; } const botId = sb.labels?.[BOT_ID_LABEL_KEY]; if (!botId || resetSandboxes.get(botId) === sb.id) { continue; } - const preview = await sb.getPreviewLink(COMPUTER_PORT); - result.push({ - botId, - status: - state === "started" || state === "running" - ? "running" - : "exited", - url: preview.url, - startedAt: sb.createdAt, - }); + matching.push({ botId, sb }); } + + const result = await Promise.all( + matching.map(async ({ botId, sb }) => { + const preview = await sb.getPreviewLink(COMPUTER_PORT); + const status: "running" | "stopped" = + sb.state === SandboxState.STARTED ? "running" : "stopped"; + return { + botId, + status, + url: preview.url, + startedAt: sb.createdAt, + }; + }), + ); + + return result; } catch (err) { throw new ProviderError( `Daytona failed to list computers: ${toErrorMessage(err)}`, ); } - return result; }, async warm(): Promise { @@ -793,5 +845,3 @@ export function createDaytonaComputerProvider( }, }; } - -export const createDaytonaSupervisorClient = createDaytonaComputerProvider; diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index 68020157..6c9e3845 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -99,81 +99,62 @@ export interface ComputerGateway { snapshot(botId: string): Promise; read(botId: string): Promise; navigate( - computerId: string, botId: string, actor: ActionActor, url: string, ): Promise; click( - computerId: string, botId: string, actor: ActionActor, input: ClickInput, signal?: AbortSignal, ): Promise; type( - computerId: string, botId: string, actor: ActionActor, input: TypeInput, signal?: AbortSignal, ): Promise; key( - computerId: string, botId: string, actor: ActionActor, input: KeyInput, signal?: AbortSignal, ): Promise; scroll( - computerId: string, botId: string, actor: ActionActor, input: ScrollInput, ): Promise; readFile( - computerId: string, botId: string, actor: ActionActor, input: ReadFileInput, ): Promise; listFiles( - computerId: string, botId: string, actor: ActionActor, input: ListFilesInput, ): Promise; writeFile( - computerId: string, botId: string, actor: ActionActor, input: WriteFileInput, ): Promise; control(botId: string): Promise; requestHelp( - computerId: string, botId: string, actor: ActionActor, reason: string, ): Promise; - takeControl( - computerId: string, - botId: string, - actor: ActionActor, - ): Promise; - releaseControl( - computerId: string, - botId: string, - actor: ActionActor, - ): Promise; + takeControl(botId: string, actor: ActionActor): Promise; + releaseControl(botId: string, actor: ActionActor): Promise; requestSecret( - computerId: string, botId: string, actor: ActionActor, input: SecretRequest, ): Promise; supplySecret( - computerId: string, botId: string, actor: ActionActor, text: string, @@ -185,16 +166,14 @@ export interface ComputerGateway { botId: string; running: boolean; startedAt: string | null; - egress: null; + egress?: string | null; }[]; }>; stopComputer( - computerId: string, botId: string, actor: ActionActor, ): Promise<{ wasRunning: boolean }>; resetComputer( - computerId: string, botId: string, actor: ActionActor, ): Promise<{ cleared: boolean }>; @@ -251,13 +230,7 @@ export function createComputerGateway( payload: unknown, signal?: AbortSignal, ): Promise { - return transport.post( - await locate(botId), - botId, - path, - payload, - signal, - ); + return transport.post(await locate(botId), botId, path, payload, signal); } /** Read-only, so it passes straight through. Nothing has changed and there is nothing to decide. */ @@ -309,7 +282,6 @@ export function createComputerGateway( * show that sequence. */ async function govern( - computerId: string, toolName: string, botId: string, actor: ActionActor, @@ -358,7 +330,6 @@ export function createComputerGateway( toolName, botId, actor, - computerId, element, ref, ...(subject.key ? { key: subject.key } : {}), @@ -366,7 +337,6 @@ export function createComputerGateway( pageUrl, decision, }); - if (!decision.forward) { throw new ActionRefusedError(decision.reason, decision.matched); } @@ -389,7 +359,6 @@ export function createComputerGateway( toolName, botId, actor, - computerId, element, ref, filePath, @@ -427,32 +396,23 @@ export function createComputerGateway( * row and do not ask. What IS recorded is the period: who, when, and why the Bot asked, the fact * an investigator wants is that a human drove this browser between two times. */ - async requestHelp( - computerId: string, - botId: string, - actor: ActionActor, - reason: string, - ) { - const state = await post( - botId, - "/control/request", - { reason }, - ); + async requestHelp(botId: string, actor: ActionActor, reason: string) { + const state = await post(botId, "/control/request", { + reason, + }); await writeControlEvent(auditStore, "computer.help_requested", { botId, actor, - computerId, reason, }); return state; }, - async takeControl(computerId: string, botId: string, actor: ActionActor) { + async takeControl(botId: string, actor: ActionActor) { const state = await post(botId, "/control/take", {}); await writeControlEvent(auditStore, "computer.control_taken", { botId, actor, - computerId, // Carried onto the row so the trail says what the person was handed, not merely that they // took over. reason: state.reason, @@ -460,16 +420,11 @@ export function createComputerGateway( return state; }, - async releaseControl( - computerId: string, - botId: string, - actor: ActionActor, - ) { + async releaseControl(botId: string, actor: ActionActor) { const state = await post(botId, "/control/release", {}); await writeControlEvent(auditStore, "computer.control_released", { botId, actor, - computerId, }); return state; }, @@ -485,11 +440,9 @@ export function createComputerGateway( isolation: provider.isolation, computers: computers.map((computer) => ({ botId: computer.botId, - running: ["running", "started"].includes( - computer.status.toLowerCase(), - ), + running: computer.status === "running", startedAt: computer.startedAt ?? null, - egress: null, + egress: computer.egress, })), }; }, @@ -502,15 +455,16 @@ export function createComputerGateway( * fact worth having, and a trail that only records effective actions cannot tell you what somebody * tried. */ - async stopComputer(computerId: string, botId: string, actor: ActionActor) { - await provider.stop(botId); + async stopComputer(botId: string, actor: ActionActor) { + const result = await provider.stop(botId); await writeControlEvent(auditStore, "computer.stopped", { botId, actor, - computerId, - reason: "the computer was stopped", + reason: result.wasRunning + ? "the computer was stopped" + : "the computer was already stopped", }); - return { wasRunning: true }; + return result; }, /** @@ -519,16 +473,17 @@ export function createComputerGateway( * The most destructive button we have. Every login the Bot had is gone and no undo exists, so the * row is written whatever happens next. */ - async resetComputer(computerId: string, botId: string, actor: ActionActor) { - await provider.reset(botId); + async resetComputer(botId: string, actor: ActionActor) { + const result = await provider.reset(botId); snapshots.delete(botId); await writeControlEvent(auditStore, "computer.reset", { botId, actor, - computerId, - reason: "the computer and its saved state were deleted", + reason: result.cleared + ? "the computer and its saved state were deleted" + : "no saved state was present to delete", }); - return { cleared: true }; + return result; }, /** @@ -540,40 +495,24 @@ export function createComputerGateway( * keyboard to the page, and is not on this one. */ async requestSecret( - computerId: string, botId: string, actor: ActionActor, input: SecretRequest, ) { - const state = await post( - botId, - "/control/secret", - input, - ); + const state = await post(botId, "/control/secret", input); await writeControlEvent(auditStore, "computer.secret_requested", { botId, actor, - computerId, reason: `${input.label} (into ${input.ref})`, }); return state; }, - async supplySecret( - computerId: string, - botId: string, - actor: ActionActor, - text: string, - ) { - const result = await post( - botId, - "/human/secret", - { text }, - ); + async supplySecret(botId: string, actor: ActionActor, text: string) { + const result = await post(botId, "/human/secret", { text }); await writeControlEvent(auditStore, "computer.secret_supplied", { botId, actor, - computerId, // Length, never content. Enough to show something real was entered. reason: `${result.characters} characters`, }); @@ -594,32 +533,23 @@ export function createComputerGateway( * The transport applies its target guard before it sends a request. This is * the minimum rule that applies even when the action policy permits the URL. */ - navigate( - computerId: string, - botId: string, - actor: ActionActor, - url: string, - ) { + navigate(botId: string, actor: ActionActor, url: string) { return govern( - computerId, "computer_navigate", botId, actor, { targetUrl: url }, - async () => - transport.navigate(await locate(botId), botId, url), + async () => transport.navigate(await locate(botId), botId, url), ); }, click( - computerId: string, botId: string, actor: ActionActor, input: ClickInput, signal?: AbortSignal, ) { return govern( - computerId, "computer_click", botId, actor, @@ -629,14 +559,12 @@ export function createComputerGateway( }, type( - computerId: string, botId: string, actor: ActionActor, input: TypeInput, signal?: AbortSignal, ) { return govern( - computerId, "computer_type", botId, actor, @@ -646,14 +574,12 @@ export function createComputerGateway( }, key( - computerId: string, botId: string, actor: ActionActor, input: KeyInput, signal?: AbortSignal, ) { return govern( - computerId, "computer_key", botId, actor, @@ -664,13 +590,8 @@ export function createComputerGateway( ); }, - scroll( - computerId: string, - botId: string, - actor: ActionActor, - input: ScrollInput, - ) { - return govern(computerId, "computer_scroll", botId, actor, {}, () => + scroll(botId: string, actor: ActionActor, input: ScrollInput) { + return govern("computer_scroll", botId, actor, {}, () => post(botId, "/scroll", input), ); }, @@ -682,14 +603,8 @@ export function createComputerGateway( * workspace accumulates whatever a Bot has saved across every task it has ever run, so which of * those files it may read back is a real question for a deployment to be able to answer. */ - readFile( - computerId: string, - botId: string, - actor: ActionActor, - input: ReadFileInput, - ) { + readFile(botId: string, actor: ActionActor, input: ReadFileInput) { return govern( - computerId, "computer_read_file", botId, actor, @@ -703,14 +618,8 @@ export function createComputerGateway( * every task it has run is worth being able to restrict. A rule denying a folder hides it from the * listing as well as from reads, which is the consistent answer. */ - listFiles( - computerId: string, - botId: string, - actor: ActionActor, - input: ListFilesInput, - ) { + listFiles(botId: string, actor: ActionActor, input: ListFilesInput) { return govern( - computerId, "computer_list_files", botId, actor, @@ -719,14 +628,8 @@ export function createComputerGateway( ); }, - writeFile( - computerId: string, - botId: string, - actor: ActionActor, - input: WriteFileInput, - ) { + writeFile(botId: string, actor: ActionActor, input: WriteFileInput) { return govern( - computerId, "computer_write_file", botId, actor, @@ -759,7 +662,6 @@ function describeFile(path: string): { }; } - /** * One audit row for one decision. * @@ -816,7 +718,6 @@ async function write( toolName: string; botId: string; actor: ActionActor; - computerId: string; element: SnapshotElement | undefined; ref: string | undefined; /** Which key, for a keypress. Recorded because a keypress can act without naming a button. */ @@ -837,7 +738,7 @@ async function write( ? "computer.action_allowed" : "computer.action_refused", targetType: "computer", - targetId: entry.computerId, + targetId: entry.botId, // Only ever a real users row. The audit table has a foreign key to it, so writing the local // development actor's id here makes every action fail on a constraint violation instead of being // recorded. Who it was is in the payload either way. @@ -913,14 +814,13 @@ async function writeControlEvent( entry: { botId: string; actor: ActionActor; - computerId: string; reason?: string; }, ) { await recordAuditEvent(auditStore, { eventType, targetType: "computer", - targetId: entry.computerId, + targetId: entry.botId, ...(entry.actor.userId ? { actorUserId: entry.actor.userId } : {}), payload: { bot: entry.botId, diff --git a/server/src/computer/provider.ts b/server/src/computer/provider.ts index 40bb585f..65854e89 100644 --- a/server/src/computer/provider.ts +++ b/server/src/computer/provider.ts @@ -10,14 +10,15 @@ import type { ComputerStatus } from "./schema"; /** The address and lifecycle details for one Bot's computer. */ export type ComputerLocation = { botId: string; - status: string; + status: "running" | "stopped"; url?: string; startedAt?: string; + egress?: string | null; }; /** A description of how a provider separates one Bot's computer from another. */ export type IsolationDescription = { - isolation: "one computer per Bot" | "one shared computer"; + isolation: "off" | "one computer per Bot" | "one shared computer"; note: string; warning?: string; }; @@ -30,6 +31,32 @@ export class ProviderError extends Error { } } +/** Describe the isolation that this provider (or lack of provider) gives to Bots. */ +export function describeComputerIsolation( + provider?: ComputerProvider, +): IsolationDescription { + if (!provider) { + return { + isolation: "off", + note: "The computer feature is off. No computer provider is configured.", + }; + } + + if (provider.isolation === "per-bot") { + return { + isolation: "one computer per Bot", + note: "Each Bot gets its own isolated computer with its own /workspace and browser profile.", + }; + } + + return { + isolation: "one shared computer", + note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", + warning: + "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", + }; +} + /** * A backend that gives Bots access to a computer. * @@ -41,35 +68,34 @@ export interface ComputerProvider { readonly name: string; /** How the provider separates computers between Bots. */ readonly isolation: "per-bot" | "shared"; - /** Describe the isolation that this provider gives to Bots. */ - describeIsolation(): IsolationDescription; /** Return the base address of the computer for this Bot. */ locate(botId: string): Promise; /** Return the lifecycle state of the computer for this Bot. */ status(botId: string): Promise; /** Stop the computer for this Bot if it exists. */ - stop(botId: string): Promise; + stop(botId: string): Promise<{ wasRunning: boolean }>; /** Remove the computer state for this Bot if it exists. */ - reset(botId: string): Promise; + reset(botId: string): Promise<{ cleared: boolean }>; /** List the computers that this provider owns. */ list(): Promise; /** Prepare provider resources before the first computer request. */ warm?(): Promise; } -type SharedComputerProviderOptions = { +export type SharedComputerProviderOptions = { baseUrl: string; token?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; }; - type SharedComputerEntry = { botId: string; running?: boolean; status?: string; url?: string; startedAt?: string | null; + egress?: string | null; }; - /** * Give every Bot the same computer. * @@ -80,13 +106,13 @@ export function createSharedComputerProvider( options: SharedComputerProviderOptions, ): ComputerProvider { const base = options.baseUrl.replace(/\/$/, ""); + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? 45_000; function headers(botId?: string): Record { return { ...(botId ? { "x-openbot-bot-id": botId } : {}), - ...(options.token - ? { "x-openbot-computer-token": options.token } - : {}), + ...(options.token ? { "x-openbot-computer-token": options.token } : {}), }; } @@ -97,9 +123,10 @@ export function createSharedComputerProvider( ): Promise { let response: Response; try { - response = await fetch(`${base}${path}`, { + response = await fetchImpl(`${base}${path}`, { method, headers: headers(botId), + signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { throw new ProviderError( @@ -121,14 +148,6 @@ export function createSharedComputerProvider( return { name: "shared", isolation: "shared", - describeIsolation(): IsolationDescription { - return { - isolation: "one shared computer", - note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", - warning: - "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", - }; - }, async locate(_botId: string): Promise { return options.baseUrl; @@ -150,12 +169,24 @@ export function createSharedComputerProvider( } }, - async stop(botId: string): Promise { - await call("/stop", "POST", botId); + async stop(botId: string): Promise<{ wasRunning: boolean }> { + const body = (await call("/computers/stop", "POST", botId)) as { + wasRunning?: boolean; + stopped?: boolean; + } | null; + return { + wasRunning: body?.wasRunning ?? body?.stopped ?? false, + }; }, - async reset(botId: string): Promise { - await call("/reset", "POST", botId); + async reset(botId: string): Promise<{ cleared: boolean }> { + const body = (await call("/computers/reset", "POST", botId)) as { + cleared?: boolean; + reset?: boolean; + } | null; + return { + cleared: body?.cleared ?? body?.reset ?? false, + }; }, async list(): Promise { @@ -165,16 +196,21 @@ export function createSharedComputerProvider( return (body?.computers ?? []).map((computer) => ({ botId: computer.botId, status: - computer.status ?? (computer.running === true ? "running" : "stopped"), + computer.status === "running" || computer.running === true + ? "running" + : "stopped", url: computer.url ?? base, ...(computer.startedAt ? { startedAt: computer.startedAt } : {}), + ...(computer.egress !== undefined ? { egress: computer.egress } : {}), })); }, }; } /** Build the one computer provider selected by deployment configuration. */ -export function createComputerProvider(config: ComputerConfig): ComputerProvider { +export function createComputerProvider( + config: ComputerConfig, +): ComputerProvider { switch (config.provider) { case "daytona": return createDaytonaComputerProvider({ diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 0a7b736f..63a10013 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -39,9 +39,7 @@ export function createComputerRoutes( routes.get("/:botId/screenshot", requireUser, async (context) => { try { - return context.json( - await gateway.screenshot(context.req.param("botId")), - ); + return context.json(await gateway.screenshot(context.req.param("botId"))); } catch (error) { return context.json({ error: describe(error) }, statusFor(error)); } @@ -66,7 +64,6 @@ export function createComputerRoutes( try { return context.json( await gateway.navigate( - context.req.param("botId") ?? "default", context.req.param("botId") ?? "default", { id: context.var.actor.id, @@ -102,14 +99,14 @@ export function createComputerRoutes( /** * The acting routes. * - * Each one hands the gateway the computer id, the Bot, the actor and the input, and does no checking + * Each one hands the gateway the Bot, the actor and the input, and does no checking * of its own beyond the shape of the request. Where a decision gets made is a single place. */ routes.post("/:botId/click", requireUser, (context) => act(context, (botId, actor, body, signal) => { const ref = asRef(body); if (!ref) return badRef; - return gateway.click(botId, botId, actor, ref, signal); + return gateway.click(botId, actor, ref, signal); }), ); @@ -121,7 +118,6 @@ export function createComputerRoutes( return { error: "The text to enter is required." }; } return gateway.type( - botId, botId, actor, { @@ -141,7 +137,6 @@ export function createComputerRoutes( } const ref = asRef(body); return gateway.key( - botId, botId, actor, { @@ -155,7 +150,7 @@ export function createComputerRoutes( routes.post("/:botId/scroll", requireUser, (context) => act(context, (botId, actor, body) => - gateway.scroll(botId, botId, actor, { + gateway.scroll(botId, actor, { ...(typeof body?.deltaY === "number" ? { deltaY: body.deltaY } : {}), }), ), @@ -176,7 +171,6 @@ export function createComputerRoutes( routes.post("/:botId/control/request", requireUser, (context) => act(context, (botId, actor, body) => gateway.requestHelp( - botId, botId, actor, typeof body?.reason === "string" && body.reason.trim() @@ -203,20 +197,20 @@ export function createComputerRoutes( /** Stop the browser, keep the logins. */ routes.post("/:botId/computers/stop", requireUser, (context) => - act(context, (botId, actor) => gateway.stopComputer(botId, botId, actor)), + act(context, (botId, actor) => gateway.stopComputer(botId, actor)), ); /** Delete the profile. Every login goes with it, which is the point and also the danger. */ routes.post("/:botId/computers/reset", requireUser, (context) => - act(context, (botId, actor) => gateway.resetComputer(botId, botId, actor)), + act(context, (botId, actor) => gateway.resetComputer(botId, actor)), ); routes.post("/:botId/control/take", requireUser, (context) => - act(context, (botId, actor) => gateway.takeControl(botId, botId, actor)), + act(context, (botId, actor) => gateway.takeControl(botId, actor)), ); routes.post("/:botId/control/release", requireUser, (context) => - act(context, (botId, actor) => gateway.releaseControl(botId, botId, actor)), + act(context, (botId, actor) => gateway.releaseControl(botId, actor)), ); /** The Bot asking for a value it must not be told. */ @@ -231,7 +225,7 @@ export function createComputerRoutes( if (typeof body?.snapshotId !== "number") { return { error: "The snapshotId the ref came from is required." }; } - return gateway.requestSecret(botId, botId, actor, { + return gateway.requestSecret(botId, actor, { label: typeof body?.label === "string" && body.label.trim() ? body.label.trim() @@ -254,7 +248,7 @@ export function createComputerRoutes( if (typeof body?.text !== "string" || !body.text) { return { error: "A value is required." }; } - return gateway.supplySecret(botId, botId, actor, body.text); + return gateway.supplySecret(botId, actor, body.text); }), ); @@ -294,7 +288,7 @@ export function createComputerRoutes( /** The Bot's files. Through the gateway, like every other acting call. */ routes.post("/:botId/files/list", requireUser, (context) => act(context, (botId, actor, body) => - gateway.listFiles(botId, botId, actor, { + gateway.listFiles(botId, actor, { ...(typeof body?.path === "string" && body.path.trim() ? { path: body.path.trim() } : {}), @@ -307,7 +301,7 @@ export function createComputerRoutes( if (typeof body?.path !== "string" || !body.path.trim()) { return { error: "A file path is required." }; } - return gateway.readFile(botId, botId, actor, { path: body.path.trim() }); + return gateway.readFile(botId, actor, { path: body.path.trim() }); }), ); @@ -319,7 +313,7 @@ export function createComputerRoutes( if (typeof body?.contents !== "string") { return { error: "The contents to write are required." }; } - return gateway.writeFile(botId, botId, actor, { + return gateway.writeFile(botId, actor, { path: body.path.trim(), contents: body.contents, append: body.append === true, diff --git a/server/src/computer/supervisor.ts b/server/src/computer/supervisor.ts index 7613a513..2af756c7 100644 --- a/server/src/computer/supervisor.ts +++ b/server/src/computer/supervisor.ts @@ -14,11 +14,7 @@ */ import type { ComputerStatus } from "./schema"; -import type { - ComputerLocation, - ComputerProvider, - IsolationDescription, -} from "./provider"; +import type { ComputerLocation, ComputerProvider } from "./provider"; type SupervisorComputerLocation = { botId: string; @@ -73,6 +69,9 @@ export function createDockerSupervisorProvider( const body = (await response.json().catch(() => null)) as { error?: string; + stopped?: boolean; + reset?: boolean; + computers?: SupervisorComputerLocation[]; } | null; if (!response.ok) { throw new SupervisorError( @@ -82,13 +81,19 @@ export function createDockerSupervisorProvider( return body; } - async function list(): Promise { + async function listRaw(): Promise { const body = (await call("/computers", "GET")) as { computers?: SupervisorComputerLocation[]; - }; - return (body?.computers ?? []).map((computer) => ({ + } | null; + return body?.computers ?? []; + } + + async function list(): Promise { + const computers = await listRaw(); + return computers.map((computer) => ({ botId: computer.botId, - status: computer.status, + status: + computer.status.toLowerCase() === "running" ? "running" : "stopped", ...(computer.url ? { url: computer.url } : computer.port @@ -100,58 +105,39 @@ export function createDockerSupervisorProvider( function statusFromLocation( botId: string, - location: ComputerLocation | undefined, + location: SupervisorComputerLocation | undefined, ): ComputerStatus { if (!location) return { botId, state: "absent" }; - const rawStatus = location.status; - switch (rawStatus.toLowerCase()) { + const rawStatus = location.status.toLowerCase(); + switch (rawStatus) { case "running": - case "started": return { botId, state: "ready" }; case "created": case "restarting": - case "creating": - case "starting": - case "restoring": - case "pulling_snapshot": - case "resuming": return { botId, state: "starting" }; - case "stopped": case "paused": - case "archived": - case "exited": - case "destroyed": case "removing": - case "archiving": - case "pausing": - case "stopping": + case "exited": return { botId, state: "absent" }; - case "error": - case "build_failed": + case "dead": return { botId, state: "unreachable", - reason: `The computer reported state "${rawStatus}".`, + reason: `The computer reported state "${location.status}".`, }; default: return { botId, state: "unreachable", - reason: `The computer reported unknown state "${rawStatus}".`, + reason: `The computer reported unknown state "${location.status}".`, }; } } - const isolation: IsolationDescription = { - isolation: "one computer per Bot", - note: "Each Bot gets its own container, its own /workspace and its own browser profile.", - }; - return { name: "Docker supervisor", isolation: "per-bot", - describeIsolation: () => isolation, /** * The URL of this Bot's computer, starting it if it is not already up. @@ -175,7 +161,7 @@ export function createDockerSupervisorProvider( async status(botId: string): Promise { try { - const computers = await list(); + const computers = await listRaw(); return statusFromLocation( botId, computers.find((computer) => computer.botId === botId), @@ -192,18 +178,20 @@ export function createDockerSupervisorProvider( } }, - async stop(botId: string): Promise { - await call(`/computers/${encodeURIComponent(botId)}/stop`); + async stop(botId: string): Promise<{ wasRunning: boolean }> { + const result = (await call( + `/computers/${encodeURIComponent(botId)}/stop`, + )) as { stopped?: boolean } | null; + return { wasRunning: result?.stopped === true }; }, - async reset(botId: string): Promise { - await call(`/computers/${encodeURIComponent(botId)}/reset`); + async reset(botId: string): Promise<{ cleared: boolean }> { + const result = (await call( + `/computers/${encodeURIComponent(botId)}/reset`, + )) as { reset?: boolean } | null; + return { cleared: result?.reset === true }; }, list, }; } - -export const createSupervisorClient = createDockerSupervisorProvider; - -export type SupervisorClient = ComputerProvider; diff --git a/server/src/index.ts b/server/src/index.ts index 19094ee2..46354e71 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,7 +22,10 @@ import { createPolicyStore, DEFAULT_ACTION_POLICY, } from "./computer/policy-store"; -import { createComputerProvider } from "./computer/provider"; +import { + createComputerProvider, + describeComputerIsolation, +} from "./computer/provider"; import { loadConfig } from "./config"; import { createConnectorAdminService } from "./connectors"; import { @@ -186,7 +189,6 @@ const computerGateway = computerProvider }) : undefined; - /** * What a Bot can reach beyond its own computer. * @@ -229,14 +231,7 @@ void recordAuditEvent(bootAuditStore, { * A shared provider is a fine way to run on a laptop, but the shared isolation state must be visible * rather than inferred. */ -const isolation = computerProvider - ? computerProvider.describeIsolation() - : { - isolation: "one shared computer" as const, - note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", - warning: - "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", - }; +const isolation = describeComputerIsolation(computerProvider); void recordAuditEvent(bootAuditStore, { eventType: "computer.isolation_loaded", @@ -250,7 +245,7 @@ void recordAuditEvent(bootAuditStore, { console.info( JSON.stringify({ type: "computer-isolation", - provider: computerProvider ? computerProvider.name : "shared", + provider: computerProvider ? computerProvider.name : "none", isolation: isolation.isolation, ...(isolation.warning ? { warning: isolation.warning } : {}), }), diff --git a/server/tests/computer-client.test.ts b/server/tests/computer-client.test.ts index 23e80fd0..aa2ae90a 100644 --- a/server/tests/computer-client.test.ts +++ b/server/tests/computer-client.test.ts @@ -18,18 +18,6 @@ function clientWith( const botId = "bot-1"; return { navigate: (url: string) => transport.navigate(baseUrl, botId, url), - async status(requestedBotId: string) { - try { - await transport.call(baseUrl, requestedBotId, "/health"); - return { botId: requestedBotId, state: "ready" as const }; - } catch (error) { - return { - botId: requestedBotId, - state: "unreachable" as const, - reason: error instanceof Error ? error.message : "Unknown failure.", - }; - } - }, screenshot: () => transport.call(baseUrl, botId, "/screenshot"), click: (input: unknown, signal?: AbortSignal) => transport.post(baseUrl, botId, "/click", input, signal), @@ -121,6 +109,15 @@ describe("computer client", () => { "The assistant's computer is not running.", ); + const timedOut = clientWith(() => { + const error = new Error("timed out"); + error.name = "TimeoutError"; + throw error; + }); + await expect(timedOut.navigate("https://example.com")).rejects.toThrow( + "The assistant's computer did not respond in time.", + ); + const badPage = clientWith( () => new Response(JSON.stringify({ error: "net::ERR_NAME_NOT_RESOLVED" }), { @@ -133,18 +130,6 @@ describe("computer client", () => { ); }); - test("status reports unreachable rather than throwing", async () => { - const client = clientWith(() => { - throw new Error("down"); - }); - - await expect(client.status("bot-1")).resolves.toEqual({ - botId: "bot-1", - state: "unreachable", - reason: "The assistant's computer is not running.", - }); - }); - test("screenshot returns the png a transcript can render", async () => { const client = clientWith(() => ok({ @@ -160,20 +145,6 @@ describe("computer client", () => { width: 1280, }); }); - - test("surfaces a timeout as the computer not responding", async () => { - const client = clientWith(() => { - const error = new Error("timed out"); - error.name = "TimeoutError"; - throw error; - }); - - await expect(client.status("bot-1")).resolves.toMatchObject({ - state: "unreachable", - reason: "The assistant's computer did not respond in time.", - }); - }); - }); /** diff --git a/server/tests/computer-daytona-fixture.ts b/server/tests/computer-daytona-fixture.ts new file mode 100644 index 00000000..34fe4d97 --- /dev/null +++ b/server/tests/computer-daytona-fixture.ts @@ -0,0 +1,258 @@ +import { createHash } from "node:crypto"; +import { SandboxState } from "@daytona/sdk"; +import { + createDaytonaComputerProvider, + type DaytonaProviderOptions, + type DaytonaSdk, + type SandboxHandle, +} from "../src/computer/daytona"; + +export class DaytonaNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "DaytonaNotFoundError"; + } +} + +export type DeleteCall = { + timeout?: number; + wait?: boolean; +}; + +export type FakeSandbox = { + id: string; + state: SandboxState; + labels: Record; + envVars: Record; + public: boolean; + autoStopInterval: number; + createdAt: string; + previewUrl: string; + startCalls: number; + stopCalls: number; + deleteCalls: number; + deleteArgs: DeleteCall[]; + refreshActivityCalls: number; + deleteHandler?: (timeout?: number, wait?: boolean) => void | Promise; + refreshActivityHandler?: () => void | Promise; +}; + +export type CreateParams = { + snapshot: string; + envVars: Record; + labels: Record; + public: boolean; + autoStopInterval: number; + options?: { timeout?: number }; +}; + +export function makeSandbox(options: { + id: string; + botId?: string; + state?: SandboxState | string; + labels?: Record; + envVars?: Record; + public?: boolean; + autoStopInterval?: number; + createdAt?: string; + previewUrl?: string; + deleteHandler?: (timeout?: number, wait?: boolean) => void | Promise; + refreshActivityHandler?: () => void | Promise; +}): FakeSandbox { + const botId = options.botId; + const token = options.envVars?.COMPUTER_TOKEN ?? "tok"; + const tokenHash = createHash("sha256").update(token).digest("hex"); + const defaultLabels = botId + ? { + "openbot/computer": "true", + "openbot/bot-id": botId, + "openbot/computer-token-hash": tokenHash, + } + : { "openbot/computer": "true" }; + const labels = options.labels ?? defaultLabels; + const envVars = + options.envVars ?? + (botId ? { COMPUTER_TOKEN: token, COMPUTER_BOT_ID: botId } : {}); + + return { + id: options.id, + state: (options.state as SandboxState) ?? SandboxState.STARTED, + labels, + envVars, + public: options.public ?? true, + autoStopInterval: options.autoStopInterval ?? 15, + createdAt: options.createdAt ?? "2026-08-20T10:00:00Z", + previewUrl: + options.previewUrl ?? `https://${options.id}.preview.daytona.app`, + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + deleteArgs: [], + refreshActivityCalls: 0, + ...(options.deleteHandler ? { deleteHandler: options.deleteHandler } : {}), + ...(options.refreshActivityHandler + ? { refreshActivityHandler: options.refreshActivityHandler } + : {}), + }; +} + +export function makeSandboxHandle(sb: FakeSandbox): SandboxHandle { + const handle: SandboxHandle = { + id: sb.id, + get state() { + return sb.state; + }, + set state(val: SandboxState | undefined) { + sb.state = val ?? SandboxState.UNKNOWN; + }, + labels: sb.labels, + createdAt: sb.createdAt, + start: async () => { + sb.startCalls++; + sb.state = SandboxState.STARTED; + }, + stop: async () => { + sb.stopCalls++; + sb.state = SandboxState.STOPPED; + }, + delete: async (timeout?: number, wait?: boolean) => { + sb.deleteCalls++; + sb.deleteArgs.push({ timeout, wait }); + if (sb.deleteHandler) { + await sb.deleteHandler(timeout, wait); + } else { + sb.state = SandboxState.DESTROYED; + } + }, + refreshActivity: async () => { + sb.refreshActivityCalls++; + if (sb.refreshActivityHandler) { + await sb.refreshActivityHandler(); + } + }, + getPreviewLink: async (_port: number) => ({ url: sb.previewUrl }), + }; + return handle; +} + +export type FakeSdk = DaytonaSdk & { + sandboxes: Map; + snapshots: Map; + creates: CreateParams[]; +}; + +export function createFakeSdk(initialSandboxes: FakeSandbox[] = []): FakeSdk { + let idCounter = 1; + const sandboxes = new Map(); + const snapshots = new Map(); + const creates: CreateParams[] = []; + + for (const sb of initialSandboxes) { + sandboxes.set(sb.id, sb); + } + + const sdk: FakeSdk = { + sandboxes, + snapshots, + creates, + create: async (params, options) => { + creates.push({ ...params, options }); + const id = `sb-${idCounter++}`; + const sb: FakeSandbox = { + id, + state: SandboxState.STARTED, + labels: params.labels, + envVars: params.envVars, + public: params.public, + autoStopInterval: params.autoStopInterval, + createdAt: new Date().toISOString(), + previewUrl: `https://${id}.preview.daytona.app`, + startCalls: 0, + stopCalls: 0, + deleteCalls: 0, + deleteArgs: [], + refreshActivityCalls: 0, + }; + sandboxes.set(id, sb); + return makeSandboxHandle(sb); + }, + get: async (id: string) => { + const sb = sandboxes.get(id); + if (!sb) { + throw new DaytonaNotFoundError(`Sandbox ${id} not found`); + } + return makeSandboxHandle(sb); + }, + list: (query?: { labels?: Record }) => { + async function* generator() { + for (const sb of sandboxes.values()) { + if (query?.labels) { + const matches = Object.entries(query.labels).every( + ([k, v]) => sb.labels[k] === v, + ); + if (!matches) continue; + } + yield makeSandboxHandle(sb); + } + } + return generator(); + }, + snapshot: { + get: async (name: string) => { + const snap = snapshots.get(name); + if (!snap) { + throw new DaytonaNotFoundError(`Snapshot ${name} not found`); + } + return snap; + }, + create: async (params, _options) => { + snapshots.set(params.name, { state: "active" }); + return { name: params.name }; + }, + }, + }; + + return sdk; +} + +export function fakeFetch( + handler?: (url: string, init?: RequestInit) => Response | Promise, +): typeof fetch { + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (handler) return handler(url, init); + if (url.endsWith("/health")) { + return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }) as unknown as typeof fetch; +} + +export type ClientOverrides = Partial & { + snapshotTimeoutMs?: number; +}; + +export function makeClient( + sdk: FakeSdk = createFakeSdk(), + overrides: ClientOverrides = {}, +) { + const defaultSnapshot = + !overrides.agentComputerDir && overrides.snapshot === undefined + ? { snapshot: "prebuilt" } + : {}; + return createDaytonaComputerProvider({ + apiKey: "test-api-key", + computerToken: "tok", + pollIntervalMs: 1, + healthTimeoutMs: 200, + sdk, + fetchImpl: fakeFetch(), + ...defaultSnapshot, + ...overrides, + } as DaytonaProviderOptions); +} diff --git a/server/tests/computer-daytona-lifecycle.test.ts b/server/tests/computer-daytona-lifecycle.test.ts new file mode 100644 index 00000000..712c9c7b --- /dev/null +++ b/server/tests/computer-daytona-lifecycle.test.ts @@ -0,0 +1,561 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { ProviderError } from "../src/computer/provider"; +import { + createFakeSdk, + fakeFetch, + makeClient, + makeSandbox, +} from "./computer-daytona-fixture"; + +describe("Daytona computer lifecycle supervisor", () => { + test("create carries snapshot, ownership labels including token hash, public:true, autoStopInterval:15, COMPUTER_TOKEN and COMPUTER_BOT_ID then returns preview URL after healthy /health", async () => { + const sdk = createFakeSdk(); + let healthCheckedUrl: string | undefined; + let healthHeaders: HeadersInit | undefined; + + const fetchImpl = fakeFetch((url, init) => { + if (url.endsWith("/health")) { + healthCheckedUrl = url; + healthHeaders = init?.headers; + return new Response("ok", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const token = "secret-token-123"; + const expectedHash = createHash("sha256").update(token).digest("hex"); + + const client = makeClient(sdk, { + computerToken: token, + fetchImpl, + }); + + const url = await client.locate("sales"); + + expect(sdk.creates).toHaveLength(1); + const createParams = sdk.creates[0]; + expect(createParams.snapshot).toBe("prebuilt"); + expect(createParams.public).toBe(true); + expect(createParams.autoStopInterval).toBe(15); + expect(createParams.labels).toEqual({ + "openbot/computer": "true", + "openbot/bot-id": "sales", + "openbot/computer-token-hash": expectedHash, + }); + expect(createParams.envVars.COMPUTER_TOKEN).toBe(token); + expect(createParams.envVars.COMPUTER_BOT_ID).toBe("sales"); + + expect(url).toBe("https://sb-1.preview.daytona.app"); + expect(healthCheckedUrl).toBe("https://sb-1.preview.daytona.app/health"); + expect( + new Headers(healthHeaders).get("X-Daytona-Skip-Preview-Warning"), + ).toBe("true"); + }); + + test("stopped labeled sandbox is reused and started", async () => { + const existing = makeSandbox({ + id: "sb-stopped-1", + botId: "support", + state: "stopped", + }); + + const sdk = createFakeSdk([existing]); + const client = makeClient(sdk); + + const url = await client.locate("support"); + + expect(url).toBe("https://sb-stopped-1.preview.daytona.app"); + expect(sdk.creates).toHaveLength(0); + expect(existing.startCalls).toBe(1); + expect(existing.state).toBe("started"); + }); + + test("locate on a reusable started sandbox calls refreshActivity exactly once before returning", async () => { + const token = "tok"; + const tokenHash = createHash("sha256").update(token).digest("hex"); + const startedSandbox = makeSandbox({ + id: "sb-started-activity", + botId: "activity-bot", + state: "started", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "activity-bot", + "openbot/computer-token-hash": tokenHash, + }, + }); + + const sdk = createFakeSdk([startedSandbox]); + const client = makeClient(sdk, { computerToken: token }); + + const url = await client.locate("activity-bot"); + + expect(url).toBe("https://sb-started-activity.preview.daytona.app"); + expect(startedSandbox.refreshActivityCalls).toBe(1); + expect(startedSandbox.startCalls).toBe(0); + expect(sdk.creates).toHaveLength(0); + }); + + test("existing sandbox with stale openbot/computer-token-hash is deleted and replaced with correct fingerprint label", async () => { + const currentToken = "rotated-secret-456"; + const expectedHash = createHash("sha256") + .update(currentToken) + .digest("hex"); + + const staleSandbox = makeSandbox({ + id: "sb-stale-token", + botId: "rotate-bot", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "rotate-bot", + "openbot/computer-token-hash": "outdated-sha256-hash", + }, + }); + + const sdk = createFakeSdk([staleSandbox]); + const client = makeClient(sdk, { + computerToken: currentToken, + }); + + const url = await client.locate("rotate-bot"); + + expect(staleSandbox.deleteCalls).toBe(1); + expect(sdk.creates).toHaveLength(1); + const createParams = sdk.creates[0]; + expect(createParams.labels).toEqual({ + "openbot/computer": "true", + "openbot/bot-id": "rotate-bot", + "openbot/computer-token-hash": expectedHash, + }); + expect(createParams.envVars.COMPUTER_TOKEN).toBe(currentToken); + expect(url).toBe("https://sb-1.preview.daytona.app"); + }); + + test("existing sandbox with missing openbot/computer-token-hash label is deleted and replaced", async () => { + const currentToken = "active-token"; + const expectedHash = createHash("sha256") + .update(currentToken) + .digest("hex"); + + const legacySandbox = makeSandbox({ + id: "sb-legacy-token", + botId: "legacy-bot", + labels: { + "openbot/computer": "true", + "openbot/bot-id": "legacy-bot", + }, + }); + + const sdk = createFakeSdk([legacySandbox]); + const client = makeClient(sdk, { + computerToken: currentToken, + }); + + const url = await client.locate("legacy-bot"); + + expect(legacySandbox.deleteCalls).toBe(1); + expect(sdk.creates).toHaveLength(1); + expect(sdk.creates[0].labels["openbot/computer-token-hash"]).toBe( + expectedHash, + ); + expect(url).toBe("https://sb-1.preview.daytona.app"); + }); + + test("concurrent locate calls create once", async () => { + const sdk = createFakeSdk(); + let createsCount = 0; + const { promise: gatePromise, resolve: openGate } = + Promise.withResolvers(); + const origCreate = sdk.create; + sdk.create = async (params, options) => { + createsCount++; + await gatePromise; + return origCreate(params, options); + }; + + const client = makeClient(sdk); + + const firstLocate = client.locate("marketing"); + const secondLocate = client.locate("marketing"); + openGate(); + + const [url1, url2] = await Promise.all([firstLocate, secondLocate]); + + expect(url1).toBe(url2); + expect(createsCount).toBe(1); + }); + + test("concurrent resets for the same Bot serialize, delete at most once, and both resolve", async () => { + let deleteCalls = 0; + const { promise: deleteGate, resolve: releaseDelete } = + Promise.withResolvers(); + + const existing = makeSandbox({ + id: "sb-concurrent-reset", + botId: "concurrent-bot", + deleteHandler: async () => { + deleteCalls++; + if (deleteCalls > 1) { + throw new Error("Daytona conflict: sandbox is already being deleted"); + } + await deleteGate; + existing.state = "destroyed"; + }, + }); + + const sdk = createFakeSdk([existing]); + const client = makeClient(sdk); + + const firstReset = client.reset("concurrent-bot"); + const secondReset = client.reset("concurrent-bot"); + + releaseDelete(); + const [res1, res2] = await Promise.all([firstReset, secondReset]); + + expect(existing.deleteCalls).toBe(1); + expect(res1).toEqual({ cleared: true }); + expect(res2).toEqual({ cleared: false }); + const list = await client.list(); + expect(list.find((b) => b.botId === "concurrent-bot")).toBeUndefined(); + expect(list).toHaveLength(0); + }); + + test("locate started during reset waits for reset completion, never health-polls the old sandbox, and returns a new sandbox", async () => { + const fetchedUrls: string[] = []; + const { promise: resetBlocker, resolve: unblockReset } = + Promise.withResolvers(); + + const oldSandbox = makeSandbox({ + id: "sb-old", + botId: "race-bot", + previewUrl: "https://sb-old.preview.daytona.app", + deleteHandler: async () => { + await resetBlocker; + oldSandbox.state = "destroyed"; + }, + }); + + const sdk = createFakeSdk([oldSandbox]); + const fetchImpl = fakeFetch((url) => { + fetchedUrls.push(url); + if (url === "https://sb-old.preview.daytona.app/health") { + throw new Error("poll attempted against deleted sandbox preview URL"); + } + if (url === "https://sb-1.preview.daytona.app/health") { + return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const client = makeClient(sdk, { fetchImpl }); + + const resetPromise = client.reset("race-bot"); + const locatePromise = client.locate("race-bot"); + + unblockReset(); + const [resetResult, newUrl] = await Promise.all([ + resetPromise, + locatePromise, + ]); + + expect(resetResult).toEqual({ cleared: true }); + expect(oldSandbox.deleteCalls).toBe(1); + expect(newUrl).toBe("https://sb-1.preview.daytona.app"); + expect(sdk.creates).toHaveLength(1); + expect(fetchedUrls).toEqual(["https://sb-1.preview.daytona.app/health"]); + expect(fetchedUrls).not.toContain( + "https://sb-old.preview.daytona.app/health", + ); + }); + + test("reset deletes and the next locate creates fresh", async () => { + const sdk = createFakeSdk(); + const client = makeClient(sdk); + + const firstUrl = await client.locate("finance"); + expect(sdk.creates).toHaveLength(1); + const firstSandbox = sdk.sandboxes.get("sb-1"); + expect(firstSandbox).toBeDefined(); + if (!firstSandbox) { + throw new Error("firstSandbox must be defined"); + } + + const resetResult = await client.reset("finance"); + expect(resetResult).toEqual({ cleared: true }); + expect(firstSandbox.deleteCalls).toBe(1); + + const secondUrl = await client.locate("finance"); + expect(sdk.creates).toHaveLength(2); + expect(secondUrl).not.toBe(firstUrl); + }); + + test("reset with no existing sandbox returns cleared false", async () => { + const sdk = createFakeSdk(); + const client = makeClient(sdk); + + const result = await client.reset("nonexistent"); + expect(result).toEqual({ cleared: false }); + }); + + test("stop on a started sandbox calls sandbox.stop and returns wasRunning true", async () => { + const runningSandbox = makeSandbox({ + id: "sb-running", + botId: "running-bot", + state: "started", + }); + + const sdk = createFakeSdk([runningSandbox]); + const client = makeClient(sdk); + + const result = await client.stop("running-bot"); + + expect(result).toEqual({ wasRunning: true }); + expect(runningSandbox.stopCalls).toBe(1); + }); + + test("stop on an already stopped sandbox resolves with wasRunning false without calling sandbox.stop", async () => { + const stoppedSandbox = makeSandbox({ + id: "sb-already-stopped", + botId: "already-stopped-bot", + state: "stopped", + }); + + const sdk = createFakeSdk([stoppedSandbox]); + const client = makeClient(sdk); + + const result = await client.stop("already-stopped-bot"); + + expect(result).toEqual({ wasRunning: false }); + expect(stoppedSandbox.stopCalls).toBe(0); + }); + + test("stop with no sandbox returns wasRunning false", async () => { + const sdk = createFakeSdk(); + const client = makeClient(sdk); + + await expect(client.stop("nonexistent")).resolves.toEqual({ + wasRunning: false, + }); + expect(sdk.creates).toHaveLength(0); + }); + + test("list maps openbot/bot-id and skips destroyed", async () => { + const activeBot = makeSandbox({ + id: "sb-active", + botId: "bot-active", + state: "started", + createdAt: "2026-08-20T10:00:00Z", + }); + + const destroyedBot = makeSandbox({ + id: "sb-destroyed", + botId: "bot-destroyed", + state: "destroyed", + createdAt: "2026-08-20T09:00:00Z", + }); + + const stoppedBot = makeSandbox({ + id: "sb-stopped", + botId: "bot-stopped", + state: "stopped", + createdAt: "2026-08-20T11:00:00Z", + }); + + const unrelatedSandbox = makeSandbox({ + id: "sb-unrelated", + labels: { + "some-other-label": "true", + }, + createdAt: "2026-08-20T08:00:00Z", + }); + + const sdk = createFakeSdk([ + activeBot, + destroyedBot, + stoppedBot, + unrelatedSandbox, + ]); + const client = makeClient(sdk); + + const list = await client.list(); + + expect(list).toHaveLength(2); + expect(list).toEqual( + expect.arrayContaining([ + { + botId: "bot-active", + status: "running", + url: "https://sb-active.preview.daytona.app", + startedAt: "2026-08-20T10:00:00Z", + }, + { + botId: "bot-stopped", + status: "stopped", + url: "https://sb-stopped.preview.daytona.app", + startedAt: "2026-08-20T11:00:00Z", + }, + ]), + ); + }); + + test("reset waits for sandbox deletion so list does not return the reset bot", async () => { + const existing = makeSandbox({ + id: "sb-reset-wait-1", + botId: "reset-wait-bot", + deleteHandler: (_timeout, wait) => { + if (wait) { + existing.state = "destroyed"; + } + }, + }); + + const sdk = createFakeSdk([existing]); + const client = makeClient(sdk); + + const result = await client.reset("reset-wait-bot"); + expect(result).toEqual({ cleared: true }); + + expect(existing.deleteCalls).toBe(1); + expect(existing.deleteArgs).toEqual([{ timeout: 60, wait: true }]); + const remaining = await client.list(); + expect( + remaining.find((bot) => bot.botId === "reset-wait-bot"), + ).toBeUndefined(); + }); + + test("reset waits for Daytona list convergence across eventual consistency stale started responses", async () => { + const existing = makeSandbox({ + id: "sb-reset-convergence-1", + botId: "reset-convergence-bot", + }); + + const sdk = createFakeSdk([existing]); + let postDeleteListCalls = 0; + const origList = sdk.list; + + sdk.list = (query?: { labels?: Record }) => { + if (existing.deleteCalls > 0) { + postDeleteListCalls++; + if (postDeleteListCalls === 1) { + async function* staleGenerator() { + yield { + id: existing.id, + state: "started", + labels: existing.labels, + createdAt: existing.createdAt, + start: async () => {}, + stop: async () => {}, + delete: async () => {}, + refreshActivity: async () => {}, + getPreviewLink: async () => ({ url: existing.previewUrl }), + }; + } + return staleGenerator(); + } + async function* emptyGenerator() {} + return emptyGenerator(); + } + return origList(query); + }; + + const client = makeClient(sdk); + + const result = await client.reset("reset-convergence-bot"); + expect(result).toEqual({ cleared: true }); + + const immediate = await client.list(); + expect( + immediate.find((bot) => bot.botId === "reset-convergence-bot"), + ).toBeUndefined(); + + const later = await client.list(); + expect( + later.find((bot) => bot.botId === "reset-convergence-bot"), + ).toBeUndefined(); + }); + + test("failed reset deletion does not tombstone sandbox so retry deletes it and removes it from list", async () => { + let deleteAttempts = 0; + const existing = makeSandbox({ + id: "sb-failed-delete-1", + botId: "retry-delete-bot", + deleteHandler: (_timeout, _wait) => { + deleteAttempts++; + if (deleteAttempts === 1) { + throw new Error("transient deletion error"); + } + existing.state = "destroyed"; + }, + }); + + const sdk = createFakeSdk([existing]); + const client = makeClient(sdk); + + let resetError: unknown; + try { + await client.reset("retry-delete-bot"); + } catch (err) { + resetError = err; + } + + expect(resetError).toBeInstanceOf(ProviderError); + expect((resetError as Error)?.message).toContain("retry-delete-bot"); + expect(existing.deleteCalls).toBe(1); + + const retryResult = await client.reset("retry-delete-bot"); + expect(retryResult).toEqual({ cleared: true }); + + expect(existing.deleteCalls).toBe(2); + const remaining = await client.list(); + expect( + remaining.find((bot) => bot.botId === "retry-delete-bot"), + ).toBeUndefined(); + }); + + test("locate polls stopping sandbox until stopped, calls start(300), and returns preview URL after healthy /health", async () => { + const stoppingSandbox = makeSandbox({ + id: "sb-stopping-1", + botId: "stopping-bot", + state: "stopping", + }); + + const sdk = createFakeSdk([stoppingSandbox]); + let getCalls = 0; + const origGet = sdk.get; + sdk.get = async (id: string) => { + if (id === stoppingSandbox.id && ++getCalls === 1) { + stoppingSandbox.state = "stopped"; + } + return origGet(id); + }; + + const client = makeClient(sdk); + + const url = await client.locate("stopping-bot"); + + expect(url).toBe("https://sb-stopping-1.preview.daytona.app"); + expect(stoppingSandbox.startCalls).toBe(1); + expect(stoppingSandbox.state).toBe("started"); + }); + + test("locate creates a fresh sandbox when a cached sandbox is destroying or not found", async () => { + const sdk = createFakeSdk(); + const client = makeClient(sdk); + + const firstUrl = await client.locate("destroying-bot"); + expect(sdk.creates).toHaveLength(1); + expect(firstUrl).toBe("https://sb-1.preview.daytona.app"); + + const firstSandbox = sdk.sandboxes.get("sb-1"); + expect(firstSandbox).toBeDefined(); + if (!firstSandbox) { + throw new Error("firstSandbox must be defined"); + } + firstSandbox.state = "destroying"; + + const secondUrl = await client.locate("destroying-bot"); + expect(sdk.creates).toHaveLength(2); + expect(secondUrl).toBe("https://sb-2.preview.daytona.app"); + expect(secondUrl).not.toBe(firstUrl); + }); +}); diff --git a/server/tests/computer-daytona.test.ts b/server/tests/computer-daytona.test.ts index 756fe863..6a032aa2 100644 --- a/server/tests/computer-daytona.test.ts +++ b/server/tests/computer-daytona.test.ts @@ -2,250 +2,34 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { SandboxState } from "@daytona/sdk"; +import { createDaytonaComputerProvider } from "../src/computer/daytona"; import { - createDaytonaComputerProvider, - createDaytonaSupervisorClient, - type DaytonaSdk, - type DaytonaSupervisorOptions, - type SandboxHandle, -} from "../src/computer/daytona"; -import { ProviderError } from "../src/computer/provider"; - -/** - * Daytona computer provider test suite. - * - * OpenBot gives each Bot a remote Daytona sandbox when DAYTONA_API_KEY is set. - * The provider creates, locates, stops, resets, and lists these sandboxes through - * the Daytona SDK. It checks the preview URL before it returns a computer address. - */ - -class DaytonaNotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "DaytonaNotFoundError"; - } -} - -type DeleteCall = { - timeout?: number; - wait?: boolean; -}; - -type FakeSandbox = { - id: string; - state: string; - labels: Record; - envVars: Record; - public: boolean; - autoStopInterval: number; - createdAt: string; - previewUrl: string; - startCalls: number; - stopCalls: number; - deleteCalls: number; - deleteArgs: DeleteCall[]; - deleteHandler?: (timeout?: number, wait?: boolean) => void | Promise; -}; - -type CreateParams = { - snapshot: string; - envVars: Record; - labels: Record; - public: boolean; - autoStopInterval: number; - options?: { timeout?: number }; -}; - -function makeSandbox(options: { - id: string; - botId?: string; - state?: string; - labels?: Record; - envVars?: Record; - public?: boolean; - autoStopInterval?: number; - createdAt?: string; - previewUrl?: string; - deleteHandler?: (timeout?: number, wait?: boolean) => void | Promise; -}): FakeSandbox { - const botId = options.botId; - const defaultLabels = botId - ? { "openbot/computer": "true", "openbot/bot-id": botId } - : { "openbot/computer": "true" }; - const labels = options.labels ?? defaultLabels; - const envVars = - options.envVars ?? - (botId ? { COMPUTER_TOKEN: "tok", COMPUTER_BOT_ID: botId } : {}); - - return { - id: options.id, - state: options.state ?? "started", - labels, - envVars, - public: options.public ?? true, - autoStopInterval: options.autoStopInterval ?? 15, - createdAt: options.createdAt ?? "2026-08-20T10:00:00Z", - previewUrl: - options.previewUrl ?? `https://${options.id}.preview.daytona.app`, - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - deleteArgs: [], - ...(options.deleteHandler ? { deleteHandler: options.deleteHandler } : {}), - }; -} - -function makeSandboxHandle(sb: FakeSandbox): SandboxHandle { - return { - id: sb.id, - state: sb.state, - labels: sb.labels, - createdAt: sb.createdAt, - start: async () => { - sb.startCalls++; - sb.state = "started"; - }, - stop: async () => { - sb.stopCalls++; - sb.state = "stopped"; - }, - delete: async (timeout?: number, wait?: boolean) => { - sb.deleteCalls++; - sb.deleteArgs.push({ timeout, wait }); - if (sb.deleteHandler) { - await sb.deleteHandler(timeout, wait); - } else { - sb.state = "destroyed"; - } - }, - getPreviewLink: async (_port: number) => ({ url: sb.previewUrl }), - }; -} - -type FakeSdk = DaytonaSdk & { - sandboxes: Map; - snapshots: Map; - creates: CreateParams[]; -}; - -function createFakeSdk(initialSandboxes: FakeSandbox[] = []): FakeSdk { - let idCounter = 1; - const sandboxes = new Map(); - const snapshots = new Map(); - const creates: CreateParams[] = []; - - for (const sb of initialSandboxes) { - sandboxes.set(sb.id, sb); - } - - const sdk: FakeSdk = { - sandboxes, - snapshots, - creates, - create: async (params, options) => { - creates.push({ ...params, options }); - const id = `sb-${idCounter++}`; - const sb: FakeSandbox = { - id, - state: "started", - labels: params.labels, - envVars: params.envVars, - public: params.public, - autoStopInterval: params.autoStopInterval, - createdAt: new Date().toISOString(), - previewUrl: `https://${id}.preview.daytona.app`, - startCalls: 0, - stopCalls: 0, - deleteCalls: 0, - deleteArgs: [], - }; - sandboxes.set(id, sb); - return makeSandboxHandle(sb); - }, - get: async (id: string) => { - const sb = sandboxes.get(id); - if (!sb) { - throw new DaytonaNotFoundError(`Sandbox ${id} not found`); - } - return makeSandboxHandle(sb); - }, - list: (query?: { labels?: Record }) => { - async function* generator() { - for (const sb of sandboxes.values()) { - if (query?.labels) { - const matches = Object.entries(query.labels).every( - ([k, v]) => sb.labels[k] === v, - ); - if (!matches) continue; - } - yield makeSandboxHandle(sb); - } - } - return generator(); - }, - snapshot: { - get: async (name: string) => { - const snap = snapshots.get(name); - if (!snap) { - throw new DaytonaNotFoundError(`Snapshot ${name} not found`); - } - return snap; - }, - create: async (params, _options) => { - snapshots.set(params.name, { state: "active" }); - return { name: params.name }; - }, - }, - }; - - return sdk; -} - -function fakeFetch( - handler?: (url: string, init?: RequestInit) => Response | Promise, -): typeof fetch { - return (async (input: string | URL | Request, init?: RequestInit) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - if (handler) return handler(url, init); - if (url.endsWith("/health")) { - return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); - } - return new Response("Not Found", { status: 404 }); - }) as unknown as typeof fetch; -} - -type ClientOverrides = Partial & { - snapshotTimeoutMs?: number; -}; - -function makeClient( - sdk: FakeSdk = createFakeSdk(), - overrides: ClientOverrides = {}, -) { - const defaultSnapshot = - !overrides.agentComputerDir && overrides.snapshot === undefined - ? { snapshot: "prebuilt" } - : {}; - return createDaytonaSupervisorClient({ - apiKey: "test-api-key", - computerToken: "tok", - pollIntervalMs: 1, - healthTimeoutMs: 200, - sdk, - fetchImpl: fakeFetch(), - ...defaultSnapshot, - ...overrides, - } as DaytonaSupervisorOptions); -} + describeComputerIsolation, + ProviderError, +} from "../src/computer/provider"; +import type { ComputerStatus } from "../src/computer/schema"; +import { + createFakeSdk, + fakeFetch, + makeClient, + makeSandbox, +} from "./computer-daytona-fixture"; describe("Daytona computer supervisor", () => { const tempDirs: string[] = []; + afterEach(() => { + for (const dir of tempDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // cleanup best-effort + } + } + tempDirs.length = 0; + }); + test("exposes the Daytona per-Bot provider contract", () => { const provider = createDaytonaComputerProvider({ apiKey: "test-api-key", @@ -257,79 +41,159 @@ describe("Daytona computer supervisor", () => { expect(provider.name).toBe("Daytona"); expect(provider.isolation).toBe("per-bot"); - expect(provider.describeIsolation()).toEqual({ - isolation: "one computer per Bot", - note: "Each Bot gets a remote Daytona sandbox with its own /workspace and its own browser profile.", - }); + expect(describeComputerIsolation(provider).isolation).toBe( + "one computer per Bot", + ); }); test("status maps Daytona lifecycle states without starting or creating a sandbox", async () => { - const states = { - readyStarted: "started", - readyRunning: "running", - startingCreated: "created", - startingRestarting: "restarting", - startingCreating: "creating", - startingStarting: "starting", - startingRestoring: "restoring", - startingPulling: "pulling_snapshot", - startingResuming: "resuming", - absentStopped: "stopped", - absentPaused: "paused", - absentArchived: "archived", - absentExited: "exited", - absentDestroyed: "destroyed", - absentRemoving: "removing", - absentArchiving: "archiving", - absentPausing: "pausing", - absentStopping: "stopping", - unreachableError: "error", - unreachableBuildFailed: "build_failed", - unreachableUnknown: "new_state", - } as const; - const sandboxes = Object.entries(states).map(([botId, state], index) => - makeSandbox({ id: `status-${index}`, botId, state }), + const cases: Array<{ + botId: string; + state: SandboxState; + expected: ComputerStatus; + }> = [ + { + botId: "bot-started", + state: SandboxState.STARTED, + expected: { botId: "bot-started", state: "ready" }, + }, + { + botId: "bot-starting", + state: SandboxState.STARTING, + expected: { botId: "bot-starting", state: "starting" }, + }, + { + botId: "bot-creating", + state: SandboxState.CREATING, + expected: { botId: "bot-creating", state: "starting" }, + }, + { + botId: "bot-restoring", + state: SandboxState.RESTORING, + expected: { botId: "bot-restoring", state: "starting" }, + }, + { + botId: "bot-pulling", + state: SandboxState.PULLING_SNAPSHOT, + expected: { botId: "bot-pulling", state: "starting" }, + }, + { + botId: "bot-resuming", + state: SandboxState.RESUMING, + expected: { botId: "bot-resuming", state: "starting" }, + }, + { + botId: "bot-pending-build", + state: SandboxState.PENDING_BUILD, + expected: { botId: "bot-pending-build", state: "starting" }, + }, + { + botId: "bot-building-snapshot", + state: SandboxState.BUILDING_SNAPSHOT, + expected: { botId: "bot-building-snapshot", state: "starting" }, + }, + { + botId: "bot-resizing", + state: SandboxState.RESIZING, + expected: { botId: "bot-resizing", state: "starting" }, + }, + { + botId: "bot-snapshotting", + state: SandboxState.SNAPSHOTTING, + expected: { botId: "bot-snapshotting", state: "starting" }, + }, + { + botId: "bot-forking", + state: SandboxState.FORKING, + expected: { botId: "bot-forking", state: "starting" }, + }, + { + botId: "bot-stopped", + state: SandboxState.STOPPED, + expected: { botId: "bot-stopped", state: "absent" }, + }, + { + botId: "bot-paused", + state: SandboxState.PAUSED, + expected: { botId: "bot-paused", state: "absent" }, + }, + { + botId: "bot-archived", + state: SandboxState.ARCHIVED, + expected: { botId: "bot-archived", state: "absent" }, + }, + { + botId: "bot-destroyed", + state: SandboxState.DESTROYED, + expected: { botId: "bot-destroyed", state: "absent" }, + }, + { + botId: "bot-destroying", + state: SandboxState.DESTROYING, + expected: { botId: "bot-destroying", state: "absent" }, + }, + { + botId: "bot-archiving", + state: SandboxState.ARCHIVING, + expected: { botId: "bot-archiving", state: "absent" }, + }, + { + botId: "bot-pausing", + state: SandboxState.PAUSING, + expected: { botId: "bot-pausing", state: "absent" }, + }, + { + botId: "bot-stopping", + state: SandboxState.STOPPING, + expected: { botId: "bot-stopping", state: "absent" }, + }, + { + botId: "bot-error", + state: SandboxState.ERROR, + expected: { + botId: "bot-error", + state: "unreachable", + reason: 'Daytona reported the computer state "error".', + }, + }, + { + botId: "bot-build-failed", + state: SandboxState.BUILD_FAILED, + expected: { + botId: "bot-build-failed", + state: "unreachable", + reason: 'Daytona reported the computer state "build_failed".', + }, + }, + { + botId: "bot-unknown", + state: SandboxState.UNKNOWN, + expected: { + botId: "bot-unknown", + state: "unreachable", + reason: 'Daytona reported the computer state "unknown".', + }, + }, + { + botId: "bot-unknown-default-open-api", + state: SandboxState.UNKNOWN_DEFAULT_OPEN_API, + expected: { + botId: "bot-unknown-default-open-api", + state: "unreachable", + reason: 'Daytona reported the computer state "11184809".', + }, + }, + ]; + + const sandboxes = cases.map((c, index) => + makeSandbox({ id: `status-${index}`, botId: c.botId, state: c.state }), ); const sdk = createFakeSdk(sandboxes); const provider = makeClient(sdk); - for (const [botId, state] of Object.entries(states)) { - const status = await provider.status(botId); - if (state === "started" || state === "running") { - expect(status).toEqual({ botId, state: "ready" }); - } else if ( - [ - "created", - "restarting", - "creating", - "starting", - "restoring", - "pulling_snapshot", - "resuming", - ].includes(state) - ) { - expect(status).toEqual({ botId, state: "starting" }); - } else if ( - [ - "stopped", - "paused", - "archived", - "exited", - "destroyed", - "removing", - "archiving", - "pausing", - "stopping", - ].includes(state) - ) { - expect(status).toEqual({ botId, state: "absent" }); - } else { - expect(status).toMatchObject({ - botId, - state: "unreachable", - reason: expect.any(String), - }); - } + for (const c of cases) { + const status = await provider.status(c.botId); + expect(status).toEqual(c.expected); } expect(sdk.creates).toHaveLength(0); expect(sandboxes.every((sandbox) => sandbox.startCalls === 0)).toBe(true); @@ -353,122 +217,6 @@ describe("Daytona computer supervisor", () => { }); expect(sdk.creates).toHaveLength(0); }); - afterEach(() => { - for (const dir of tempDirs) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // cleanup best-effort - } - } - tempDirs.length = 0; - }); - - test("create carries snapshot, both ownership labels, public:true, autoStopInterval:15, COMPUTER_TOKEN and COMPUTER_BOT_ID then returns preview URL after healthy /health", async () => { - const sdk = createFakeSdk(); - let healthCheckedUrl: string | undefined; - let healthHeaders: HeadersInit | undefined; - - const fetchImpl = fakeFetch((url, init) => { - if (url.endsWith("/health")) { - healthCheckedUrl = url; - healthHeaders = init?.headers; - return new Response("ok", { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); - - const client = makeClient(sdk, { - computerToken: "secret-token-123", - fetchImpl, - }); - - const url = await client.locate("sales"); - - expect(sdk.creates).toHaveLength(1); - const createParams = sdk.creates[0]; - expect(createParams.snapshot).toBe("prebuilt"); - expect(createParams.public).toBe(true); - expect(createParams.autoStopInterval).toBe(15); - expect(createParams.labels).toEqual({ - "openbot/computer": "true", - "openbot/bot-id": "sales", - }); - expect(createParams.envVars.COMPUTER_TOKEN).toBe("secret-token-123"); - expect(createParams.envVars.COMPUTER_BOT_ID).toBe("sales"); - - expect(url).toBe("https://sb-1.preview.daytona.app"); - expect(healthCheckedUrl).toBe("https://sb-1.preview.daytona.app/health"); - expect( - new Headers(healthHeaders).get("X-Daytona-Skip-Preview-Warning"), - ).toBe("true"); - }); - - test("stopped labeled sandbox is reused and started", async () => { - const existing = makeSandbox({ - id: "sb-stopped-1", - botId: "support", - state: "stopped", - }); - - const sdk = createFakeSdk([existing]); - const client = makeClient(sdk); - - const url = await client.locate("support"); - - expect(url).toBe("https://sb-stopped-1.preview.daytona.app"); - expect(sdk.creates).toHaveLength(0); - expect(existing.startCalls).toBe(1); - expect(existing.state).toBe("started"); - }); - - test("concurrent locate calls create once", async () => { - const sdk = createFakeSdk(); - let createsCount = 0; - const { promise: gatePromise, resolve: openGate } = - Promise.withResolvers(); - const origCreate = sdk.create; - sdk.create = async (params, options) => { - createsCount++; - await gatePromise; - return origCreate(params, options); - }; - - const client = makeClient(sdk); - - const firstLocate = client.locate("marketing"); - const secondLocate = client.locate("marketing"); - openGate(); - - const [url1, url2] = await Promise.all([firstLocate, secondLocate]); - - expect(url1).toBe(url2); - expect(createsCount).toBe(1); - }); - - test("reset deletes and the next locate creates fresh", async () => { - const sdk = createFakeSdk(); - const client = makeClient(sdk); - - const firstUrl = await client.locate("finance"); - expect(sdk.creates).toHaveLength(1); - const firstSandbox = sdk.sandboxes.get("sb-1")!; - - await client.reset("finance"); - expect(firstSandbox.deleteCalls).toBe(1); - - const secondUrl = await client.locate("finance"); - expect(sdk.creates).toHaveLength(2); - expect(secondUrl).not.toBe(firstUrl); - }); - - test("stop with no sandbox is a no-op", async () => { - const sdk = createFakeSdk(); - const client = makeClient(sdk); - - await expect(client.stop("nonexistent")).resolves.toBeUndefined(); - expect(sdk.creates).toHaveLength(0); - }); test("SDK failure throws ProviderError naming the Bot", async () => { const sdk = createFakeSdk(); @@ -482,65 +230,6 @@ describe("Daytona computer supervisor", () => { await expect(client.locate("analytics")).rejects.toThrow(/analytics/); }); - test("list maps openbot/bot-id and skips destroyed", async () => { - const activeBot = makeSandbox({ - id: "sb-active", - botId: "bot-active", - state: "started", - createdAt: "2026-08-20T10:00:00Z", - }); - - const destroyedBot = makeSandbox({ - id: "sb-destroyed", - botId: "bot-destroyed", - state: "destroyed", - createdAt: "2026-08-20T09:00:00Z", - }); - - const stoppedBot = makeSandbox({ - id: "sb-stopped", - botId: "bot-stopped", - state: "stopped", - createdAt: "2026-08-20T11:00:00Z", - }); - - const unrelatedSandbox = makeSandbox({ - id: "sb-unrelated", - labels: { - "some-other-label": "true", - }, - createdAt: "2026-08-20T08:00:00Z", - }); - - const sdk = createFakeSdk([ - activeBot, - destroyedBot, - stoppedBot, - unrelatedSandbox, - ]); - const client = makeClient(sdk); - - const list = await client.list(); - - expect(list).toHaveLength(2); - expect(list).toEqual( - expect.arrayContaining([ - { - botId: "bot-active", - status: "running", - url: "https://sb-active.preview.daytona.app", - startedAt: "2026-08-20T10:00:00Z", - }, - { - botId: "bot-stopped", - status: "exited", - url: "https://sb-stopped.preview.daytona.app", - startedAt: "2026-08-20T11:00:00Z", - }, - ]), - ); - }); - test("health timeout throws ProviderError", async () => { const sdk = createFakeSdk(); const failingFetch = fakeFetch( @@ -552,9 +241,7 @@ describe("Daytona computer supervisor", () => { fetchImpl: failingFetch, }); - await expect(client.locate("unhealthy-bot")).rejects.toThrow( - ProviderError, - ); + await expect(client.locate("unhealthy-bot")).rejects.toThrow(ProviderError); await expect(client.locate("unhealthy-bot")).rejects.toThrow( /The computer for unhealthy-bot started but never answered \/health/, ); @@ -751,7 +438,10 @@ describe("Daytona computer supervisor", () => { await client.locate("recipe-test-bot"); expect(capturedImage).toBeDefined(); - const dockerfile = capturedImage!.dockerfile; + if (!capturedImage) { + throw new Error("capturedImage must be defined"); + } + const dockerfile = capturedImage.dockerfile; const envPathLine = dockerfile .split("\n") @@ -761,213 +451,8 @@ describe("Daytona computer supervisor", () => { expect(envPathLine).toContain("/root/.bun/bin"); expect(envPathLine).toContain("/usr/bin"); expect(envPathLine).toContain("/bin"); - expect(envPathLine).not.toContain("${PATH}"); - expect(dockerfile).not.toContain("${PATH}"); - }); - - test("reset waits for sandbox deletion so list does not return the reset bot", async () => { - const existing = makeSandbox({ - id: "sb-reset-wait-1", - botId: "reset-wait-bot", - deleteHandler: (_timeout, wait) => { - if (wait) { - existing.state = "destroyed"; - } - }, - }); - - const sdk = createFakeSdk([existing]); - const client = makeClient(sdk); - - await client.reset("reset-wait-bot"); - - expect(existing.deleteCalls).toBe(1); - expect(existing.deleteArgs).toEqual([{ timeout: 60, wait: true }]); - const remaining = await client.list(); - expect( - remaining.find((bot) => bot.botId === "reset-wait-bot"), - ).toBeUndefined(); - }); - - test("reset waits for Daytona list convergence across eventual consistency stale started responses", async () => { - const existing = makeSandbox({ - id: "sb-reset-convergence-1", - botId: "reset-convergence-bot", - }); - - const sdk = createFakeSdk([existing]); - let postDeleteListCalls = 0; - const origList = sdk.list; - - sdk.list = (query?: { labels?: Record }) => { - if (existing.deleteCalls > 0) { - postDeleteListCalls++; - if (postDeleteListCalls === 1) { - async function* staleGenerator() { - yield { - id: existing.id, - state: "started", - labels: existing.labels, - createdAt: existing.createdAt, - start: async () => {}, - stop: async () => {}, - delete: async () => {}, - getPreviewLink: async () => ({ url: existing.previewUrl }), - }; - } - return staleGenerator(); - } - async function* emptyGenerator() {} - return emptyGenerator(); - } - return origList(query); - }; - - const client = makeClient(sdk); - - await client.reset("reset-convergence-bot"); - - const immediate = await client.list(); - expect( - immediate.find((bot) => bot.botId === "reset-convergence-bot"), - ).toBeUndefined(); - - const later = await client.list(); - expect( - later.find((bot) => bot.botId === "reset-convergence-bot"), - ).toBeUndefined(); - }); - - test("failed reset deletion does not tombstone sandbox so retry deletes it and removes it from list", async () => { - let deleteAttempts = 0; - const existing = makeSandbox({ - id: "sb-failed-delete-1", - botId: "retry-delete-bot", - deleteHandler: (_timeout, _wait) => { - deleteAttempts++; - if (deleteAttempts === 1) { - throw new Error("transient deletion error"); - } - existing.state = "destroyed"; - }, - }); - - const sdk = createFakeSdk([existing]); - const client = makeClient(sdk); - - let resetError: unknown; - try { - await client.reset("retry-delete-bot"); - } catch (err) { - resetError = err; - } - - expect(resetError).toBeInstanceOf(ProviderError); - expect((resetError as Error)?.message).toContain("retry-delete-bot"); - expect(existing.deleteCalls).toBe(1); - - await client.reset("retry-delete-bot"); - - expect(existing.deleteCalls).toBe(2); - const remaining = await client.list(); - expect( - remaining.find((bot) => bot.botId === "retry-delete-bot"), - ).toBeUndefined(); - }); - - test("locate polls stopping sandbox until stopped, calls start(300), and returns preview URL after healthy /health", async () => { - const stoppingSandbox = makeSandbox({ - id: "sb-stopping-1", - botId: "stopping-bot", - state: "stopping", - }); - - const sdk = createFakeSdk([stoppingSandbox]); - let getCalls = 0; - const origGet = sdk.get; - sdk.get = async (id: string) => { - if (id === stoppingSandbox.id && ++getCalls === 1) { - stoppingSandbox.state = "stopped"; - } - return origGet(id); - }; - - const client = makeClient(sdk); - - const url = await client.locate("stopping-bot"); - - expect(url).toBe("https://sb-stopping-1.preview.daytona.app"); - expect(stoppingSandbox.startCalls).toBe(1); - expect(stoppingSandbox.state).toBe("started"); - }); - - test("locate creates a fresh sandbox when a cached sandbox is destroying or not found", async () => { - const sdk = createFakeSdk(); - const client = makeClient(sdk); - - const firstUrl = await client.locate("destroying-bot"); - expect(sdk.creates).toHaveLength(1); - expect(firstUrl).toBe("https://sb-1.preview.daytona.app"); - - const firstSandbox = sdk.sandboxes.get("sb-1")!; - firstSandbox.state = "destroying"; - - const secondUrl = await client.locate("destroying-bot"); - expect(sdk.creates).toHaveLength(2); - expect(secondUrl).toBe("https://sb-2.preview.daytona.app"); - expect(secondUrl).not.toBe(firstUrl); - }); - - test("stop on an already stopped sandbox resolves without calling sandbox.stop", async () => { - const stoppedSandbox = makeSandbox({ - id: "sb-already-stopped", - botId: "already-stopped-bot", - state: "stopped", - }); - - const sdk = createFakeSdk([stoppedSandbox]); - const client = makeClient(sdk); - - await client.stop("already-stopped-bot"); - - expect(stoppedSandbox.stopCalls).toBe(0); - }); - - test("list maps Daytona started to running and stopped to exited in shared supervisor vocabulary", async () => { - const startedSandbox = makeSandbox({ - id: "sb-started-voc", - botId: "started-voc-bot", - state: "started", - createdAt: "2026-08-20T10:00:00Z", - }); - - const stoppedSandbox = makeSandbox({ - id: "sb-stopped-voc", - botId: "stopped-voc-bot", - state: "stopped", - createdAt: "2026-08-20T11:00:00Z", - }); - - const sdk = createFakeSdk([startedSandbox, stoppedSandbox]); - const client = makeClient(sdk); - - const list = await client.list(); - - expect(list).toEqual( - expect.arrayContaining([ - { - botId: "started-voc-bot", - status: "running", - url: "https://sb-started-voc.preview.daytona.app", - startedAt: "2026-08-20T10:00:00Z", - }, - { - botId: "stopped-voc-bot", - status: "exited", - url: "https://sb-stopped-voc.preview.daytona.app", - startedAt: "2026-08-20T11:00:00Z", - }, - ]), - ); + const dollarPath = "$" + "{PATH}"; + expect(envPathLine).not.toContain(dollarPath); + expect(dockerfile).not.toContain(dollarPath); }); }); diff --git a/server/tests/computer-gateway-provider.test.ts b/server/tests/computer-gateway-provider.test.ts deleted file mode 100644 index a6d9330c..00000000 --- a/server/tests/computer-gateway-provider.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { AuditEventInput, AuditStore } from "../src/audit"; -import { createComputerGateway } from "../src/computer/gateway"; -import type { ComputerProvider } from "../src/computer/provider"; - -function setup() { - const calls: string[] = []; - const rows: AuditEventInput[] = []; - const provider: ComputerProvider = { - name: "test", - isolation: "per-bot", - describeIsolation: () => ({ - isolation: "one computer per Bot", - note: "Test provider.", - }), - locate: async (botId) => { - calls.push(`locate:${botId}`); - return `http://${botId}.computer`; - }, - status: async (botId) => { - calls.push(`status:${botId}`); - return { botId, state: "ready" }; - }, - stop: async (botId) => void calls.push(`stop:${botId}`), - reset: async (botId) => void calls.push(`reset:${botId}`), - list: async () => [ - { - botId: "bot-1", - status: "started", - startedAt: "2026-08-20T12:00:00.000Z", - }, - ], - }; - const auditStore: AuditStore = { - insert: async (event) => void rows.push(event), - }; - const requests: Array<{ url: string; init?: RequestInit }> = []; - const gateway = createComputerGateway({ - provider, - auditStore, - policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), - token: "computer-secret", - fetchImpl: (async (url: string, init?: RequestInit) => { - requests.push({ url, init }); - return Response.json({ image: "aGVsbG8=", mimeType: "image/png" }); - }) as unknown as typeof fetch, - }); - return { gateway, provider, calls, rows, requests }; -} - -const ACTOR = { id: "dev-local-user" }; - -describe("the provider-backed computer gateway", () => { - test("exposes the provider and delegates address and status lookup", async () => { - const { gateway, provider, calls } = setup(); - - expect(gateway.provider).toBe(provider); - expect(await gateway.locate("bot-1")).toBe("http://bot-1.computer"); - expect(await gateway.status("bot-1")).toEqual({ - botId: "bot-1", - state: "ready", - }); - expect(calls).toEqual(["locate:bot-1", "status:bot-1"]); - }); - - test("takes a screenshot through the located computer with its identity and token", async () => { - const { gateway, requests } = setup(); - - expect(await gateway.screenshot("bot-1")).toEqual({ - image: "aGVsbG8=", - mimeType: "image/png", - }); - expect(requests).toHaveLength(1); - expect(requests[0]?.url).toBe("http://bot-1.computer/screenshot"); - expect(requests[0]?.init?.headers).toMatchObject({ - "x-openbot-bot-id": "bot-1", - "x-openbot-computer-token": "computer-secret", - }); - }); - - test("uses the provider for inventory and audited lifecycle actions", async () => { - const { gateway, calls, rows } = setup(); - - expect(await gateway.computers()).toEqual({ - isolation: "per-bot", - computers: [ - { - botId: "bot-1", - running: true, - startedAt: "2026-08-20T12:00:00.000Z", - egress: null, - }, - ], - }); - expect(await gateway.stopComputer("computer-1", "bot-1", ACTOR)).toEqual({ - wasRunning: true, - }); - expect(await gateway.resetComputer("computer-1", "bot-1", ACTOR)).toEqual({ - cleared: true, - }); - expect(calls).toEqual(["stop:bot-1", "reset:bot-1"]); - expect(rows.map((row) => row.eventType)).toEqual([ - "computer.stopped", - "computer.reset", - ]); - }); - - test("routes acting, control, file, secret, and human input calls through the gateway", async () => { - const { gateway, requests } = setup(); - - await gateway.key("bot-1", "bot-1", ACTOR, { key: "Tab" }); - await gateway.scroll("bot-1", "bot-1", ACTOR, { deltaY: 400 }); - await gateway.listFiles("bot-1", "bot-1", ACTOR, { path: "notes" }); - await gateway.control("bot-1"); - await gateway.requestHelp("bot-1", "bot-1", ACTOR, "Sign in"); - await gateway.takeControl("bot-1", "bot-1", ACTOR); - await gateway.releaseControl("bot-1", "bot-1", ACTOR); - await gateway.requestSecret("bot-1", "bot-1", ACTOR, { - label: "Password", - ref: "e1", - snapshotId: 3, - }); - await gateway.supplySecret("bot-1", "bot-1", ACTOR, "secret"); - await gateway.humanInput("bot-1", { kind: "click", x: 10, y: 20 }); - - expect(requests.map(({ url }) => new URL(url).pathname)).toEqual([ - "/key", - "/scroll", - "/files/list", - "/control", - "/control/request", - "/control/take", - "/control/release", - "/control/secret", - "/human/secret", - "/human/click", - ]); - }); -}); diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index e6b2f22c..a3346bc1 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -3,10 +3,14 @@ import type { AuditEventInput, AuditStore } from "../src/audit"; import { ActionRefusedError, createComputerGateway, + WorkspaceRefusedError, } from "../src/computer/gateway"; import type { ActionPolicy } from "../src/computer/policy"; import type { SnapshotResult } from "../src/computer/schema"; -import type { ComputerProvider } from "../src/computer/provider"; +import type { + ComputerLocation, + ComputerProvider, +} from "../src/computer/provider"; /** * What the gateway must guarantee, tested as properties rather than as call sequences. @@ -30,9 +34,18 @@ const SNAPSHOT: SnapshotResult = { }; /** A computer that records which HTTP actions reached it. */ -function fakeComputer() { +function fakeComputer(options?: { + stopResult?: { wasRunning: boolean }; + resetResult?: { cleared: boolean }; + locations?: ComputerLocation[]; + routes?: Record Response | Promise>; +}) { const calls: string[] = []; const addressedAs: string[] = []; + const requests: Array<{ url: string; init?: RequestInit }> = []; + const stopResult = options?.stopResult ?? { wasRunning: true }; + const resetResult = options?.resetResult ?? { cleared: true }; + const locations = options?.locations ?? []; const result = (action: string) => ({ action, url: SNAPSHOT.url, @@ -41,21 +54,27 @@ function fakeComputer() { const provider: ComputerProvider = { name: "test", isolation: "per-bot", - describeIsolation: () => ({ - isolation: "one computer per Bot", - note: "Test provider.", - }), locate: async (botId) => { addressedAs.push(botId); return "http://agent-computer:4100"; }, status: async (botId) => ({ botId, state: "ready" }), - stop: async () => {}, - reset: async () => {}, - list: async () => [], + stop: async (botId) => { + calls.push(`stop:${botId}`); + return stopResult; + }, + reset: async (botId) => { + calls.push(`reset:${botId}`); + return resetResult; + }, + list: async () => locations, }; - const fetchImpl = (async (url: string) => { + const fetchImpl = (async (url: string, init?: RequestInit) => { + requests.push({ url, init }); const path = new URL(url).pathname; + if (options?.routes && path in options.routes) { + return options.routes[path](init); + } switch (path) { case "/snapshot": return Response.json(SNAPSHOT); @@ -66,6 +85,12 @@ function fakeComputer() { text: "", truncated: false, }); + case "/screenshot": + calls.push("screenshot"); + return Response.json({ + image: "aGVsbG8=", + mimeType: "image/png", + }); case "/files/read": calls.push("readFile"); return Response.json({ @@ -81,6 +106,12 @@ function fakeComputer() { bytes: 4, appended: false, }); + case "/files/list": + calls.push("listFiles"); + return Response.json({ + path: "notes", + entries: [], + }); case "/navigate": calls.push("navigate"); return Response.json({ @@ -88,14 +119,50 @@ function fakeComputer() { title: "Example", elapsedMs: 1, }); - default: { - const action = path.slice(1); - calls.push(action); - return Response.json(result(action)); - } + case "/click": + calls.push("click"); + return Response.json(result("click")); + case "/type": + calls.push("type"); + return Response.json(result("type")); + case "/key": + calls.push("key"); + return Response.json(result("key")); + case "/scroll": + calls.push("scroll"); + return Response.json(result("scroll")); + case "/control": + calls.push("control"); + return Response.json({ mode: "bot" }); + case "/control/request": + calls.push("requestHelp"); + return Response.json({ mode: "human", reason: "Sign in" }); + case "/control/take": + calls.push("takeControl"); + return Response.json({ mode: "human" }); + case "/control/release": + calls.push("releaseControl"); + return Response.json({ mode: "bot" }); + case "/control/secret": + calls.push("requestSecret"); + return Response.json({ mode: "secret", ref: "e1" }); + case "/human/secret": + calls.push("supplySecret"); + return Response.json({ supplied: true }); + case "/human/click": + case "/human/move": + case "/human/button": + case "/human/wheel": + calls.push(path.slice(1)); + return Response.json({ ok: true }); + default: + return Response.json( + { error: `Unknown endpoint: ${path}` }, + { status: 404 }, + ); } }) as unknown as typeof fetch; - return { provider, fetchImpl, calls, addressedAs }; + return { provider, fetchImpl, calls, addressedAs, requests }; } function fakeAudit() { @@ -107,24 +174,34 @@ function fakeAudit() { const ACTOR = { id: "dev-local-user" }; const PERMISSIVE: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; -async function gatewayWith(policy: ActionPolicy | undefined) { - const { provider, fetchImpl, calls } = fakeComputer(); +async function gatewayWith( + policy: ActionPolicy | undefined, + options?: { + stopResult?: { wasRunning: boolean }; + resetResult?: { cleared: boolean }; + locations?: ComputerLocation[]; + token?: string; + }, +) { + const { provider, fetchImpl, calls, addressedAs, requests } = + fakeComputer(options); const { store, rows } = fakeAudit(); const gateway = createComputerGateway({ provider, fetchImpl, auditStore: store, policy: () => policy, + token: options?.token, }); // Every test acts on refs, so the server must hold a snapshot first, exactly as the real flow does. await gateway.snapshot("bot-1"); - return { gateway, calls, rows }; + return { gateway, calls, rows, addressedAs, requests, provider }; } describe("the computer gateway", () => { test("carries out an allowed action and records it", async () => { const { gateway, calls, rows } = await gatewayWith(PERMISSIVE); - await gateway.click("default", "bot-1", ACTOR, { + await gateway.click("bot-1", ACTOR, { ref: "e9", snapshotId: 7, }); @@ -132,6 +209,7 @@ describe("the computer gateway", () => { expect(calls).toEqual(["click"]); expect(rows).toHaveLength(1); expect(rows[0]?.eventType).toBe("computer.action_allowed"); + expect(rows[0]?.targetId).toBe("bot-1"); }); test("a refused action never reaches the computer", async () => { @@ -141,13 +219,14 @@ describe("the computer gateway", () => { }); await expect( - gateway.click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }), + gateway.click("bot-1", ACTOR, { ref: "e9", snapshotId: 7 }), ).rejects.toThrow(ActionRefusedError); // The decision happens before the effect. expect(calls).toEqual([]); expect(rows).toHaveLength(1); expect(rows[0]?.eventType).toBe("computer.action_refused"); + expect(rows[0]?.targetId).toBe("bot-1"); }); test("the refusal names the rule, so an operator can find it", async () => { @@ -157,7 +236,7 @@ describe("the computer gateway", () => { }); const error = await gateway - .click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) + .click("bot-1", ACTOR, { ref: "e9", snapshotId: 7 }) .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ActionRefusedError); @@ -175,7 +254,7 @@ describe("the computer gateway", () => { }); await expect( - gateway.click("default", "bot-1", ACTOR, { + gateway.click("bot-1", ACTOR, { ref: "e9", snapshotId: 7, // Not part of the input contract, and must not influence anything even when supplied. @@ -187,7 +266,7 @@ describe("the computer gateway", () => { test("an allowed action reports the element's label for the transcript", async () => { const { gateway } = await gatewayWith(PERMISSIVE); - const result = await gateway.type("default", "bot-1", ACTOR, { + const result = await gateway.type("bot-1", ACTOR, { ref: "e1", snapshotId: 7, text: "Grace Hopper", @@ -197,7 +276,7 @@ describe("the computer gateway", () => { test("the typed text never enters the audit payload", async () => { const { gateway, rows } = await gatewayWith(PERMISSIVE); - await gateway.type("default", "bot-1", ACTOR, { + await gateway.type("bot-1", ACTOR, { ref: "e1", snapshotId: 7, text: "hunter2-not-a-real-password", @@ -218,7 +297,7 @@ describe("the computer gateway", () => { test("an absent policy refuses every action", async () => { const { gateway, calls, rows } = await gatewayWith(undefined); await expect( - gateway.click("default", "bot-1", ACTOR, { ref: "e9", snapshotId: 7 }), + gateway.click("bot-1", ACTOR, { ref: "e9", snapshotId: 7 }), ).rejects.toThrow(ActionRefusedError); expect(calls).toEqual([]); expect(rows[0]?.eventType).toBe("computer.action_refused"); @@ -231,7 +310,7 @@ describe("the computer gateway", () => { allow: ["true"], }); - await gateway.click("default", "bot-1", ACTOR, { + await gateway.click("bot-1", ACTOR, { ref: "e9", snapshotId: 7, }); @@ -247,7 +326,7 @@ describe("the computer gateway", () => { // Writing an id with no `users` row fails the constraint and loses the row entirely, so who it // was is carried in the payload instead. The route decides this; the gateway must honour it. const { gateway, rows } = await gatewayWith(PERMISSIVE); - await gateway.click("default", "bot-1", ACTOR, { + await gateway.click("bot-1", ACTOR, { ref: "e9", snapshotId: 7, }); @@ -262,7 +341,7 @@ describe("the computer gateway", () => { }); await expect( - gateway.readFile("default", "bot-1", ACTOR, { + gateway.readFile("bot-1", ACTOR, { path: "credentials/aws.txt", }), ).rejects.toThrow(ActionRefusedError); @@ -278,10 +357,10 @@ describe("the computer gateway", () => { }); await expect( - gateway.readFile("default", "bot-1", ACTOR, { path: "config/prod.env" }), + gateway.readFile("bot-1", ACTOR, { path: "config/prod.env" }), ).rejects.toThrow(ActionRefusedError); // A different extension in the same folder is untouched by that rule. - await gateway.readFile("default", "bot-1", ACTOR, { + await gateway.readFile("bot-1", ACTOR, { path: "config/prod.json", }); expect(calls).toEqual(["readFile"]); @@ -289,7 +368,7 @@ describe("the computer gateway", () => { test("a permitted write happens and is recorded by path, never by contents", async () => { const { gateway, calls, rows } = await gatewayWith(PERMISSIVE); - await gateway.writeFile("default", "bot-1", ACTOR, { + await gateway.writeFile("bot-1", ACTOR, { path: "notes.md", contents: "the customer's card number is 4111-1111-1111-1111", }); @@ -315,7 +394,7 @@ describe("the computer gateway", () => { }); await gateway.snapshot("sales-bot"); - await gateway.click("sales-bot", "sales-bot", ACTOR, { + await gateway.click("sales-bot", ACTOR, { ref: "e1", snapshotId: 7, }); @@ -332,28 +411,28 @@ describe("the computer gateway", () => { test("a permitted action that FAILS gets its own row, not an allowed one", async () => { // A permitted read that later fails path confinement records both the decision and the failed // outcome, so the trail does not imply the Bot received the file. - const { provider, fetchImpl, calls } = fakeComputer(); + const { provider, fetchImpl, calls } = fakeComputer({ + routes: { + "/files/read": () => { + calls.push("readFile"); + return Response.json( + { error: "that path is outside your workspace" }, + { status: 403 }, + ); + }, + }, + }); const { store, rows } = fakeAudit(); - const failingFetch = (async (url: string, init?: RequestInit) => { - if (new URL(url).pathname === "/files/read") { - calls.push("readFile"); - return Response.json( - { error: "that path is outside your workspace" }, - { status: 403 }, - ); - } - return fetchImpl(url, init); - }) as typeof fetch; const gateway = createComputerGateway({ provider, - fetchImpl: failingFetch, + fetchImpl, auditStore: store, policy: () => PERMISSIVE, }); await expect( - gateway.readFile("default", "bot-1", ACTOR, { path: "../../etc/passwd" }), - ).rejects.toThrow(); + gateway.readFile("bot-1", ACTOR, { path: "../../etc/passwd" }), + ).rejects.toThrow(WorkspaceRefusedError); expect(rows).toHaveLength(2); // The decision, then the outcome. Both are needed: the first says it was permitted, the second @@ -366,7 +445,7 @@ describe("the computer gateway", () => { test("opening a page is recorded, with the address it was opening", async () => { // The target guard refuses forbidden addresses before the request leaves. const { gateway, calls, rows } = await gatewayWith(PERMISSIVE); - await gateway.navigate("default", "bot-1", ACTOR, "https://example.com/"); + await gateway.navigate("bot-1", ACTOR, "https://example.com/"); expect(calls).toEqual(["navigate"]); expect(rows[0]?.eventType).toBe("computer.action_allowed"); @@ -383,22 +462,17 @@ describe("the computer gateway", () => { }); await expect( - gateway.navigate( - "default", - "bot-1", - ACTOR, - "https://intranet.example.com/hr", - ), + gateway.navigate("bot-1", ACTOR, "https://intranet.example.com/hr"), ).rejects.toThrow(ActionRefusedError); expect(calls).toEqual([]); - await gateway.navigate("default", "bot-1", ACTOR, "https://example.com/"); + await gateway.navigate("bot-1", ACTOR, "https://example.com/"); expect(calls).toEqual(["navigate"]); }); test("an action on an unresolvable ref is still decided and still recorded", async () => { const { gateway, rows } = await gatewayWith(PERMISSIVE); - await gateway.click("default", "bot-1", ACTOR, { + await gateway.click("bot-1", ACTOR, { ref: "e404", snapshotId: 7, }); @@ -407,26 +481,165 @@ describe("the computer gateway", () => { expect(rows[0]?.payload.element).toBe("not in the current snapshot"); }); - test("resolves an element from the Bot snapshot when the audit computer id differs", async () => { - const { provider, fetchImpl } = fakeComputer(); - const { store, rows } = fakeAudit(); - const gateway = createComputerGateway({ - provider, - fetchImpl, - auditStore: store, - policy: () => PERMISSIVE, + test("stopComputer returns true when the computer was running and audits with the bot id as target", async () => { + const { gateway, calls, rows } = await gatewayWith(PERMISSIVE, { + stopResult: { wasRunning: true }, }); - await gateway.snapshot("bot-1"); - await gateway.click("audit-computer-7", "bot-1", ACTOR, { - ref: "e9", - snapshotId: 7, + const result = await gateway.stopComputer("bot-1", ACTOR); + + expect(result).toEqual({ wasRunning: true }); + expect(calls).toContain("stop:bot-1"); + expect(rows).toHaveLength(1); + expect(rows[0]?.eventType).toBe("computer.stopped"); + expect(rows[0]?.targetId).toBe("bot-1"); + expect(rows[0]?.targetType).toBe("computer"); + }); + + test("stopComputer preserves wasRunning=false when the computer was already stopped", async () => { + const { gateway, calls, rows } = await gatewayWith(PERMISSIVE, { + stopResult: { wasRunning: false }, }); - expect(rows[0]?.payload.element).toEqual({ - role: "button", - name: "Submit order", + const result = await gateway.stopComputer("bot-2", ACTOR); + + expect(result).toEqual({ wasRunning: false }); + expect(calls).toContain("stop:bot-2"); + expect(rows).toHaveLength(1); + expect(rows[0]?.eventType).toBe("computer.stopped"); + expect(rows[0]?.targetId).toBe("bot-2"); + }); + + test("resetComputer returns true when state was cleared and audits with the bot id as target", async () => { + const { gateway, calls, rows } = await gatewayWith(PERMISSIVE, { + resetResult: { cleared: true }, + }); + + const result = await gateway.resetComputer("bot-1", ACTOR); + + expect(result).toEqual({ cleared: true }); + expect(calls).toContain("reset:bot-1"); + expect(rows).toHaveLength(1); + expect(rows[0]?.eventType).toBe("computer.reset"); + expect(rows[0]?.targetId).toBe("bot-1"); + expect(rows[0]?.targetType).toBe("computer"); + }); + + test("resetComputer preserves cleared=false when provider could not clear state and audits the bot id", async () => { + const { gateway, calls, rows } = await gatewayWith(PERMISSIVE, { + resetResult: { cleared: false }, }); + + const result = await gateway.resetComputer("bot-2", ACTOR); + + expect(result).toEqual({ cleared: false }); + expect(calls).toContain("reset:bot-2"); + expect(rows).toHaveLength(1); + expect(rows[0]?.eventType).toBe("computer.reset"); + expect(rows[0]?.targetId).toBe("bot-2"); }); + test("computers maps provider status 'running' and 'stopped' directly and preserves egress distinctions", async () => { + const locations: ComputerLocation[] = [ + { + botId: "bot-proxied", + status: "running", + startedAt: "2026-08-20T12:00:00.000Z", + egress: "198.51.100.42", + }, + { + botId: "bot-direct", + status: "stopped", + startedAt: "2026-08-20T11:00:00.000Z", + egress: null, + }, + { + botId: "bot-unknown-egress", + status: "stopped", + startedAt: "2026-08-20T10:00:00.000Z", + egress: undefined, + }, + ]; + const { gateway } = await gatewayWith(PERMISSIVE, { locations }); + + const result = await gateway.computers(); + + expect(result).toEqual({ + isolation: "per-bot", + computers: [ + { + botId: "bot-proxied", + running: true, + startedAt: "2026-08-20T12:00:00.000Z", + egress: "198.51.100.42", + }, + { + botId: "bot-direct", + running: false, + startedAt: "2026-08-20T11:00:00.000Z", + egress: null, + }, + { + botId: "bot-unknown-egress", + running: false, + startedAt: "2026-08-20T10:00:00.000Z", + egress: undefined, + }, + ], + }); + }); + + test("takes a screenshot through the located computer with its identity and token", async () => { + const { gateway, requests } = await gatewayWith(PERMISSIVE, { + token: "computer-secret", + }); + + const result = await gateway.screenshot("bot-1"); + + expect(result).toEqual({ + image: "aGVsbG8=", + mimeType: "image/png", + }); + const screenshotReq = requests.find((r) => r.url.endsWith("/screenshot")); + expect(screenshotReq).toBeDefined(); + expect(screenshotReq?.url).toBe("http://agent-computer:4100/screenshot"); + expect(screenshotReq?.init?.headers).toMatchObject({ + "x-openbot-bot-id": "bot-1", + "x-openbot-computer-token": "computer-secret", + }); + }); + + test("routes acting, control, file, secret, and human input calls to the correct endpoint paths", async () => { + const { gateway, requests } = await gatewayWith(PERMISSIVE); + + await gateway.key("bot-1", ACTOR, { key: "Tab" }); + await gateway.scroll("bot-1", ACTOR, { deltaY: 400 }); + await gateway.listFiles("bot-1", ACTOR, { path: "notes" }); + await gateway.control("bot-1"); + await gateway.requestHelp("bot-1", ACTOR, "Sign in"); + await gateway.takeControl("bot-1", ACTOR); + await gateway.releaseControl("bot-1", ACTOR); + await gateway.requestSecret("bot-1", ACTOR, { + label: "Password", + ref: "e1", + snapshotId: 7, + }); + await gateway.supplySecret("bot-1", ACTOR, "secret"); + await gateway.humanInput("bot-1", { kind: "click", x: 10, y: 20 }); + + const paths = requests.map(({ url }) => new URL(url).pathname); + expect(paths).toEqual([ + "/snapshot", + "/key", + "/scroll", + "/files/list", + "/control", + "/control/request", + "/control/take", + "/control/release", + "/control/secret", + "/human/secret", + "/human/click", + ]); + }); }); diff --git a/server/tests/computer-provider.test.ts b/server/tests/computer-provider.test.ts index f779e911..5bfaa5e4 100644 --- a/server/tests/computer-provider.test.ts +++ b/server/tests/computer-provider.test.ts @@ -3,6 +3,8 @@ import type { ComputerConfig } from "../src/config"; import { createComputerProvider, createSharedComputerProvider, + describeComputerIsolation, + ProviderError, } from "../src/computer/provider"; const servers: { stop(closeActiveConnections?: boolean): void }[] = []; @@ -17,27 +19,91 @@ function serve(handler: (request: Request) => Response | Promise) { return `http://127.0.0.1:${server.port}`; } -describe("shared computer provider", () => { - test("describes the shared browser and how to isolate Bots", () => { +type FakeAgentComputerHandler = { + health?: (request: Request) => Response | Promise; + computers?: (request: Request) => Response | Promise; + stop?: (request: Request) => Response | Promise; + reset?: (request: Request) => Response | Promise; +}; + +function serveAgentComputer( + handlers: FakeAgentComputerHandler = {}, + options?: { token?: string }, +) { + return serve(async (request) => { + const url = new URL(request.url); + const token = request.headers.get("x-openbot-computer-token"); + + if ( + options?.token && + url.pathname !== "/health" && + token !== options.token + ) { + return Response.json({ error: "Not authorised." }, { status: 401 }); + } + + if (url.pathname === "/health" && request.method === "GET") { + if (handlers.health) return handlers.health(request); + return Response.json({ status: "ok", browser: true }); + } + + if (url.pathname === "/computers" && request.method === "GET") { + if (handlers.computers) return handlers.computers(request); + return Response.json({ computers: [] }); + } + + if (url.pathname === "/computers/stop" && request.method === "POST") { + if (handlers.stop) return handlers.stop(request); + return Response.json({ stopped: true, wasRunning: true }); + } + + if (url.pathname === "/computers/reset" && request.method === "POST") { + if (handlers.reset) return handlers.reset(request); + const botId = request.headers.get("x-openbot-bot-id") ?? "shared"; + return Response.json({ reset: true, botId }); + } + + return Response.json({ error: "Not found." }, { status: 404 }); + }); +} + +describe("computer isolation description", () => { + test("describes the computer feature as off when no provider is configured", () => { + const description = describeComputerIsolation(undefined); + expect(description.isolation).toBe("off"); + expect(description.note.toLowerCase()).toContain("off"); + expect(description.note.toLowerCase()).not.toContain("shared"); + expect(description.note.toLowerCase()).not.toContain("browser"); + }); + + test("describes provider machine isolation when configured", () => { const provider = createSharedComputerProvider({ baseUrl: "http://computer:4100/", }); expect(provider.name).toBe("shared"); expect(provider.isolation).toBe("shared"); - expect(provider.describeIsolation()).toEqual({ - isolation: "one shared computer", - note: "No supervisor is configured, so every Bot uses the same browser. Sessions, files and logins are shared between them. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY to give each Bot its own.", - warning: "Every Bot shares one browser. Set COMPUTER_SUPERVISOR_URL or DAYTONA_API_KEY for a computer each.", + expect(describeComputerIsolation(provider).isolation).toBe( + "one shared computer", + ); + }); +}); + +describe("shared computer provider", () => { + test("locates the shared computer address", async () => { + const provider = createSharedComputerProvider({ + baseUrl: "http://computer:4100/", }); - expect(provider.locate("sales")).resolves.toBe("http://computer:4100/"); + expect(await provider.locate("sales")).toBe("http://computer:4100/"); }); test("reports a healthy shared computer as ready", async () => { const paths: string[] = []; - const baseUrl = serve((request) => { - paths.push(new URL(request.url).pathname); - return Response.json({ status: "ok" }); + const baseUrl = serveAgentComputer({ + health: (request) => { + paths.push(new URL(request.url).pathname); + return Response.json({ status: "ok" }); + }, }); const provider = createSharedComputerProvider({ baseUrl }); @@ -49,7 +115,9 @@ describe("shared computer provider", () => { }); test("reports the HTTP failure when the shared computer is not healthy", async () => { - const baseUrl = serve(() => new Response("not ready", { status: 503 })); + const baseUrl = serveAgentComputer({ + health: () => new Response("not ready", { status: 503 }), + }); const provider = createSharedComputerProvider({ baseUrl }); expect(await provider.status("sales")).toEqual({ @@ -59,65 +127,121 @@ describe("shared computer provider", () => { }); }); - test("sends lifecycle requests with the Bot identity and computer token", async () => { + test("posts /computers/stop with identity and token and returns wasRunning", async () => { const requests: { path: string; method: string; botId: string | null; token: string | null; }[] = []; - const baseUrl = serve((request) => { - requests.push({ - path: new URL(request.url).pathname, - method: request.method, - botId: request.headers.get("x-openbot-bot-id"), - token: request.headers.get("x-openbot-computer-token"), - }); - return Response.json({ ok: true }); - }); + const baseUrl = serveAgentComputer( + { + stop: (request) => { + const botId = request.headers.get("x-openbot-bot-id"); + requests.push({ + path: new URL(request.url).pathname, + method: request.method, + botId, + token: request.headers.get("x-openbot-computer-token"), + }); + const wasRunning = botId === "running-bot"; + return Response.json({ stopped: true, wasRunning }); + }, + }, + { token: "computer-secret" }, + ); const provider = createSharedComputerProvider({ baseUrl, token: "computer-secret", }); - await provider.stop("sales"); - await provider.reset("sales"); + const runningResult = await provider.stop("running-bot"); + expect(runningResult).toEqual({ wasRunning: true }); + + const idleResult = await provider.stop("idle-bot"); + expect(idleResult).toEqual({ wasRunning: false }); expect(requests).toEqual([ { - path: "/stop", + path: "/computers/stop", method: "POST", - botId: "sales", + botId: "running-bot", token: "computer-secret", }, { - path: "/reset", + path: "/computers/stop", method: "POST", - botId: "sales", + botId: "idle-bot", token: "computer-secret", }, ]); }); - test("maps the shared computer inventory to provider locations", async () => { - const baseUrl = serve(() => - Response.json({ - computers: [ - { - botId: "sales", - running: true, - startedAt: "2026-08-20T12:00:00.000Z", - egress: null, - }, - { - botId: "support", - running: false, - startedAt: null, - egress: null, - }, - ], - }), + test("posts /computers/reset with identity and token and returns cleared", async () => { + const requests: { + path: string; + method: string; + botId: string | null; + token: string | null; + }[] = []; + const baseUrl = serveAgentComputer( + { + reset: (request) => { + const botId = request.headers.get("x-openbot-bot-id"); + requests.push({ + path: new URL(request.url).pathname, + method: request.method, + botId, + token: request.headers.get("x-openbot-computer-token"), + }); + return Response.json({ reset: true, botId }); + }, + }, + { token: "computer-secret" }, ); + const provider = createSharedComputerProvider({ + baseUrl, + token: "computer-secret", + }); + + const resetResult = await provider.reset("sales"); + expect(resetResult).toEqual({ cleared: true }); + + expect(requests).toEqual([ + { + path: "/computers/reset", + method: "POST", + botId: "sales", + token: "computer-secret", + }, + ]); + }); + + test("maps the shared computer inventory to provider locations preserving egress and status", async () => { + const baseUrl = serveAgentComputer({ + computers: () => + Response.json({ + computers: [ + { + botId: "sales", + running: true, + startedAt: "2026-08-20T12:00:00.000Z", + egress: null, + }, + { + botId: "support", + running: false, + startedAt: null, + egress: "us-east-egress", + }, + { + botId: "analytics", + status: "running", + egress: null, + }, + ], + }), + }); const provider = createSharedComputerProvider({ baseUrl }); expect(await provider.list()).toEqual([ @@ -126,14 +250,32 @@ describe("shared computer provider", () => { status: "running", url: baseUrl, startedAt: "2026-08-20T12:00:00.000Z", + egress: null, }, { botId: "support", status: "stopped", url: baseUrl, + egress: "us-east-egress", + }, + { + botId: "analytics", + status: "running", + url: baseUrl, + egress: null, }, ]); }); + + test("aborts fetch that never settles with configurable timeoutMs and throws ProviderError", async () => { + const baseUrl = serve(() => new Promise(() => {})); + const provider = createSharedComputerProvider({ + baseUrl, + timeoutMs: 25, + }); + + await expect(provider.stop("sales")).rejects.toThrow(ProviderError); + }); }); describe("computer provider factory", () => { diff --git a/server/tests/computer-supervisor.test.ts b/server/tests/computer-supervisor.test.ts index 8680cdb8..919d4cc3 100644 --- a/server/tests/computer-supervisor.test.ts +++ b/server/tests/computer-supervisor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; +import type { ComputerProvider } from "../src/computer/provider"; import { createDockerSupervisorProvider, - createSupervisorClient, SupervisorError, } from "../src/computer/supervisor"; @@ -14,8 +14,8 @@ import { * to prevent, and it would look like it was working. */ -function clientWith(handler: (path: string) => Response) { - return createSupervisorClient({ +function clientWith(handler: (path: string) => Response): ComputerProvider { + return createDockerSupervisorProvider({ baseUrl: "http://supervisor:4300", token: "t", fetchImpl: (async (url: string | URL | Request) => @@ -77,7 +77,7 @@ describe("locating a Bot's computer", () => { test("an unreachable supervisor says so, rather than looking like a broken computer", async () => { // These are different problems for whoever has to fix them: one is the supervisor, the other is // the Bot's own container. - const client = createSupervisorClient({ + const client = createDockerSupervisorProvider({ baseUrl: "http://supervisor:4300", fetchImpl: (async () => { throw new Error("connection refused"); @@ -88,7 +88,7 @@ describe("locating a Bot's computer", () => { test("the bot id is escaped into the path", async () => { let seen = ""; - const client = createSupervisorClient({ + const client = createDockerSupervisorProvider({ baseUrl: "http://supervisor:4300", fetchImpl: (async (url: string | URL | Request) => { seen = new URL(String(url)).pathname; @@ -110,59 +110,53 @@ describe("Docker supervisor provider", () => { expect(provider.name).toBe("Docker supervisor"); expect(provider.isolation).toBe("per-bot"); - expect(provider.describeIsolation()).toEqual({ - isolation: "one computer per Bot", - note: "Each Bot gets its own container, its own /workspace and its own browser profile.", - }); }); - test("maps supervisor lifecycle states without starting the computer", async () => { + test.each([ + ["created", { botId: "bot", state: "starting" }], + ["running", { botId: "bot", state: "ready" }], + ["paused", { botId: "bot", state: "absent" }], + ["restarting", { botId: "bot", state: "starting" }], + ["removing", { botId: "bot", state: "absent" }], + ["exited", { botId: "bot", state: "absent" }], + ["dead", { botId: "bot", state: "unreachable" }], + ] as const)( + "maps Docker container status %s to lifecycle state", + async (dockerStatus, expected) => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ + computers: [ + { + botId: "bot", + container: "openbot-computer-bot", + status: dockerStatus, + url: "http://openbot-computer-bot:4100", + }, + ], + })) as unknown as typeof fetch, + }); + + const result = await provider.status("bot"); + expect(result).toMatchObject(expected); + }, + ); + + test("reports missing bot as absent", async () => { const provider = createDockerSupervisorProvider({ baseUrl: "http://supervisor:4300", fetchImpl: (async () => - Response.json({ - computers: [ - { - botId: "ready-bot", - container: "computer-ready", - status: "running", - url: "http://computer-ready:4100", - startedAt: "2026-08-20T12:00:00.000Z", - }, - { - botId: "starting-bot", - container: "computer-starting", - status: "creating", - }, - { - botId: "broken-bot", - container: "computer-broken", - status: "error", - }, - ], - })) as unknown as typeof fetch, + Response.json({ computers: [] })) as unknown as typeof fetch, }); - expect(await provider.status("ready-bot")).toEqual({ - botId: "ready-bot", - state: "ready", - }); - expect(await provider.status("starting-bot")).toEqual({ - botId: "starting-bot", - state: "starting", - }); expect(await provider.status("missing-bot")).toEqual({ botId: "missing-bot", state: "absent", }); - expect(await provider.status("broken-bot")).toEqual({ - botId: "broken-bot", - state: "unreachable", - reason: 'The computer reported state "error".', - }); }); - test("lists only the provider location fields", async () => { + test("lists only the provider location fields with mapped status", async () => { const provider = createDockerSupervisorProvider({ baseUrl: "http://supervisor:4300", fetchImpl: (async () => @@ -176,6 +170,13 @@ describe("Docker supervisor provider", () => { url: "http://computer-sales:4100", startedAt: "2026-08-20T12:00:00.000Z", }, + { + botId: "support", + container: "computer-support", + status: "exited", + port: 49153, + url: "http://computer-support:4100", + }, ], })) as unknown as typeof fetch, }); @@ -187,6 +188,51 @@ describe("Docker supervisor provider", () => { url: "http://computer-sales:4100", startedAt: "2026-08-20T12:00:00.000Z", }, + { + botId: "support", + status: "stopped", + url: "http://computer-support:4100", + }, ]); }); + + test("stop reports whether the container was running", async () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ stopped: true })) as unknown as typeof fetch, + }); + + expect(await provider.stop("bot")).toEqual({ wasRunning: true }); + }); + + test("stop reports false when container was not running", async () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ stopped: false })) as unknown as typeof fetch, + }); + + expect(await provider.stop("bot")).toEqual({ wasRunning: false }); + }); + + test("reset reports whether container state was cleared", async () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ reset: true })) as unknown as typeof fetch, + }); + + expect(await provider.reset("bot")).toEqual({ cleared: true }); + }); + + test("reset reports false when container was not present to clear", async () => { + const provider = createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + fetchImpl: (async () => + Response.json({ reset: false })) as unknown as typeof fetch, + }); + + expect(await provider.reset("bot")).toEqual({ cleared: false }); + }); }); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index cbcacac4..0864ba7b 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -254,22 +254,19 @@ describe("deployment configuration", () => { ...baseEnvironment, DAYTONA_API_KEY: "dtn_test_key", }), - ).toThrow( - "DAYTONA_API_KEY is set but COMPUTER_TOKEN is not. Daytona computers are reached over a public preview URL, and the token is the only thing that refuses strangers. Generate one: openssl rand -base64 32", - ); + ).toThrow("COMPUTER_TOKEN"); }); test("refuses to configure both Daytona and Docker computer providers", () => { - expect(() => + const attempt = () => loadConfig({ ...baseEnvironment, DAYTONA_API_KEY: "dtn_test_key", COMPUTER_TOKEN: "secret-token", COMPUTER_SUPERVISOR_URL: "http://localhost:4000", - }), - ).toThrow( - "Set either DAYTONA_API_KEY or COMPUTER_SUPERVISOR_URL, not both. They are two ways of giving each Bot its own computer.", - ); + }); + expect(attempt).toThrow("DAYTONA_API_KEY"); + expect(attempt).toThrow("COMPUTER_SUPERVISOR_URL"); }); test.each([ From 3753e56959a5ff045a6e0ac60a8d391facad0fde Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 13:41:33 -0700 Subject: [PATCH 10/16] Give a Bot a shell, and let Chromium sandbox itself where the host allows A browser alone cannot install a tool, read a file it just downloaded, or run the thing it was asked to run. `computer_run_command` runs a command in the Bot's workspace through the same gate as every other action: the target is resolved, the CEL policy decides, an audit row is written, and only then does anything happen. The command is recorded in full; its output never is, because output is the one part that can carry a page's contents back into the audit log. Bounded rather than trusted. Two minutes by default and ten at most, 64KB of output kept from the end, and the abort signal kills the child rather than detaching from it. The sandbox flag is the other half. `--no-sandbox` was unconditional, which is what a default container seccomp profile forces, but Playwright re-adds the flag on its own unless `chromiumSandbox` says otherwise, so a deployment that could have had the sandbox silently did not. Now COMPUTER_SANDBOX=on means it, and either way the choice is printed at start-up. --- agent-computer/src/index.ts | 37 +++++++ agent-computer/src/profiles.ts | 42 +++++++- agent-computer/src/shell.ts | 127 +++++++++++++++++++++++++ app/src/lib/copilot/computer-tools.tsx | 44 +++++++++ server/src/computer/client.ts | 9 ++ server/src/computer/gateway.ts | 41 ++++++++ server/src/computer/policy.ts | 23 ++++- server/src/computer/routes.ts | 21 ++++ server/src/computer/schema.ts | 29 ++++++ server/tests/computer-policy.test.ts | 70 ++++++++++++++ 10 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 agent-computer/src/shell.ts diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index f8690607..350fe812 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -22,6 +22,7 @@ import { WorkspaceFileError, WorkspacePathError, } from "./workspace"; +import { createShell } from "./shell"; /** * The Bot's computer: one long-lived browser, reachable over HTTP. @@ -165,6 +166,9 @@ const workspace = createWorkspace(process.env.WORKSPACE_DIR ?? "/workspace"); * mounted volume so sign-in state survives the container. */ const profiles = createProfiles(process.env.PROFILES_DIR ?? "/profiles"); +// Rooted in the same workspace the file tools use, so a command and a written file see one +// directory rather than two. +const shell = createShell(process.env.WORKSPACE_DIR ?? "/workspace"); /** * The id normally arrives as a header on every request. This is the fallback for a caller that has no @@ -746,6 +750,39 @@ serve({ } } + /* + * A command on this computer. + * + * Nothing here decides whether it may run: the gateway already asked the deployment's policy and + * wrote the audit row before this was called. Refusing again here would be a second, quieter + * policy nobody configured. + */ + if (url.pathname === "/exec" && request.method === "POST") { + const body = (await request.json().catch(() => null)) as { + command?: unknown; + timeoutMs?: unknown; + } | null; + if (typeof body?.command !== "string" || !body.command.trim()) { + return json({ error: "A command is required." }, 400); + } + try { + return json( + await shell.run({ + command: body.command, + ...(typeof body.timeoutMs === "number" + ? { timeoutMs: body.timeoutMs } + : {}), + signal: request.signal, + }), + ); + } catch (error) { + return json( + { error: describe(error, "The command could not be run.") }, + 500, + ); + } + } + if (url.pathname === "/files/write" && request.method === "POST") { const body = (await request.json().catch(() => null)) as { path?: unknown; diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts index de45097d..368a93bf 100644 --- a/agent-computer/src/profiles.ts +++ b/agent-computer/src/profiles.ts @@ -60,12 +60,48 @@ const SINGLETON_FILES = ["SingletonLock", "SingletonSocket", "SingletonCookie"]; * This is obfuscation at rest, not protection. Anything that can read the volume can read the * cookies. The volume's own permissions are the security boundary. */ +/** + * Whether Chromium gets to use its own sandbox. + * + * OFF BY DEFAULT, AND THAT IS NOT A PREFERENCE. Chromium's sandbox creates user namespaces, and + * Docker's default seccomp profile blocks the syscall it needs, so a container that does nothing + * special gets `No usable sandbox!` and the browser will not start at all. Verified both ways in + * this image: default profile fails, relaxed profile renders. + * + * TURN IT ON WHERE THE HOST ALLOWS IT. On a VM or self-hosted Docker, run with a Chromium seccomp + * profile and set `COMPUTER_SANDBOX=on`. That is strictly better than everything below, because it + * is the boundary Chromium itself maintains against the pages it renders. + * + * WHERE IT CANNOT BE ON. Serverless container platforms do not let you set a seccomp profile or add + * capabilities; Fargate restricts `CAP_SYS_ADMIN` explicitly. There the sandbox is unavailable, and + * the compensating controls are the ones this image already has, a non-root user, plus gVisor + * underneath, which Cloud Run applies to everything by default. + * + * Said out loud at start-up either way. An operator should not have to read this file to find out + * whether the browser rendering the open internet is sandboxed. + */ +const SANDBOX_ENABLED = process.env.COMPUTER_SANDBOX === "on"; + const LAUNCH_ARGS = [ - "--no-sandbox", + ...(SANDBOX_ENABLED ? [] : ["--no-sandbox"]), "--disable-dev-shm-usage", "--password-store=basic", ]; +console.info( + JSON.stringify({ + type: "computer-sandbox", + sandbox: SANDBOX_ENABLED ? "on" : "off", + ...(SANDBOX_ENABLED + ? { + note: "Chromium's own sandbox is in use. It will refuse to start if the host does not permit user namespaces.", + } + : { + note: "Chromium runs without its own sandbox, which is the only thing that works under a default container seccomp profile. Set COMPUTER_SANDBOX=on where the host allows it.", + }), + }), +); + /** * How long to let a closing browser finish writing before moving on. * @@ -160,6 +196,10 @@ export function createProfiles(root: string) { const proxy = egressFor(botId, process.env); const context = await chromium.launchPersistentContext(dir, { args: LAUNCH_ARGS, + // Playwright adds `--no-sandbox` on its own unless told otherwise, so leaving this out + // means the flag above decides nothing and a deployment that asked for the sandbox does + // not get one. Verified by reading the launched process arguments, not by trusting either. + chromiumSandbox: SANDBOX_ENABLED, viewport: VIEWPORT, // This process owns shutdown. Playwright's signal handlers kill Chromium immediately on // SIGTERM, before pending cookie writes have time to flush. diff --git a/agent-computer/src/shell.ts b/agent-computer/src/shell.ts new file mode 100644 index 00000000..40e487fa --- /dev/null +++ b/agent-computer/src/shell.ts @@ -0,0 +1,127 @@ +import { spawn } from "node:child_process"; + +/** + * Running a command on the Bot's computer. + * + * WHY THIS EXISTS. A browser answers questions about the web; a shell answers everything else. A Bot + * that can install a tool and run it is a Bot that can do the task rather than describe it. + * + * WHAT MAKES IT SAFE IS NOT THIS FILE. Nothing here decides whether a command may run. The gateway + * decides, against the deployment's policy, and writes the audit row before this is called at all, + * the same as it does for a click. This file is the hands, not the judgement. + * + * WHAT THIS DOES DEFEND is the shape of the call rather than its content: a command cannot run + * forever, cannot return unbounded output, and runs in the workspace rather than wherever the + * process happens to be. + * + * ISOLATION IS THE CONTAINER'S JOB. A shell can reach whatever the container can reach, so the + * deployment that gives a Bot one should give each Bot a computer of its own. In a container shared + * between Bots, a shell is shared too. + */ + +/** Long enough for an install, short enough that a hung command is not a hung Bot. */ +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_TIMEOUT_MS = 600_000; + +/** + * How much output comes back. + * + * A build log is megabytes and the model that reads this has a context window. Truncated at a size + * a person can still read, and the result says it was truncated rather than quietly ending. + */ +const MAX_OUTPUT_BYTES = 64 * 1024; + +export type ShellResult = { + command: string; + exitCode: number; + stdout: string; + stderr: string; + truncated: boolean; + timedOut: boolean; + elapsedMs: number; +}; + +function clamp(text: string): { text: string; truncated: boolean } { + if (Buffer.byteLength(text, "utf8") <= MAX_OUTPUT_BYTES) { + return { text, truncated: false }; + } + // Kept from the end. A command that fails says why in its last lines, and the first 64KB of a + // build log is the part nobody needs. + const kept = Buffer.from(text, "utf8") + .subarray(-MAX_OUTPUT_BYTES) + .toString("utf8"); + return { text: kept, truncated: true }; +} + +export function createShell(workspaceDir: string) { + return { + async run(input: { + command: string; + timeoutMs?: number; + signal?: AbortSignal; + }): Promise { + const started = Date.now(); + const timeoutMs = Math.min( + input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + MAX_TIMEOUT_MS, + ); + + /* + * Through a shell on purpose. A Bot writes `apt-get install -y jq && jq --version`, and pipes, + * redirection and `&&` are most of why a shell is useful. Refusing them would leave something + * that runs one binary and calls itself a shell. + * + * This is the argument for the container boundary rather than for string parsing: there is no + * safe way to read a command's intent from its text, so nothing here tries. The policy decides + * whether this Bot may run commands at all and what they may say; the container decides what a + * command can reach. + */ + const child = spawn("/bin/bash", ["-lc", input.command], { + cwd: workspaceDir, + env: { ...process.env, HOME: workspaceDir }, + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + + // The person's Stop reaches the command, not just the request that started it. + const onAbort = () => child.kill("SIGKILL"); + input.signal?.addEventListener("abort", onAbort, { once: true }); + + const exitCode = await new Promise((resolve) => { + child.on("close", (code) => resolve(code ?? -1)); + child.on("error", () => resolve(-1)); + }); + + clearTimeout(timer); + input.signal?.removeEventListener("abort", onAbort); + + const out = clamp(stdout); + const err = clamp(stderr); + return { + command: input.command, + exitCode, + stdout: out.text, + stderr: err.text, + truncated: out.truncated || err.truncated, + timedOut, + elapsedMs: Date.now() - started, + }; + }, + }; +} + +export type Shell = ReturnType; diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index ce480b13..d6629af0 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -624,6 +624,50 @@ export function ComputerTools() { }, }); + useFrontendTool({ + name: "computer_run_command", + description: + "Run a shell command on your own computer. Use this for anything the browser cannot do: " + + "installing a tool you need, processing a file you saved, running a script. The working " + + "directory is your workspace, so paths are relative to it and files you write here are the " + + "same ones the file tools see. Commands run in bash, so pipes and && work. Long output is " + + "truncated from the start, and a command that runs too long is stopped.", + parameters: z.object({ + command: z + .string() + .describe("The command to run, such as: apt-get install -y jq"), + }), + handler: async (input: { command: string }) => + callComputer(bot.current, "/exec", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + render: ({ args, result, status }) => { + const outcome = outcomeOf(result); + /* + * The command, not its output. A person watching wants to know what their Bot just ran on a + * machine holding their logins, and that is the command; the output belongs in the answer the + * Bot gives, where the model has already decided which part of it mattered. + */ + return ( + + ); + }, + }); + useFrontendTool({ name: "computer_write_file", description: diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index f019ef81..9c44aa03 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -19,6 +19,8 @@ import type { SecretResult, SnapshotResult, TypeInput, + RunCommandInput, + RunCommandResult, WriteFileInput, WriteFileResult, } from "./schema"; @@ -349,6 +351,13 @@ export function createComputerClient(options: ComputerClientOptions) { return (await post("/files/read", input)) as ReadFileResult; }, + async runCommand( + input: RunCommandInput, + caller?: AbortSignal, + ): Promise { + return (await post("/exec", input, caller)) as RunCommandResult; + }, + async writeFile(input: WriteFileInput): Promise { return (await post("/files/write", input)) as WriteFileResult; }, diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index b2337be9..390d606d 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -36,6 +36,7 @@ import type { SnapshotElement, SnapshotResult, TypeInput, + RunCommandInput, WriteFileInput, } from "./schema"; @@ -154,6 +155,8 @@ export function createComputerGateway(options: ComputerGatewayOptions) { filePath?: string; targetUrl?: string; key?: string; + /** The command a shell call is about to run, so a rule can be written against it. */ + command?: string; /** The person's Stop, on its way to the browser. See the acting methods below. */ signal?: AbortSignal; }, @@ -187,6 +190,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { } : {}), ...(filePath ? { file: describeFile(filePath) } : {}), + ...(subject.command ? { command: subject.command } : {}), }; const decision = evaluateActionPolicy(options.policy(), context); @@ -198,6 +202,7 @@ export function createComputerGateway(options: ComputerGatewayOptions) { element, ref, ...(subject.key ? { key: subject.key } : {}), + ...(subject.command ? { command: subject.command } : {}), filePath, pageUrl, decision, @@ -567,6 +572,30 @@ export function createComputerGateway(options: ComputerGatewayOptions) { ); }, + /** + * A command, judged before it runs. + * + * The same four steps as a click: resolve, decide, record, act. The policy sees the command + * text, so a deployment can refuse a shell outright with `intent == "run_command"` or refuse + * particular commands, and either way the attempt is a row in the trail whether or not it ran. + */ + runCommand( + computerId: string, + botId: string, + actor: ActionActor, + input: RunCommandInput, + caller?: AbortSignal, + ) { + return govern( + computerId, + "computer_run_command", + botId, + actor, + { command: input.command, ...(caller ? { signal: caller } : {}) }, + () => as(botId).runCommand(input, caller), + ); + }, + writeFile( computerId: string, botId: string, @@ -652,6 +681,8 @@ function intentOf( return "read_file"; case "computer_write_file": return "write_file"; + case "computer_run_command": + return "run_command"; case "computer_list_files": return "list_files"; default: @@ -673,6 +704,8 @@ async function write( filePath: string | undefined; pageUrl: string; decision: PolicyDecision; + /** The command a shell call ran, so the trail says what was run and not merely that something was. */ + command?: string; /** Set only when a permitted action was attempted and did not succeed. */ failure?: string; }, @@ -706,6 +739,14 @@ async function write( // The path, never the contents. A Bot writes down what it was told, so a file body is exactly as // sensitive as text typed into a form field, and for the same reason it is not put here. ...(entry.filePath ? { file: entry.filePath } : {}), + /* + * The command, in full, and its output never. + * + * The opposite call from the file body above, deliberately. A command IS the action, so a + * trail recording that a Bot "ran something" answers nothing anyone would ask it. Its output + * is the file body of this pair, and stays out. + */ + ...(entry.command ? { command: entry.command } : {}), element: entry.element ? { role: entry.element.role, diff --git a/server/src/computer/policy.ts b/server/src/computer/policy.ts index a5b96d04..299e7f22 100644 --- a/server/src/computer/policy.ts +++ b/server/src/computer/policy.ts @@ -96,7 +96,8 @@ export type PolicyContext = { // intents: an operator thinks "nothing may change anything in Jira", not "nothing may call // editJiraIssue, transitionJiraIssue, addCommentToJiraIssue and the six others". | "read_tool" - | "write_tool"; + | "write_tool" + | "run_command"; /** * The file a `computer_read_file` or `computer_write_file` call is aimed at. * @@ -131,6 +132,17 @@ export type PolicyContext = { tool: string; effect: "read" | "write"; }; + /** + * The command a Bot is about to run on its computer, verbatim. + * + * Verbatim because a rule about a shell can only be written against what was actually typed. This + * is the field for `deny: contains(command, "rm -rf")`, and for the blunter and more useful + * `deny: intent == "run_command"`, which is how a deployment says its Bots do not get a shell. + * + * Matching on command text is a filter, not a boundary: a command can be written a hundred ways + * and no list catches them all. The boundary is the container the command runs in. + */ + command?: string; }; export type PolicyDecision = { @@ -272,6 +284,15 @@ function describeRefusal(context: PolicyContext, expression: string): string { // tests below true of a tool call and all of them wrong about it: without this branch a refused // Jira call reads "the file is blocked", naming a workspace it never touched and a path that is // not there. Checked first because it is the only one of these that is ever certain. + // A command is described by the command. Falling through to the page branch below would produce + // "a run_command action on " with an empty host, because a shell call has no page. + if (context.command) { + return ( + `This deployment's policy does not allow that: the command \`${context.command}\` ` + + `is blocked by the rule \`${expression}\`.` + ); + } + if (context.mcp) { return ( `This deployment's policy does not allow that: ${context.mcp.tool} on ` + diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index 3ce1c285..45da4107 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -316,6 +316,27 @@ export function createComputerRoutes( }), ); + /* + * A command on the Bot's computer. + * + * Same shape as every other acting route: the gateway decides and records, this only shapes the + * request. `timeoutMs` is passed through and capped by the computer rather than here, so one place + * owns the limit. + */ + routes.post("/:botId/exec", requireUser, (context) => + act(context, (botId, actor, body) => { + if (typeof body?.command !== "string" || !body.command.trim()) { + return { error: "A command is required." }; + } + return gateway.runCommand(botId, botId, actor, { + command: body.command, + ...(typeof body.timeoutMs === "number" + ? { timeoutMs: body.timeoutMs } + : {}), + }); + }), + ); + routes.post("/:botId/files/write", requireUser, (context) => act(context, (botId, actor, body) => { if (typeof body?.path !== "string" || !body.path.trim()) { diff --git a/server/src/computer/schema.ts b/server/src/computer/schema.ts index 659e8d7a..70e88ac9 100644 --- a/server/src/computer/schema.ts +++ b/server/src/computer/schema.ts @@ -209,6 +209,35 @@ export type ReadFileResult = { bytes: number; }; +/** + * A command for the Bot's computer to run. + * + * The whole command as one string, because a shell's usefulness is pipes, redirection and `&&`, and + * a shape that took a binary plus arguments would be a worse shell wearing the name. + */ +export type RunCommandInput = { + command: string; + /** Capped by the computer. Absent takes its default. */ + timeoutMs?: number; +}; + +/** + * What running one reports back. + * + * `truncated` and `timedOut` are separate from the exit code because they are different facts about + * the same run: a command can succeed and still have had its output cut, and one that was killed on + * the clock never produced an exit code of its own. + */ +export type RunCommandResult = { + command: string; + exitCode: number; + stdout: string; + stderr: string; + truncated: boolean; + timedOut: boolean; + elapsedMs: number; +}; + export type WriteFileInput = { path: string; contents: string; diff --git a/server/tests/computer-policy.test.ts b/server/tests/computer-policy.test.ts index 94c2b7df..b4168fd4 100644 --- a/server/tests/computer-policy.test.ts +++ b/server/tests/computer-policy.test.ts @@ -407,3 +407,73 @@ describe("describing a refusal", () => { expect(decision.reason).toContain("the file /workspace/secrets.env"); }); }); + +/** + * A shell command, judged like any other action. + * + * The blunt rule matters more than the clever one here. A deployment that does not want its Bots + * running commands says so once with `intent`, and does not have to imagine every command it would + * have wanted to refuse. + */ +describe("commands", () => { + const runCommand = (command: string): PolicyContext => ({ + tool: { name: "computer_run_command" }, + bot: { id: "general-assistant" }, + actor: { id: "dev-local-user" }, + page: { url: "", host: "" }, + intent: "run_command", + command, + }); + + test("a deployment can refuse the shell outright", () => { + const decision = evaluateActionPolicy( + { mode: "enforce", deny: ['intent == "run_command"'], allow: ["true"] }, + runCommand("apt-get install -y jq"), + ); + expect(decision.allowed).toBe(false); + expect(decision.matched).toBe('intent == "run_command"'); + }); + + test("a rule can name what the command says", () => { + const policy = { + mode: "enforce" as const, + deny: ['contains(command, "rm -rf")'], + allow: ["true"], + }; + expect(evaluateActionPolicy(policy, runCommand("rm -rf /")).allowed).toBe( + false, + ); + expect(evaluateActionPolicy(policy, runCommand("ls -la")).allowed).toBe( + true, + ); + }); + + test("commands are allowed when nothing refuses them", () => { + const decision = evaluateActionPolicy( + { mode: "enforce", deny: [], allow: ["true"] }, + runCommand("echo hello"), + ); + expect(decision.allowed).toBe(true); + }); + + /* + * A rule written about the browser must not catch a command. The neutral empty fields make + * `page.host` and the element fields evaluate to false rather than being unevaluable, which is + * what keeps the shipped deny preset from refusing every command a Bot ever runs. + */ + test("a browser rule does not refuse a command", () => { + const decision = evaluateActionPolicy( + { + mode: "enforce", + deny: ['contains(element.name, "submit") || key == "Enter"'], + allow: ["true"], + }, + { + ...runCommand("echo hello"), + element: { ref: "", role: "", name: "", type: "" }, + key: "", + }, + ); + expect(decision.allowed).toBe(true); + }); +}); From 5ce35222da7f3a3a76b3950a0ccebd86cc775abb Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 13:41:33 -0700 Subject: [PATCH 11/16] Run the whole thing as one container, with Postgres as a choice A laptop runs the app, the API, a browser, and a database. Deploying that shouldn't mean learning Kubernetes first, so the image carries all four and s6 supervises them: the app is served by the API process from a built directory, the browser runs beside it, and Postgres starts only when no external one was given. Point DATABASE_URL at a managed Postgres and the embedded one never starts; leave it unset and the container is self-contained. s6 rather than a shell script or supervisord because a browser that dies should take the container down and let the platform restart it, not leave an API answering requests it cannot serve. Runs as pwuser, not root. Chromium rendering the open internet as root inside a container that also holds the database is the pairing to avoid, and the earlier version had it. One replica, and the deployment doc says so out loud: this image holds state a load balancer would split. The fleet shape is A6. Minimum sizes in the doc are measured, not guessed, and /dev/shm gets its own note because the platforms that cap it at 64MB make Chromium fail in a way that reads like a bug in us. --- .dockerignore | 15 ++ Dockerfile | 192 ++++++++++++++++++ docker/s6/s6-rc.d/api/dependencies.d/computer | 0 docker/s6/s6-rc.d/api/dependencies.d/migrate | 0 docker/s6/s6-rc.d/api/dependencies.d/postgres | 0 docker/s6/s6-rc.d/api/run | 5 + docker/s6/s6-rc.d/api/type | 1 + docker/s6/s6-rc.d/computer-token/type | 1 + docker/s6/s6-rc.d/computer-token/up | 1 + .../computer/dependencies.d/computer-token | 0 docker/s6/s6-rc.d/computer/run | 9 + docker/s6/s6-rc.d/computer/type | 1 + .../s6-rc.d/migrate/dependencies.d/postgres | 0 docker/s6/s6-rc.d/migrate/type | 1 + docker/s6/s6-rc.d/migrate/up | 1 + docker/s6/s6-rc.d/postgres-init/type | 1 + docker/s6/s6-rc.d/postgres-init/up | 1 + .../postgres/dependencies.d/postgres-init | 0 docker/s6/s6-rc.d/postgres/run | 13 ++ docker/s6/s6-rc.d/postgres/type | 1 + docker/s6/s6-rc.d/user/contents.d/api | 0 docker/s6/s6-rc.d/user/contents.d/computer | 0 .../s6/s6-rc.d/user/contents.d/computer-token | 0 docker/s6/s6-rc.d/user/contents.d/migrate | 0 docker/s6/s6-rc.d/user/contents.d/postgres | 0 .../s6/s6-rc.d/user/contents.d/postgres-init | 0 docker/s6/scripts/computer-token.sh | 14 ++ docker/s6/scripts/migrate.sh | 11 + docker/s6/scripts/postgres-init.sh | 17 ++ docs/deployment.md | 107 ++++++++++ server/Dockerfile | 60 +++++- server/src/app.ts | 40 ++++ server/src/config.ts | 10 + 33 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker/s6/s6-rc.d/api/dependencies.d/computer create mode 100644 docker/s6/s6-rc.d/api/dependencies.d/migrate create mode 100644 docker/s6/s6-rc.d/api/dependencies.d/postgres create mode 100755 docker/s6/s6-rc.d/api/run create mode 100644 docker/s6/s6-rc.d/api/type create mode 100644 docker/s6/s6-rc.d/computer-token/type create mode 100755 docker/s6/s6-rc.d/computer-token/up create mode 100644 docker/s6/s6-rc.d/computer/dependencies.d/computer-token create mode 100755 docker/s6/s6-rc.d/computer/run create mode 100644 docker/s6/s6-rc.d/computer/type create mode 100644 docker/s6/s6-rc.d/migrate/dependencies.d/postgres create mode 100644 docker/s6/s6-rc.d/migrate/type create mode 100644 docker/s6/s6-rc.d/migrate/up create mode 100644 docker/s6/s6-rc.d/postgres-init/type create mode 100644 docker/s6/s6-rc.d/postgres-init/up create mode 100644 docker/s6/s6-rc.d/postgres/dependencies.d/postgres-init create mode 100755 docker/s6/s6-rc.d/postgres/run create mode 100644 docker/s6/s6-rc.d/postgres/type create mode 100644 docker/s6/s6-rc.d/user/contents.d/api create mode 100644 docker/s6/s6-rc.d/user/contents.d/computer create mode 100644 docker/s6/s6-rc.d/user/contents.d/computer-token create mode 100644 docker/s6/s6-rc.d/user/contents.d/migrate create mode 100644 docker/s6/s6-rc.d/user/contents.d/postgres create mode 100644 docker/s6/s6-rc.d/user/contents.d/postgres-init create mode 100755 docker/s6/scripts/computer-token.sh create mode 100755 docker/s6/scripts/migrate.sh create mode 100755 docker/s6/scripts/postgres-init.sh create mode 100644 docs/deployment.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..022eb638 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Build context hygiene. Without this the whole tree ships to the daemon, including every +# node_modules and every local artifact, which is slow and puts a developer's .env in an image. +node_modules +**/node_modules +.git +.env +.env.* +!.env.example +dist +**/dist +app/dist +.nx +*.log +.DS_Store +assets/*.svg diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0e29dc63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,192 @@ +# OpenBot, whole, in one container. +# +# WHAT THIS IS FOR. Everything a laptop runs, minus the database, in one image on one port. Deploy it +# anywhere that runs a container and you get what `scripts/start.sh` gives you locally: the app, the +# API, and a browser the Bots can drive. +# +# WHAT IS NOT HERE, AND WHY. +# +# PostgreSQL. A container filesystem does not survive a redeploy and the audit trail is the +# product. `DATABASE_URL` points at a managed instance, which is one click on every platform this +# is meant to run on. +# +# The supervisor. It exists to give each Bot its own container, which needs a Docker socket, which +# no serverless container platform permits. Without it every Bot shares the browser below, exactly +# as they do on a laptop with no supervisor configured. Per-Bot isolation is A6. +# +# THE BASE IS PLAYWRIGHT'S, not Bun's, because Chromium and its system libraries have to stay +# matched and that image is the only place that is guaranteed. The tag must move with the +# `playwright` dependency in `agent-computer/package.json`. Bump both or neither. + +FROM mcr.microsoft.com/playwright:v1.62.1-noble AS base + +# unzip is not in the Playwright image and bun's installer needs it. +# Bun is pinned. The installer takes whatever is newest otherwise, so the runtime drifts from the +# one the lockfile was resolved against and an image built next month is not the image built today. +ARG BUN_VERSION=1.3.14 +# Into /usr/local rather than /root/.bun, because the runtime stage runs as `pwuser` and cannot read +# root's home. Set before the install, or the installer has already chosen the wrong directory. +ENV BUN_INSTALL=/usr/local +ENV PATH="/usr/local/bin:${PATH}" +RUN apt-get update && apt-get install -y --no-install-recommends unzip xz-utils \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" + + +FROM base AS deps + +WORKDIR /src + +# Manifests first, so editing a source file does not reinstall the world. +COPY package.json bun.lock ./ +COPY tsconfig.base.json bunfig.toml ./ +COPY app/package.json app/package.json +COPY server/package.json server/package.json +COPY worker/package.json worker/package.json +RUN bun install --frozen-lockfile + +COPY agent-computer/package.json agent-computer/package.json +RUN cd agent-computer && bun install + +# A second tree with the build-time dependencies left out, for the runtime stage to take. Vite, +# biome and the test tooling are a gigabyte that nothing in a running container imports. +RUN mkdir -p /prod && cp package.json bun.lock /prod/ \ + && cp -r app/package.json /prod/app-package.json \ + && cd /prod && mkdir -p app server worker \ + && cp /src/app/package.json app/package.json \ + && cp /src/server/package.json server/package.json \ + && cp /src/worker/package.json worker/package.json \ + && bun install --frozen-lockfile --production + + +FROM deps AS app-build + +COPY app app +COPY scripts scripts +COPY shared shared +# The server's source as well: the app's prebuild step reads the tenant package through +# `server/src/tenant-package`, so the app cannot be built without it. +COPY server server +COPY examples examples +RUN bun run --cwd app build + + +FROM base AS runtime + +# s6 rather than supervisord. The deciding difference is that s6 brings the container down when a +# supervised process exits, which is what makes the platform restart it. supervisord stays alive and +# the container keeps reporting healthy while the API inside it is dead. +ARG S6_OVERLAY_VERSION=3.2.1.0 +# `TARGETARCH` is filled in by the builder. s6 names its tarballs by uname, so amd64 and arm64 have +# to be translated. Hardcoding one of them builds fine on the other and then fails at start with an +# exec format error, which reads as a broken image rather than a wrong download. +ARG TARGETARCH +ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/ +RUN case "${TARGETARCH}" in \ + amd64) S6_ARCH=x86_64 ;; \ + arm64) S6_ARCH=aarch64 ;; \ + *) echo "unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/s6-overlay-arch.tar.xz \ + "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" \ + && tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \ + && tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz \ + && rm /tmp/s6-overlay-*.tar.xz + +WORKDIR /app + +COPY --from=deps /prod/node_modules node_modules +COPY --from=deps /src/package.json package.json +COPY --from=deps /src/bun.lock bun.lock +COPY --from=deps /src/server/node_modules server/node_modules +COPY --from=deps /src/agent-computer/node_modules agent-computer/node_modules + +COPY server server +COPY shared shared +COPY examples examples +COPY agent-computer/src agent-computer/src +COPY agent-computer/package.json agent-computer/package.json + +# The built app, served by the API on the same origin. There is no CORS in this server, so this is +# not a convenience: two origins would simply fail. +COPY --from=app-build /src/app/dist app/dist +ENV APP_DIST_DIR=/app/app/dist + +COPY docker/s6 /etc/s6-overlay + +# PostgreSQL, for the deployment that wants one thing to run rather than two. +# +# OFF UNLESS ASKED FOR. Set `EMBEDDED_POSTGRES=on` and the container runs its own; leave it and +# `DATABASE_URL` points wherever you like. The trade is the one you would expect: a database inside +# a container lives and dies with that container unless /var/lib/postgresql is a mounted volume, and +# the audit trail is the thing you would be losing. +RUN apt-get update && apt-get install -y --no-install-recommends \ + postgresql-16 postgresql-16-pgvector \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/lib/postgresql/data /var/run/postgresql \ + && chown -R postgres:postgres /var/lib/postgresql /var/run/postgresql + +# A Bot can install what a task needs. +# +# `sudo` for one user, no password, because a package manager that cannot install is not one, and +# "install a tool then use it" is the whole point of giving a Bot a shell. +# +# BE CLEAR WHAT THIS COSTS. It means a Bot can become root inside its container. That is acceptable +# when the container is the Bot's alone and is contained from below, which is why per-Bot computers +# and gVisor are not optional extras next to this feature; they are what makes it sane. In a +# container shared between Bots, or one holding a database, a Bot with sudo can reach all of it. +RUN apt-get update && apt-get install -y --no-install-recommends sudo \ + && rm -rf /var/lib/apt/lists/* \ + && echo 'pwuser ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/pwuser \ + && chmod 0440 /etc/sudoers.d/pwuser + +# THE PACKAGE MANAGER AND THE SHELL STAY. Both were removed here once as hardening, which was +# backwards: a Bot being able to open a shell and install what a task needs is a requested feature, +# not an oversight. Removing them hardens the image by deleting the product. +# +# What makes that safe is not their absence. It is that a Bot reaches them the same way it reaches +# anything else, through the gateway: resolve, decide against the policy, write the audit row, then +# act. A command is a decision like a click is. + +# Where a Bot's files live. Mount a volume here to keep them across a redeploy; without one they are +# as durable as the container, which for a trial is the honest default. +ENV WORKSPACE_DIR=/workspace +ENV PROFILES_DIR=/profiles + +# The browser is on loopback inside this container and reachable from nowhere else, which is why the +# private-host allowance is on: the server is browsing to its own sibling process, not the internet. +ENV AGENT_COMPUTER_URL=http://127.0.0.1:4100 +ENV AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true + +# NOTHING THAT MATTERS RUNS AS ROOT. +# +# s6 stays root because that is the only way it can drop each service to a different user, and they +# genuinely differ: the browser and API run as `pwuser`, the database as `postgres`. One shared +# account would put the process that renders the open internet in the same skin as the one holding +# the audit trail. +# +# This matters more than usual here. Chromium is launched with `--no-sandbox` unless the host can +# support its sandbox, and with that flag the process user IS the boundary, so root would mean a +# page exploit lands as root. +# +# The two directories the browser writes are its workspace and its profile, the second being what +# keeps a Bot signed in between turns. Owned here, because a non-root process cannot create them at +# the root of the filesystem and the failure surfaces as EACCES on the first navigation. +RUN mkdir -p /workspace /profiles \ + && chown -R pwuser:pwuser /workspace /profiles /app + +# Where the embedded database answers, when there is one. Overridden by whatever you set, so an +# external database needs no special case: set DATABASE_URL and EMBEDDED_POSTGRES stays off. +ENV EMBEDDED_POSTGRES=off +ENV DATABASE_URL=postgres://openbot@127.0.0.1:5432/openbot + +ENV NODE_ENV=production +ENV PORT=3001 +EXPOSE 3001 + +# One port out. The browser's 4100 is deliberately not exposed: it holds real logins and its only +# caller is the process next to it. +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \ + CMD bun -e "const r = await fetch('http://127.0.0.1:3001/health'); process.exit(r.ok ? 0 : 1)" + +ENTRYPOINT ["/init"] diff --git a/docker/s6/s6-rc.d/api/dependencies.d/computer b/docker/s6/s6-rc.d/api/dependencies.d/computer new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/api/dependencies.d/migrate b/docker/s6/s6-rc.d/api/dependencies.d/migrate new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/api/dependencies.d/postgres b/docker/s6/s6-rc.d/api/dependencies.d/postgres new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/api/run b/docker/s6/s6-rc.d/api/run new file mode 100755 index 00000000..231ef2e2 --- /dev/null +++ b/docker/s6/s6-rc.d/api/run @@ -0,0 +1,5 @@ +#!/command/with-contenv sh +# The API, and the app it serves. Started after the browser so a Bot's first action does not race a +# computer that is still coming up. +cd /app/server +exec s6-setuidgid pwuser /usr/local/bin/bun src/index.ts diff --git a/docker/s6/s6-rc.d/api/type b/docker/s6/s6-rc.d/api/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/docker/s6/s6-rc.d/api/type @@ -0,0 +1 @@ +longrun diff --git a/docker/s6/s6-rc.d/computer-token/type b/docker/s6/s6-rc.d/computer-token/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/docker/s6/s6-rc.d/computer-token/type @@ -0,0 +1 @@ +oneshot diff --git a/docker/s6/s6-rc.d/computer-token/up b/docker/s6/s6-rc.d/computer-token/up new file mode 100755 index 00000000..f977bf28 --- /dev/null +++ b/docker/s6/s6-rc.d/computer-token/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/computer-token.sh diff --git a/docker/s6/s6-rc.d/computer/dependencies.d/computer-token b/docker/s6/s6-rc.d/computer/dependencies.d/computer-token new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/computer/run b/docker/s6/s6-rc.d/computer/run new file mode 100755 index 00000000..77989bda --- /dev/null +++ b/docker/s6/s6-rc.d/computer/run @@ -0,0 +1,9 @@ +#!/command/with-contenv sh +# The Bot's browser. Bound to loopback: its only caller is the API beside it. +# +# `with-contenv` is not decoration. Without it s6 starts a service with none of the container's +# environment, and the failure is a config error naming a variable that is plainly set. +cd /app/agent-computer +export PORT=4100 +export WORKSPACE_DIR=/workspace +exec s6-setuidgid pwuser /usr/local/bin/bun src/index.ts diff --git a/docker/s6/s6-rc.d/computer/type b/docker/s6/s6-rc.d/computer/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/docker/s6/s6-rc.d/computer/type @@ -0,0 +1 @@ +longrun diff --git a/docker/s6/s6-rc.d/migrate/dependencies.d/postgres b/docker/s6/s6-rc.d/migrate/dependencies.d/postgres new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/migrate/type b/docker/s6/s6-rc.d/migrate/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/docker/s6/s6-rc.d/migrate/type @@ -0,0 +1 @@ +oneshot diff --git a/docker/s6/s6-rc.d/migrate/up b/docker/s6/s6-rc.d/migrate/up new file mode 100644 index 00000000..e6776a21 --- /dev/null +++ b/docker/s6/s6-rc.d/migrate/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/migrate.sh diff --git a/docker/s6/s6-rc.d/postgres-init/type b/docker/s6/s6-rc.d/postgres-init/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/docker/s6/s6-rc.d/postgres-init/type @@ -0,0 +1 @@ +oneshot diff --git a/docker/s6/s6-rc.d/postgres-init/up b/docker/s6/s6-rc.d/postgres-init/up new file mode 100644 index 00000000..61624548 --- /dev/null +++ b/docker/s6/s6-rc.d/postgres-init/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/postgres-init.sh diff --git a/docker/s6/s6-rc.d/postgres/dependencies.d/postgres-init b/docker/s6/s6-rc.d/postgres/dependencies.d/postgres-init new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/postgres/run b/docker/s6/s6-rc.d/postgres/run new file mode 100755 index 00000000..a2ee4bdd --- /dev/null +++ b/docker/s6/s6-rc.d/postgres/run @@ -0,0 +1,13 @@ +#!/command/with-contenv sh +# The database, when this container is asked to be its own. +# +# `EMBEDDED_POSTGRES=on` turns it on. Off, this service exits 0 immediately and s6 leaves it alone, +# which is how one image serves both shapes without two Dockerfiles. +set -eu +if [ "${EMBEDDED_POSTGRES:-off}" != "on" ]; then + exec /bin/true +fi +exec s6-setuidgid postgres /usr/lib/postgresql/16/bin/postgres \ + -D /var/lib/postgresql/data \ + -c listen_addresses=127.0.0.1 \ + -c port=5432 diff --git a/docker/s6/s6-rc.d/postgres/type b/docker/s6/s6-rc.d/postgres/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/docker/s6/s6-rc.d/postgres/type @@ -0,0 +1 @@ +longrun diff --git a/docker/s6/s6-rc.d/user/contents.d/api b/docker/s6/s6-rc.d/user/contents.d/api new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/user/contents.d/computer b/docker/s6/s6-rc.d/user/contents.d/computer new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/user/contents.d/computer-token b/docker/s6/s6-rc.d/user/contents.d/computer-token new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/user/contents.d/migrate b/docker/s6/s6-rc.d/user/contents.d/migrate new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/user/contents.d/postgres b/docker/s6/s6-rc.d/user/contents.d/postgres new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/s6-rc.d/user/contents.d/postgres-init b/docker/s6/s6-rc.d/user/contents.d/postgres-init new file mode 100644 index 00000000..e69de29b diff --git a/docker/s6/scripts/computer-token.sh b/docker/s6/scripts/computer-token.sh new file mode 100755 index 00000000..5cd436fa --- /dev/null +++ b/docker/s6/scripts/computer-token.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# The secret the API presents to the browser beside it. +# +# Both processes live in this container and the browser's port is not published, so nobody outside +# can present anything. What this defends is somebody publishing 4100 anyway, and the browser +# refuses to start without it regardless. +# +# Generated rather than required, because an operator should not have to invent a shared secret for +# two processes they cannot address separately. Set COMPUTER_TOKEN yourself and this leaves it be. +set -eu +if [ -z "${COMPUTER_TOKEN:-}" ]; then + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' \ + > /run/s6/container_environment/COMPUTER_TOKEN +fi diff --git a/docker/s6/scripts/migrate.sh b/docker/s6/scripts/migrate.sh new file mode 100755 index 00000000..3a71d4df --- /dev/null +++ b/docker/s6/scripts/migrate.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Migrations, for the embedded database only. +# +# An external database is somebody else's release process: two replicas starting together would +# race, and a failed migration should stop a deploy rather than leave a half-migrated database +# serving. An embedded one has exactly one process and no deploy pipeline, so doing it here is the +# difference between the container working and the operator reading a runbook. +set -eu +[ "${EMBEDDED_POSTGRES:-off}" = "on" ] || exit 0 +cd /app/server +exec s6-setuidgid pwuser /usr/local/bin/bun x drizzle-kit migrate --config=drizzle.config.ts diff --git a/docker/s6/scripts/postgres-init.sh b/docker/s6/scripts/postgres-init.sh new file mode 100755 index 00000000..69bbeb14 --- /dev/null +++ b/docker/s6/scripts/postgres-init.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Create the cluster the first time, and only the first time. +# +# Bound to loopback and trust-auth on purpose: the only client is the process beside it, inside this +# container, and a password would be a secret with nobody to keep it from. Publishing 5432 from this +# container would change that, which is why nothing here does. +set -eu +[ "${EMBEDDED_POSTGRES:-off}" = "on" ] || exit 0 + +DATA=/var/lib/postgresql/data +if [ ! -s "$DATA/PG_VERSION" ]; then + s6-setuidgid postgres /usr/lib/postgresql/16/bin/initdb -D "$DATA" -A trust -U openbot >/dev/null + s6-setuidgid postgres /usr/lib/postgresql/16/bin/pg_ctl -D "$DATA" -o "-c listen_addresses=127.0.0.1" -w start >/dev/null + s6-setuidgid postgres /usr/lib/postgresql/16/bin/createdb -U openbot openbot + s6-setuidgid postgres /usr/lib/postgresql/16/bin/psql -U openbot -d openbot -c 'CREATE EXTENSION IF NOT EXISTS vector' >/dev/null + s6-setuidgid postgres /usr/lib/postgresql/16/bin/pg_ctl -D "$DATA" -w stop >/dev/null +fi diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..baf01a1e --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,107 @@ +# Deployment + +OpenBot ships as one container. It carries the app, the API that serves it, and the browser the Bots +drive. Point it at a PostgreSQL database and it does what it does on a laptop. + +```sh +docker build -t openbot . +docker run -p 3001:3001 --env-file .env openbot +``` + +## What is in the image, and what is not + +**In it:** the built app, the API, and Chromium. One port, 3001. The browser listens on 4100 inside +the container and is deliberately not published: it holds real logins and its only caller is the +process beside it. + +**Not in it:** + +**PostgreSQL.** A container filesystem does not survive a redeploy and the audit trail is the +product. Point `DATABASE_URL` at a managed instance. The `vector` extension must be enabled; RDS, +Cloud SQL and Azure Database all support it, none enable it for you. + +**The supervisor.** It gives each Bot its own container, which needs a Docker socket, which no +serverless container platform permits. Without it every Bot shares the one browser, exactly as they +do on a laptop with no supervisor configured. A shared browser means shared logins, shared files and +shared session between Bots, which is fine for a deployment where one team trusts its own Bots and +is not fine as a boundary between tenants. + +## Minimum size + +Measured on the real image, one Bot, arm64. + +| | Measured | Minimum | Recommended | +| --- | --- | --- | --- | +| Memory | 409 MB idle, 498 MB after three page loads, 548 MB after a snapshot | **2 GB** | **4 GB** | +| vCPU | 3 to 6 percent at rest, bursty while a page renders | **1** | **2** | +| Disk | 5.3 GB image | **8 GB** | 10 GB with room for `/workspace` | + +**Why 2 GB when it measures at 550 MB.** That figure is one Bot with one page open. Every additional +concurrent page is roughly another 100 to 200 MB, and Playwright's own guidance is to allow about +1 GB per concurrent browser. 2 GB is the floor at which one person using it does not meet the OOM +killer; 4 GB is where a handful of Bots working at once stays comfortable. + +**Do not configure shared memory.** Chromium is launched with `--disable-dev-shm-usage`, so it writes +to `/tmp` rather than `/dev/shm` and the 64 MB default is irrelevant. This matters because **AWS +Fargate does not support `sharedMemorySize` at all**; without that flag Chromium would crash there +and the fix would not be available. + +## Required configuration + +| Variable | | +| --- | --- | +| `DATABASE_URL` | PostgreSQL with the `vector` extension | +| `KEY_ENCRYPTION_KEY` | base64 32 bytes. `openssl rand -base64 32`. The example key is refused in production | +| `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` | CopilotKit Intelligence. A free plan is available and it can be self-hosted | +| `COPILOTKIT_LICENSE_TOKEN` | from `npx copilotkit@latest license --write` | +| `MANAGED_AGENT_AG_UI_URL` | the AG-UI endpoint for the example remote Bot | +| a model key | `OPENAI_API_KEY`, or the provider you configured | + +`COMPUTER_TOKEN` is generated at start if you do not set one. Both processes that need it are inside +the container, so there is nothing to share it with. + +**Authentication is required.** `OPENBOT_DEV_NO_AUTH` is refused when `NODE_ENV=production`, which +the image sets. A deployment anybody can reach needs Google sign-in configured, or every visitor is +an administrator. + +## Migrations + +A release step, not a start step. Two replicas starting together would race, and a failed migration +should stop a deploy rather than leave a half-migrated database serving traffic. + +```sh +docker run --rm --env-file .env openbot \ + sh -c "cd /app/server && bun x drizzle-kit migrate --config=drizzle.config.ts" +``` + +## One replica, for now + +Run one. The gateway still caches the page snapshot a Bot resolves element references against in +process memory, so a second replica answers a click with a snapshot it never took. The symptom is an +element that cannot be found, intermittently, which reads as a flaky Bot rather than as a +configuration problem. Pin the platform's maximum instance count until that moves to the database. + +## Platform notes + +**Google Cloud Run.** Set memory to at least 2 GB and max instances to 1. Cloud Run runs every +container under gVisor, which Chromium is sensitive to; test a navigation before trusting it. +`gcloud run compose up` will also deploy the whole compose file if you want a throwaway database +alongside. + +**AWS.** ECS Express Mode provisions the cluster, load balancer, HTTPS and autoscaling from an image +in ECR, and is what AWS points App Runner users at now that App Runner takes no new customers. +Plain ECS on Fargate behind an ALB is the answer if you want task definitions and fine-grained IAM. +No shared-memory configuration is needed or possible. + +**Azure Container Apps.** Managed ingress with TLS and custom domains. Note the **240 second request +timeout**: the live screen holds a long connection, so expect it to reconnect. Concurrent WebSockets +are capped at 350 per instance on the basic tier. + +**Railway, Render, Fly.io.** All run this image directly and all provision PostgreSQL in a click, +which makes them the shortest path from nothing to a running deployment. + +## Known costs + +**The image is 5.3 GB**, most of it the Playwright base, which ships Firefox and WebKit alongside the +Chromium we use. Deleting them afterwards does not help, because the bytes still ship in the layer +below. Building Chromium-only onto a slim base would cut this substantially and is not done yet. diff --git a/server/Dockerfile b/server/Dockerfile index 1a92fae9..be891a31 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,14 +1,72 @@ -FROM oven/bun:1.3.14 +# The API and the app it serves, in one image. +# +# ONE IMAGE ON PURPOSE. There is no CORS anywhere in the server, so the app must reach `/api` on its +# own origin. Shipping them together means a deployment needs one service and no path rules, and the +# two halves cannot end up disagreeing about which host they are on. +# +# Multi-stage so the build's dependencies do not ship. Non-root because nothing here needs to be. + +FROM oven/bun:1.3.14 AS deps WORKDIR /app +# Manifests before sources, so a source change does not reinstall the world. COPY package.json bun.lock ./ +# The shared tsconfig, which every package extends. Vite reads it while transforming and fails +# without it, in a stack trace that names esbuild rather than a missing file. +COPY tsconfig.base.json tsconfig.base.json +COPY bunfig.toml bunfig.toml COPY app/package.json app/package.json COPY server/package.json server/package.json COPY worker/package.json worker/package.json RUN bun install --frozen-lockfile + +FROM deps AS app-build + +# `prebuild` generates the app's config, so the app is built through its own script rather than by +# calling vite directly, or the generated file is missing and the build fails on an import. +COPY app app +COPY scripts scripts +COPY shared shared +# The server's source too: `generate-app-config` reads the tenant package through +# `server/src/tenant-package`, so the app cannot be built without it. +COPY server server +COPY examples examples +RUN bun run --cwd app build + + +FROM oven/bun:1.3.14 AS runtime + +# Not root. The API opens a socket and talks to Postgres; neither needs privileges, and a Chromium +# container is a separate image with its own reasons. +RUN groupadd --system --gid 1001 openbot \ + && useradd --system --uid 1001 --gid openbot openbot + +WORKDIR /app + +COPY --from=deps /app/node_modules node_modules +COPY --from=deps /app/package.json package.json +COPY --from=deps /app/bun.lock bun.lock +COPY --from=deps /app/server/node_modules server/node_modules + COPY server server +COPY shared shared COPY examples examples +# The built app, served by the process below. `APP_DIST_DIR` is what turns that on, so an image +# without this layer is still a working API. +COPY --from=app-build /app/app/dist app/dist +ENV APP_DIST_DIR=/app/app/dist + +USER openbot:openbot + +ENV NODE_ENV=production +ENV PORT=3001 +EXPOSE 3001 + WORKDIR /app/server + +# Migrations are a release step, not a start step: two replicas starting together would race, and a +# failed migration should stop a deploy rather than leave a half-migrated database serving. +CMD ["bun", "src/index.ts"] diff --git a/server/src/app.ts b/server/src/app.ts index 35ac76d3..a826cd95 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -27,6 +27,7 @@ import { authoriseAgentCall } from "./agents/callback-token"; import type { DeploymentConfig } from "./config"; import type { ConnectorAdminService } from "./connectors"; import type { CredentialAdminService, CredentialInput } from "./credentials"; +import { serveStatic } from "hono/bun"; import { createPluginRoutes } from "./plugins/routes"; import { REFUSAL_MARKER } from "./plugins/tools"; import type { PluginStore } from "./plugins/store"; @@ -426,6 +427,45 @@ export function createApp( app.route("/api/threads", createThreadRoutes(threadIdentity, requireUser)); } + /* + * The built app, served by the API that serves it. + * + * WHY THE SAME PROCESS. There is no CORS anywhere in this server, deliberately, so the app has to + * reach `/api` on its own origin. Two containers behind one ingress does that too, and costs a + * path rule on every deployment plus a way for the two to disagree about which host they are on. + * One process cannot disagree with itself. + * + * MOUNTED LAST, so every `/api` route above already claimed its path. The catch-all below would + * otherwise answer an unmatched `/api` call with the app's HTML, which is the failure that reads + * as "the API returned HTML" and takes an hour to place. + * + * Absent in development: Vite serves the app and proxies `/api` here, so `APP_DIST_DIR` is unset + * and none of this mounts. + */ + if (config.appDistDir) { + const root = config.appDistDir; + app.use("/*", serveStatic({ root })); + /* + * A single-page app owns its routing, so a path with no file behind it is not missing: it is a + * route the browser resolves once index.html has loaded. Without this, every deep link and every + * refresh away from `/` is a 404, which is the classic way this deployment shape breaks. + * + * Written out rather than a second `serveStatic`, whose `path` option is resolved relative to the + * working directory and silently matches nothing when handed the absolute root used above. + * + * `/api` is excluded so an unmatched API route still answers as one. Returning the app's HTML to + * a fetch that expected JSON is the failure that gets read as "the API returned HTML". + */ + app.get("*", async (context) => { + if (context.req.path.startsWith("/api")) return context.notFound(); + const index = Bun.file(`${root}/index.html`); + if (!(await index.exists())) return context.notFound(); + return new Response(index, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }); + } + return app; } diff --git a/server/src/config.ts b/server/src/config.ts index d3bb3eed..c5393439 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -60,6 +60,13 @@ export type DeploymentConfig = { devNoAuth: boolean; /** Names OpenBot on the analytics the runtime already sends. Off with OPENBOT_ACCESSIBILITY_DISABLED. */ accessibility: boolean; + /** + * Where the built app is, when this process serves it. + * + * Set in a container image that carries both. Unset in development, where Vite serves the app and + * proxies the API here, so the server stays an API and nothing shadows a route. + */ + appDistDir?: string; /** * The Bot computer. Absent means the feature is off and its routes are not mounted, rather than * mounted and failing: a capability that is not configured should be missing, not broken. @@ -388,6 +395,9 @@ export function loadConfig( auth: authConfig(environment, google), devNoAuth: devAuthEnabled(environment), accessibility: accessibilityEnabled(environment), + ...(optional(environment, "APP_DIST_DIR") + ? { appDistDir: optional(environment, "APP_DIST_DIR") as string } + : {}), computer: computerConfig(environment), ...(optional(environment, "AGENT_TOOL_TOKEN") ? { agentToolToken: optional(environment, "AGENT_TOOL_TOKEN") as string } From a5e1a3f45de295dd70371417d3d90dc302b914e1 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 13:47:19 -0700 Subject: [PATCH 12/16] Keep a changelog, and ask a PR to add to it There is no way today for somebody running OpenBot to find out what changed between two commits other than reading them, and the commits are written for whoever touches the code next. So one file, newest first, written for the operator: a line lands when a deployment behaves differently afterwards, and does not when only the code moved. The PR template asks for the line, or for a sentence saying why there isn't one. Backfilled with what is on this branch and not yet released. --- .github/pull_request_template.md | 5 +++++ CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 CHANGELOG.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 389183f8..cb7df08d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -33,6 +33,11 @@ conditional update, `LISTEN`/`NOTIFY` for fan-out. - [ ] New refusals and new failures each write a row. - [ ] Nothing new is trusted from the client that the server can resolve itself. +## Changelog + +- [ ] A line in `CHANGELOG.md` under `Unreleased`, or a sentence on why a deployment behaves no + differently afterwards. + ## Proof diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a3c41f29 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +What changed, for somebody deciding whether to upgrade. Written for the person running OpenBot, not +for the person who wrote the commit: a line belongs here when a deployment behaves differently +afterwards, and does not when only the code moved. + +Newest first. `Unreleased` is what is on `main` and not yet tagged. + +## Unreleased + +### Added + +- **One container that runs the whole thing.** The root `Dockerfile` builds an image carrying the + app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point `DATABASE_URL` + at a database you already run and the built-in one never starts; leave it unset and the container + is self-contained. See [docs/deployment.md](docs/deployment.md) for the measured minimum sizes and + the platforms it has been run on. +- **Bots can run commands.** `computer_run_command` runs a command in the Bot's `/workspace`, so a + Bot can install a tool, unpack what it downloaded, or run what it was asked to run instead of only + driving a browser. Governed like every other action: the policy decides, the audit row is written + first, and a rule can refuse a shell outright with `intent == "run_command"` or refuse particular + commands. The command is recorded; its output is not. +- **`COMPUTER_SANDBOX=on`** turns on Chromium's own sandbox where the host permits user namespaces. + Which way it went is printed at start-up either way. + +### Changed + +- **Where a Bot's computer runs is now a plug.** One `ComputerProvider` interface sits under the + gateway, with the Docker supervisor as one implementation and a shared computer as another. A + computer somewhere else is an adapter rather than a change to the governed path. Thanks to + [@mu-hashmi](https://github.com/CopilotKit/OpenBot/pull/57) for the refactor. +- The address a provider hands back is checked before anything is sent to it, and the cloud metadata + addresses are refused whatever a provider says. +- The container image runs as an unprivileged user rather than root. + +## 0.0.1 + +First tag. From 4ee0b994b7030c2621671a05a180535e9987ae4b Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 14:01:42 -0700 Subject: [PATCH 13/16] Say what a Bot's shell can do, and show the command it ran Three things found by driving the built container rather than reading it. The first attempt to install a package failed. The shell runs as an unprivileged user, `sudo` is there and needs no password, and nothing told the model any of that, so it ran `apt-get install` bare, got permission denied, and offered to explain how to do it somewhere else. The tool description now says so, and the same request installs on the first try. The audit row for a command read "not in the current snapshot" under what it acted on. A command has no page element and never will, the same as a file action, and the row now carries the command instead. The deployment doc still said PostgreSQL was not in the image, and the README had no deployment section at all. --- CHANGELOG.md | 2 ++ README.md | 18 +++++++++++++ app/src/lib/copilot/computer-tools.tsx | 8 ++++-- app/src/routes/_authed/admin/audit.tsx | 4 +++ docs/deployment.md | 33 +++++++++++++++++------ server/src/computer/gateway.ts | 6 ++--- server/tests/computer-gateway.test.ts | 37 ++++++++++++++++++++++++++ 7 files changed, 95 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c41f29..ee6ba17c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. driving a browser. Governed like every other action: the policy decides, the audit row is written first, and a rule can refuse a shell outright with `intent == "run_command"` or refuse particular commands. The command is recorded; its output is not. +- **The audit trail shows the command.** A command row names what ran, the way a file row names the + path, rather than reporting an element it was never about. - **`COMPUTER_SANDBOX=on`** turns on Chromium's own sandbox where the host permits user namespaces. Which way it went is printed at start-up either way. diff --git a/README.md b/README.md index 015ae9bf..27d13b0c 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,21 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), `scripts/start.sh` starts Docker services, applies migrations, starts the API server on port 3001, starts the app on port 3010, and checks that the services answer their own health routes before printing next steps. +## Deploy it + +One image carries the app, the API, the browser the Bots drive, and optionally PostgreSQL. Same +`.env`, no Kubernetes. + +```sh +docker build -t openbot . +docker run -p 3001:3001 --env-file .env \ + -e EMBEDDED_POSTGRES=on -v openbot-data:/var/lib/postgresql/data openbot +``` + +Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you already run. +[docs/deployment.md](docs/deployment.md) has the minimum sizes, the platform notes, and why this runs +as one replica for now. + ## Try it - Open `/bot` and ask: `Open news.ycombinator.com and tell me the top story.` @@ -125,6 +140,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), ## Features - **A computer per Bot**: the supervisor gives each Bot its own container, its own `/workspace` volume and its own browser profile. Set `COMPUTER_RUNTIME=runsc` to run them under gVisor where the host supports it. +- **A shell, not just a browser**: a Bot can run a command in its workspace, install what it needs, and process a file it saved. Through the same gate as everything else, so a rule can refuse a shell outright or refuse particular commands, and the command is on the record either way. - **The gateway is the only way in**: it resolves the target from a server-held snapshot, evaluates the policy, writes the audit row, and only then calls the computer. There is no path that acts without the record existing first. - **CEL policy, fail closed**: rules can inspect `tool.name`, `intent`, `bot.id`, `actor.id`, `page.url`, `page.host`, `element.*`, `key`, `file.*` and `mcp.*`. Deny is evaluated before allow, a missing policy permits nothing, and a broken rule refuses rather than opens. - **Take the wheel**: a Bot that hits a login wall or a 2FA prompt asks for help. Control is handed over in the same panel and recorded as `computer.help_requested`, `computer.control_taken` and `computer.control_released`. While a person is driving, Bot actions are refused rather than queued. @@ -181,6 +197,8 @@ Settings worth knowing: | `SUPERVISOR_TOKEN` | Secret the supervisor requires. `start.sh` sets one. | | `COMPUTER_SUPERVISOR_URL` | Gives each Bot a computer of its own instead of one shared computer. | | `COMPUTER_RUNTIME` | Set to `runsc` to run computers under gVisor, where the host has it. | +| `COMPUTER_SANDBOX` | Set to `on` for Chromium's own sandbox, where the host permits it. | +| `EMBEDDED_POSTGRES` | Set to `on` for a database inside the deployment container. | | `AGENT_COMPUTER_POLICY` | JSON action policy. Malformed JSON stops server startup. | | `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Lets a Bot reach this machine's own services. | | `TENANT_PACKAGE_DIR` | Directory containing tenant YAML. Defaults to `../examples/fintech`. | diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index d6629af0..5dc23e03 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -631,11 +631,15 @@ export function ComputerTools() { "installing a tool you need, processing a file you saved, running a script. The working " + "directory is your workspace, so paths are relative to it and files you write here are the " + "same ones the file tools see. Commands run in bash, so pipes and && work. Long output is " + - "truncated from the start, and a command that runs too long is stopped.", + "truncated from the start, and a command that runs too long is stopped. " + + "You are not the root user, so anything that writes outside your workspace needs sudo, " + + "which asks for no password: installing a package is " + + "`sudo apt-get update && sudo apt-get install -y `. If sudo is refused, this " + + "computer does not grant it, so say so rather than retrying.", parameters: z.object({ command: z .string() - .describe("The command to run, such as: apt-get install -y jq"), + .describe("The command to run, such as: sudo apt-get install -y jq"), }), handler: async (input: { command: string }) => callComputer(bot.current, "/exec", { diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx index 2a6319b8..8e22800a 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -175,6 +175,9 @@ function Row({ ) : typeof payload.file === "string" ? ( {payload.file} + ) : typeof payload.command === "string" ? ( + // The command is the subject of its own row, the way a path is for a file action. + {payload.command} ) : typeof element === "object" && element?.name ? ( {element.name} @@ -189,6 +192,7 @@ function Row({ )} {/* Page host is meaningful only for browser actions, not workspace file actions. */} {typeof payload.file !== "string" && + typeof payload.command !== "string" && typeof payload.page === "string" && payload.page ? (
diff --git a/docs/deployment.md b/docs/deployment.md index baf01a1e..f00e2dc6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,11 +1,17 @@ # Deployment OpenBot ships as one container. It carries the app, the API that serves it, and the browser the Bots -drive. Point it at a PostgreSQL database and it does what it does on a laptop. +drive, and it can carry its own PostgreSQL as well. It does what it does on a laptop. ```sh docker build -t openbot . + +# A database you already run. docker run -p 3001:3001 --env-file .env openbot + +# Or one inside the container. Nothing else to provision. +docker run -p 3001:3001 --env-file .env \ + -e EMBEDDED_POSTGRES=on -v openbot-data:/var/lib/postgresql/data openbot ``` ## What is in the image, and what is not @@ -14,11 +20,17 @@ docker run -p 3001:3001 --env-file .env openbot the container and is deliberately not published: it holds real logins and its only caller is the process beside it. -**Not in it:** +**PostgreSQL, if you ask for it.** `EMBEDDED_POSTGRES=on` starts one inside the container, creates +the database and the `vector` extension the first time, and runs the migrations on every start. It +listens on loopback only and is never published, so there is no password to manage. -**PostgreSQL.** A container filesystem does not survive a redeploy and the audit trail is the -product. Point `DATABASE_URL` at a managed instance. The `vector` extension must be enabled; RDS, -Cloud SQL and Azure Database all support it, none enable it for you. +Give it a volume at `/var/lib/postgresql/data`. Without one, a redeploy takes the audit trail with +it, and the audit trail is the product. Platforms that offer no persistent volume are the ones to +point at a managed database instead: set `DATABASE_URL` and leave `EMBEDDED_POSTGRES` off. The +`vector` extension must be enabled there; RDS, Cloud SQL and Azure Database all support it, none +enable it for you. + +**Not in it:** **The supervisor.** It gives each Bot its own container, which needs a Docker socket, which no serverless container platform permits. Without it every Bot shares the one browser, exactly as they @@ -50,7 +62,8 @@ and the fix would not be available. | Variable | | | --- | --- | -| `DATABASE_URL` | PostgreSQL with the `vector` extension | +| `DATABASE_URL` | PostgreSQL with the `vector` extension. Not needed with `EMBEDDED_POSTGRES=on` | +| `EMBEDDED_POSTGRES` | `on` to run the database inside the container. Off by default | | `KEY_ENCRYPTION_KEY` | base64 32 bytes. `openssl rand -base64 32`. The example key is refused in production | | `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` | CopilotKit Intelligence. A free plan is available and it can be self-hosted | | `COPILOTKIT_LICENSE_TOKEN` | from `npx copilotkit@latest license --write` | @@ -66,8 +79,12 @@ an administrator. ## Migrations -A release step, not a start step. Two replicas starting together would race, and a failed migration -should stop a deploy rather than leave a half-migrated database serving traffic. +With `EMBEDDED_POSTGRES=on` they run at start and there is nothing to do. There is exactly one +process and no deploy pipeline, so the alternative would be a runbook. + +With an external database they are a release step, not a start step. Two replicas starting together +would race, and a failed migration should stop a deploy rather than leave a half-migrated database +serving traffic. ```sh docker run --rm --env-file .env openbot \ diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index 94dc463b..c7d3e32b 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -827,9 +827,9 @@ async function write( name: entry.element.name, ...(entry.element.type ? { type: entry.element.type } : {}), } - : entry.filePath - ? // A file action has no element and never will. File rows leave the element field absent - // rather than describing a browser snapshot. + : entry.filePath || entry.command + ? // A file or command action has no element and never will. Those rows leave the element + // field absent rather than describing a browser snapshot. undefined : // An action on an element the server cannot identify is worth recording plainly, rather // than as an absent field that reads like a logging gap. diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index a3346bc1..2213dd4d 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -112,6 +112,15 @@ function fakeComputer(options?: { path: "notes", entries: [], }); + case "/exec": + calls.push("runCommand"); + return Response.json({ + command: "cat secrets.txt", + exitCode: 0, + output: "the customer's card number is 4111-1111-1111-1111", + truncated: false, + timedOut: false, + }); case "/navigate": calls.push("navigate"); return Response.json({ @@ -381,6 +390,34 @@ describe("the computer gateway", () => { expect(JSON.stringify(rows[0]?.payload)).not.toContain("4111"); }); + test("a permitted command runs and is recorded by command, never by output", async () => { + const { gateway, calls, rows } = await gatewayWith(PERMISSIVE); + await gateway.runCommand("bot-1", ACTOR, { command: "cat secrets.txt" }); + + expect(calls).toEqual(["runCommand"]); + expect(rows[0]?.eventType).toBe("computer.action_allowed"); + expect(rows[0]?.payload.command).toBe("cat secrets.txt"); + // The command IS the action, so it is recorded in full. Its output is the file body of this + // pair: whatever the command read off a page or out of a file, and never in the row. + expect(JSON.stringify(rows[0]?.payload)).not.toContain("4111"); + // A command has no page element, so the row says nothing about a snapshot it was never part of. + expect(rows[0]?.payload.element).toBeUndefined(); + }); + + test("a command the policy refuses is recorded and never reaches the computer", async () => { + const { gateway, calls, rows } = await gatewayWith({ + ...PERMISSIVE, + deny: ['intent == "run_command"'], + }); + + await expect( + gateway.runCommand("bot-1", ACTOR, { command: "cat secrets.txt" }), + ).rejects.toThrow(ActionRefusedError); + expect(calls).toEqual([]); + expect(rows[0]?.eventType).toBe("computer.action_refused"); + expect(rows[0]?.payload.command).toBe("cat secrets.txt"); + }); + test("the computer is told WHICH Bot is asking", async () => { // Every per-Bot behaviour on the computer keys off this id: the profile it opens, the logins it // has, the proxy its traffic leaves through, and who holds its wheel. From cd55f4797398e8cd9966858457e9e5d88f64b942 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 14:10:01 -0700 Subject: [PATCH 14/16] Say that a rule can match on the command The Boundaries page lists what a CEL rule may ask about, and the shell added a field the list did not mention. An operator reading that page would conclude there was no way to write a rule against a command, when `contains(command, "...")` works and refuses before anything runs. --- app/src/routes/_authed/admin/boundaries.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx index 518ea304..8216205c 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -177,11 +177,12 @@ function BoundariesPage() { about tool.name, intent,{" "} bot.id, actor.id, page.url{" "} and page.host, the element being acted on, the{" "} - key being pressed, the file being touched, and{" "} - mcp.server, mcp.tool and{" "} - mcp.effect for a call to somebody else’s tools. A - rule that cannot be evaluated counts as a match, so a mistyped deny - refuses rather than quietly permitting what it was meant to forbid. + key being pressed, the file being touched, the{" "} + command being run, and mcp.server,{" "} + mcp.tool and mcp.effect for a call to + somebody else’s tools. A rule that cannot be evaluated counts + as a match, so a mistyped deny refuses rather than quietly + permitting what it was meant to forbid. } title="It may never" From 0a4feea757faaad450c9e7613498a7a1accb2ec5 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 14:50:19 -0700 Subject: [PATCH 15/16] Stop the chat dying silently on a deployment without TLS `crypto.randomUUID` is only defined in a secure context. A laptop never sees this, because `http://localhost` counts as one; a deployment reached at `http://
` does not, so the function is simply absent. The chat surface called it directly in five places, all of them on the submit path. The throw landed inside a React event handler, was swallowed, and the surface did nothing: no message, no error, no clue. The channel was created, so it looked like the Bot had nothing to say. Found by deploying the container to a real address and pressing send, which is the only place this is visible. Ids now come from `crypto.getRandomValues`, which has no such restriction, and the fallback is the case under test rather than the one nobody runs. TLS is still what a deployment should have. This is about failing where an operator can see it. --- CHANGELOG.md | 8 +++ app/src/components/channels/channel-chat.tsx | 7 +-- .../components/channels/conversation-view.tsx | 3 +- app/src/lib/copilot/bot-thread.ts | 3 +- app/src/lib/copilot/repair-history.ts | 3 +- app/src/lib/new-id.ts | 36 +++++++++++++ app/src/routes/_authed/_app/channel/new.tsx | 3 +- app/tests/new-id.test.ts | 50 +++++++++++++++++++ docs/deployment.md | 5 ++ 9 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 app/src/lib/new-id.ts create mode 100644 app/tests/new-id.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6ba17c..c19cfef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - **`COMPUTER_SANDBOX=on`** turns on Chromium's own sandbox where the host permits user namespaces. Which way it went is printed at start-up either way. +### Fixed + +- **A deployment served over plain HTTP could not start a conversation.** The chat surface minted + identifiers with `crypto.randomUUID`, which browsers withhold outside a secure context. On a + laptop `http://localhost` counts as one, so this never showed up in development; on a real + address it does not, and the surface did nothing at all when you pressed send. No message, no + error. Ids now come from an API with no such restriction. + ### Changed - **Where a Bot's computer runs is now a plug.** One `ComputerProvider` interface sits under the diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index dabb8ba9..43c1d97f 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -21,6 +21,7 @@ import { ConversationProvider } from "@/lib/copilot/conversation"; import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history"; import { stoppedReason } from "@/lib/copilot/stopped-turn"; import { useSkillCommands } from "@/lib/plugins/skill-commands"; +import { newId } from "../../lib/new-id"; /** * Backstop for the first message of a new channel; a stalled join must not lose the message. @@ -60,7 +61,7 @@ export function ChannelChat({ */ const [seed] = useState(() => { const pending = takeFirstMessage(channel.id); - return pending ? seedMessage(pending, crypto.randomUUID()) : null; + return pending ? seedMessage(pending, newId()) : null; }); /** Cleared by the send-on-mount effect without restarting it. */ @@ -219,14 +220,14 @@ export function ChannelChat({ for (const instruction of skillInstructions) { agent.addMessage({ content: instruction, - id: crypto.randomUUID(), + id: newId(), role: "system", }); } agent.addMessage({ content: trimmed, - id: crypto.randomUUID(), + id: newId(), role: "user", }); report(trimmed, null); diff --git a/app/src/components/channels/conversation-view.tsx b/app/src/components/channels/conversation-view.tsx index 13ac5135..ff7406dc 100644 --- a/app/src/components/channels/conversation-view.tsx +++ b/app/src/components/channels/conversation-view.tsx @@ -16,6 +16,7 @@ import { type QueuedMessage, reduceQueue, } from "@/components/channels/composer"; +import { newId } from "../../lib/new-id"; export function ConversationView({ messages, @@ -147,7 +148,7 @@ export function ConversationView({ const run = apply({ busy: whileBusy, draft, - id: crypto.randomUUID(), + id: newId(), type: "submit", }); return run ? startRef.current(run) : undefined; diff --git a/app/src/lib/copilot/bot-thread.ts b/app/src/lib/copilot/bot-thread.ts index 087b852d..1842e9db 100644 --- a/app/src/lib/copilot/bot-thread.ts +++ b/app/src/lib/copilot/bot-thread.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { newId } from "../new-id"; /** * The thread the direct Bot chat talks in. @@ -65,7 +66,7 @@ export function useBotThread(agentId: string): string | undefined { if (!current) return; // Falling back to one made here keeps the chat working when the deployment cannot be asked; // it is simply a thread nothing can later attribute. - const next = minted ?? crypto.randomUUID(); + const next = minted ?? newId(); if (minted) remember(agentId, minted); setThreadId(next); }); diff --git a/app/src/lib/copilot/repair-history.ts b/app/src/lib/copilot/repair-history.ts index 424d438b..04ac6156 100644 --- a/app/src/lib/copilot/repair-history.ts +++ b/app/src/lib/copilot/repair-history.ts @@ -1,4 +1,5 @@ import type { Message } from "@ag-ui/core"; +import { newId } from "../new-id"; /** * Insert explanatory tool results for unanswered tool calls before sending history to providers. @@ -21,7 +22,7 @@ function isToolResult(message: Message): message is Message & ToolResult { */ export function repairUnansweredToolCalls( messages: ReadonlyArray, - newId: () => string = () => crypto.randomUUID(), + newId: () => string = () => newId(), ): ReadonlyArray { const answered = new Set(); for (const message of messages) { diff --git a/app/src/lib/new-id.ts b/app/src/lib/new-id.ts new file mode 100644 index 00000000..d07fd661 --- /dev/null +++ b/app/src/lib/new-id.ts @@ -0,0 +1,36 @@ +/** + * A fresh identifier, on every origin this app can be served from. + * + * `crypto.randomUUID` exists only in a secure context. On a laptop that is invisible, because + * `http://localhost` counts as one; on a deployment reached at `http://
` it does not, and + * the function is simply not there. Calling it throws inside a submit handler, the throw is + * swallowed by React's event boundary, and the surface does nothing at all: no message, no error, + * no clue. Found by opening a real deployment over plain HTTP and watching a chat quietly refuse to + * start. This module exists so that failure cannot come back. + * + * `crypto.getRandomValues` has no such restriction and is what the fallback uses, so the ids are as + * random either way. They are not secrets, but they do have to be unique, and `Math.random` is the + * thing to avoid here rather than something to fall back to further. + * + * TLS is still what a deployment should have, for the cookies and the logins if nothing else. The + * point is that a deployment without it should misbehave in ways an operator can see. + */ +export function newId(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + const bytes = crypto.getRandomValues(new Uint8Array(16)); + // Version 4 and the RFC variant, so the result is a UUID rather than sixteen random bytes wearing + // the shape of one. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); +} diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx index 0c15c6af..520b5f89 100644 --- a/app/src/routes/_authed/_app/channel/new.tsx +++ b/app/src/routes/_authed/_app/channel/new.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/agents/queries"; import { useStartChannel } from "@/lib/channels/start"; import { useSkillCommands } from "@/lib/plugins/skill-commands"; +import { newId } from "../../../../lib/new-id"; /** * Creates the channel on first send. The selected coworker stays in the URL so profile links and @@ -122,7 +123,7 @@ function RouteComponent() { if (!recipient || !canSend(recipients, draft.text)) return; setError(null); - setSent(seedMessage(draft.text, crypto.randomUUID())); + setSent(seedMessage(draft.text, newId())); try { await start(recipient.id, draft.text); diff --git a/app/tests/new-id.test.ts b/app/tests/new-id.test.ts new file mode 100644 index 00000000..90f193a7 --- /dev/null +++ b/app/tests/new-id.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { newId } from "../src/lib/new-id"; + +/** + * The identifiers a surface mints, on an origin that is not a secure context. + * + * A deployment reached at `http://
` has no `crypto.randomUUID`, and the app used to call it + * directly. The throw landed inside a submit handler, React swallowed it, and the chat did nothing + * at all. These assert the fallback rather than the happy path, because the happy path is the one + * that was never broken. + */ +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +const original = crypto.randomUUID; + +/** An origin that is not a secure context, which is what the absent function means in practice. */ +function withoutRandomUUID(): void { + Object.defineProperty(crypto, "randomUUID", { + configurable: true, + value: undefined, + }); +} + +afterEach(() => { + Object.defineProperty(crypto, "randomUUID", { + configurable: true, + value: original, + }); +}); + +describe("newId", () => { + test("mints a UUID where randomUUID exists", () => { + expect(newId()).toMatch(UUID_V4); + }); + + test("mints one where it does not", () => { + withoutRandomUUID(); + + expect(newId()).toMatch(UUID_V4); + }); + + test("does not repeat itself without randomUUID", () => { + withoutRandomUUID(); + + const ids = new Set(Array.from({ length: 1000 }, () => newId())); + + expect(ids.size).toBe(1000); + }); +}); diff --git a/docs/deployment.md b/docs/deployment.md index f00e2dc6..47da5cfe 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -77,6 +77,11 @@ the container, so there is nothing to share it with. the image sets. A deployment anybody can reach needs Google sign-in configured, or every visitor is an administrator. +**Put TLS in front of it.** Not only for the cookies. A page served from `http://
` is not a +secure context, which removes a set of browser APIs that are present on `http://localhost` and so +never missing on a laptop. The app no longer depends on any of them, but sign-in cookies still want +`Secure`, and every platform below terminates TLS for you. + ## Migrations With `EMBEDDED_POSTGRES=on` they run at start and there is nothing to do. There is exactly one From c85db03f0908dfcb160d2f00f1c5c0f948cf7943 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 15:31:33 -0700 Subject: [PATCH 16/16] Use a neutral host in the computer address test --- server/tests/computer-target.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/computer-target.test.ts b/server/tests/computer-target.test.ts index c3acadf6..60bbd112 100644 --- a/server/tests/computer-target.test.ts +++ b/server/tests/computer-target.test.ts @@ -98,7 +98,7 @@ describe("checkComputerAddress", () => { }); test("allows a hosted provider's public address", () => { - const verdict = checkComputerAddress("https://sandbox-abc123.daytona.app"); + const verdict = checkComputerAddress("https://sandbox-abc123.example.net"); expect(verdict.allowed).toBe(true); });