From 739570c026175cd1931e3c3d691d4c40fdb0d9bc Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 09:21:10 -0700 Subject: [PATCH 1/7] 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 3205c40..0b994f2 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 244670c..029626a 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 594db89..5ca8507 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 f62f688..e01c391 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 304377f..104e466 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 f019ef8..8714675 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 0000000..771c81c --- /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 3ce1c28..638c791 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 9e9c2c8..5dc6f63 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 3fc067a..d6b3713 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 462a7b5..ba3e177 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 0000000..063d38a --- /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 326ad5c..1877f9e 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 2/7] 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 771c81c..b61b9fd 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 063d38a..c04d420 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 3/7] 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 b61b9fd..4fdfc8e 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 c04d420..63e2359 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 4/7] 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 4fdfc8e..0a455f1 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 b2337be..c95c12a 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 638c791..ddcae9a 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 d6b3713..e3165d8 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 63e2359..069b9dc 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 eff68c0..6f6e327 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 5/7] 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 c95c12a..248d8c5 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 e3165d8..4472dbb 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 069b9dc..d5b6444 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 6f6e327..ab14d50 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 e8b291b46cd9a6e4d2b291455fde6a319e931a04 Mon Sep 17 00:00:00 2001 From: Muhammad Hashmi Date: Thu, 20 Aug 2026 11:12:34 -0700 Subject: [PATCH 6/7] 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 7675d47..001f710 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 8714675..02e43e2 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 0a455f1..80bfb10 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 248d8c5..6802015 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 0000000..40bb585 --- /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 ddcae9a..0a7b736 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 6212973..7613a51 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 5dc6f63..cc8d02b 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 4472dbb..19094ee 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 b05774e..68ff108 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 baaf7cf..5812027 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 ba3e177..23e80fd 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 d5b6444..756fe86 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 0000000..a6d9330 --- /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 ab14d50..e6b2f22 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 0000000..f779e91 --- /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 0000000..641abe8 --- /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 f591a47..8680cdb 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 1877f9e..cbcacac 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 7/7] 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 3676ea9..b1e7d96 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 02e43e2..8d0a83c 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 80bfb10..0278aa1 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 6802015..6c9e384 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 40bb585..65854e8 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 0a7b736..63a1001 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 7613a51..2af756c 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 19094ee..46354e7 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 23e80fd..aa2ae90 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 0000000..34fe4d9 --- /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 0000000..712c9c7 --- /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 756fe86..6a032aa 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 a6d9330..0000000 --- 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 e6b2f22..a3346bc 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 f779e91..5bfaa5e 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 8680cdb..919d4cc 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 cbcacac..0864ba7 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([