Refactor Studio around domain control-plane architecture

This commit is contained in:
George Pickett
2026-02-28 12:09:32 -08:00
parent 9015500ed1
commit c4d19ff912
85 changed files with 3723 additions and 1378 deletions
+1 -11
View File
@@ -1,15 +1,8 @@
# Optional overrides for local dev.
# Default behavior uses ~/.openclaw and falls back to ~/.moltbot or ~/.clawdbot.
# Default behavior uses ~/.openclaw.
# Point to a specific state directory.
OPENCLAW_STATE_DIR=/Users/yourname/.openclaw
# MOLTBOT_STATE_DIR=/Users/yourname/.moltbot
# CLAWDBOT_STATE_DIR=/Users/yourname/.clawdbot
# Point to a specific config file.
OPENCLAW_CONFIG_PATH=/Users/yourname/.openclaw/openclaw.json
# MOLTBOT_CONFIG_PATH=/Users/yourname/.moltbot/moltbot.json
# CLAWDBOT_CONFIG_PATH=/Users/yourname/.clawdbot/clawdbot.json
# Default upstream gateway URL for the Studio host (used when Studio settings are missing).
# Defaults to ws://127.0.0.1:18789
@@ -21,6 +14,3 @@ OPENCLAW_GATEWAY_SSH_TARGET=
# Optional: SSH user when SSH target is derived from the gateway URL (default: ubuntu).
OPENCLAW_GATEWAY_SSH_USER=
# Optional: which existing agent to copy auth profiles from when creating tiles.
CLAWDBOT_DEFAULT_AGENT_ID=main
-4
View File
@@ -60,10 +60,6 @@ test-results
/.agent/*.local.md
/.agent/future-plans
/.openclaw
/.clawdbot
/.moltbot
/agent-canvas
/worktrees
/.claude
# local issue tracker
+5 -5
View File
@@ -51,7 +51,7 @@ This keeps feature cohesion high while preserving a clear client/server boundary
## Data flow & key boundaries
### 1) Studio settings + focused preferences
- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir`, with legacy fallbacks in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences.
- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir` in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences.
- **Server boundary**: `src/app/api/studio/route.ts` loads/saves settings by reading and writing `openclaw-studio/settings.json` under the resolved state dir.
- **Client boundary**: `useGatewayConnection` and focused/session flows in `src/app/page.tsx` use a shared `StudioSettingsCoordinator` to load settings and coalesce debounced `/api/studio` patch writes.
@@ -98,7 +98,7 @@ Flow:
- **Transport boundary**: `syncGatewaySessionSettings` in `src/lib/gateway/GatewayClient.ts` is the only client-side builder/invoker for `sessions.patch` payloads.
## Cross-cutting concerns
- **Configuration**: environment variables are read directly from `process.env`. The browser uses `NEXT_PUBLIC_GATEWAY_URL` only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State/config path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`/`OPENCLAW_CONFIG_PATH` with legacy fallbacks. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`).
- **Configuration**: environment variables are read directly from `process.env`. The browser uses `NEXT_PUBLIC_GATEWAY_URL` only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`).
- **Testing**: Playwright e2e runs Studio with an isolated `OPENCLAW_STATE_DIR` so the Studio WS proxy does not read real upstream gateway settings from the developer machine.
- **Logging**: API routes and the gateway client use built-in `console.*` logging.
- **Error handling**:
@@ -108,7 +108,7 @@ Flow:
- Gateway connect failures with `INVALID_REQUEST: invalid config` surface a doctor hint in Studio (`npx openclaw doctor --fix` / `pnpm openclaw doctor --fix`).
- Gateway connect failures that close with `connect failed: <CODE> ...` are preserved as `GatewayResponseError` codes so auto-retry gating can be code-driven (instead of message-driven).
- Gateway browser client truncates close reasons to WebSocket protocol limits (123 UTF-8 bytes) to avoid client-side close exceptions on long error messages.
- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. Home-scoped path autocomplete is implemented directly in `src/app/api/path-suggestions/route.ts`. These helpers are used for local settings and path suggestions, not for agent file edits.
- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. These helpers are used for local settings and gateway-adjacent file operations, not for agent file edits.
- **Remote gateway tools over SSH**: some server routes execute small scripts on the gateway host (for example agent-state operations and remote media reads). Shared helpers in `src/lib/ssh/gateway-host.ts` own SSH invocation and JSON parsing so routes do not hand-roll `spawnSync` error handling.
- **Tracing**: `src/instrumentation.ts` registers `@vercel/otel` for telemetry.
- **Validation**: request payload validation in API routes and typed client/server helpers in `src/lib/*`.
@@ -159,12 +159,12 @@ C4Container
Container_Boundary(app, "Next.js App") {
Container(client, "Client UI", "React", "Focused agent-management UI, state, gateway client")
Container(api, "API Routes", "Next.js route handlers", "Studio settings, path suggestions, gateway-host state tools")
Container(api, "API Routes", "Next.js route handlers", "Studio settings and gateway-host state tools")
Container(proxy, "WS Proxy", "Custom Node server", "Bridges /api/gateway/ws to upstream gateway with token injection")
}
Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime")
Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads (e.g. path suggestions)")
Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads")
Rel(user, client, "Uses")
Rel(client, api, "HTTP JSON")
+1 -1
View File
@@ -7,7 +7,7 @@ Thanks for helping improve OpenClaw Studio.
## Before you start
- Install OpenClaw and confirm the gateway runs locally.
- This repo is UI-only and reads config from `~/.openclaw` with legacy fallback to `~/.moltbot` or `~/.clawdbot`.
- This repo is UI-only and reads config from `~/.openclaw`.
- It does not run or build the gateway from source.
## Local setup
+1 -1
View File
@@ -97,7 +97,7 @@ npm run dev
## Configuration
Paths and key settings:
- OpenClaw config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`)
- OpenClaw config: `~/.openclaw/openclaw.json` (or via `OPENCLAW_STATE_DIR`)
- Studio settings: `~/.openclaw/openclaw-studio/settings.json`
- Default gateway URL: `ws://localhost:18789` (override via Studio Settings or `NEXT_PUBLIC_GATEWAY_URL`)
- `STUDIO_ACCESS_TOKEN`: required when binding Studio to a public host (`HOST=0.0.0.0`, `HOST=::`, or non-loopback hostnames/IPs); optional for loopback-only binds (`127.0.0.1`, `::1`, `localhost`)
+2 -2
View File
@@ -27,10 +27,10 @@ Non-scope:
Studio vendors the browser Gateway client used to speak the Gateway protocol:
- Vendored client: `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
- Sync script: `scripts/sync-openclaw-gateway-client.ts`
- Current sync source path used by that script: `~/clawdbot/ui/src/ui/gateway.ts`
- Current sync source path used by that script: `~/openclaw/ui/src/ui/gateway.ts`
Important:
- Studio does not currently sync `GatewayBrowserClient.ts` directly from `~/openclaw`.
- Studio syncs `GatewayBrowserClient.ts` from `~/openclaw` via the sync script above.
- If protocol mismatch is suspected, first verify the sync source file and the upstream Gateway runtime/protocol files are aligned.
If a protocol mismatch is suspected (missing event fields, renamed streams, different error codes), start by checking whether Studios vendored client is in sync with the Gateway version youre running.
+441 -90
View File
@@ -10,16 +10,14 @@
"dependencies": {
"@multiavatar/multiavatar": "github:multiavatar/Multiavatar",
"@noble/ed25519": "^3.0.0",
"@radix-ui/react-slot": "^1.2.4",
"@vercel/otel": "^2.1.0",
"class-variance-authority": "^0.7.1",
"better-sqlite3": "^12.6.2",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-mentions-ts": "^5.4.7",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"ws": "^8.18.3"
@@ -29,6 +27,7 @@
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -37,9 +36,7 @@
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.4.0",
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
}
@@ -2085,39 +2082,6 @@
"node": ">=18"
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
@@ -2868,6 +2832,16 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -4009,6 +3983,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
@@ -4018,6 +4012,20 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/better-sqlite3": {
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -4028,6 +4036,26 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4086,6 +4114,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -4243,6 +4295,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cjs-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
@@ -4250,18 +4308,6 @@
"license": "MIT",
"peer": true
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -4511,6 +4557,30 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4567,7 +4637,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -4636,6 +4705,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
@@ -5372,6 +5450,15 @@
"node": ">=0.10.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -5462,6 +5549,12 @@
"node": ">=16.0.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5529,11 +5622,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -5675,6 +5773,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5940,6 +6044,26 @@
"node": ">= 14"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6000,6 +6124,18 @@
"node": ">=8"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -7951,6 +8087,18 @@
"node": ">=8.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -7978,12 +8126,17 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
@@ -8015,6 +8168,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@@ -8119,6 +8278,30 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-abi": {
"version": "3.87.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -8260,6 +8443,15 @@
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8503,6 +8695,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8513,22 +8732,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -8589,6 +8792,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8620,6 +8833,30 @@
],
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
@@ -8675,20 +8912,18 @@
"react": ">=18"
}
},
"node_modules/react-mentions-ts": {
"version": "5.4.7",
"resolved": "https://registry.npmjs.org/react-mentions-ts/-/react-mentions-ts-5.4.7.tgz",
"integrity": "sha512-bTK6joPmyvLckVf1v7vE2xSSeqvL4ZwuzFvGZpt+IrtdDOdFZjiwTUBo5920kiE6WbH/v1PP81xAo6pQ0NQ0Pg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=20"
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"peerDependencies": {
"class-variance-authority": ">=0.6.0",
"clsx": ">=2.0.0",
"react": ">=19.0.0",
"react-dom": ">=19.0.0",
"tailwind-merge": ">=3.0.0"
"engines": {
"node": ">= 6"
}
},
"node_modules/redent": {
@@ -8980,6 +9215,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -9257,6 +9512,51 @@
"dev": true,
"license": "ISC"
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -9311,6 +9611,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -9579,6 +9888,34 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -9778,14 +10115,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
@@ -10106,6 +10445,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -10525,6 +10870,12 @@
"node": ">=0.10.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+2 -6
View File
@@ -10,7 +10,6 @@
"lint": "eslint .",
"cleanup:ux-artifacts": "node scripts/cleanup-ux-artifacts.mjs",
"sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts",
"migrate:architecture": "node scripts/migrate-architecture.ts",
"studio:setup": "node scripts/studio-setup.js",
"smoke:dev-server": "node scripts/smoke-dev-server.mjs",
"typecheck": "tsc --noEmit",
@@ -21,16 +20,14 @@
"dependencies": {
"@multiavatar/multiavatar": "github:multiavatar/Multiavatar",
"@noble/ed25519": "^3.0.0",
"@radix-ui/react-slot": "^1.2.4",
"@vercel/otel": "^2.1.0",
"class-variance-authority": "^0.7.1",
"better-sqlite3": "^12.6.2",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-mentions-ts": "^5.4.7",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"ws": "^8.18.3"
@@ -40,6 +37,7 @@
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -48,9 +46,7 @@
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.4.0",
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
}
-468
View File
@@ -1,468 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const WORKSPACE_FILE_NAMES = [
"AGENTS.md",
"SOUL.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"TOOLS.md",
"MEMORY.md",
];
const WORKSPACE_IGNORE_ENTRIES = [...WORKSPACE_FILE_NAMES, "memory/"];
const STORE_VERSION = 3;
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"];
const NEW_STATE_DIRNAME = ".openclaw";
const CONFIG_FILENAME = "openclaw.json";
const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"];
const resolveUserPath = (input: string, homedir: () => string = os.homedir) => {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
const expanded = trimmed.replace(/^~(?=$|[\\/])/, homedir());
return path.resolve(expanded);
}
return path.resolve(trimmed);
};
const resolveStateDir = (env = process.env, homedir = os.homedir) => {
const override =
env.OPENCLAW_STATE_DIR?.trim() ||
env.MOLTBOT_STATE_DIR?.trim() ||
env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override, homedir);
const newDir = path.join(homedir(), NEW_STATE_DIRNAME);
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir));
if (fs.existsSync(newDir)) return newDir;
const existingLegacy = legacyDirs.find((dir) => {
try {
return fs.existsSync(dir);
} catch {
return false;
}
});
return existingLegacy ?? newDir;
};
const resolveConfigPathCandidates = (env = process.env, homedir = os.homedir) => {
const explicit =
env.OPENCLAW_CONFIG_PATH?.trim() ||
env.MOLTBOT_CONFIG_PATH?.trim() ||
env.CLAWDBOT_CONFIG_PATH?.trim();
if (explicit) return [resolveUserPath(explicit, homedir)];
const candidates = [];
const stateDir =
env.OPENCLAW_STATE_DIR?.trim() ||
env.MOLTBOT_STATE_DIR?.trim() ||
env.CLAWDBOT_STATE_DIR?.trim();
if (stateDir) {
const resolved = resolveUserPath(stateDir, homedir);
candidates.push(path.join(resolved, CONFIG_FILENAME));
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name)));
}
const defaultDirs = [
path.join(homedir(), NEW_STATE_DIRNAME),
...LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)),
];
for (const dir of defaultDirs) {
candidates.push(path.join(dir, CONFIG_FILENAME));
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name)));
}
return candidates;
};
const resolveAgentCanvasDir = () => path.join(resolveStateDir(), "openclaw-studio");
const resolveAgentWorktreeDir = (projectId: string, agentId: string) =>
path.join(resolveAgentCanvasDir(), "worktrees", projectId, agentId);
const parseAgentIdFromSessionKey = (sessionKey: string, fallback = "main") => {
const match = sessionKey.match(/^agent:([^:]+):/);
return match ? match[1] : fallback;
};
const parseJsonLoose = (raw: string) => {
try {
return JSON.parse(raw);
} catch {
const cleaned = raw.replace(/,(\s*[}\]])/g, "$1");
return JSON.parse(cleaned);
}
};
const loadStore = (storePath: string) => {
const raw = fs.readFileSync(storePath, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.projects)) {
throw new Error(`Workspaces store is invalid at ${storePath}.`);
}
return parsed;
};
const normalizeStore = (store: { projects?: unknown[]; activeProjectId?: unknown }) => {
const projects = Array.isArray(store.projects)
? (store.projects as Array<Record<string, unknown>>)
: [];
const normalizedProjects = projects.map((project) => {
const projectId = typeof project.id === "string" ? project.id : "";
const tiles = Array.isArray(project.tiles) ? project.tiles : [];
return {
id: projectId,
name: typeof project.name === "string" ? project.name : "",
repoPath: typeof project.repoPath === "string" ? project.repoPath : "",
createdAt: typeof project.createdAt === "number" ? project.createdAt : Date.now(),
updatedAt: typeof project.updatedAt === "number" ? project.updatedAt : Date.now(),
archivedAt: typeof project.archivedAt === "number" ? project.archivedAt : null,
tiles: tiles.map((tile) => {
const agentId =
typeof tile.agentId === "string" && tile.agentId.trim()
? tile.agentId.trim()
: parseAgentIdFromSessionKey(
typeof tile.sessionKey === "string" ? tile.sessionKey : ""
);
return {
...tile,
agentId,
role: typeof tile.role === "string" ? tile.role : "coding",
workspacePath:
typeof tile.workspacePath === "string" && tile.workspacePath.trim()
? tile.workspacePath
: resolveAgentWorktreeDir(projectId, agentId),
archivedAt: typeof tile.archivedAt === "number" ? tile.archivedAt : null,
};
}),
};
});
const activeProjectId =
typeof store.activeProjectId === "string" &&
normalizedProjects.some(
(project) => project.id === store.activeProjectId && !project.archivedAt
)
? store.activeProjectId
: normalizedProjects.find((project) => !project.archivedAt)?.id ?? null;
return {
version: STORE_VERSION,
activeProjectId,
projects: normalizedProjects,
};
};
const resolveGitDir = (worktreeDir: string) => {
const gitPath = path.join(worktreeDir, ".git");
const stat = fs.statSync(gitPath);
if (stat.isDirectory()) {
return gitPath;
}
if (!stat.isFile()) {
throw new Error(`.git is not a file or directory at ${gitPath}`);
}
const raw = fs.readFileSync(gitPath, "utf8");
const match = raw.trim().match(/^gitdir:\s*(.+)$/i);
if (!match || !match[1]) {
throw new Error(`Unable to resolve gitdir from ${gitPath}`);
}
return path.resolve(worktreeDir, match[1].trim());
};
const ensureWorktreeIgnores = (worktreeDir: string, files: string[]) => {
if (files.length === 0) return;
const gitDir = resolveGitDir(worktreeDir);
const infoDir = path.join(gitDir, "info");
fs.mkdirSync(infoDir, { recursive: true });
const excludePath = path.join(infoDir, "exclude");
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
const lines = existing.split(/\r?\n/);
const additions = files.filter((entry) => !lines.includes(entry));
if (additions.length === 0) return;
let next = existing;
if (next.length > 0 && !next.endsWith("\n")) {
next += "\n";
}
next += `${additions.join("\n")}\n`;
fs.writeFileSync(excludePath, next, "utf8");
};
const ensureAgentWorktree = (repoPath: string, worktreeDir: string, branchName: string) => {
const trimmedRepo = repoPath.trim();
if (!trimmedRepo) {
throw new Error("Repository path is required.");
}
if (!fs.existsSync(trimmedRepo)) {
throw new Error(`Repository path does not exist: ${trimmedRepo}`);
}
const repoStat = fs.statSync(trimmedRepo);
if (!repoStat.isDirectory()) {
throw new Error(`Repository path is not a directory: ${trimmedRepo}`);
}
if (!fs.existsSync(path.join(trimmedRepo, ".git"))) {
throw new Error(`Repository is missing a .git directory: ${trimmedRepo}`);
}
if (fs.existsSync(worktreeDir)) {
const stat = fs.statSync(worktreeDir);
if (!stat.isDirectory()) {
throw new Error(`Worktree path is not a directory: ${worktreeDir}`);
}
if (!fs.existsSync(path.join(worktreeDir, ".git"))) {
throw new Error(`Existing worktree is missing .git at ${worktreeDir}`);
}
return;
}
fs.mkdirSync(path.dirname(worktreeDir), { recursive: true });
const branchCheck = spawnSync("git", ["rev-parse", "--verify", branchName], {
cwd: trimmedRepo,
encoding: "utf8",
});
const args =
branchCheck.status === 0
? ["worktree", "add", worktreeDir, branchName]
: ["worktree", "add", "-b", branchName, worktreeDir];
const result = spawnSync("git", args, { cwd: trimmedRepo, encoding: "utf8" });
if (result.status !== 0) {
const stderr = result.stderr?.trim();
throw new Error(
stderr
? `git worktree add failed for ${worktreeDir}: ${stderr}`
: `git worktree add failed for ${worktreeDir}.`
);
}
};
const ensureWorkspaceFiles = (workspaceDir: string) => {
fs.mkdirSync(workspaceDir, { recursive: true });
for (const name of WORKSPACE_FILE_NAMES) {
const filePath = path.join(workspaceDir, name);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf8");
}
}
fs.mkdirSync(path.join(workspaceDir, "memory"), { recursive: true });
};
const copyWorkspaceFile = (fromPath: string, toPath: string) => {
if (!fs.existsSync(fromPath)) return false;
if (fs.existsSync(toPath)) {
const current = fs.readFileSync(toPath, "utf8");
if (current.trim()) return false;
}
fs.copyFileSync(fromPath, toPath);
return true;
};
const copyWorkspaceMemory = (fromDir: string, toDir: string) => {
if (!fs.existsSync(fromDir)) return 0;
fs.mkdirSync(toDir, { recursive: true });
let copied = 0;
for (const entry of fs.readdirSync(fromDir, { withFileTypes: true })) {
const fromPath = path.join(fromDir, entry.name);
const toPath = path.join(toDir, entry.name);
if (entry.isDirectory()) {
copied += copyWorkspaceMemory(fromPath, toPath);
} else if (!fs.existsSync(toPath)) {
fs.copyFileSync(fromPath, toPath);
copied += 1;
}
}
return copied;
};
const reserveLegacyPath = (targetPath: string) => {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
let candidate = `${targetPath}.legacy-${stamp}`;
let suffix = 1;
while (fs.existsSync(candidate)) {
candidate = `${targetPath}.legacy-${stamp}-${suffix}`;
suffix += 1;
}
return candidate;
};
const ensureLegacyWorktreeSlot = (worktreeDir: string) => {
if (!fs.existsSync(worktreeDir)) return null;
if (fs.existsSync(path.join(worktreeDir, ".git"))) return null;
const legacyPath = reserveLegacyPath(worktreeDir);
fs.renameSync(worktreeDir, legacyPath);
return legacyPath;
};
const readAgentList = (config: {
agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> };
}) => {
const agents = config.agents ?? {};
const list = Array.isArray(agents.list) ? agents.list : [];
return list.filter((entry) => Boolean(entry && typeof entry === "object"));
};
const writeAgentList = (
config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } },
list: Array<{ id?: string; name?: string; workspace?: string }>
) => {
const agents = config.agents ?? {};
agents.list = list;
config.agents = agents;
};
const upsertAgentEntry = (
config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } },
entry: { agentId: string; agentName: string; workspaceDir: string }
) => {
const list = readAgentList(config);
let changed = false;
let found = false;
const next = list.map((item) => {
if (item.id !== entry.agentId) return item;
found = true;
const nextItem = { ...item };
if (entry.agentName && entry.agentName !== item.name) {
nextItem.name = entry.agentName;
changed = true;
}
if (entry.workspaceDir !== item.workspace) {
nextItem.workspace = entry.workspaceDir;
changed = true;
}
return nextItem;
});
if (!found) {
next.push({ id: entry.agentId, name: entry.agentName, workspace: entry.workspaceDir });
changed = true;
}
if (changed) {
writeAgentList(config, next);
}
return changed;
};
const loadClawdbotConfig = () => {
const candidates = resolveConfigPathCandidates();
const fallbackPath = path.join(resolveStateDir(), CONFIG_FILENAME);
const configPath = candidates.find((candidate) => fs.existsSync(candidate)) ?? fallbackPath;
if (!fs.existsSync(configPath)) {
throw new Error(`Missing config at ${configPath}.`);
}
const raw = fs.readFileSync(configPath, "utf8");
return { config: parseJsonLoose(raw), configPath };
};
const saveClawdbotConfig = (configPath: string, config: unknown) => {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
};
const migrate = () => {
const stateDir = resolveStateDir();
const storePath = path.join(stateDir, "openclaw-studio", "projects.json");
if (!fs.existsSync(storePath)) {
console.error(`Missing projects store at ${storePath}.`);
process.exit(1);
}
const rawStore = loadStore(storePath);
const store = normalizeStore(rawStore);
const backupPath = `${storePath}.backup-${Date.now()}`;
fs.copyFileSync(storePath, backupPath);
const warnings = [];
const errors = [];
const legacyMoves = [];
let config = null;
let configPath = "";
let configLoaded = false;
try {
const loaded = loadClawdbotConfig();
config = loaded.config;
configPath = loaded.configPath;
configLoaded = true;
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load config.";
warnings.push(`Agent config not updated: ${message}`);
}
for (const project of store.projects) {
const repoPath = typeof project.repoPath === "string" ? project.repoPath : "";
for (const tile of project.tiles) {
const agentId =
typeof tile.agentId === "string" && tile.agentId.trim()
? tile.agentId.trim()
: parseAgentIdFromSessionKey(tile.sessionKey ?? "");
const worktreeDir = resolveAgentWorktreeDir(project.id, agentId);
if (tile.workspacePath !== worktreeDir) {
tile.workspacePath = worktreeDir;
}
const branchName = `agent/${agentId}`;
let legacyPath = null;
try {
legacyPath = ensureLegacyWorktreeSlot(worktreeDir);
if (legacyPath) {
legacyMoves.push({ from: legacyPath, to: worktreeDir });
}
ensureAgentWorktree(repoPath, worktreeDir, branchName);
ensureWorktreeIgnores(worktreeDir, WORKSPACE_IGNORE_ENTRIES);
ensureWorkspaceFiles(worktreeDir);
if (legacyPath) {
for (const name of WORKSPACE_FILE_NAMES) {
const fromPath = path.join(legacyPath, name);
const toPath = path.join(worktreeDir, name);
copyWorkspaceFile(fromPath, toPath);
}
copyWorkspaceMemory(path.join(legacyPath, "memory"), path.join(worktreeDir, "memory"));
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error.";
errors.push(`Worktree migration failed for ${project.id}/${agentId}: ${message}`);
continue;
}
if (configLoaded && config) {
try {
upsertAgentEntry(config, {
agentId,
agentName: tile.name ?? agentId,
workspaceDir: worktreeDir,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error.";
warnings.push(`Failed to update agent config for ${agentId}: ${message}`);
}
}
}
}
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
if (configLoaded && config && configPath) {
saveClawdbotConfig(configPath, config);
}
if (legacyMoves.length > 0) {
console.log("Legacy workspace directories renamed:");
for (const move of legacyMoves) {
console.log(` ${move.from} -> ${move.to}`);
}
}
if (warnings.length > 0) {
console.log("Warnings:");
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
if (errors.length > 0) {
console.error("Migration errors:");
for (const error of errors) {
console.error(` - ${error}`);
}
process.exitCode = 1;
}
console.log(`Migration complete. Store backup: ${backupPath}`);
};
migrate();
+1 -1
View File
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
const repoRoot = process.cwd();
const sourcePath = path.join(os.homedir(), "clawdbot", "ui", "src", "ui", "gateway.ts");
const sourcePath = path.join(os.homedir(), "openclaw", "ui", "src", "ui", "gateway.ts");
const destPath = path.join(
repoRoot,
"src",
+3 -21
View File
@@ -2,7 +2,6 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"];
const NEW_STATE_DIRNAME = ".openclaw";
const resolveUserPath = (input) => {
@@ -26,24 +25,11 @@ const resolveDefaultHomeDir = () => {
};
const resolveStateDir = (env = process.env) => {
const override =
env.OPENCLAW_STATE_DIR?.trim() ||
env.MOLTBOT_STATE_DIR?.trim() ||
env.CLAWDBOT_STATE_DIR?.trim();
const override = env.OPENCLAW_STATE_DIR?.trim();
if (override) return resolveUserPath(override);
const home = resolveDefaultHomeDir();
const newDir = path.join(home, NEW_STATE_DIRNAME);
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(home, dir));
try {
if (fs.existsSync(newDir)) return newDir;
} catch {}
for (const dir of legacyDirs) {
try {
if (fs.existsSync(dir)) return dir;
} catch {}
}
return newDir;
return path.join(home, NEW_STATE_DIRNAME);
};
const resolveStudioSettingsPath = (env = process.env) => {
@@ -83,8 +69,7 @@ const readOpenclawGatewayDefaults = (env = process.env) => {
};
const loadUpstreamGatewaySettings = (env = process.env) => {
const settingsPath = resolveStudioSettingsPath(env);
const parsed = readJsonFile(settingsPath);
const parsed = readJsonFile(resolveStudioSettingsPath(env));
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
@@ -94,19 +79,16 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
return {
url: url || defaults.url,
token: defaults.token,
settingsPath,
};
}
}
return {
url: url || DEFAULT_GATEWAY_URL,
token,
settingsPath,
};
};
module.exports = {
resolveStateDir,
resolveStudioSettingsPath,
loadUpstreamGatewaySettings,
};
-125
View File
@@ -1,125 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import {
resolveConfiguredSshTarget,
resolveGatewaySshTargetFromGatewayUrl,
runSshJson,
} from "@/lib/ssh/gateway-host";
import { loadStudioSettings } from "@/lib/studio/settings-store";
export const runtime = "nodejs";
type DotenvKeysResponse = { keys: string[] };
const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
const parseDotEnvKeys = (raw: string): string[] => {
const keys: string[] = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed;
const idx = withoutExport.indexOf("=");
if (idx === -1) continue;
const key = withoutExport.slice(0, idx).trim();
if (!ENV_KEY_PATTERN.test(key)) continue;
const value = withoutExport.slice(idx + 1).trim();
if (!value) continue;
keys.push(key);
}
return Array.from(new Set(keys)).sort();
};
const readLocalDotEnvKeys = (): string[] => {
const envPath = path.join(resolveStateDir(), ".env");
if (!fs.existsSync(envPath)) return [];
const raw = fs.readFileSync(envPath, "utf8");
return parseDotEnvKeys(raw);
};
const DOTENV_KEYS_SCRIPT = `
set -euo pipefail
python3 - <<'PY'
import json
import pathlib
import re
pattern = re.compile(r"^[A-Z_][A-Z0-9_]*$")
env_path = pathlib.Path.home() / ".openclaw" / ".env"
keys = []
try:
raw = env_path.read_text(encoding="utf-8")
except FileNotFoundError:
raw = ""
for line in raw.splitlines():
trimmed = line.strip()
if not trimmed or trimmed.startswith("#"):
continue
if trimmed.startswith("export "):
trimmed = trimmed[len("export "):].strip()
if "=" not in trimmed:
continue
key, value = trimmed.split("=", 1)
key = key.strip()
if not pattern.fullmatch(key):
continue
value = value.strip()
if not value:
continue
keys.append(key)
print(json.dumps({"keys": sorted(set(keys))}))
PY
`;
const readRemoteDotEnvKeys = (sshTarget: string): string[] => {
const result = runSshJson({
sshTarget,
argv: ["bash", "-s"],
input: DOTENV_KEYS_SCRIPT,
label: "read dotenv keys",
fallbackMessage: "Failed to read remote ~/.openclaw/.env.",
}) as DotenvKeysResponse;
return Array.isArray(result?.keys) ? result.keys.filter((key) => typeof key === "string") : [];
};
const resolveDotEnvSshTarget = (): string | null => {
const configured = resolveConfiguredSshTarget(process.env);
if (configured) return configured;
const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? "";
if (!gatewayUrl.trim()) return null;
if (isLocalGatewayUrl(gatewayUrl)) return null;
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
};
export async function GET() {
try {
const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url ?? "";
const sshTarget = resolveDotEnvSshTarget();
const keys = sshTarget ? readRemoteDotEnvKeys(sshTarget) : readLocalDotEnvKeys();
if (!isLocalGatewayUrl(gatewayUrl) && !sshTarget) {
return NextResponse.json(
{ error: "Gateway is remote but no SSH target is configured." },
{ status: 400 },
);
}
return NextResponse.json({ keys });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to read dotenv keys.";
console.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
if (!agentId) {
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
}
return await executeGatewayIntent("agents.delete", { agentId });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : "";
if (!agentId || !name) {
return NextResponse.json({ error: "agentId and name are required." }, { status: 400 });
}
return await executeGatewayIntent("agents.update", { agentId, name });
}
+22
View File
@@ -0,0 +1,22 @@
import { parseIntentBody, executeGatewayIntent } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const parsed = await parseIntentBody(request);
if (parsed instanceof Response) return parsed;
const runId = typeof parsed.runId === "string" ? parsed.runId.trim() : "";
if (!runId) {
return Response.json({ error: "runId is required." }, { status: 400 });
}
const timeoutMs =
typeof parsed.timeoutMs === "number" && Number.isFinite(parsed.timeoutMs)
? Math.max(1, Math.floor(parsed.timeoutMs))
: undefined;
return executeGatewayIntent("agent.wait", {
runId,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
});
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const sessionKey = typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : "";
if (!sessionKey) {
return NextResponse.json({ error: "sessionKey is required." }, { status: 400 });
}
return await executeGatewayIntent("chat.abort", { sessionKey });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const sessionKey = typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : "";
const message = typeof bodyOrError.message === "string" ? bodyOrError.message : "";
const idempotencyKey =
typeof bodyOrError.idempotencyKey === "string" ? bodyOrError.idempotencyKey.trim() : "";
const deliver = Boolean(bodyOrError.deliver);
if (!sessionKey || !message.trim() || !idempotencyKey) {
return NextResponse.json(
{ error: "sessionKey, message, and idempotencyKey are required." },
{ status: 400 }
);
}
return await executeGatewayIntent("chat.send", {
sessionKey,
message,
idempotencyKey,
deliver,
});
}
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
const VALID_DECISIONS = new Set(["allow-once", "allow-always", "deny"]);
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const id = typeof bodyOrError.id === "string" ? bodyOrError.id.trim() : "";
const decision = typeof bodyOrError.decision === "string" ? bodyOrError.decision.trim() : "";
if (!id || !VALID_DECISIONS.has(decision)) {
return NextResponse.json({ error: "id and valid decision are required." }, { status: 400 });
}
return await executeGatewayIntent("exec.approval.resolve", { id, decision });
}
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import {
ensureDomainIntentRuntime,
executeGatewayIntent,
parseIntentBody,
} from "@/lib/controlplane/intent-route";
import {
upsertAgentExecApprovalsPolicyViaRuntime,
type ExecutionRoleId,
} from "@/lib/controlplane/exec-approvals";
export const runtime = "nodejs";
const VALID_ROLES = new Set<ExecutionRoleId>(["conservative", "collaborative", "autonomous"]);
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const hasFilePayload = "file" in bodyOrError;
if (hasFilePayload) {
const baseHash = typeof bodyOrError.baseHash === "string" ? bodyOrError.baseHash.trim() : "";
return await executeGatewayIntent("exec.approvals.set", {
file: bodyOrError.file,
...(baseHash ? { baseHash } : {}),
});
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
const role = typeof bodyOrError.role === "string" ? bodyOrError.role.trim() : "";
if (!agentId || !VALID_ROLES.has(role as ExecutionRoleId)) {
return NextResponse.json({ error: "agentId and valid role are required." }, { status: 400 });
}
const runtimeOrError = await ensureDomainIntentRuntime();
if (runtimeOrError instanceof Response) {
return runtimeOrError as NextResponse;
}
try {
await upsertAgentExecApprovalsPolicyViaRuntime({
runtime: runtimeOrError,
agentId,
role: role as ExecutionRoleId,
});
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "exec_approvals_set_failed";
return NextResponse.json({ error: message }, { status: 400 });
}
}
@@ -0,0 +1,15 @@
import { parseIntentBody, executeGatewayIntent } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const parsed = await parseIntentBody(request);
if (parsed instanceof Response) return parsed;
const key = typeof parsed.key === "string" ? parsed.key.trim() : "";
if (!key) {
return Response.json({ error: "key is required." }, { status: 400 });
}
return executeGatewayIntent("sessions.reset", { key });
}
-121
View File
@@ -1,121 +0,0 @@
import { NextResponse } from "next/server";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveUserPath } from "@/lib/clawdbot/paths";
export const runtime = "nodejs";
type PathAutocompleteEntry = {
name: string;
fullPath: string;
displayPath: string;
isDirectory: boolean;
};
type PathAutocompleteResult = {
query: string;
directory: string;
entries: PathAutocompleteEntry[];
};
type PathAutocompleteOptions = {
query: string;
maxResults?: number;
homedir?: () => string;
};
const normalizeQuery = (query: string): string => {
const trimmed = query.trim();
if (!trimmed) {
throw new Error("Query is required.");
}
if (trimmed === "~") {
return "~/";
}
if (trimmed.startsWith("~")) {
return trimmed;
}
const withoutLeading = trimmed.replace(/^[\\/]+/, "");
return `~/${withoutLeading}`;
};
const isWithinHome = (target: string, home: string): boolean => {
const relative = path.relative(home, target);
if (!relative) return true;
return !relative.startsWith("..") && !path.isAbsolute(relative);
};
const listPathAutocompleteEntries = ({
query,
maxResults = 10,
homedir = os.homedir,
}: PathAutocompleteOptions): PathAutocompleteResult => {
const normalized = normalizeQuery(query);
const resolvedHome = path.resolve(homedir());
const resolvedQuery = resolveUserPath(normalized, homedir);
if (!isWithinHome(resolvedQuery, resolvedHome)) {
throw new Error("Path must stay within the home directory.");
}
const endsWithSlash = normalized.endsWith("/") || normalized.endsWith(path.sep);
const directoryPath = endsWithSlash ? resolvedQuery : path.dirname(resolvedQuery);
const prefix = endsWithSlash ? "" : path.basename(resolvedQuery);
if (!isWithinHome(directoryPath, resolvedHome)) {
throw new Error("Path must stay within the home directory.");
}
if (!fs.existsSync(directoryPath)) {
throw new Error(`Directory does not exist: ${directoryPath}`);
}
const stat = fs.statSync(directoryPath);
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${directoryPath}`);
}
const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : 10;
const entries = fs
.readdirSync(directoryPath, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith("."))
.filter((entry) => entry.name.startsWith(prefix))
.map((entry) => {
const fullPath = path.join(directoryPath, entry.name);
const relative = path.relative(resolvedHome, fullPath);
const normalizedRelative = relative.split(path.sep).join("/");
const displayBase = `~/${normalizedRelative}`;
return {
name: entry.name,
fullPath,
displayPath: entry.isDirectory() ? `${displayBase}/` : displayBase,
isDirectory: entry.isDirectory(),
} satisfies PathAutocompleteEntry;
})
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.slice(0, limit);
return { query: normalized, directory: directoryPath, entries };
};
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const rawQuery = searchParams.get("q");
const query = rawQuery && rawQuery.trim() ? rawQuery.trim() : "~/";
const result = listPathAutocompleteEntries({ query, maxResults: 10 });
return NextResponse.json(result);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to list path suggestions.";
console.error(message);
const status = message.includes("does not exist") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read";
import { selectAgentHistoryEntries } from "@/lib/controlplane/read-model";
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
export const runtime = "nodejs";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 1000;
const resolveLimit = (raw: string | null): number => {
if (!raw) return DEFAULT_LIMIT;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return DEFAULT_LIMIT;
if (parsed <= 0) return DEFAULT_LIMIT;
return Math.min(Math.floor(parsed), MAX_LIMIT);
};
export async function GET(
request: Request,
context: { params: Promise<{ agentId: string }> }
) {
if (!isStudioDomainApiModeEnabled()) {
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
}
const { agentId } = await context.params;
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) {
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
}
const controlPlane = getControlPlaneRuntime();
let startError: string | null = null;
try {
await controlPlane.ensureStarted();
} catch (err) {
startError = err instanceof Error ? err.message : "controlplane_start_failed";
}
const url = new URL(request.url);
const limit = resolveLimit(url.searchParams.get("limit"));
const snapshot = controlPlane.snapshot();
const probe = snapshot.status === "connected" ? null : await probeOpenClawLocalState();
const allEntries = controlPlane.eventsAfter(0, MAX_LIMIT * 5);
const entries = selectAgentHistoryEntries(allEntries, normalizedAgentId, limit);
return NextResponse.json({
enabled: true,
agentId: normalizedAgentId,
...(startError ? { error: startError } : {}),
entries,
freshness: deriveRuntimeFreshness(snapshot, probe),
...(probe ? { probe } : {}),
});
}
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
import { loadStudioSettings } from "@/lib/studio/settings-store";
export const runtime = "nodejs";
export async function POST(request: Request) {
if (!isStudioDomainApiModeEnabled()) {
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
}
let cachedConfigSnapshot: GatewayModelPolicySnapshot | null = null;
try {
const body = (await request.json()) as unknown;
if (body && typeof body === "object" && !Array.isArray(body)) {
const record = body as { cachedConfigSnapshot?: unknown };
if (record.cachedConfigSnapshot && typeof record.cachedConfigSnapshot === "object") {
cachedConfigSnapshot = record.cachedConfigSnapshot as GatewayModelPolicySnapshot;
}
}
} catch {}
const controlPlane = getControlPlaneRuntime();
try {
await controlPlane.ensureStarted();
} catch (err) {
const message = err instanceof Error ? err.message : "controlplane_start_failed";
return NextResponse.json(
{ enabled: true, error: message, code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable" },
{ status: 503 }
);
}
try {
const settings = loadStudioSettings();
const gatewayUrl = settings.gateway?.url?.trim() ?? "";
if (!gatewayUrl) {
return NextResponse.json({ enabled: true, error: "gateway_url_not_configured" }, { status: 503 });
}
const result = await hydrateAgentFleetFromGateway({
client: {
call: (method, params) => controlPlane.callGateway(method, params),
},
gatewayUrl,
cachedConfigSnapshot,
loadStudioSettings: async () => settings,
isDisconnectLikeError: () => false,
logError: (message, error) => console.error(message, error),
});
return NextResponse.json({ enabled: true, result });
} catch (err) {
const message = err instanceof Error ? err.message : "fleet_load_failed";
return NextResponse.json({ enabled: true, error: message }, { status: 500 });
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts";
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
export const runtime = "nodejs";
const REPLAY_LIMIT = 2000;
const HEARTBEAT_INTERVAL_MS = 15_000;
const encoder = new TextEncoder();
const parseLastEventId = (request: Request): number => {
const headerValue = request.headers.get("last-event-id");
if (!headerValue) return 0;
const parsed = Number(headerValue.trim());
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.floor(parsed);
};
const toSseFrame = (entry: ControlPlaneOutboxEntry): Uint8Array => {
const eventName = entry.event.type === "runtime.status" ? "runtime.status" : "gateway.event";
return encoder.encode(
`id: ${entry.id}\nevent: ${eventName}\ndata: ${JSON.stringify(entry.event)}\n\n`
);
};
const heartbeatFrame = (): Uint8Array => encoder.encode(": heartbeat\n\n");
export async function GET(request: Request) {
if (!isStudioDomainApiModeEnabled()) {
return new Response(
JSON.stringify({ enabled: false, error: "domain_api_mode_disabled" }),
{ status: 404, headers: { "content-type": "application/json; charset=utf-8" } }
);
}
const controlPlane = getControlPlaneRuntime();
try {
await controlPlane.ensureStarted();
} catch (err) {
return new Response(
JSON.stringify({
enabled: true,
error: err instanceof Error ? err.message : "controlplane_start_failed",
}),
{ status: 503, headers: { "content-type": "application/json; charset=utf-8" } }
);
}
const lastSeenId = parseLastEventId(request);
const replayEntries = controlPlane.eventsAfter(lastSeenId, REPLAY_LIMIT);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
let unsubscribe: () => void = () => {};
let heartbeat: ReturnType<typeof setInterval> | null = null;
const close = () => {
if (closed) return;
closed = true;
unsubscribe();
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
try {
controller.close();
} catch {}
};
for (const entry of replayEntries) {
controller.enqueue(toSseFrame(entry));
}
unsubscribe = controlPlane.subscribe((entry) => {
if (closed) return;
controller.enqueue(toSseFrame(entry));
});
heartbeat = setInterval(() => {
if (closed) return;
controller.enqueue(heartbeatFrame());
}, HEARTBEAT_INTERVAL_MS);
request.signal.addEventListener("abort", close, { once: true });
},
});
return new Response(stream, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read";
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
export const runtime = "nodejs";
export async function GET() {
if (!isStudioDomainApiModeEnabled()) {
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
}
const controlPlane = getControlPlaneRuntime();
let startError: string | null = null;
try {
await controlPlane.ensureStarted();
} catch (err) {
startError = err instanceof Error ? err.message : "controlplane_start_failed";
}
const snapshot = controlPlane.snapshot();
const probe = snapshot.status === "connected" ? null : await probeOpenClawLocalState();
return NextResponse.json({
enabled: true,
...(startError ? { error: startError } : {}),
summary: snapshot,
freshness: deriveRuntimeFreshness(snapshot, probe),
...(probe ? { probe } : {}),
});
}
+16 -5
View File
@@ -1,10 +1,13 @@
import { NextResponse } from "next/server";
import { type StudioSettingsPatch } from "@/lib/studio/settings";
import { isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
import {
applyStudioSettingsPatch,
loadLocalGatewayDefaults,
loadStudioSettings,
redactLocalGatewayDefaultsSecrets,
redactStudioSettingsSecrets,
} from "@/lib/studio/settings-store";
export const runtime = "nodejs";
@@ -12,11 +15,19 @@ export const runtime = "nodejs";
const isPatch = (value: unknown): value is StudioSettingsPatch =>
Boolean(value && typeof value === "object");
const buildSettingsResponseBody = () => {
const settings = loadStudioSettings();
const localGatewayDefaults = loadLocalGatewayDefaults();
return {
settings: redactStudioSettingsSecrets(settings),
localGatewayDefaults: redactLocalGatewayDefaultsSecrets(localGatewayDefaults),
domainApiModeEnabled: isStudioDomainApiModeEnabled(),
};
};
export async function GET() {
try {
const settings = loadStudioSettings();
const localGatewayDefaults = loadLocalGatewayDefaults();
return NextResponse.json({ settings, localGatewayDefaults });
return NextResponse.json(buildSettingsResponseBody());
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load studio settings.";
console.error(message);
@@ -30,8 +41,8 @@ export async function PUT(request: Request) {
if (!isPatch(body)) {
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
}
const settings = applyStudioSettingsPatch(body);
return NextResponse.json({ settings });
applyStudioSettingsPatch(body);
return NextResponse.json(buildSettingsResponseBody());
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save studio settings.";
console.error(message);
-2
View File
@@ -1,6 +1,4 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../../node_modules/react-mentions-ts/styles/tailwind.css";
@import "./styles/markdown.css";
@custom-variant dark (&:is(.dark *));
+62 -29
View File
@@ -18,6 +18,7 @@ import {
} from "@/lib/text/message-extract";
import {
useGatewayConnection,
type GatewayStatus,
} from "@/lib/gateway/GatewayClient";
import {
type GatewayModelChoice,
@@ -113,6 +114,7 @@ import {
type SettingsRouteTab,
} from "@/features/agents/operations/settingsRouteWorkflow";
import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
type MobilePane = "fleet" | "chat";
@@ -218,6 +220,7 @@ const AgentStudioPage = () => {
gatewayUrl,
token,
localGatewayDefaults,
domainApiModeEnabled,
error: gatewayError,
connect,
disconnect,
@@ -225,6 +228,9 @@ const AgentStudioPage = () => {
setGatewayUrl,
setToken,
} = useGatewayConnection(settingsCoordinator);
const useDomainApiMode = domainApiModeEnabled ?? isStudioDomainIntentModeEnabled();
const coreConnected = useDomainApiMode ? true : status === "connected";
const coreStatus: GatewayStatus = coreConnected ? "connected" : status;
const { state, dispatch, hydrateAgents, setError, setLoading } = useAgentStore();
const [showConnectionPanel, setShowConnectionPanel] = useState(false);
@@ -455,7 +461,7 @@ const AgentStudioPage = () => {
}, [specialLatestUpdate]);
const loadAgents = useCallback(async () => {
if (status !== "connected") return;
if (!coreConnected) return;
setLoading(true);
try {
const commands = await runStudioBootstrapLoadOperation({
@@ -490,7 +496,7 @@ const AgentStudioPage = () => {
gatewayUrl,
gatewayConfigSnapshot,
settingsCoordinator,
status,
coreConnected,
]);
const enqueueConfigMutationFromRef = useCallback(
@@ -563,7 +569,7 @@ const AgentStudioPage = () => {
queuedBlockedByRunningAgents,
activeConfigMutation,
} = useConfigMutationQueue({
status,
status: coreStatus,
hasRunningAgents,
hasRestartBlockInProgress,
});
@@ -582,9 +588,9 @@ const AgentStudioPage = () => {
}, [unscopedPendingExecApprovals]);
useEffect(() => {
if (status === "connected") return;
if (coreConnected) return;
setAgentsLoadedOnce(false);
}, [gatewayUrl, status]);
}, [coreConnected, gatewayUrl]);
useEffect(() => {
let cancelled = false;
@@ -660,24 +666,24 @@ const AgentStudioPage = () => {
]);
useEffect(() => {
if (status !== "connected" || !focusedPreferencesLoaded) return;
if (!coreConnected || !focusedPreferencesLoaded) return;
if (restartingMutationBlock && restartingMutationBlock.phase !== "queued") return;
if (createAgentBlock && createAgentBlock.phase !== "queued") return;
void loadAgents();
}, [
coreConnected,
createAgentBlock,
focusedPreferencesLoaded,
gatewayUrl,
loadAgents,
restartingMutationBlock,
status,
]);
useEffect(() => {
if (status === "disconnected") {
if (!coreConnected) {
setLoading(false);
}
}, [setLoading, status]);
}, [coreConnected, setLoading]);
useEffect(() => {
const nowMs = Date.now();
@@ -743,7 +749,7 @@ const AgentStudioPage = () => {
clearHistoryInFlight,
} = useRuntimeSyncController({
client,
status,
status: coreStatus,
agents,
focusedAgentId,
focusedAgentRunning,
@@ -766,7 +772,7 @@ const AgentStudioPage = () => {
clearPendingLivePatch,
} = useChatInteractionController({
client,
status,
status: coreStatus,
agents,
dispatch,
setError,
@@ -806,7 +812,7 @@ const AgentStudioPage = () => {
} = useSettingsRouteController({
settingsRouteActive,
settingsRouteAgentId,
status,
status: coreStatus,
agentsLoadedOnce,
selectedAgentId: state.selectedAgentId,
focusedAgentId: focusedAgent?.agentId ?? null,
@@ -1185,14 +1191,46 @@ const AgentStudioPage = () => {
},
});
runtimeEventHandlerRef.current = handler;
const unsubscribe = client.onEvent((event: EventFrame) => {
handler.handleEvent(event);
handleGatewayEventIngress(event);
});
let unsubscribeGatewayEvents: (() => void) | null = null;
let stream: EventSource | null = null;
if (useDomainApiMode) {
stream = new EventSource("/api/runtime/stream");
stream.addEventListener("gateway.event", (raw) => {
const message = raw as MessageEvent<string>;
try {
const parsed = JSON.parse(message.data) as {
event?: string;
payload?: unknown;
seq?: number;
};
if (typeof parsed.event !== "string") return;
const frame: EventFrame = {
type: "event",
event: parsed.event,
payload: parsed.payload,
...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}),
};
handler.handleEvent(frame);
handleGatewayEventIngress(frame);
} catch {}
});
stream.addEventListener("runtime.status", () => {
void loadSummarySnapshot();
});
stream.onerror = () => {
// EventSource performs automatic reconnect; keep warning low-noise.
};
} else {
unsubscribeGatewayEvents = client.onEvent((event: EventFrame) => {
handler.handleEvent(event);
handleGatewayEventIngress(event);
});
}
return () => {
runtimeEventHandlerRef.current = null;
handler.dispose();
unsubscribe();
unsubscribeGatewayEvents?.();
stream?.close();
};
}, [
client,
@@ -1204,6 +1242,7 @@ const AgentStudioPage = () => {
refreshHeartbeatLatestUpdate,
specialLatestUpdate,
handleGatewayEventIngress,
useDomainApiMode,
status,
]);
@@ -1227,7 +1266,7 @@ const AgentStudioPage = () => {
: queuedConfigMutationCount > 0
? queuedBlockedByRunningAgents
? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for ${runningAgentCount} running agent${runningAgentCount === 1 ? "" : "s"} to finish`
: status !== "connected"
: !coreConnected
? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for gateway connection`
: `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}`
: null;
@@ -1292,7 +1331,7 @@ const AgentStudioPage = () => {
);
}
if (status === "disconnected" && !agentsLoadedOnce && didAttemptGatewayConnect) {
if (!coreConnected && status === "disconnected" && !agentsLoadedOnce && didAttemptGatewayConnect) {
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="relative z-10 flex h-screen flex-col">
@@ -1329,7 +1368,7 @@ const AgentStudioPage = () => {
);
}
if (status === "connected" && !agentsLoadedOnce) {
if (coreConnected && !agentsLoadedOnce) {
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="flex min-h-screen items-center justify-center px-6">
@@ -1500,12 +1539,6 @@ const AgentStudioPage = () => {
settingsMutationController.handleDeleteAgent(inspectSidebarAgent.agentId)
}
canDelete={inspectSidebarAgent.agentId !== RESERVED_MAIN_AGENT_ID}
onToolCallingToggle={(enabled) =>
handleToolCallingToggle(inspectSidebarAgent.agentId, enabled)
}
onThinkingTracesToggle={(enabled) =>
handleThinkingTracesToggle(inspectSidebarAgent.agentId, enabled)
}
skillsReport={settingsMutationController.settingsSkillsReport}
skillsLoading={settingsMutationController.settingsSkillsLoading}
skillsError={settingsMutationController.settingsSkillsError}
@@ -1621,7 +1654,7 @@ const AgentStudioPage = () => {
onCreateAgent={() => {
handleOpenCreateAgentModal();
}}
createDisabled={status !== "connected" || createAgentBusy || state.loading}
createDisabled={!coreConnected || createAgentBusy || state.loading}
createBusy={createAgentBusy}
onSelectAgent={handleFleetSelectAgent}
/>
@@ -1636,7 +1669,7 @@ const AgentStudioPage = () => {
<AgentChatPanel
agent={focusedAgent}
isSelected={false}
canSend={status === "connected"}
canSend={coreConnected}
models={gatewayModels}
stopBusy={stopBusyAgentId === focusedAgent.agentId}
stopDisabledReason={focusedAgentStopDisabledReason}
@@ -1680,7 +1713,7 @@ const AgentStudioPage = () => {
description={
hasAnyAgents
? undefined
: status === "connected"
: coreConnected
? "Use New Agent in the sidebar to add your first agent."
: "Connect to your gateway to load agents into the studio."
}
@@ -5,6 +5,8 @@ import {
updatePendingApprovalById,
} from "@/features/agents/approvals/pendingStore";
import { shouldTreatExecApprovalResolveErrorAsUnknownId } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
@@ -33,9 +35,11 @@ export const resolveExecApprovalViaStudio = async (params: {
isDisconnectLikeError: (error: unknown) => boolean;
shouldTreatUnknownId?: (error: unknown) => boolean;
logWarn?: (message: string, error: unknown) => void;
useDomainIntents?: boolean;
}): Promise<void> => {
const id = params.approvalId.trim();
if (!id) return;
const useDomainIntents = params.useDomainIntents ?? isStudioDomainIntentModeEnabled();
const resolvePendingApproval = (
approvalId: string,
@@ -114,7 +118,11 @@ export const resolveExecApprovalViaStudio = async (params: {
setLocalApprovalState(true, null);
try {
await params.client.call("exec.approval.resolve", { id, decision: params.decision });
if (useDomainIntents) {
await postStudioIntent("/api/intents/exec-approval-resolve", { id, decision: params.decision });
} else {
await params.client.call("exec.approval.resolve", { id, decision: params.decision });
}
removeLocalApproval(id);
if (params.decision !== "allow-once" && params.decision !== "allow-always") {
@@ -14,6 +14,8 @@ import {
} from "@/features/agents/approvals/execApprovalRunControlWorkflow";
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
import type { AgentState } from "@/features/agents/state/store";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
@@ -44,6 +46,7 @@ export async function runPauseRunForExecApprovalOperation(params: {
logWarn?: (message: string, error: unknown) => void;
}): Promise<void> {
if (params.status !== "connected") return;
const useDomainIntents = isStudioDomainIntentModeEnabled();
const plan = planPauseRunControl({
approval: params.approval,
@@ -60,9 +63,15 @@ export async function runPauseRunForExecApprovalOperation(params: {
params.pausedRunIdByAgentId.set(plan.pauseIntent.agentId, plan.pauseIntent.runId);
try {
await params.client.call("chat.abort", {
sessionKey: plan.pauseIntent.sessionKey,
});
if (useDomainIntents) {
await postStudioIntent("/api/intents/chat-abort", {
sessionKey: plan.pauseIntent.sessionKey,
});
} else {
await params.client.call("chat.abort", {
sessionKey: plan.pauseIntent.sessionKey,
});
}
} catch (error) {
params.pausedRunIdByAgentId.delete(plan.pauseIntent.agentId);
if (!params.isDisconnectLikeError(error)) {
@@ -88,6 +97,7 @@ export async function runExecApprovalAutoResumeOperation(params: {
sendChatMessage?: typeof sendChatMessageViaStudio;
now?: () => number;
}): Promise<void> {
const useDomainIntents = isStudioDomainIntentModeEnabled();
const sendChatMessage = params.sendChatMessage ?? sendChatMessageViaStudio;
const pendingState = params.getPendingState();
const prePlan = planAutoResumeRunControl({
@@ -114,10 +124,17 @@ export async function runExecApprovalAutoResumeOperation(params: {
});
try {
await params.client.call("agent.wait", {
runId: preWaitIntent.pausedRunId,
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
if (useDomainIntents) {
await postStudioIntent("/api/intents/agent-wait", {
runId: preWaitIntent.pausedRunId,
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
} else {
await params.client.call("agent.wait", {
runId: preWaitIntent.pausedRunId,
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
}
} catch (error) {
if (!params.isDisconnectLikeError(error)) {
(params.logWarn ?? ((message, err) => console.warn(message, err)))(
@@ -200,7 +217,7 @@ export async function runResolveExecApprovalOperation(params: {
});
}
export function executeExecApprovalIngressCommands(params: {
function executeExecApprovalIngressCommands(params: {
commands: ExecApprovalIngressCommand[];
replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void;
pauseRunForApproval: (
@@ -98,8 +98,6 @@ type AgentSettingsPanelProps = {
onUpdateAgentPermissions?: (draft: AgentPermissionsDraft) => Promise<void> | void;
onDelete: () => void;
canDelete?: boolean;
onToolCallingToggle: (enabled: boolean) => void;
onThinkingTracesToggle: (enabled: boolean) => void;
cronJobs: CronJobSummary[];
cronLoading: boolean;
cronError: string | null;
@@ -301,8 +299,6 @@ export const AgentSettingsPanel = ({
onUpdateAgentPermissions = () => {},
onDelete,
canDelete = true,
onToolCallingToggle,
onThinkingTracesToggle,
cronJobs,
cronLoading,
cronError,
@@ -5,6 +5,8 @@ import {
upsertGatewayAgentExecApprovals,
} from "@/lib/gateway/execApprovals";
import { readConfigAgentList, updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
export type ExecutionRoleId = "conservative" | "collaborative" | "autonomous";
export type CommandModeId = "off" | "ask" | "auto";
@@ -78,18 +80,6 @@ export const resolvePresetDefaultsForRole = (role: ExecutionRoleId): AgentPermis
};
};
export const resolveEffectivePermissionsSummary = (draft: AgentPermissionsDraft): string => {
const commandLabel =
draft.commandMode === "auto"
? "Commands: Auto"
: draft.commandMode === "ask"
? "Commands: Ask"
: "Commands: Off";
const webLabel = draft.webAccess ? "Web: On" : "Web: Off";
const fileLabel = draft.fileTools ? "File tools: On" : "File tools: Off";
return `${commandLabel} | ${webLabel} | ${fileLabel}`;
};
export const isPermissionsCustom = (params: {
role: ExecutionRoleId;
draft: AgentPermissionsDraft;
@@ -303,7 +293,16 @@ const upsertExecApprovalsPolicyForRole = async (params: {
client: GatewayClient;
agentId: string;
role: ExecutionRoleId;
useDomainIntents?: boolean;
}) => {
const useDomainIntents = params.useDomainIntents ?? isStudioDomainIntentModeEnabled();
if (useDomainIntents) {
await postStudioIntent("/api/intents/exec-approvals-set", {
agentId: params.agentId,
role: params.role,
});
return;
}
const existingPolicy = await readGatewayAgentExecApprovals({
client: params.client,
agentId: params.agentId,
@@ -324,6 +323,7 @@ export async function updateAgentPermissionsViaStudio(params: {
sessionKey: string;
draft: AgentPermissionsDraft;
loadAgents?: () => Promise<void>;
useDomainIntents?: boolean;
}): Promise<void> {
const agentId = params.agentId.trim();
if (!agentId) {
@@ -335,6 +335,7 @@ export async function updateAgentPermissionsViaStudio(params: {
client: params.client,
agentId,
role,
useDomainIntents: params.useDomainIntents,
});
const runtimeConfigContext = await resolveAgentRuntimeConfigContext({
client: params.client,
@@ -377,6 +378,7 @@ export async function updateExecutionRoleViaStudio(params: {
sessionKey: string;
role: ExecutionRoleId;
loadAgents: () => Promise<void>;
useDomainIntents?: boolean;
}): Promise<void> {
const agentId = params.agentId.trim();
if (!agentId) {
@@ -387,6 +389,7 @@ export async function updateExecutionRoleViaStudio(params: {
client: params.client,
agentId,
role: params.role,
useDomainIntents: params.useDomainIntents,
});
const runtimeConfigContext = await resolveAgentRuntimeConfigContext({
client: params.client,
@@ -3,7 +3,7 @@ import {
type MutationStartGuardResult,
} from "@/features/agents/operations/mutationLifecycleWorkflow";
export const RESERVED_MAIN_AGENT_ID = "main";
const RESERVED_MAIN_AGENT_ID = "main";
type GuardedActionKind =
| "delete-agent"
@@ -8,6 +8,8 @@ import {
isMetaMarkdown,
parseMetaMarkdown,
} from "@/lib/text/message-extract";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
import type { AgentState } from "@/features/agents/state/store";
import { randomUUID } from "@/lib/uuid";
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
@@ -71,6 +73,7 @@ export async function sendChatMessageViaStudio(params: {
echoUserMessage?: boolean;
now?: () => number;
generateRunId?: () => string;
useDomainIntents?: boolean;
}): Promise<void> {
const trimmed = params.message.trim();
if (!trimmed) return;
@@ -78,6 +81,7 @@ export async function sendChatMessageViaStudio(params: {
const generateRunId = params.generateRunId ?? (() => randomUUID());
const now = params.now ?? (() => Date.now());
const useDomainIntents = params.useDomainIntents ?? isStudioDomainIntentModeEnabled();
const agentId = params.agentId;
const runId = generateRunId();
@@ -184,12 +188,23 @@ export async function sendChatMessageViaStudio(params: {
}
}
const sendResult = await params.client.call("chat.send", {
const sendPayload = {
sessionKey: params.sessionKey,
message: buildAgentInstruction({ message: trimmed }),
deliver: false,
idempotencyKey: runId,
});
};
const sendResult = useDomainIntents
? await postStudioIntent<unknown>("/api/intents/chat-send", sendPayload).then(
(result) =>
(result &&
typeof result === "object" &&
"payload" in result &&
(result as { payload?: unknown }).payload !== undefined
? (result as { payload: unknown }).payload
: result) as unknown
)
: await params.client.call("chat.send", sendPayload);
if (!createdSession) {
params.dispatch({
@@ -12,7 +12,7 @@ import {
} from "@/lib/cron/types";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
export const CRON_ACTION_BUSY_MESSAGE = "Please wait for the current cron action to finish.";
const CRON_ACTION_BUSY_MESSAGE = "Please wait for the current cron action to finish.";
const resolveCreateAgentId = (agentId: string) => {
const trimmed = agentId.trim();
@@ -6,6 +6,7 @@ import {
type CronJobRestoreInput,
} from "@/lib/cron/types";
import { deleteGatewayAgent } from "@/lib/gateway/agentConfig";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
type FetchJson = typeof defaultFetchJson;
@@ -74,6 +75,7 @@ export const deleteAgentViaStudio = async (params: {
agentId: string;
fetchJson?: FetchJson;
logError?: (message: string, error: unknown) => void;
useDomainIntents?: boolean;
}): Promise<DeleteAgentTransactionResult> => {
const fetchJson = params.fetchJson ?? defaultFetchJson;
const logError = params.logError ?? ((message, error) => console.error(message, error));
@@ -109,6 +111,10 @@ export const deleteAgentViaStudio = async (params: {
await restoreCronJobs(params.client, jobs);
},
deleteGatewayAgent: async (agentId) => {
if (params.useDomainIntents) {
await postStudioIntent("/api/intents/agent-delete", { agentId });
return;
}
await deleteGatewayAgent({ client: params.client, agentId });
},
logError,
@@ -19,7 +19,7 @@ export type ReconcileEligibility = {
const SUMMARY_PREVIEW_LIMIT = 8;
const SUMMARY_PREVIEW_MAX_CHARS = 240;
export const resolveSummarySnapshotKeys = (params: {
const resolveSummarySnapshotKeys = (params: {
agents: Array<{ sessionCreated: boolean; sessionKey: string }>;
maxKeys: number;
}): string[] => {
@@ -416,13 +416,6 @@ export const resolveConfigMutationStatusLine = (params: {
: "Gateway restart in progress";
};
export const buildAwaitingRestartPatch = (): AwaitingRestartPatch => {
return {
phase: "awaiting-restart",
sawDisconnect: false,
};
};
export const resolveConfigMutationPostRunEffects = (
result: MutationWorkflowResult
): MutationWorkflowPostRunEffects => {
@@ -6,6 +6,8 @@ import {
planFocusedSelectionPatch,
} from "@/features/agents/operations/studioBootstrapWorkflow";
import type { AgentState, AgentStoreSeed, FocusFilter } from "@/features/agents/state/store";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { fetchJson } from "@/lib/http";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import type { StudioSettings, StudioSettingsPatch } from "@/lib/studio/settings";
@@ -31,14 +33,26 @@ export async function runStudioBootstrapLoadOperation(params: {
logError?: (message: string, error: unknown) => void;
}): Promise<StudioBootstrapLoadCommand[]> {
try {
const result = await hydrateAgentFleetFromGateway({
client: params.client,
gatewayUrl: params.gatewayUrl,
cachedConfigSnapshot: params.cachedConfigSnapshot,
loadStudioSettings: params.loadStudioSettings,
isDisconnectLikeError: params.isDisconnectLikeError,
logError: params.logError,
});
const result = isStudioDomainIntentModeEnabled()
? (
await fetchJson<{ result: Awaited<ReturnType<typeof hydrateAgentFleetFromGateway>> }>(
"/api/runtime/fleet",
{
method: "POST",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cachedConfigSnapshot: params.cachedConfigSnapshot }),
}
)
).result
: await hydrateAgentFleetFromGateway({
client: params.client,
gatewayUrl: params.gatewayUrl,
cachedConfigSnapshot: params.cachedConfigSnapshot,
loadStudioSettings: params.loadStudioSettings,
isDisconnectLikeError: params.isDisconnectLikeError,
logError: params.logError,
});
const selectionIntent = planBootstrapSelection({
hasCurrentSelection: params.hasCurrentSelection,
@@ -36,6 +36,8 @@ import {
updateGatewayAgentSkillsAllowlist,
} from "@/lib/gateway/agentConfig";
import { fetchJson } from "@/lib/http";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
import { canRemoveSkillSource, filterOsCompatibleSkills } from "@/lib/skills/presentation";
import { removeSkillFromGateway } from "@/lib/skills/remove";
import {
@@ -78,6 +80,7 @@ export type UseAgentSettingsMutationControllerParams = {
};
export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) {
const useDomainIntents = isStudioDomainIntentModeEnabled();
const skillsLoadRequestIdRef = useRef(0);
const [settingsSkillsReport, setSettingsSkillsReport] = useState<SkillStatusReport | null>(null);
const [settingsSkillsLoading, setSettingsSkillsLoading] = useState(false);
@@ -107,7 +110,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
const mutationContext: AgentSettingsMutationContext = useMemo(
() => ({
status: params.status,
status: useDomainIntents ? "connected" : params.status,
hasCreateBlock: params.hasCreateBlock,
hasRenameBlock: hasRenameMutationBlock,
hasDeleteBlock: hasDeleteMutationBlock,
@@ -123,6 +126,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
hasRenameMutationBlock,
params.hasCreateBlock,
params.status,
useDomainIntents,
]
);
@@ -455,12 +459,13 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
agentId: decision.normalizedAgentId,
fetchJson,
logError: (message, error) => console.error(message, error),
useDomainIntents,
});
params.clearInspectSidebar();
},
});
},
[mutationContext, params, runRestartingMutationLifecycle]
[mutationContext, params, runRestartingMutationLifecycle, useDomainIntents]
);
const handleCreateCronJob = useCallback(
@@ -587,16 +592,23 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
agentName: name,
label: `Rename ${agent.name}`,
executeMutation: async () => {
await renameGatewayAgent({
client: params.client,
agentId: decision.normalizedAgentId,
name,
});
if (useDomainIntents) {
await postStudioIntent("/api/intents/agent-rename", {
agentId: decision.normalizedAgentId,
name,
});
} else {
await renameGatewayAgent({
client: params.client,
agentId: decision.normalizedAgentId,
name,
});
}
params.dispatchUpdateAgent(decision.normalizedAgentId, { name });
},
});
},
[mutationContext, params, runRestartingMutationLifecycle]
[mutationContext, params, runRestartingMutationLifecycle, useDomainIntents]
);
const handleUpdateAgentPermissions = useCallback(
@@ -625,6 +637,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
sessionKey: agent.sessionKey,
draft,
loadAgents: async () => {},
useDomainIntents,
});
await params.loadAgents();
await params.refreshGatewayConfigSnapshot();
@@ -11,6 +11,8 @@ import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendO
import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue";
import { buildNewSessionAgentPatch, type AgentState } from "@/features/agents/state/store";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
type ChatInteractionDispatchAction =
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
@@ -54,6 +56,7 @@ export type ChatInteractionController = {
export function useChatInteractionController(
params: UseChatInteractionControllerParams
): ChatInteractionController {
const useDomainIntents = isStudioDomainIntentModeEnabled();
const [stopBusyAgentId, setStopBusyAgentId] = useState<string | null>(null);
const stopBusyAgentIdRef = useRef<string | null>(stopBusyAgentId);
const pendingDraftValuesRef = useRef<Map<string, string>>(new Map());
@@ -308,9 +311,13 @@ export function useChatInteractionController(
setStopBusyAgentId(agentId);
stopBusyAgentIdRef.current = agentId;
try {
await params.client.call("chat.abort", {
sessionKey: stopIntent.sessionKey,
});
if (useDomainIntents) {
await postStudioIntent("/api/intents/chat-abort", { sessionKey: stopIntent.sessionKey });
} else {
await params.client.call("chat.abort", {
sessionKey: stopIntent.sessionKey,
});
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to stop run.";
params.setError(message);
@@ -328,7 +335,7 @@ export function useChatInteractionController(
});
}
},
[params]
[params, useDomainIntents]
);
const handleNewSession = useCallback(
@@ -348,7 +355,11 @@ export function useChatInteractionController(
if (newSessionIntent.kind === "deny") {
throw new Error(newSessionIntent.message);
}
await params.client.call("sessions.reset", { key: newSessionIntent.sessionKey });
if (useDomainIntents) {
await postStudioIntent("/api/intents/sessions-reset", { key: newSessionIntent.sessionKey });
} else {
await params.client.call("sessions.reset", { key: newSessionIntent.sessionKey });
}
const patch = buildNewSessionAgentPatch(agent);
params.clearRunTracking(agent.runId);
params.clearHistoryInFlight(newSessionIntent.sessionKey);
@@ -371,7 +382,7 @@ export function useChatInteractionController(
});
}
},
[params]
[params, useDomainIntents]
);
return {
@@ -27,6 +27,8 @@ import {
import type { AgentState } from "@/features/agents/state/store";
import { TRANSCRIPT_V2_ENABLED, logTranscriptDebugMetric } from "@/features/agents/state/transcript";
import { randomUUID } from "@/lib/uuid";
import { fetchJson } from "@/lib/http";
import { isStudioDomainIntentModeEnabled } from "@/lib/controlplane/domain-mode";
type RuntimeSyncDispatchAction = {
type: "updateAgent";
@@ -36,7 +38,7 @@ type RuntimeSyncDispatchAction = {
type GatewayClientLike = {
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
onGap: (handler: (info: { expected: number; received: number }) => void) => () => void;
onGap?: (handler: (info: { expected: number; received: number }) => void) => () => void;
};
export type UseRuntimeSyncControllerParams = {
@@ -63,6 +65,7 @@ export type RuntimeSyncController = {
export function useRuntimeSyncController(
params: UseRuntimeSyncControllerParams
): RuntimeSyncController {
const useDomainApiReads = isStudioDomainIntentModeEnabled();
const agentsRef = useRef(params.agents);
const historyInFlightRef = useRef<Set<string>>(new Set());
const reconcileRunInFlightRef = useRef<Set<string>>(new Set());
@@ -81,6 +84,18 @@ export function useRuntimeSyncController(
}, []);
const loadSummarySnapshot = useCallback(async () => {
if (useDomainApiReads) {
try {
await fetchJson<{ summary?: unknown; freshness?: unknown }>("/api/runtime/summary", {
cache: "no-store",
});
} catch (error) {
if (!params.isDisconnectLikeError(error)) {
console.error("Failed to load domain runtime summary.", error);
}
}
return;
}
const snapshotAgents = agentsRef.current;
const summaryIntent = resolveSummarySnapshotIntent({
agents: snapshotAgents,
@@ -113,10 +128,48 @@ export function useRuntimeSyncController(
console.error("Failed to load summary snapshot.", error);
}
}
}, [params.client, params.dispatch, params.isDisconnectLikeError]);
}, [params.client, params.dispatch, params.isDisconnectLikeError, useDomainApiReads]);
const loadAgentHistoryViaDomainApi = useCallback(
async (agentId: string, limit: number) => {
const encodedAgentId = encodeURIComponent(agentId.trim());
if (!encodedAgentId) return;
const result = await fetchJson<{ entries?: unknown[] }>(
`/api/runtime/agents/${encodedAgentId}/history?limit=${limit}`,
{ cache: "no-store" }
);
const entries = Array.isArray(result.entries) ? result.entries : [];
params.dispatch({
type: "updateAgent",
agentId,
patch: {
historyLoadedAt: Date.now(),
historyFetchLimit: limit,
historyFetchedCount: entries.length,
historyMaybeTruncated: false,
},
});
},
[params.dispatch]
);
const loadAgentHistory = useCallback(
async (agentId: string, options?: { limit?: number }) => {
if (useDomainApiReads) {
const agent = agentsRef.current.find((entry) => entry.agentId === agentId) ?? null;
const limit =
typeof options?.limit === "number" && Number.isFinite(options.limit)
? Math.max(1, Math.floor(options.limit))
: agent?.historyFetchLimit ?? defaultHistoryLimit;
try {
await loadAgentHistoryViaDomainApi(agentId, limit);
} catch (error) {
if (!params.isDisconnectLikeError(error)) {
console.error("Failed to load domain runtime history.", error);
}
}
return;
}
const commands = await runHistorySyncOperation({
client: params.client,
agentId,
@@ -140,10 +193,12 @@ export function useRuntimeSyncController(
},
[
defaultHistoryLimit,
loadAgentHistoryViaDomainApi,
maxHistoryLimit,
params.client,
params.dispatch,
params.isDisconnectLikeError,
useDomainApiReads,
]
);
@@ -162,6 +217,7 @@ export function useRuntimeSyncController(
const reconcileRunningAgents = useCallback(async () => {
if (params.status !== "connected") return;
if (useDomainApiReads) return;
const commands = await runAgentReconcileOperation({
client: params.client,
agents: agentsRef.current,
@@ -198,6 +254,7 @@ export function useRuntimeSyncController(
params.dispatch,
params.isDisconnectLikeError,
params.status,
useDomainApiReads,
]);
useEffect(() => {
@@ -251,6 +308,8 @@ export function useRuntimeSyncController(
}, [loadAgentHistory, params.focusedAgentId, params.focusedAgentRunning, params.status]);
useEffect(() => {
if (useDomainApiReads) return;
if (!params.client.onGap) return;
return params.client.onGap((info) => {
const recoveryIntent = resolveRuntimeSyncGapRecoveryIntent();
console.warn(`Gateway event gap expected ${info.expected}, received ${info.received}.`);
@@ -261,7 +320,7 @@ export function useRuntimeSyncController(
void reconcileRunningAgents();
}
});
}, [loadSummarySnapshot, params.client, reconcileRunningAgents]);
}, [loadSummarySnapshot, params.client, reconcileRunningAgents, useDomainApiReads]);
return {
loadSummarySnapshot,
+3 -1
View File
@@ -18,7 +18,9 @@ export const mergePendingLivePatch = (
}
if (incomingRunId && !existingRunId) {
const { streamText: _dropStreamText, thinkingTrace: _dropThinkingTrace, ...rest } = existing;
const rest = { ...existing };
delete rest.streamText;
delete rest.thinkingTrace;
return { ...rest, ...incoming };
}
-57
View File
@@ -10,66 +10,9 @@ export const AGENT_FILE_NAMES = [
export type AgentFileName = (typeof AGENT_FILE_NAMES)[number];
export const PERSONALITY_FILE_NAMES = [
"SOUL.md",
"AGENTS.md",
"USER.md",
"IDENTITY.md",
] as const satisfies readonly AgentFileName[];
export type PersonalityFileName = (typeof PERSONALITY_FILE_NAMES)[number];
export const PERSONALITY_FILE_LABELS: Record<PersonalityFileName, string> = {
"SOUL.md": "Persona",
"AGENTS.md": "Directives",
"USER.md": "Context",
"IDENTITY.md": "Identity",
};
export const isAgentFileName = (value: string): value is AgentFileName =>
AGENT_FILE_NAMES.includes(value as AgentFileName);
export const AGENT_FILE_META: Record<AgentFileName, { title: string; hint: string }> = {
"AGENTS.md": {
title: "AGENTS.md",
hint: "Operating instructions, priorities, and rules.",
},
"SOUL.md": {
title: "SOUL.md",
hint: "Persona, tone, and boundaries.",
},
"IDENTITY.md": {
title: "IDENTITY.md",
hint: "Name, vibe, and emoji.",
},
"USER.md": {
title: "USER.md",
hint: "User profile and preferences.",
},
"TOOLS.md": {
title: "TOOLS.md",
hint: "Local tool notes and conventions.",
},
"HEARTBEAT.md": {
title: "HEARTBEAT.md",
hint: "Small checklist for heartbeat runs.",
},
"MEMORY.md": {
title: "MEMORY.md",
hint: "Durable memory for this agent.",
},
};
export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = {
"AGENTS.md": "How should this agent work? Priorities, rules, and habits.",
"SOUL.md": "Tone, personality, boundaries, and how it should sound.",
"IDENTITY.md": "Name, vibe, emoji, and a one-line identity.",
"USER.md": "How should it address you? Preferences and context.",
"TOOLS.md": "Local tool notes, conventions, and shortcuts.",
"HEARTBEAT.md": "A tiny checklist for periodic runs.",
"MEMORY.md": "Durable facts, decisions, and preferences to remember.",
};
export const createAgentFilesState = () =>
Object.fromEntries(
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }])
File diff suppressed because one or more lines are too long
+2 -52
View File
@@ -2,10 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"] as const;
const NEW_STATE_DIRNAME = ".openclaw";
const CONFIG_FILENAME = "openclaw.json";
const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"] as const;
const resolveDefaultHomeDir = (homedir: () => string = os.homedir): string => {
const home = homedir();
@@ -38,55 +35,8 @@ export const resolveStateDir = (
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir
): string => {
const override =
env.OPENCLAW_STATE_DIR?.trim() ||
env.MOLTBOT_STATE_DIR?.trim() ||
env.CLAWDBOT_STATE_DIR?.trim();
const override = env.OPENCLAW_STATE_DIR?.trim();
if (override) return resolveUserPath(override, homedir);
const defaultHome = resolveDefaultHomeDir(homedir);
const newDir = path.join(defaultHome, NEW_STATE_DIRNAME);
const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir));
const hasNew = fs.existsSync(newDir);
if (hasNew) return newDir;
const existingLegacy = legacyDirs.find((dir) => {
try {
return fs.existsSync(dir);
} catch {
return false;
}
});
return existingLegacy ?? newDir;
};
export const resolveConfigPathCandidates = (
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir
): string[] => {
const explicit =
env.OPENCLAW_CONFIG_PATH?.trim() ||
env.MOLTBOT_CONFIG_PATH?.trim() ||
env.CLAWDBOT_CONFIG_PATH?.trim();
if (explicit) return [resolveUserPath(explicit, homedir)];
const defaultHome = resolveDefaultHomeDir(homedir);
const candidates: string[] = [];
const stateDir =
env.OPENCLAW_STATE_DIR?.trim() ||
env.MOLTBOT_STATE_DIR?.trim() ||
env.CLAWDBOT_STATE_DIR?.trim();
if (stateDir) {
const resolved = resolveUserPath(stateDir, homedir);
candidates.push(path.join(resolved, CONFIG_FILENAME));
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name)));
}
const defaultDirs = [
path.join(defaultHome, NEW_STATE_DIRNAME),
...LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir)),
];
for (const dir of defaultDirs) {
candidates.push(path.join(dir, CONFIG_FILENAME));
candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name)));
}
return candidates;
return path.join(defaultHome, NEW_STATE_DIRNAME);
};
+54
View File
@@ -0,0 +1,54 @@
export type ControlPlaneConnectionStatus =
| "stopped"
| "connecting"
| "connected"
| "reconnecting"
| "error";
export type ControlPlaneDomainEvent =
| {
type: "runtime.status";
status: ControlPlaneConnectionStatus;
asOf: string;
reason: string | null;
}
| {
type: "gateway.event";
event: string;
seq: number | null;
payload: unknown;
asOf: string;
};
export type ControlPlaneOutboxEntry = {
id: number;
event: ControlPlaneDomainEvent;
createdAt: string;
};
export type GatewayEventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
};
export type GatewayResponseFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: { code: string; message: string; details?: unknown };
};
export type ControlPlaneGatewaySettings = {
url: string;
token: string;
};
export type ControlPlaneRuntimeSnapshot = {
status: ControlPlaneConnectionStatus;
reason: string | null;
asOf: string | null;
outboxHead: number;
};
+100
View File
@@ -0,0 +1,100 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { ControlPlaneRuntimeSnapshot } from "@/lib/controlplane/contracts";
const execFileAsync = promisify(execFile);
const CLI_TIMEOUT_MS = 1_500;
type RuntimeProbeCommandResult =
| { ok: true; value: unknown }
| { ok: false; error: string };
export type RuntimeProbeSnapshot = {
at: string;
status: RuntimeProbeCommandResult;
sessions: RuntimeProbeCommandResult;
};
export type RuntimeFreshness = {
source: "gateway" | "projection" | "probe";
stale: boolean;
asOf: string | null;
reason: "gateway_unavailable" | "probe_only" | "startup" | "controlplane_not_connected" | null;
};
const parseJson = (raw: string): unknown => {
return JSON.parse(raw);
};
const resolveProbeError = (error: unknown): string => {
if (!(error instanceof Error)) return "probe_failed";
if (error.message.includes("ENOENT")) return "openclaw_cli_not_found";
if (error.message.includes("timed out")) return "probe_timeout";
const normalized = error.message.trim();
return normalized || "probe_failed";
};
const runOpenClawJsonCommand = async (args: string[]): Promise<RuntimeProbeCommandResult> => {
try {
const { stdout } = await execFileAsync("openclaw", args, {
timeout: CLI_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
windowsHide: true,
});
return { ok: true, value: parseJson(stdout) };
} catch (error) {
return { ok: false, error: resolveProbeError(error) };
}
};
export const probeOpenClawLocalState = async (): Promise<RuntimeProbeSnapshot> => {
const [status, sessions] = await Promise.all([
runOpenClawJsonCommand(["status", "--json"]),
runOpenClawJsonCommand(["sessions", "--json"]),
]);
return {
at: new Date().toISOString(),
status,
sessions,
};
};
const resolveFallbackReason = (
snapshot: Pick<ControlPlaneRuntimeSnapshot, "status" | "reason" | "asOf">
): RuntimeFreshness["reason"] => {
if (snapshot.reason && snapshot.reason.trim()) return "gateway_unavailable";
if (snapshot.status === "stopped") return "startup";
return "controlplane_not_connected";
};
export const deriveRuntimeFreshness = (
snapshot: Pick<ControlPlaneRuntimeSnapshot, "status" | "reason" | "asOf">,
probe: RuntimeProbeSnapshot | null = null
): RuntimeFreshness => {
if (snapshot.status === "connected") {
return {
source: "gateway",
stale: false,
asOf: snapshot.asOf,
reason: null,
};
}
const probeHealthy = Boolean(probe?.status.ok || probe?.sessions.ok);
if (probeHealthy) {
return {
source: "probe",
stale: true,
asOf: probe?.at ?? snapshot.asOf,
reason: "probe_only",
};
}
return {
source: "projection",
stale: true,
asOf: snapshot.asOf,
reason: resolveFallbackReason(snapshot),
};
};
+7
View File
@@ -0,0 +1,7 @@
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
export const isStudioDomainIntentModeEnabled = (): boolean => {
const raw = process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
if (!raw) return true;
return !FALSE_VALUES.has(raw);
};
+137
View File
@@ -0,0 +1,137 @@
import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime";
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
export type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full";
export type GatewayExecApprovalAsk = "off" | "on-miss" | "always";
export type ExecutionRoleId = "conservative" | "collaborative" | "autonomous";
type ExecAllowlistEntry = {
pattern: string;
};
type ExecApprovalsAgent = {
security?: GatewayExecApprovalSecurity;
ask?: GatewayExecApprovalAsk;
askFallback?: string;
autoAllowSkills?: boolean;
allowlist?: ExecAllowlistEntry[];
};
type ExecApprovalsFile = {
version: 1;
socket?: {
path?: string;
token?: string;
};
defaults?: {
security?: string;
ask?: string;
askFallback?: string;
autoAllowSkills?: boolean;
};
agents?: Record<string, ExecApprovalsAgent>;
};
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file?: ExecApprovalsFile;
};
const normalizeAllowlist = (patterns: Array<{ pattern: string }>): Array<{ pattern: string }> => {
const next = patterns
.map((entry) => entry.pattern.trim())
.filter((pattern) => pattern.length > 0);
return Array.from(new Set(next)).map((pattern) => ({ pattern }));
};
const resolvePolicyForRole = (params: {
role: ExecutionRoleId;
allowlist: Array<{ pattern: string }>;
}):
| {
security: "full" | "allowlist";
ask: "off" | "always";
allowlist: Array<{ pattern: string }>;
}
| null => {
if (params.role === "conservative") return null;
if (params.role === "autonomous") {
return { security: "full", ask: "off", allowlist: params.allowlist };
}
return { security: "allowlist", ask: "always", allowlist: params.allowlist };
};
const isRetryableSetError = (err: unknown): boolean => {
if (!(err instanceof ControlPlaneGatewayError)) return false;
const message = err.message.toLowerCase();
return (
err.code.trim().toUpperCase() === "INVALID_REQUEST" &&
(message.includes("re-run exec.approvals.get") || message.includes("changed since last load"))
);
};
export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: {
runtime: ControlPlaneRuntime;
agentId: string;
role: ExecutionRoleId;
}): Promise<void> => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const snapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
const baseFile: ExecApprovalsFile =
snapshot.file && typeof snapshot.file === "object"
? {
version: 1,
socket: snapshot.file.socket,
defaults: snapshot.file.defaults,
agents: { ...(snapshot.file.agents ?? {}) },
}
: { version: 1, agents: {} };
const existingAllowlist = Array.isArray(baseFile.agents?.[agentId]?.allowlist)
? baseFile.agents?.[agentId]?.allowlist?.filter(
(entry): entry is ExecAllowlistEntry =>
Boolean(entry && typeof entry.pattern === "string" && entry.pattern.trim().length > 0)
) ?? []
: [];
const policy = resolvePolicyForRole({
role: params.role,
allowlist: existingAllowlist.map((entry) => ({ pattern: entry.pattern })),
});
const nextAgents = { ...(baseFile.agents ?? {}) };
if (!policy) {
delete nextAgents[agentId];
} else {
const existing = nextAgents[agentId] ?? {};
nextAgents[agentId] = {
...existing,
security: policy.security,
ask: policy.ask,
allowlist: normalizeAllowlist(policy.allowlist),
};
}
const nextFile: ExecApprovalsFile = {
...baseFile,
version: 1,
agents: nextAgents,
};
const setPayload = { file: nextFile, ...(snapshot.exists ? { baseHash: snapshot.hash } : {}) };
try {
await params.runtime.callGateway("exec.approvals.set", setPayload);
} catch (err) {
if (!isRetryableSetError(err)) throw err;
const retrySnapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
await params.runtime.callGateway("exec.approvals.set", {
file: nextFile,
...(retrySnapshot.exists ? { baseHash: retrySnapshot.hash } : {}),
});
}
};
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const isConfigConflict = (error: ControlPlaneGatewayError): boolean => {
const code = error.code.trim().toUpperCase();
const message = error.message.toLowerCase();
return (
code === "INVALID_REQUEST" &&
(message.includes("basehash") ||
message.includes("base hash") ||
message.includes("changed since last load") ||
message.includes("re-run config.get"))
);
};
export const ensureDomainIntentRuntime = async (): Promise<
ReturnType<typeof getControlPlaneRuntime> | Response
> => {
if (!isStudioDomainApiModeEnabled()) {
return NextResponse.json({ error: "domain_api_mode_disabled" }, { status: 404 });
}
const runtime = getControlPlaneRuntime();
try {
await runtime.ensureStarted();
} catch (err) {
const message = err instanceof Error ? err.message : "controlplane_start_failed";
return NextResponse.json(
{ error: message, code: "GATEWAY_UNAVAILABLE", reason: "gateway_unavailable" },
{ status: 503 }
);
}
return runtime;
};
export const parseIntentBody = async (request: Request): Promise<Record<string, unknown> | Response> => {
try {
const body = (await request.json()) as unknown;
if (!isRecord(body)) {
return NextResponse.json({ error: "Invalid intent payload." }, { status: 400 });
}
return body;
} catch {
return NextResponse.json({ error: "Invalid JSON payload." }, { status: 400 });
}
};
export const executeGatewayIntent = async <T>(
method: string,
params: unknown
): Promise<NextResponse> => {
const runtimeOrError = await ensureDomainIntentRuntime();
if (runtimeOrError instanceof Response) {
return runtimeOrError as NextResponse;
}
try {
const payload = await runtimeOrError.callGateway<T>(method, params);
return NextResponse.json({ ok: true, payload });
} catch (err) {
if (err instanceof ControlPlaneGatewayError) {
if (err.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") {
return NextResponse.json(
{
error: err.message,
code: "GATEWAY_UNAVAILABLE",
reason: "gateway_unavailable",
},
{ status: 503 }
);
}
if (isConfigConflict(err)) {
return NextResponse.json(
{
error: err.message,
code: err.code,
conflict: "base_hash_mismatch",
},
{ status: 409 }
);
}
return NextResponse.json(
{
error: err.message,
code: err.code,
details: err.details,
},
{ status: 400 }
);
}
const message = err instanceof Error ? err.message : "intent_failed";
return NextResponse.json({ error: message }, { status: 500 });
}
};
+9
View File
@@ -0,0 +1,9 @@
import { fetchJson } from "@/lib/http";
export const postStudioIntent = async <T>(path: string, body: Record<string, unknown>): Promise<T> => {
return await fetchJson<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
};
+378
View File
@@ -0,0 +1,378 @@
import { WebSocket } from "ws";
import type {
ControlPlaneConnectionStatus,
ControlPlaneDomainEvent,
ControlPlaneGatewaySettings,
GatewayEventFrame,
GatewayResponseFrame,
} from "@/lib/controlplane/contracts";
import { loadStudioSettings } from "@/lib/studio/settings-store";
const CONNECT_TIMEOUT_MS = 8_000;
const REQUEST_TIMEOUT_MS = 15_000;
const INITIAL_RECONNECT_DELAY_MS = 1_000;
const MAX_RECONNECT_DELAY_MS = 15_000;
const CONNECT_PROTOCOL = 3;
const DEFAULT_METHOD_ALLOWLIST = new Set<string>([
"status",
"chat.send",
"chat.abort",
"agents.update",
"agents.delete",
"agents.list",
"agents.create",
"sessions.list",
"sessions.preview",
"sessions.patch",
"sessions.reset",
"config.get",
"config.patch",
"config.set",
"exec.approval.resolve",
"exec.approvals.get",
"exec.approvals.set",
"agent.wait",
]);
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export class ControlPlaneGatewayError extends Error {
readonly code: string;
readonly details?: unknown;
constructor(params: { code: string; message: string; details?: unknown }) {
super(params.message);
this.name = "ControlPlaneGatewayError";
this.code = params.code;
this.details = params.details;
}
}
const isObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const resolveOriginForUpstream = (upstreamUrl: string): string => {
const url = new URL(upstreamUrl);
const proto = url.protocol === "wss:" ? "https:" : "http:";
const hostname =
url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0"
? "localhost"
: url.hostname;
const host = url.port ? `${hostname}:${url.port}` : hostname;
return `${proto}//${host}`;
};
const loadGatewaySettings = (): ControlPlaneGatewaySettings => {
const settings = loadStudioSettings();
const gateway = settings.gateway;
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
if (!url) {
throw new Error("Control-plane start failed: Studio gateway URL is not configured.");
}
if (!token) {
throw new Error("Control-plane start failed: Studio gateway token is not configured.");
}
return { url, token };
};
export type OpenClawAdapterOptions = {
loadSettings?: () => ControlPlaneGatewaySettings;
createWebSocket?: (url: string, opts: { origin: string }) => WebSocket;
methodAllowlist?: Set<string>;
onDomainEvent?: (event: ControlPlaneDomainEvent) => void;
};
export class OpenClawGatewayAdapter {
private ws: WebSocket | null = null;
private status: ControlPlaneConnectionStatus = "stopped";
private statusReason: string | null = null;
private connectRequestId: string | null = null;
private connectTimer: ReturnType<typeof setTimeout> | null = null;
private startPromise: Promise<void> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempt = 0;
private stopping = false;
private nextRequestNumber = 1;
private pending = new Map<string, PendingRequest>();
private loadSettings: () => ControlPlaneGatewaySettings;
private createWebSocket: (url: string, opts: { origin: string }) => WebSocket;
private methodAllowlist: Set<string>;
private onDomainEvent?: (event: ControlPlaneDomainEvent) => void;
constructor(options?: OpenClawAdapterOptions) {
this.loadSettings = options?.loadSettings ?? loadGatewaySettings;
this.createWebSocket = options?.createWebSocket ?? ((url, opts) => new WebSocket(url, opts));
this.methodAllowlist = options?.methodAllowlist ?? DEFAULT_METHOD_ALLOWLIST;
this.onDomainEvent = options?.onDomainEvent;
}
getStatus(): ControlPlaneConnectionStatus {
return this.status;
}
getStatusReason(): string | null {
return this.statusReason;
}
async start(): Promise<void> {
if (this.status === "connected") return;
if (this.startPromise) return this.startPromise;
this.stopping = false;
this.startPromise = this.connect().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
async stop(): Promise<void> {
this.stopping = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
this.rejectPending("Control-plane adapter stopped.");
const ws = this.ws;
this.ws = null;
this.connectRequestId = null;
if (ws && ws.readyState === WebSocket.OPEN) {
await new Promise<void>((resolve) => {
ws.once("close", () => resolve());
ws.close(1000, "controlplane stopping");
});
} else {
ws?.terminate();
}
this.updateStatus("stopped", null);
}
async request<T = unknown>(method: string, params: unknown): Promise<T> {
const normalizedMethod = method.trim();
if (!normalizedMethod) {
throw new Error("Gateway method is required.");
}
if (!this.methodAllowlist.has(normalizedMethod)) {
throw new Error(`Gateway method is not allowlisted: ${normalizedMethod}`);
}
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN || this.status !== "connected") {
throw new ControlPlaneGatewayError({
code: "GATEWAY_UNAVAILABLE",
message: "Gateway is unavailable.",
});
}
const id = String(this.nextRequestNumber++);
const frame = { type: "req", id, method: normalizedMethod, params };
const response = await new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Gateway request timed out for method: ${normalizedMethod}`));
}, REQUEST_TIMEOUT_MS);
this.pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify(frame), (err) => {
if (!err) return;
clearTimeout(timer);
this.pending.delete(id);
reject(new Error(`Failed to send gateway request for method: ${normalizedMethod}`));
});
});
return response as T;
}
private async connect(): Promise<void> {
const settings = this.loadSettings();
const ws = this.createWebSocket(settings.url, { origin: resolveOriginForUpstream(settings.url) });
this.ws = ws;
this.connectRequestId = null;
this.updateStatus(this.reconnectAttempt > 0 ? "reconnecting" : "connecting", null);
await new Promise<void>((resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
fn();
};
this.connectTimer = setTimeout(() => {
settle(() => {
ws.close(1011, "connect timeout");
reject(new Error("Control-plane connect timed out waiting for connect response."));
});
}, CONNECT_TIMEOUT_MS);
ws.on("message", (raw) => {
const parsed = this.parseFrame(String(raw ?? ""));
if (!parsed) return;
if (parsed.type === "event") {
if (parsed.event === "connect.challenge") {
this.sendConnectRequest(settings.token);
return;
}
this.emitEvent({
type: "gateway.event",
event: parsed.event,
seq: typeof parsed.seq === "number" ? parsed.seq : null,
payload: parsed.payload,
asOf: new Date().toISOString(),
});
return;
}
if (!this.handleResponseFrame(parsed)) return;
if (parsed.id === this.connectRequestId) {
if (parsed.ok) {
this.reconnectAttempt = 0;
this.updateStatus("connected", null);
settle(() => resolve());
return;
}
const code = parsed.error?.code ?? "CONNECT_FAILED";
const message = parsed.error?.message ?? "Connect failed.";
settle(() => {
ws.close(1011, "connect failed");
reject(new Error(`Control-plane connect rejected: ${code} ${message}`));
});
}
});
ws.on("close", () => {
if (this.stopping) return;
if (!settled) {
settle(() => reject(new Error("Control-plane gateway connection closed during connect.")));
return;
}
this.updateStatus("reconnecting", "gateway_closed");
this.scheduleReconnect();
});
ws.on("error", () => {
if (this.stopping) return;
if (!settled) {
settle(() => reject(new Error("Control-plane gateway connection failed.")));
}
});
}).catch((err) => {
this.updateStatus("error", err instanceof Error ? err.message : "connect_error");
this.scheduleReconnect();
throw err;
});
}
private scheduleReconnect(): void {
if (this.stopping) return;
if (this.reconnectTimer) return;
const delay = Math.min(
INITIAL_RECONNECT_DELAY_MS * Math.pow(1.7, this.reconnectAttempt),
MAX_RECONNECT_DELAY_MS
);
this.reconnectAttempt += 1;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.start().catch(() => {});
}, delay);
}
private sendConnectRequest(token: string): void {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN || this.connectRequestId) return;
const id = String(this.nextRequestNumber++);
this.connectRequestId = id;
ws.send(
JSON.stringify({
type: "req",
id,
method: "connect",
params: {
minProtocol: CONNECT_PROTOCOL,
maxProtocol: CONNECT_PROTOCOL,
client: {
id: "openclaw-studio-controlplane",
version: "dev",
platform: "node",
mode: "operator",
},
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
caps: [],
auth: { token },
},
})
);
}
private parseFrame(raw: string): GatewayEventFrame | GatewayResponseFrame | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isObject(parsed) || typeof parsed.type !== "string") return null;
if (parsed.type === "event" && typeof parsed.event === "string") {
return parsed as GatewayEventFrame;
}
if (parsed.type === "res" && typeof parsed.id === "string") {
return parsed as GatewayResponseFrame;
}
return null;
}
private handleResponseFrame(frame: GatewayResponseFrame): boolean {
const pending = this.pending.get(frame.id);
if (!pending) return true;
clearTimeout(pending.timer);
this.pending.delete(frame.id);
if (frame.ok) {
pending.resolve(frame.payload);
return true;
}
pending.reject(
new ControlPlaneGatewayError({
code: frame.error?.code ?? "GATEWAY_REQUEST_FAILED",
message: frame.error?.message ?? "Gateway request failed.",
details: frame.error?.details,
})
);
return true;
}
private rejectPending(message: string): void {
for (const [, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(new Error(message));
}
this.pending.clear();
}
private updateStatus(status: ControlPlaneConnectionStatus, reason: string | null): void {
this.status = status;
this.statusReason = reason;
this.emitEvent({
type: "runtime.status",
status,
reason,
asOf: new Date().toISOString(),
});
}
private emitEvent(event: ControlPlaneDomainEvent): void {
this.onDomainEvent?.(event);
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { ControlPlaneDomainEvent } from "@/lib/controlplane/contracts";
const safeString = (value: unknown): string => (typeof value === "string" ? value : "");
export const deriveControlPlaneEventKey = (event: ControlPlaneDomainEvent): string => {
if (event.type === "runtime.status") {
return ["runtime.status", event.status, safeString(event.reason), event.asOf].join(":");
}
if (typeof event.seq === "number" && Number.isFinite(event.seq)) {
return ["gateway.event", event.event, "seq", String(event.seq)].join(":");
}
return ["gateway.event", event.event, "", event.asOf].join(":");
};
+217
View File
@@ -0,0 +1,217 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import type {
ControlPlaneDomainEvent,
ControlPlaneOutboxEntry,
ControlPlaneRuntimeSnapshot,
} from "@/lib/controlplane/contracts";
import { deriveControlPlaneEventKey } from "@/lib/controlplane/outbox";
import { resolveStateDir } from "@/lib/clawdbot/paths";
const RUNTIME_DB_DIRNAME = "openclaw-studio";
const RUNTIME_DB_FILENAME = "runtime.db";
const DEFAULT_STATUS = "stopped" as const;
type OutboxRow = {
id: number;
event_json: string;
created_at: string;
};
type ProjectionRow = {
status: string;
reason: string | null;
as_of: string | null;
};
const parseDomainEvent = (raw: string): ControlPlaneDomainEvent => {
return JSON.parse(raw) as ControlPlaneDomainEvent;
};
const toOutboxEntry = (row: OutboxRow): ControlPlaneOutboxEntry => {
return {
id: row.id,
event: parseDomainEvent(row.event_json),
createdAt: row.created_at,
};
};
const resolveControlPlaneRuntimeDbPath = (): string =>
path.join(resolveStateDir(), RUNTIME_DB_DIRNAME, RUNTIME_DB_FILENAME);
export class SQLiteControlPlaneProjectionStore {
private readonly db: Database.Database;
private readonly readProjectionStmt: Database.Statement<[], ProjectionRow | undefined>;
private readonly readOutboxHeadStmt: Database.Statement<[], { head: number }>;
private readonly readOutboxAfterStmt: Database.Statement<[number, number], OutboxRow>;
private readonly readOutboxByIdStmt: Database.Statement<[number], OutboxRow | undefined>;
private readonly readProcessedStmt: Database.Statement<[string], { outbox_id: number | null } | undefined>;
private readonly insertProcessedStmt: Database.Statement<[string, string]>;
private readonly insertOutboxStmt: Database.Statement<[string, string, string]>;
private readonly updateProcessedOutboxStmt: Database.Statement<[number, string]>;
private readonly upsertStatusProjectionStmt: Database.Statement<
[string, string | null, string, string]
>;
private readonly upsertGatewayProjectionStmt: Database.Statement<[string, string]>;
private readonly applyEventTx: (
event: ControlPlaneDomainEvent,
eventKey: string
) => ControlPlaneOutboxEntry;
constructor(dbPath: string = resolveControlPlaneRuntimeDbPath()) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = new Database(dbPath);
this.db.pragma("journal_mode = WAL");
this.db.pragma("foreign_keys = ON");
this.migrate();
this.readProjectionStmt = this.db.prepare(
"SELECT status, reason, as_of FROM runtime_projection WHERE id = 1"
);
this.readOutboxHeadStmt = this.db.prepare("SELECT COALESCE(MAX(id), 0) AS head FROM outbox");
this.readOutboxAfterStmt = this.db.prepare(
"SELECT id, event_json, created_at FROM outbox WHERE id > ? ORDER BY id ASC LIMIT ?"
);
this.readOutboxByIdStmt = this.db.prepare(
"SELECT id, event_json, created_at FROM outbox WHERE id = ?"
);
this.readProcessedStmt = this.db.prepare(
"SELECT outbox_id FROM processed_events WHERE event_key = ?"
);
this.insertProcessedStmt = this.db.prepare(
"INSERT OR IGNORE INTO processed_events (event_key, created_at) VALUES (?, ?)"
);
this.insertOutboxStmt = this.db.prepare(
"INSERT INTO outbox (event_type, event_json, created_at) VALUES (?, ?, ?)"
);
this.updateProcessedOutboxStmt = this.db.prepare(
"UPDATE processed_events SET outbox_id = ? WHERE event_key = ?"
);
this.upsertStatusProjectionStmt = this.db.prepare(`
INSERT INTO runtime_projection (id, status, reason, as_of, updated_at)
VALUES (1, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
reason = excluded.reason,
as_of = excluded.as_of,
updated_at = excluded.updated_at
`);
this.upsertGatewayProjectionStmt = this.db.prepare(`
INSERT INTO runtime_projection (id, status, reason, as_of, updated_at)
VALUES (1, '${DEFAULT_STATUS}', NULL, ?, ?)
ON CONFLICT(id) DO UPDATE SET
as_of = excluded.as_of,
updated_at = excluded.updated_at
`);
this.applyEventTx = this.db.transaction((event: ControlPlaneDomainEvent, eventKey: string) => {
const existing = this.readProcessedStmt.get(eventKey);
if (existing?.outbox_id) {
const row = this.readOutboxByIdStmt.get(existing.outbox_id);
if (!row) {
throw new Error(`Outbox row missing for processed event key: ${eventKey}`);
}
return toOutboxEntry(row);
}
const now = new Date().toISOString();
this.insertProcessedStmt.run(eventKey, now);
if (event.type === "runtime.status") {
this.upsertStatusProjectionStmt.run(event.status, event.reason ?? null, event.asOf, now);
} else {
this.upsertGatewayProjectionStmt.run(event.asOf, now);
}
const info = this.insertOutboxStmt.run(event.type, JSON.stringify(event), now);
const outboxId = Number(info.lastInsertRowid);
this.updateProcessedOutboxStmt.run(outboxId, eventKey);
const row = this.readOutboxByIdStmt.get(outboxId);
if (!row) {
throw new Error(`Failed to read inserted outbox row id=${outboxId}`);
}
return toOutboxEntry(row);
});
}
applyDomainEvent(
event: ControlPlaneDomainEvent,
eventKey: string = deriveControlPlaneEventKey(event)
): ControlPlaneOutboxEntry {
return this.applyEventTx(event, eventKey);
}
readOutboxAfter(lastSeenId: number, limit: number = 500): ControlPlaneOutboxEntry[] {
const safeLastSeen = Number.isFinite(lastSeenId) && lastSeenId >= 0 ? lastSeenId : 0;
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 500;
return this.readOutboxAfterStmt.all(safeLastSeen, safeLimit).map(toOutboxEntry);
}
outboxHead(): number {
const row = this.readOutboxHeadStmt.get();
return row?.head ?? 0;
}
snapshot(): ControlPlaneRuntimeSnapshot {
const projection = this.readProjectionStmt.get();
const outboxHead = this.outboxHead();
if (!projection) {
return {
status: DEFAULT_STATUS,
reason: null,
asOf: null,
outboxHead,
};
}
return {
status: projection.status as ControlPlaneRuntimeSnapshot["status"],
reason: projection.reason,
asOf: projection.as_of,
outboxHead,
};
}
close(): void {
this.db.close();
}
private migrate(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS runtime_projection (
id INTEGER PRIMARY KEY CHECK (id = 1),
status TEXT NOT NULL,
reason TEXT,
as_of TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS processed_events (
event_key TEXT PRIMARY KEY,
outbox_id INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY (outbox_id) REFERENCES outbox(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_outbox_id ON outbox(id);
`);
const version = Number(this.db.pragma("user_version", { simple: true }));
if (version < 1) {
this.db.pragma("user_version = 1");
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { ControlPlaneDomainEvent, ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts";
const AGENT_SESSION_KEY_RE = /^agent:([^:]+):/;
const isObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const parseAgentIdFromSessionKey = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const match = value.match(AGENT_SESSION_KEY_RE);
return match ? match[1] : null;
};
const resolveAgentIdForDomainEvent = (event: ControlPlaneDomainEvent): string | null => {
if (event.type !== "gateway.event") return null;
const payload = event.payload;
if (!isObject(payload)) return null;
const directAgentId = typeof payload.agentId === "string" ? payload.agentId.trim() : "";
if (directAgentId) return directAgentId;
const fromSession =
parseAgentIdFromSessionKey(payload.sessionKey) ??
parseAgentIdFromSessionKey(payload.key) ??
parseAgentIdFromSessionKey(payload.runSessionKey);
return fromSession;
};
export const selectAgentHistoryEntries = (
entries: ControlPlaneOutboxEntry[],
agentId: string,
limit: number
): ControlPlaneOutboxEntry[] => {
const normalizedAgent = agentId.trim();
if (!normalizedAgent) return [];
const filtered = entries.filter((entry) => resolveAgentIdForDomainEvent(entry.event) === normalizedAgent);
if (limit <= 0) return [];
if (filtered.length <= limit) return filtered;
return filtered.slice(filtered.length - limit);
};
+102
View File
@@ -0,0 +1,102 @@
import type {
ControlPlaneDomainEvent,
ControlPlaneOutboxEntry,
ControlPlaneRuntimeSnapshot,
} from "@/lib/controlplane/contracts";
import { OpenClawGatewayAdapter, type OpenClawAdapterOptions } from "@/lib/controlplane/openclaw-adapter";
import { SQLiteControlPlaneProjectionStore } from "@/lib/controlplane/projection-store";
const DOMAIN_MODE_FALSE_VALUES = new Set(["0", "false", "no", "off"]);
const readDomainModeRawValue = (env: NodeJS.ProcessEnv = process.env): string => {
const serverValue = env.STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
if (serverValue) return serverValue;
return env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
};
const readDomainApiMode = (env: NodeJS.ProcessEnv = process.env): boolean => {
const raw = readDomainModeRawValue(env);
if (!raw) return true;
return !DOMAIN_MODE_FALSE_VALUES.has(raw);
};
export type ControlPlaneRuntimeOptions = {
adapterOptions?: OpenClawAdapterOptions;
dbPath?: string;
};
export class ControlPlaneRuntime {
private readonly store: SQLiteControlPlaneProjectionStore;
private readonly adapter: OpenClawGatewayAdapter;
private readonly eventSubscribers = new Set<(entry: ControlPlaneOutboxEntry) => void>();
constructor(options?: ControlPlaneRuntimeOptions) {
this.store = new SQLiteControlPlaneProjectionStore(options?.dbPath);
this.adapter = new OpenClawGatewayAdapter({
...(options?.adapterOptions ?? {}),
onDomainEvent: (event) => this.handleDomainEvent(event),
});
}
isDomainApiModeEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return readDomainApiMode(env);
}
async ensureStarted(): Promise<void> {
await this.adapter.start();
}
async disconnect(): Promise<void> {
await this.adapter.stop();
}
snapshot(): ControlPlaneRuntimeSnapshot {
return this.store.snapshot();
}
eventsAfter(lastSeenId: number, limit?: number): ControlPlaneOutboxEntry[] {
return this.store.readOutboxAfter(lastSeenId, limit);
}
subscribe(handler: (entry: ControlPlaneOutboxEntry) => void): () => void {
this.eventSubscribers.add(handler);
return () => {
this.eventSubscribers.delete(handler);
};
}
async callGateway<T = unknown>(method: string, params: unknown): Promise<T> {
return await this.adapter.request<T>(method, params);
}
close(): void {
this.store.close();
}
private handleDomainEvent(event: ControlPlaneDomainEvent): void {
const entry = this.store.applyDomainEvent(event);
for (const subscriber of this.eventSubscribers) {
subscriber(entry);
}
}
}
type GlobalControlPlaneState = typeof globalThis & {
__openclawStudioControlPlaneRuntime?: ControlPlaneRuntime;
};
export const getControlPlaneRuntime = (options?: ControlPlaneRuntimeOptions): ControlPlaneRuntime => {
const globalState = globalThis as GlobalControlPlaneState;
if (!globalState.__openclawStudioControlPlaneRuntime) {
globalState.__openclawStudioControlPlaneRuntime = new ControlPlaneRuntime(options);
}
return globalState.__openclawStudioControlPlaneRuntime;
};
export const resetControlPlaneRuntimeForTests = (): void => {
const globalState = globalThis as GlobalControlPlaneState;
delete globalState.__openclawStudioControlPlaneRuntime;
};
export const isStudioDomainApiModeEnabled = (env: NodeJS.ProcessEnv = process.env): boolean =>
readDomainApiMode(env);
+30 -12
View File
@@ -100,7 +100,7 @@ const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings |
const raw = value as { url?: unknown; token?: unknown };
const url = typeof raw.url === "string" ? raw.url.trim() : "";
const token = typeof raw.token === "string" ? raw.token.trim() : "";
if (!url || !token) return null;
if (!url) return null;
return { url, token };
};
@@ -411,6 +411,7 @@ export type GatewayConnectionState = {
gatewayUrl: string;
token: string;
localGatewayDefaults: StudioGatewaySettings | null;
domainApiModeEnabled: boolean | null;
error: string | null;
connect: () => Promise<void>;
disconnect: () => void;
@@ -486,16 +487,18 @@ export const useGatewayConnection = (
): GatewayConnectionState => {
const [client] = useState(() => new GatewayClient());
const didAutoConnect = useRef(false);
const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null);
const loadedGatewaySettings = useRef<{ gatewayUrl: string } | null>(null);
const retryAttemptRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasManualDisconnectRef = useRef(false);
const tokenDirtyRef = useRef(false);
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
const [token, setToken] = useState("");
const [token, setTokenState] = useState("");
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
null
);
const [domainApiModeEnabled, setDomainApiModeEnabled] = useState<boolean | null>(null);
const [status, setStatus] = useState<GatewayStatus>("disconnected");
const [error, setError] = useState<string | null>(null);
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
@@ -513,14 +516,19 @@ export const useGatewayConnection = (
const gateway = settings?.gateway ?? null;
if (cancelled) return;
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
const envelopeDomainMode =
"domainApiModeEnabled" in envelope ? envelope.domainApiModeEnabled : undefined;
setDomainApiModeEnabled(
typeof envelopeDomainMode === "boolean" ? envelopeDomainMode : null
);
const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
const nextToken = typeof gateway?.token === "string" ? gateway.token : "";
loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl.trim(),
token: nextToken,
};
setGatewayUrl(nextGatewayUrl);
setToken(nextToken);
setTokenState(nextToken);
tokenDirtyRef.current = false;
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
@@ -531,7 +539,6 @@ export const useGatewayConnection = (
if (!loadedGatewaySettings.current) {
loadedGatewaySettings.current = {
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
token: "",
};
}
setSettingsLoaded(true);
@@ -636,18 +643,22 @@ export const useGatewayConnection = (
const baseline = loadedGatewaySettings.current;
if (!baseline) return;
const nextGatewayUrl = gatewayUrl.trim();
if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) {
const shouldPersistToken = tokenDirtyRef.current;
if (!shouldPersistToken && nextGatewayUrl === baseline.gatewayUrl) {
return;
}
const gatewayPatch: { url: string; token?: string } = { url: nextGatewayUrl };
if (shouldPersistToken) {
gatewayPatch.token = token;
tokenDirtyRef.current = false;
}
settingsCoordinator.schedulePatch(
{
gateway: {
url: nextGatewayUrl,
token,
},
gateway: gatewayPatch,
},
400
);
loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl };
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
const useLocalGatewayDefaults = useCallback(() => {
@@ -655,11 +666,17 @@ export const useGatewayConnection = (
return;
}
setGatewayUrl(localGatewayDefaults.url);
setToken(localGatewayDefaults.token);
setTokenState(localGatewayDefaults.token);
tokenDirtyRef.current = false;
setError(null);
setConnectErrorCode(null);
}, [localGatewayDefaults]);
const setToken = useCallback((value: string) => {
tokenDirtyRef.current = true;
setTokenState(value);
}, []);
const disconnect = useCallback(() => {
setError(null);
setConnectErrorCode(null);
@@ -678,6 +695,7 @@ export const useGatewayConnection = (
gatewayUrl,
token,
localGatewayDefaults,
domainApiModeEnabled,
error,
connect,
disconnect,
+1 -1
View File
@@ -24,7 +24,7 @@ const resolveReloadModeFromConfig = (config: unknown): string | null => {
return mode.length > 0 ? mode : "hybrid";
};
export const shouldAwaitDisconnectRestartForReloadMode = (mode: string | null): boolean =>
const shouldAwaitDisconnectRestartForReloadMode = (mode: string | null): boolean =>
mode !== "hot" && mode !== "off" && mode !== "hybrid";
export async function shouldAwaitDisconnectRestartForRemoteMutation(params: {
-109
View File
@@ -1,109 +0,0 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
import type { GatewayConfigSnapshot } from "@/lib/gateway/agentConfig";
import { fetchJson } from "@/lib/http";
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const shouldRetryConfigWrite = (err: unknown) => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run config\.get|config changed since last load/i.test(err.message);
};
const readDotEnvKeys = async (): Promise<string[]> => {
if (typeof window === "undefined") {
return [];
}
const url = new URL("/api/gateway/dotenv-keys", window.location.origin).toString();
const { keys } = await fetchJson<{ keys: string[] }>(url);
return Array.isArray(keys) ? keys : [];
};
const readDefaultSandboxEnvMap = (config: Record<string, unknown>): Record<string, string> => {
const agents = isRecord(config.agents) ? config.agents : null;
const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null;
const sandbox = defaults && isRecord(defaults.sandbox) ? defaults.sandbox : null;
const docker = sandbox && isRecord(sandbox.docker) ? sandbox.docker : null;
const env = docker && isRecord(docker.env) ? docker.env : null;
const result: Record<string, string> = {};
if (!env) return result;
for (const [key, value] of Object.entries(env)) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
};
const writeDefaultSandboxEnvMap = (
config: Record<string, unknown>,
env: Record<string, string>,
): Record<string, unknown> => {
const agents = isRecord(config.agents) ? { ...config.agents } : {};
const defaults = isRecord(agents.defaults) ? { ...(agents.defaults as Record<string, unknown>) } : {};
const sandbox = isRecord(defaults.sandbox) ? { ...(defaults.sandbox as Record<string, unknown>) } : {};
const docker = isRecord(sandbox.docker) ? { ...(sandbox.docker as Record<string, unknown>) } : {};
docker.env = env;
sandbox.docker = docker;
defaults.sandbox = sandbox;
(agents as Record<string, unknown>).defaults = defaults;
return { ...config, agents };
};
export const ensureGatewaySandboxEnvAllowlistFromDotEnv = async (params: {
client: GatewayClient;
}): Promise<void> => {
let keys: string[] = [];
try {
keys = await readDotEnvKeys();
} catch (err) {
const message = err instanceof Error ? err.message : "";
if (message.includes("status 404")) {
return;
}
console.warn("Failed to sync sandbox env allowlist from dotenv keys.", err);
return;
}
if (keys.length === 0) return;
const tryOnce = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const currentEnv = readDefaultSandboxEnvMap(baseConfig);
const nextEnv: Record<string, string> = { ...currentEnv };
let changed = false;
for (const key of keys) {
if (key in nextEnv) continue;
nextEnv[key] = `\${${key}}`;
changed = true;
}
if (!changed) return;
const nextConfig = writeDefaultSandboxEnvMap(baseConfig, nextEnv);
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
const baseHash = typeof snapshot.hash === "string" ? snapshot.hash.trim() : "";
if (snapshot.exists !== false) {
if (!baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
payload.baseHash = baseHash;
}
try {
await params.client.call("config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return tryOnce(attempt + 1);
}
throw err;
}
};
await tryOnce(0);
};
+2 -11
View File
@@ -1,4 +1,3 @@
import { loadStudioSettings } from "@/lib/studio/settings-store";
import * as childProcess from "node:child_process";
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
@@ -45,15 +44,7 @@ export const resolveGatewaySshTargetFromGatewayUrl = (
return `${user}@${hostname}`;
};
export const resolveGatewaySshTarget = (env: NodeJS.ProcessEnv = process.env): string => {
const configured = resolveConfiguredSshTarget(env);
if (configured) return configured;
const settings = loadStudioSettings();
return resolveGatewaySshTargetFromGatewayUrl(settings.gateway?.url?.trim() ?? "", env);
};
export const extractJsonErrorMessage = (value: string): string | null => {
const extractJsonErrorMessage = (value: string): string | null => {
const trimmed = value.trim();
if (!trimmed) return null;
try {
@@ -72,7 +63,7 @@ export const extractJsonErrorMessage = (value: string): string | null => {
}
};
export const parseJsonOutput = (raw: string, label: string): unknown => {
const parseJsonOutput = (raw: string, label: string): unknown => {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error(`Command produced empty JSON output (${label}).`);
+12 -4
View File
@@ -9,6 +9,7 @@ import type {
export type StudioSettingsResponse = {
settings: StudioSettings;
localGatewayDefaults?: StudioGatewaySettings | null;
domainApiModeEnabled?: boolean;
};
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
@@ -124,12 +125,19 @@ export class StudioSettingsCoordinator {
this.timer = null;
}
const patch = this.pendingPatch;
this.pendingPatch = null;
if (!patch) {
return this.queue;
}
const write = this.queue.then(async () => {
await this.transport.updateSettings(patch);
if (this.pendingPatch === patch) {
this.pendingPatch = null;
}
try {
await this.transport.updateSettings(patch);
} catch (err) {
this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch);
throw err;
}
});
this.queue = write.catch((err) => {
console.error("Failed to persist studio settings patch.", err);
@@ -147,11 +155,11 @@ export class StudioSettingsCoordinator {
}
}
export const fetchStudioSettings = async (): Promise<StudioSettingsResponse> => {
const fetchStudioSettings = async (): Promise<StudioSettingsResponse> => {
return fetchJson<StudioSettingsResponse>("/api/studio", { cache: "no-store" });
};
export const updateStudioSettings = async (
const updateStudioSettings = async (
patch: StudioSettingsPatch
): Promise<StudioSettingsResponse> => {
return fetchJson<StudioSettingsResponse>("/api/studio", {
+23 -2
View File
@@ -14,7 +14,7 @@ const SETTINGS_DIRNAME = "openclaw-studio";
const SETTINGS_FILENAME = "settings.json";
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
export const resolveStudioSettingsPath = () =>
const resolveStudioSettingsPath = () =>
path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME);
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -45,6 +45,27 @@ export const loadLocalGatewayDefaults = () => {
return readOpenclawGatewayDefaults();
};
export const redactStudioSettingsSecrets = (settings: StudioSettings): StudioSettings => {
if (!settings.gateway) return settings;
return {
...settings,
gateway: {
...settings.gateway,
token: "",
},
};
};
export const redactLocalGatewayDefaultsSecrets = (
defaults: { url: string; token: string } | null
): { url: string; token: string } | null => {
if (!defaults) return null;
return {
...defaults,
token: "",
};
};
export const loadStudioSettings = (): StudioSettings => {
const settingsPath = resolveStudioSettingsPath();
if (!fs.existsSync(settingsPath)) {
@@ -69,7 +90,7 @@ export const loadStudioSettings = (): StudioSettings => {
return settings;
};
export const saveStudioSettings = (next: StudioSettings) => {
const saveStudioSettings = (next: StudioSettings) => {
const settingsPath = resolveStudioSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
+24 -3
View File
@@ -3,6 +3,11 @@ export type StudioGatewaySettings = {
token: string;
};
export type StudioGatewaySettingsPatch = {
url?: string | null;
token?: string | null;
};
export type FocusFilter = "all" | "running" | "approvals";
export type StudioViewMode = "focused";
@@ -20,7 +25,7 @@ export type StudioSettings = {
};
export type StudioSettingsPatch = {
gateway?: StudioGatewaySettings | null;
gateway?: StudioGatewaySettingsPatch | null;
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
avatars?: Record<string, Record<string, string | null> | null>;
};
@@ -124,6 +129,23 @@ const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null
return { url, token };
};
const hasOwn = (value: Record<string, unknown>, key: string) =>
Object.prototype.hasOwnProperty.call(value, key);
const mergeGatewaySettings = (
current: StudioGatewaySettings | null,
patch: StudioGatewaySettingsPatch | null | undefined
): StudioGatewaySettings | null => {
if (patch === undefined) return current;
if (patch === null) return null;
if (!isRecord(patch)) return current;
const nextUrl = hasOwn(patch, "url") ? normalizeGatewayUrl(patch.url) : current?.url ?? "";
const nextToken = hasOwn(patch, "token") ? coerceString(patch.token) : current?.token ?? "";
if (!nextUrl) return null;
return { url: nextUrl, token: nextToken };
};
const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => {
if (!isRecord(value)) return {};
const focused: Record<string, StudioFocusedPreference> = {};
@@ -179,8 +201,7 @@ export const mergeStudioSettings = (
current: StudioSettings,
patch: StudioSettingsPatch
): StudioSettings => {
const nextGateway =
patch.gateway === undefined ? current.gateway : normalizeGatewaySettings(patch.gateway);
const nextGateway = mergeGatewaySettings(current.gateway, patch.gateway);
const nextFocused = { ...current.focused };
const nextAvatars = { ...current.avatars };
if (patch.focused) {
+2 -2
View File
@@ -373,7 +373,7 @@ const formatToolResultMeta = (details?: Record<string, unknown> | null, isError?
return parts.length ? parts.join(" · ") : "";
};
export const extractToolCalls = (message: unknown): ToolCallRecord[] => {
const extractToolCalls = (message: unknown): ToolCallRecord[] => {
if (!message || typeof message !== "object") return [];
const content = (message as Record<string, unknown>).content;
if (!Array.isArray(content)) return [];
@@ -391,7 +391,7 @@ export const extractToolCalls = (message: unknown): ToolCallRecord[] => {
return calls;
};
export const extractToolResult = (message: unknown): ToolResultRecord | null => {
const extractToolResult = (message: unknown): ToolResultRecord | null => {
if (!message || typeof message !== "object") return null;
const record = message as Record<string, unknown>;
const role = typeof record.role === "string" ? record.role : "";
+7
View File
@@ -1,5 +1,12 @@
import "@testing-library/jest-dom/vitest";
if (!process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE) {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
}
if (!process.env.STUDIO_DOMAIN_API_MODE) {
process.env.STUDIO_DOMAIN_API_MODE = "false";
}
const ensureLocalStorage = () => {
if (typeof window === "undefined") return;
const existing = window.localStorage as unknown as Record<string, unknown> | undefined;
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { updateExecutionRoleViaStudio } from "@/features/agents/operations/agentPermissionsOperation";
describe("agentPermissionsOperation intent mode", () => {
it("uses exec-approvals-set intent when domain mode is enabled", async () => {
const call = vi.fn(async (method: string) => {
if (method === "exec.approvals.get" || method === "exec.approvals.set") {
throw new Error(`${method} should not be called in domain mode`);
}
if (method === "config.get") {
return {
hash: "cfg-hash-1",
config: { agents: [{ id: "agent-1", sandbox: { mode: "normal" } }] },
};
}
if (method === "config.set") {
return { ok: true };
}
if (method === "sessions.patch") {
return { ok: true, key: "agent:agent-1:main" };
}
return { ok: true };
});
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
vi.stubGlobal("fetch", fetchMock);
await updateExecutionRoleViaStudio({
client: { call } as never,
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
role: "collaborative",
loadAgents: async () => {},
useDomainIntents: true,
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/intents/exec-approvals-set",
expect.objectContaining({ method: "POST" })
);
expect(call).not.toHaveBeenCalledWith("exec.approvals.get", expect.anything());
expect(call).not.toHaveBeenCalledWith("exec.approvals.set", expect.anything());
vi.unstubAllGlobals();
});
});
@@ -50,8 +50,6 @@ describe("AgentSettingsPanel header", () => {
agent: createAgent(),
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
-72
View File
@@ -131,8 +131,6 @@ describe("AgentSettingsPanel", () => {
agent: createAgent(),
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -153,8 +151,6 @@ describe("AgentSettingsPanel", () => {
agent: createAgent(),
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -176,8 +172,6 @@ describe("AgentSettingsPanel", () => {
mode: "advanced",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -198,8 +192,6 @@ describe("AgentSettingsPanel", () => {
agent: createAgent(),
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -230,8 +222,6 @@ describe("AgentSettingsPanel", () => {
agent: createAgent(),
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -260,8 +250,6 @@ describe("AgentSettingsPanel", () => {
onUpdateAgentPermissions,
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -295,8 +283,6 @@ describe("AgentSettingsPanel", () => {
agent: createAgent(),
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -349,8 +335,6 @@ describe("AgentSettingsPanel", () => {
agent: createAgent(),
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -372,8 +356,6 @@ describe("AgentSettingsPanel", () => {
mode: "advanced",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -395,8 +377,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -422,8 +402,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -446,8 +424,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -475,8 +451,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -512,8 +486,6 @@ describe("AgentSettingsPanel", () => {
mode: "system",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -558,8 +530,6 @@ describe("AgentSettingsPanel", () => {
mode: "system",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -597,8 +567,6 @@ describe("AgentSettingsPanel", () => {
mode: "system",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -631,8 +599,6 @@ describe("AgentSettingsPanel", () => {
mode: "system",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -659,8 +625,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -690,8 +654,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -717,8 +679,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -738,8 +698,6 @@ describe("AgentSettingsPanel", () => {
mode: "skills",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -762,8 +720,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [createCronJob("job-1")],
cronLoading: false,
cronError: null,
@@ -788,8 +744,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs,
cronLoading: false,
cronError: null,
@@ -809,8 +763,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs,
cronLoading: false,
cronError: null,
@@ -833,8 +785,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs,
cronLoading: false,
cronError: null,
@@ -854,8 +804,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs,
cronLoading: false,
cronError: null,
@@ -876,8 +824,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -899,8 +845,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -921,8 +865,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -944,8 +886,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -975,8 +915,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -1028,8 +966,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -1056,8 +992,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -1101,8 +1035,6 @@ describe("AgentSettingsPanel", () => {
mode: "automations",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [createCronJob("job-1")],
cronLoading: false,
cronError: null,
@@ -1124,8 +1056,6 @@ describe("AgentSettingsPanel", () => {
mode: "advanced",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
@@ -1147,8 +1077,6 @@ describe("AgentSettingsPanel", () => {
mode: "advanced",
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
+37
View File
@@ -141,6 +141,43 @@ describe("sendChatMessageViaStudio", () => {
});
});
it("uses chat-send intent in domain mode", async () => {
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
const dispatch = vi.fn();
const call = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("chat.send should not be called in domain mode");
}
return { ok: true };
});
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, payload: { runId: "run-1", status: "started" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
vi.stubGlobal("fetch", fetchMock);
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
useDomainIntents: true,
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/intents/chat-send",
expect.objectContaining({ method: "POST" })
);
expect(call).not.toHaveBeenCalledWith("chat.send", expect.anything());
vi.unstubAllGlobals();
});
it("continues_send_when_webchat_patch_is_blocked", async () => {
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
const dispatch = vi.fn();
@@ -0,0 +1,82 @@
// @vitest-environment node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { SQLiteControlPlaneProjectionStore } from "@/lib/controlplane/projection-store";
describe("SQLiteControlPlaneProjectionStore", () => {
let tempDir: string | null = null;
const makeDbPath = () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "controlplane-store-"));
return path.join(tempDir, "runtime.db");
};
afterEach(() => {
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
it("reuses same schema across restarts and preserves snapshot", () => {
const dbPath = makeDbPath();
const first = new SQLiteControlPlaneProjectionStore(dbPath);
first.applyDomainEvent({
type: "runtime.status",
status: "connected",
reason: null,
asOf: "2026-02-28T02:00:00.000Z",
});
first.close();
const second = new SQLiteControlPlaneProjectionStore(dbPath);
const snapshot = second.snapshot();
expect(snapshot.status).toBe("connected");
expect(snapshot.asOf).toBe("2026-02-28T02:00:00.000Z");
expect(snapshot.outboxHead).toBe(1);
second.close();
});
it("deduplicates reapplied events and keeps outbox ordering", () => {
const store = new SQLiteControlPlaneProjectionStore(makeDbPath());
const firstEvent = {
type: "gateway.event" as const,
event: "runtime.delta",
seq: 42,
payload: { content: "a" },
asOf: "2026-02-28T02:01:00.000Z",
};
const secondEvent = {
type: "gateway.event" as const,
event: "runtime.final",
seq: 43,
payload: { content: "b" },
asOf: "2026-02-28T02:01:02.000Z",
};
const first = store.applyDomainEvent(firstEvent);
const duplicate = store.applyDomainEvent(firstEvent);
const replayedWithNewTimestamp = store.applyDomainEvent({
...firstEvent,
asOf: "2026-02-28T02:01:05.000Z",
});
const second = store.applyDomainEvent(secondEvent);
expect(first.id).toBe(1);
expect(duplicate.id).toBe(1);
expect(replayedWithNewTimestamp.id).toBe(1);
expect(second.id).toBe(2);
const replay = store.readOutboxAfter(0, 10);
expect(replay.map((entry) => entry.id)).toEqual([1, 2]);
expect(replay[0]?.event).toEqual(firstEvent);
expect(replay[1]?.event).toEqual(secondEvent);
store.close();
});
});
+169
View File
@@ -0,0 +1,169 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
ControlPlaneRuntime,
getControlPlaneRuntime,
isStudioDomainApiModeEnabled,
resetControlPlaneRuntimeForTests,
} from "@/lib/controlplane/runtime";
const closeWebSocketServer = (server: WebSocketServer) =>
new Promise<void>((resolve) => server.close(() => resolve()));
describe("control-plane runtime", () => {
let tempDir: string | null = null;
const makeRuntimeDbPath = () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "controlplane-runtime-"));
return path.join(tempDir, "runtime.db");
};
afterEach(async () => {
const runtime = getControlPlaneRuntime();
await runtime.disconnect();
runtime.close();
resetControlPlaneRuntimeForTests();
delete process.env.STUDIO_DOMAIN_API_MODE;
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
it("connects and disconnects through adapter lifecycle", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
upstream.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n1" } }));
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw ?? ""));
if (parsed?.type !== "req" || typeof parsed.id !== "string") return;
if (parsed.method === "connect") {
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
})
);
return;
}
if (parsed.method === "status") {
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { ok: true, source: "upstream" },
})
);
}
});
});
const runtime = new ControlPlaneRuntime({
dbPath: makeRuntimeDbPath(),
adapterOptions: {
loadSettings: () => ({ url: upstreamUrl, token: "upstream-token" }),
},
});
await runtime.ensureStarted();
const connectedSnapshot = runtime.snapshot();
expect(connectedSnapshot.status).toBe("connected");
const statusPayload = await runtime.callGateway<{ ok: boolean; source: string }>("status", {});
expect(statusPayload).toEqual({ ok: true, source: "upstream" });
await runtime.disconnect();
const disconnectedSnapshot = runtime.snapshot();
expect(disconnectedSnapshot.status).toBe("stopped");
await closeWebSocketServer(upstream);
});
it("enforces gateway method allowlist", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
upstream.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: {} }));
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw ?? ""));
if (parsed?.method !== "connect") return;
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
})
);
});
});
const runtime = new ControlPlaneRuntime({
dbPath: makeRuntimeDbPath(),
adapterOptions: {
loadSettings: () => ({ url: upstreamUrl, token: "upstream-token" }),
},
});
await runtime.ensureStarted();
await expect(runtime.callGateway("sessions.delete", { key: "x" })).rejects.toThrow(
"Gateway method is not allowlisted"
);
await runtime.disconnect();
await closeWebSocketServer(upstream);
});
it("uses process-local singleton runtime", () => {
const a = getControlPlaneRuntime();
const b = getControlPlaneRuntime();
expect(a).toBe(b);
});
it("parses STUDIO_DOMAIN_API_MODE values", () => {
process.env.STUDIO_DOMAIN_API_MODE = "true";
expect(isStudioDomainApiModeEnabled()).toBe(true);
process.env.STUDIO_DOMAIN_API_MODE = "1";
expect(isStudioDomainApiModeEnabled()).toBe(true);
process.env.STUDIO_DOMAIN_API_MODE = "false";
expect(isStudioDomainApiModeEnabled()).toBe(false);
delete process.env.STUDIO_DOMAIN_API_MODE;
expect(isStudioDomainApiModeEnabled()).toBe(true);
});
it("falls back to NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE when server mode is unset", () => {
delete process.env.STUDIO_DOMAIN_API_MODE;
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
expect(isStudioDomainApiModeEnabled()).toBe(false);
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
expect(isStudioDomainApiModeEnabled()).toBe(true);
});
it("prefers STUDIO_DOMAIN_API_MODE over NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE", () => {
process.env.STUDIO_DOMAIN_API_MODE = "false";
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
expect(isStudioDomainApiModeEnabled()).toBe(false);
});
});
@@ -212,4 +212,63 @@ describe("execApprovalResolveOperation", () => {
expect(onAllowed).not.toHaveBeenCalled();
});
it("uses exec-approval-resolve intent in domain mode", async () => {
const call = vi.fn(async (method: string) => {
if (method === "exec.approval.resolve") {
throw new Error("exec.approval.resolve should not be called in domain mode");
}
return { ok: true };
});
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, payload: { ok: true } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
vi.stubGlobal("fetch", fetchMock);
const approval: PendingExecApproval = {
id: "appr-1",
agentId: "a1",
sessionKey: "sess-1",
command: "echo hi",
cwd: null,
host: null,
security: null,
ask: null,
resolvedPath: null,
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
resolving: false,
error: null,
};
const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({ a1: [approval] });
const unscopedApprovals = createState<PendingExecApproval[]>([]);
await resolveExecApprovalViaStudio({
client: { call },
approvalId: "appr-1",
decision: "deny",
getAgents: () => [],
getLatestAgent: () => null,
getPendingState: () => ({
approvalsByAgentId: approvalsByAgentId.get(),
unscopedApprovals: unscopedApprovals.get(),
}),
setPendingExecApprovalsByAgentId: approvalsByAgentId.set,
setUnscopedPendingExecApprovals: unscopedApprovals.set,
requestHistoryRefresh: vi.fn(),
isDisconnectLikeError: () => false,
useDomainIntents: true,
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/intents/exec-approval-resolve",
expect.objectContaining({ method: "POST" })
);
expect(call).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything());
vi.unstubAllGlobals();
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PendingExecApproval } from "@/features/agents/approvals/types";
import {
@@ -12,6 +12,11 @@ import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/ex
import type { AgentState } from "@/features/agents/state/store";
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
vi.mock("@/lib/controlplane/intents-client", () => ({
postStudioIntent: vi.fn(async () => ({ ok: true })),
}));
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
agentId: "agent-1",
@@ -76,7 +81,20 @@ const createPendingState = (
});
describe("execApprovalRunControlOperation", () => {
const mockedPostStudioIntent = vi.mocked(postStudioIntent);
afterEach(() => {
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
});
it("uses legacy gateway calls when domain mode is disabled", () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
expect(process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE).toBe("false");
});
it("pauses a run for pending approval after stale paused-run cleanup", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
mockedPostStudioIntent.mockReset();
const call = vi.fn(async () => ({ ok: true }));
const pausedRunIdByAgentId = new Map<string, string>([
["stale-agent", "stale-run"],
@@ -101,6 +119,8 @@ describe("execApprovalRunControlOperation", () => {
});
it("reverts paused-run map entry when pause abort call fails", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
mockedPostStudioIntent.mockReset();
const call = vi.fn(async () => {
throw new Error("abort failed");
});
@@ -126,6 +146,8 @@ describe("execApprovalRunControlOperation", () => {
});
it("auto-resumes in order: dispatch running, wait paused run, then send follow-up", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
mockedPostStudioIntent.mockReset();
const call = vi.fn(async (method: string) => {
if (method === "agent.wait") return { status: "ok" };
throw new Error(`Unexpected method ${method}`);
@@ -174,6 +196,8 @@ describe("execApprovalRunControlOperation", () => {
});
it("skips follow-up send when post-wait auto-resume intent no longer holds", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
mockedPostStudioIntent.mockReset();
const call = vi.fn(async () => ({ status: "ok" }));
const dispatch = vi.fn();
const sendChatMessage = vi.fn(async () => undefined);
@@ -209,6 +233,8 @@ describe("execApprovalRunControlOperation", () => {
});
it("resolves approvals through resolver and delegates allow flow to auto-resume operation", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
mockedPostStudioIntent.mockReset();
const resolveExecApproval = vi.fn(async (params: { onAllowed?: (input: {
approval: PendingExecApproval;
targetAgentId: string;
@@ -246,6 +272,8 @@ describe("execApprovalRunControlOperation", () => {
});
it("executes ingress commands from gateway events", () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
mockedPostStudioIntent.mockReset();
const dispatch = vi.fn();
const replacePendingState = vi.fn();
const pauseRunForApproval = vi.fn(async () => undefined);
@@ -294,4 +322,46 @@ describe("execApprovalRunControlOperation", () => {
expect(replacePendingState).not.toHaveBeenCalled();
expect(pauseRunForApproval).not.toHaveBeenCalled();
});
it("uses intents for pause and wait in domain mode", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
mockedPostStudioIntent.mockReset();
mockedPostStudioIntent.mockResolvedValue({ ok: true });
const call = vi.fn(async () => ({ ok: true }));
const pausedRunIdByAgentId = new Map<string, string>();
await runPauseRunForExecApprovalOperation({
status: "connected",
client: { call },
approval: createApproval("approval-1"),
preferredAgentId: "agent-1",
getAgents: () => [createAgent({ runId: "run-1" })],
pausedRunIdByAgentId,
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
});
await runExecApprovalAutoResumeOperation({
client: { call },
dispatch: vi.fn(),
approval: createApproval("approval-1"),
targetAgentId: "agent-1",
getAgents: () => [createAgent({ status: "running", runId: "run-1" })],
getPendingState: () => createPendingState(),
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
sendChatMessage: vi.fn(async () => undefined),
});
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/chat-abort", {
sessionKey: "agent:agent-1:main",
});
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/agent-wait", {
runId: "run-1",
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
});
expect(call).not.toHaveBeenCalledWith("chat.abort", expect.anything());
expect(call).not.toHaveBeenCalledWith("agent.wait", expect.anything());
});
});
+143
View File
@@ -0,0 +1,143 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
describe("intent routes", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
it("chat-send route forwards to gateway intent runtime", async () => {
const callGateway = vi.fn(async () => ({ runId: "run-1", status: "started" }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const mod = await import("@/app/api/intents/chat-send/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/chat-send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionKey: "agent:agent-1:main",
message: "hello",
idempotencyKey: "run-1",
deliver: false,
}),
})
);
expect(response.status).toBe(200);
expect(callGateway).toHaveBeenCalledWith("chat.send", {
sessionKey: "agent:agent-1:main",
message: "hello",
idempotencyKey: "run-1",
deliver: false,
});
});
it("sessions-reset and agent-wait routes forward expected payloads", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const resetRoute = await import("@/app/api/intents/sessions-reset/route");
const waitRoute = await import("@/app/api/intents/agent-wait/route");
const resetResponse = await resetRoute.POST(
new Request("http://localhost/api/intents/sessions-reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "agent:agent-1:main" }),
})
);
const waitResponse = await waitRoute.POST(
new Request("http://localhost/api/intents/agent-wait", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ runId: "run-1", timeoutMs: 3000 }),
})
);
expect(resetResponse.status).toBe(200);
expect(waitResponse.status).toBe(200);
expect(callGateway).toHaveBeenCalledWith("sessions.reset", { key: "agent:agent-1:main" });
expect(callGateway).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 3000 });
});
it("exec-approvals-set role mode delegates to policy upsert helper", async () => {
const upsert = vi.fn(async () => undefined);
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway: vi.fn(async () => ({ ok: true })),
}),
}));
vi.doMock("@/lib/controlplane/exec-approvals", async () => {
const actual = await vi.importActual<typeof import("@/lib/controlplane/exec-approvals")>(
"@/lib/controlplane/exec-approvals"
);
return {
...actual,
upsertAgentExecApprovalsPolicyViaRuntime: upsert,
};
});
const mod = await import("@/app/api/intents/exec-approvals-set/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/exec-approvals-set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: "agent-1", role: "collaborative" }),
})
);
expect(response.status).toBe(200);
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
role: "collaborative",
})
);
});
it("chat-send returns deterministic gateway_unavailable response when runtime cannot start", async () => {
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {
throw new Error("gateway unavailable");
},
callGateway: vi.fn(),
}),
}));
const mod = await import("@/app/api/intents/chat-send/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/chat-send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionKey: "agent:agent-1:main",
message: "hello",
idempotencyKey: "run-1",
deliver: false,
}),
})
);
expect(response.status).toBe(503);
const body = await response.json() as { code?: string; reason?: string };
expect(body.code).toBe("GATEWAY_UNAVAILABLE");
expect(body.reason).toBe("gateway_unavailable");
});
});
-87
View File
@@ -1,87 +0,0 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
let tempHome: string | null = null;
const setupHome = () => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-"));
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
fs.mkdirSync(path.join(tempHome, "Documents"), { recursive: true });
fs.mkdirSync(path.join(tempHome, "Downloads"), { recursive: true });
fs.writeFileSync(path.join(tempHome, "Doc.txt"), "doc", "utf8");
fs.writeFileSync(path.join(tempHome, "Notes.txt"), "notes", "utf8");
fs.writeFileSync(path.join(tempHome, ".secret"), "hidden", "utf8");
};
const cleanupHome = () => {
const home = tempHome;
tempHome = null;
vi.restoreAllMocks();
if (!home) return;
fs.rmSync(home, { recursive: true, force: true });
};
let GET: typeof import("@/app/api/path-suggestions/route")["GET"];
beforeAll(async () => {
({ GET } = await import("@/app/api/path-suggestions/route"));
});
beforeEach(setupHome);
afterEach(cleanupHome);
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
describe("/api/path-suggestions route", () => {
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
it("returns non-hidden entries for home by default", async () => {
const response = await GET(new Request("http://localhost/api/path-suggestions"));
const body = (await response.json()) as { entries: Array<{ displayPath: string }> };
expect(response.status).toBe(200);
expect(body.entries.map((entry) => entry.displayPath)).toEqual([
"~/Documents/",
"~/Downloads/",
"~/Doc.txt",
"~/Notes.txt",
]);
});
it("filters by prefix within the current directory", async () => {
const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/Doc"));
const body = (await response.json()) as { entries: Array<{ displayPath: string }> };
expect(response.status).toBe(200);
expect(body.entries.map((entry) => entry.displayPath)).toEqual([
"~/Documents/",
"~/Doc.txt",
]);
});
it("rejects paths outside the home directory", async () => {
const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/../"));
const body = (await response.json()) as { error: string };
expect(response.status).toBe(400);
expect(body.error).toMatch(/home/i);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it("returns 404 for missing directories", async () => {
const response = await GET(
new Request("http://localhost/api/path-suggestions?q=~/Missing/")
);
const body = (await response.json()) as { error: string };
expect(response.status).toBe(404);
expect(body.error).toMatch(/does not exist/i);
expect(consoleErrorSpy).toHaveBeenCalled();
});
});
+313
View File
@@ -0,0 +1,313 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
type RuntimeMock = {
ensureStarted: () => Promise<void>;
snapshot: () => { status: string; reason: string | null; asOf: string | null; outboxHead: number };
eventsAfter: (lastSeenId: number, limit?: number) => Array<{
id: number;
event: unknown;
createdAt: string;
}>;
subscribe: (handler: (entry: { id: number; event: unknown; createdAt: string }) => void) => () => void;
};
const loadRouteModule = async <T>(modulePath: string, runtimeMock: RuntimeMock) => {
vi.resetModules();
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => runtimeMock,
}));
return await import(modulePath) as T;
};
describe("runtime routes", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
it("summary route returns projection-backed snapshot and freshness", async () => {
const runtimeMock: RuntimeMock = {
ensureStarted: async () => {},
snapshot: () => ({
status: "connected",
reason: null,
asOf: "2026-02-28T02:40:00.000Z",
outboxHead: 12,
}),
eventsAfter: () => [],
subscribe: () => () => {},
};
const mod = await loadRouteModule<{ GET: () => Promise<Response> }>(
"@/app/api/runtime/summary/route",
runtimeMock
);
const response = await mod.GET();
expect(response.status).toBe(200);
const body = await response.json() as {
enabled: boolean;
summary: { status: string; outboxHead: number };
freshness: { stale: boolean; source: string };
};
expect(body.enabled).toBe(true);
expect(body.summary.status).toBe("connected");
expect(body.summary.outboxHead).toBe(12);
expect(body.freshness.stale).toBe(false);
expect(body.freshness.source).toBe("gateway");
});
it("summary route returns degraded projection freshness when gateway start fails", async () => {
vi.resetModules();
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {
throw new Error("gateway offline");
},
snapshot: () => ({
status: "error",
reason: "gateway_closed",
asOf: "2026-02-28T02:40:00.000Z",
outboxHead: 9,
}),
eventsAfter: () => [],
subscribe: () => () => {},
}),
}));
vi.doMock("@/lib/controlplane/degraded-read", async () => {
const actual = await vi.importActual<typeof import("@/lib/controlplane/degraded-read")>(
"@/lib/controlplane/degraded-read"
);
return {
...actual,
probeOpenClawLocalState: vi.fn(async () => ({
at: "2026-02-28T02:41:00.000Z",
status: { ok: false, error: "openclaw_cli_not_found" },
sessions: { ok: false, error: "openclaw_cli_not_found" },
})),
};
});
const mod = await import("@/app/api/runtime/summary/route");
const response = await mod.GET();
expect(response.status).toBe(200);
const body = await response.json() as {
error?: string;
freshness: { stale: boolean; source: string; reason: string | null };
};
expect(body.error).toBe("gateway offline");
expect(body.freshness.stale).toBe(true);
expect(body.freshness.source).toBe("projection");
expect(body.freshness.reason).toBe("gateway_unavailable");
});
it("agent history route filters by agent id", async () => {
const runtimeMock: RuntimeMock = {
ensureStarted: async () => {},
snapshot: () => ({
status: "connected",
reason: null,
asOf: "2026-02-28T02:40:00.000Z",
outboxHead: 3,
}),
eventsAfter: () => [
{
id: 1,
event: {
type: "gateway.event",
event: "runtime.delta",
seq: 10,
payload: { sessionKey: "agent:alpha:main", delta: "a" },
asOf: "2026-02-28T02:40:01.000Z",
},
createdAt: "2026-02-28T02:40:01.000Z",
},
{
id: 2,
event: {
type: "gateway.event",
event: "runtime.delta",
seq: 11,
payload: { sessionKey: "agent:beta:main", delta: "b" },
asOf: "2026-02-28T02:40:02.000Z",
},
createdAt: "2026-02-28T02:40:02.000Z",
},
],
subscribe: () => () => {},
};
const mod = await loadRouteModule<{
GET: (
request: Request,
context: { params: Promise<{ agentId: string }> }
) => Promise<Response>;
}>("@/app/api/runtime/agents/[agentId]/history/route", runtimeMock);
const response = await mod.GET(
new Request("http://localhost/api/runtime/agents/alpha/history?limit=50"),
{ params: Promise.resolve({ agentId: "alpha" }) }
);
expect(response.status).toBe(200);
const body = await response.json() as { entries: Array<{ id: number }> };
expect(body.entries.map((entry) => entry.id)).toEqual([1]);
});
it("stream route replays from Last-Event-ID and emits live updates", async () => {
let subscriber: ((entry: { id: number; event: unknown; createdAt: string }) => void) | null = null;
const runtimeMock: RuntimeMock = {
ensureStarted: async () => {},
snapshot: () => ({
status: "connected",
reason: null,
asOf: "2026-02-28T02:40:00.000Z",
outboxHead: 4,
}),
eventsAfter: (lastSeenId: number) => {
expect(lastSeenId).toBe(2);
return [
{
id: 3,
event: {
type: "gateway.event",
event: "runtime.delta",
seq: 20,
payload: { sessionKey: "agent:alpha:main", delta: "replay" },
asOf: "2026-02-28T02:40:03.000Z",
},
createdAt: "2026-02-28T02:40:03.000Z",
},
];
},
subscribe: (handler) => {
subscriber = handler;
return () => {
subscriber = null;
};
},
};
const mod = await loadRouteModule<{ GET: (request: Request) => Promise<Response> }>(
"@/app/api/runtime/stream/route",
runtimeMock
);
const response = await mod.GET(
new Request("http://localhost/api/runtime/stream", {
headers: { "Last-Event-ID": "2" },
})
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.body).toBeTruthy();
const reader = response.body!.getReader();
const first = await reader.read();
const firstChunk = new TextDecoder().decode(first.value);
expect(firstChunk).toContain("id: 3");
expect(firstChunk).toContain("event: gateway.event");
const emit = subscriber as ((entry: { id: number; event: unknown; createdAt: string }) => void) | null;
emit?.({
id: 4,
event: {
type: "runtime.status",
status: "reconnecting",
reason: "gateway_closed",
asOf: "2026-02-28T02:40:04.000Z",
},
createdAt: "2026-02-28T02:40:04.000Z",
});
const second = await reader.read();
const secondChunk = new TextDecoder().decode(second.value);
expect(secondChunk).toContain("id: 4");
expect(secondChunk).toContain("event: runtime.status");
await reader.cancel();
});
it("agent-rename and agent-delete intent routes forward to runtime", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const renameRoute = await import("@/app/api/intents/agent-rename/route");
const deleteRoute = await import("@/app/api/intents/agent-delete/route");
const renameRes = await renameRoute.POST(
new Request("http://localhost/api/intents/agent-rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: "agent-1", name: "Agent One Renamed" }),
})
);
expect(renameRes.status).toBe(200);
const deleteRes = await deleteRoute.POST(
new Request("http://localhost/api/intents/agent-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: "agent-1" }),
})
);
expect(deleteRes.status).toBe(200);
expect(callGateway).toHaveBeenCalledWith("agents.update", {
agentId: "agent-1",
name: "Agent One Renamed",
});
expect(callGateway).toHaveBeenCalledWith("agents.delete", {
agentId: "agent-1",
});
});
it("runtime fleet route hydrates through control-plane runtime", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
vi.doMock("@/lib/studio/settings-store", () => ({
loadStudioSettings: () => ({
version: 1,
gateway: { url: "ws://localhost:3000/ws", token: "" },
localGatewayDefaults: { url: "", token: "" },
focused: {},
avatars: {},
}),
}));
vi.doMock("@/features/agents/operations/agentFleetHydration", () => ({
hydrateAgentFleetFromGateway: vi.fn(async () => ({
seeds: [{ agentId: "agent-1", name: "Agent One", sessionKey: "agent:agent-1:main" }],
sessionCreatedAgentIds: ["agent-1"],
sessionSettingsSyncedAgentIds: ["agent-1"],
summaryPatches: [],
suggestedSelectedAgentId: "agent-1",
configSnapshot: null,
})),
}));
const route = await import("@/app/api/runtime/fleet/route");
const response = await route.POST(
new Request("http://localhost/api/runtime/fleet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cachedConfigSnapshot: null }),
})
);
expect(response.status).toBe(200);
const body = await response.json() as { result: { seeds: Array<{ agentId: string }> } };
expect(body.result.seeds[0]?.agentId).toBe("agent-1");
});
});
@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentStoreSeed } from "@/features/agents/state/store";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import type { StudioSettingsPatch } from "@/lib/studio/settings";
import { fetchJson } from "@/lib/http";
vi.mock("@/features/agents/operations/agentFleetHydration", () => ({
hydrateAgentFleetFromGateway: vi.fn(),
}));
vi.mock("@/lib/http", () => ({
fetchJson: vi.fn(),
}));
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
import {
@@ -21,10 +25,13 @@ import {
} from "@/features/agents/operations/studioBootstrapOperation";
const hydrateAgentFleetFromGatewayMock = vi.mocked(hydrateAgentFleetFromGateway);
const fetchJsonMock = vi.mocked(fetchJson);
describe("studioBootstrapOperation", () => {
beforeEach(() => {
hydrateAgentFleetFromGatewayMock.mockReset();
fetchJsonMock.mockReset();
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
});
it("builds bootstrap commands from hydrated fleet result", async () => {
@@ -96,6 +103,44 @@ describe("studioBootstrapOperation", () => {
expect(commands).toEqual([{ kind: "set-error", message: "load failed" }]);
});
it("uses runtime fleet API in domain mode", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
const seeds: AgentStoreSeed[] = [{ agentId: "agent-1", name: "Agent One", sessionKey: "s1" }];
fetchJsonMock.mockResolvedValue({
result: {
seeds,
sessionCreatedAgentIds: [],
sessionSettingsSyncedAgentIds: [],
summaryPatches: [],
suggestedSelectedAgentId: "agent-1",
configSnapshot: null,
},
});
const commands = await runStudioBootstrapLoadOperation({
client: { call: async () => null },
gatewayUrl: "https://gateway.test",
cachedConfigSnapshot: null,
loadStudioSettings: async () => null,
isDisconnectLikeError: () => false,
preferredSelectedAgentId: null,
hasCurrentSelection: false,
});
expect(fetchJsonMock).toHaveBeenCalledWith(
"/api/runtime/fleet",
expect.objectContaining({ method: "POST" })
);
expect(hydrateAgentFleetFromGatewayMock).not.toHaveBeenCalled();
expect(commands).toEqual([
{
kind: "hydrate-agents",
seeds,
initialSelectedAgentId: "agent-1",
},
]);
});
it("executes bootstrap commands with injected callbacks", () => {
const commands: StudioBootstrapLoadCommand[] = [
{
+15
View File
@@ -103,6 +103,21 @@ describe("studio settings normalization", () => {
});
});
it("preserves gateway token when patching only url", () => {
const current = normalizeStudioSettings({
gateway: { url: "ws://gateway.old:18789", token: "secret-token" },
});
const merged = mergeStudioSettings(current, {
gateway: { url: "ws://gateway.new:18789" },
});
expect(merged.gateway).toEqual({
url: "ws://gateway.new:18789",
token: "secret-token",
});
});
it("normalizes avatar seeds per gateway", () => {
const normalized = normalizeStudioSettings({
avatars: {
@@ -88,4 +88,27 @@ describe("StudioSettingsCoordinator", () => {
expect(updateSettings).not.toHaveBeenCalled();
});
it("requeues pending patch when update fails", async () => {
const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() }));
const updateSettings = vi
.fn()
.mockRejectedValueOnce(new Error("write failed"))
.mockResolvedValue({ settings: defaultStudioSettings() });
const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 1000);
coordinator.schedulePatch({
gateway: { url: "ws://localhost:18789", token: "session-a" },
});
await expect(coordinator.flushPending()).rejects.toThrow("write failed");
await coordinator.flushPending();
expect(updateSettings).toHaveBeenCalledTimes(2);
expect(updateSettings).toHaveBeenNthCalledWith(2, {
gateway: { url: "ws://localhost:18789", token: "session-a" },
});
coordinator.dispose();
});
});
+61 -3
View File
@@ -10,10 +10,14 @@ const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${n
describe("studio settings route", () => {
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
const priorStudioDomainApiMode = process.env.STUDIO_DOMAIN_API_MODE;
const priorNextPublicStudioDomainApiMode = process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
let tempDir: string | null = null;
afterEach(() => {
process.env.OPENCLAW_STATE_DIR = priorStateDir;
process.env.STUDIO_DOMAIN_API_MODE = priorStudioDomainApiMode;
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = priorNextPublicStudioDomainApiMode;
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
@@ -28,14 +32,28 @@ describe("studio settings route", () => {
const body = (await response.json()) as {
settings?: Record<string, unknown>;
localGatewayDefaults?: unknown;
domainApiModeEnabled?: unknown;
};
expect(response.status).toBe(200);
expect(body.settings?.gateway).toBe(null);
expect(body.localGatewayDefaults ?? null).toBeNull();
expect(typeof body.domainApiModeEnabled).toBe("boolean");
expect(body.settings?.version).toBe(1);
});
it("GET reports domain mode from server env with STUDIO override precedence", async () => {
tempDir = makeTempDir("studio-settings-domain-mode");
process.env.OPENCLAW_STATE_DIR = tempDir;
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
process.env.STUDIO_DOMAIN_API_MODE = "false";
const response = await GET();
const body = (await response.json()) as { domainApiModeEnabled?: unknown };
expect(response.status).toBe(200);
expect(body.domainApiModeEnabled).toBe(false);
});
it("GET returns local gateway defaults from openclaw.json", async () => {
tempDir = makeTempDir("studio-settings-get-local-defaults");
process.env.OPENCLAW_STATE_DIR = tempDir;
@@ -54,11 +72,11 @@ describe("studio settings route", () => {
expect(response.status).toBe(200);
expect(body.localGatewayDefaults).toEqual({
url: "ws://localhost:18791",
token: "local-token",
token: "",
});
expect(body.settings?.gateway).toEqual({
url: "ws://localhost:18791",
token: "local-token",
token: "",
});
});
@@ -95,7 +113,7 @@ describe("studio settings route", () => {
};
expect(getResponse.status).toBe(200);
expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "" });
const settingsPath = path.join(tempDir, "openclaw-studio", "settings.json");
expect(fs.existsSync(settingsPath)).toBe(true);
@@ -103,4 +121,44 @@ describe("studio settings route", () => {
const parsed = JSON.parse(raw) as { gateway?: { url?: string; token?: string } | null };
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
});
it("PUT url-only gateway patch preserves existing token", async () => {
tempDir = makeTempDir("studio-settings-put-url-only");
process.env.OPENCLAW_STATE_DIR = tempDir;
fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, "openclaw-studio", "settings.json"),
JSON.stringify(
{
version: 1,
gateway: { url: "ws://old.example:18789", token: "secret-token" },
focused: {},
avatars: {},
},
null,
2
),
"utf8"
);
const putResponse = await PUT({
json: async () => ({ gateway: { url: "ws://new.example:18789" } }),
} as unknown as Request);
expect(putResponse.status).toBe(200);
const getResponse = await GET();
const body = (await getResponse.json()) as {
settings?: { gateway?: { url?: string; token?: string } | null };
};
expect(getResponse.status).toBe(200);
expect(body.settings?.gateway).toEqual({ url: "ws://new.example:18789", token: "" });
const persisted = JSON.parse(
fs.readFileSync(path.join(tempDir, "openclaw-studio", "settings.json"), "utf8")
) as { gateway?: { url?: string; token?: string } };
expect(persisted.gateway).toEqual({
url: "ws://new.example:18789",
token: "secret-token",
});
});
});
@@ -234,6 +234,7 @@ describe("useAgentSettingsMutationController", () => {
const mockedUpdateSkill = vi.mocked(updateSkill);
beforeEach(() => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
restartBlockHookParams = null;
mockedDeleteAgentViaStudio.mockReset();
mockedPerformCronCreateFlow.mockReset();
@@ -277,6 +278,7 @@ describe("useAgentSettingsMutationController", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
});
it("delete_denied_by_guard_does_not_run_delete_side_effect", async () => {
@@ -290,6 +292,33 @@ describe("useAgentSettingsMutationController", () => {
expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled();
});
it("domain_mode_allows_delete_when_browser_gateway_is_disconnected", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
vi.spyOn(window, "confirm").mockReturnValue(true);
mockedRunLifecycle.mockImplementation(async ({ deps }) => {
deps.setQueuedBlock();
deps.setMutatingBlock();
await deps.executeMutation();
deps.clearBlock();
return true;
});
mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null });
const ctx = renderController({ status: "disconnected" });
await act(async () => {
await ctx.getValue().handleDeleteAgent("agent-1");
});
expect(mockedRunLifecycle).toHaveBeenCalled();
expect(mockedDeleteAgentViaStudio).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
useDomainIntents: true,
})
);
});
it("delete_cancelled_by_confirmation_does_not_run_delete_side_effect", async () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
const ctx = renderController();
@@ -5,10 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
import type { AgentState } from "@/features/agents/state/store";
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
import { postStudioIntent } from "@/lib/controlplane/intents-client";
vi.mock("@/features/agents/operations/chatSendOperation", () => ({
sendChatMessageViaStudio: vi.fn(async () => undefined),
}));
vi.mock("@/lib/controlplane/intents-client", () => ({
postStudioIntent: vi.fn(async () => ({ ok: true })),
}));
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
const base: AgentState = {
@@ -191,6 +195,7 @@ const renderController = (
describe("useChatInteractionController", () => {
const mockedSendChatMessageViaStudio = vi.mocked(sendChatMessageViaStudio);
const mockedPostStudioIntent = vi.mocked(postStudioIntent);
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
@@ -198,6 +203,9 @@ describe("useChatInteractionController", () => {
vi.useFakeTimers();
mockedSendChatMessageViaStudio.mockReset();
mockedSendChatMessageViaStudio.mockResolvedValue(undefined);
mockedPostStudioIntent.mockReset();
mockedPostStudioIntent.mockResolvedValue({ ok: true });
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
});
afterEach(() => {
@@ -205,6 +213,7 @@ describe("useChatInteractionController", () => {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
vi.restoreAllMocks();
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
});
it("flushes pending draft and cancels debounce timer", async () => {
@@ -500,6 +509,28 @@ describe("useChatInteractionController", () => {
]);
});
it("uses sessions-reset intent for new-session in domain mode", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
const ctx = renderController({
agents: [
createAgent({
agentId: "agent-1",
runId: "run-42",
sessionKey: " session-42 ",
}),
],
});
await act(async () => {
await ctx.getValue().handleNewSession("agent-1");
});
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/sessions-reset", {
key: "session-42",
});
expect(ctx.call).not.toHaveBeenCalledWith("sessions.reset", expect.anything());
});
it("appends output when new-session fails", async () => {
const ctx = renderController({
agents: [
+122 -4
View File
@@ -74,7 +74,10 @@ const setupAndImportHook = async (gatewayUrl: string | null) => {
gatewayUrl: string;
token: string;
localGatewayDefaults: { url: string; token: string } | null;
domainApiModeEnabled: boolean | null;
useLocalGatewayDefaults: () => void;
setGatewayUrl: (value: string) => void;
setToken: (value: string) => void;
},
captured,
};
@@ -166,11 +169,12 @@ describe("useGatewayConnection", () => {
loadSettingsEnvelope: async () => ({
settings: {
version: 1,
gateway: { url: "wss://remote.example", token: "remote-token" },
gateway: { url: "wss://remote.example", token: "" },
focused: {},
avatars: {},
},
localGatewayDefaults: { url: "ws://localhost:18789", token: "local-token" },
localGatewayDefaults: { url: "ws://localhost:18789", token: "" },
domainApiModeEnabled: true,
}),
schedulePatch: () => {},
flushPending: async () => {},
@@ -188,6 +192,11 @@ describe("useGatewayConnection", () => {
{ "data-testid": "localDefaultsUrl" },
state.localGatewayDefaults?.url ?? ""
),
createElement(
"div",
{ "data-testid": "domainApiModeEnabled" },
state.domainApiModeEnabled === null ? "null" : String(state.domainApiModeEnabled)
),
createElement(
"button",
{
@@ -205,14 +214,123 @@ describe("useGatewayConnection", () => {
await waitFor(() => {
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example");
});
expect(screen.getByTestId("token")).toHaveTextContent("remote-token");
expect(screen.getByTestId("token")).toHaveTextContent("");
expect(screen.getByTestId("localDefaultsUrl")).toHaveTextContent("ws://localhost:18789");
expect(screen.getByTestId("domainApiModeEnabled")).toHaveTextContent("true");
fireEvent.click(screen.getByTestId("useLocalDefaults"));
await waitFor(() => {
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789");
});
expect(screen.getByTestId("token")).toHaveTextContent("local-token");
expect(screen.getByTestId("token")).toHaveTextContent("");
});
it("persists gateway url changes without sending token", async () => {
const { useGatewayConnection } = await setupAndImportHook(null);
const schedulePatch = vi.fn();
const coordinator = {
loadSettings: async () => null,
loadSettingsEnvelope: async () => ({
settings: {
version: 1,
gateway: { url: "wss://remote.example", token: "" },
focused: {},
avatars: {},
},
localGatewayDefaults: null,
}),
schedulePatch,
flushPending: async () => {},
};
const Probe = () => {
const state = useGatewayConnection(coordinator);
return createElement(
"div",
null,
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
createElement(
"button",
{
type: "button",
onClick: () => state.setGatewayUrl("wss://remote-next.example"),
"data-testid": "changeUrl",
},
"change"
)
);
};
render(createElement(Probe));
await waitFor(() => {
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example");
});
expect(schedulePatch).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("changeUrl"));
await waitFor(() => {
expect(schedulePatch).toHaveBeenCalled();
});
expect(schedulePatch).toHaveBeenLastCalledWith(
{ gateway: { url: "wss://remote-next.example" } },
400
);
});
it("persists token only after explicit token edit", async () => {
const { useGatewayConnection } = await setupAndImportHook(null);
const schedulePatch = vi.fn();
const coordinator = {
loadSettings: async () => null,
loadSettingsEnvelope: async () => ({
settings: {
version: 1,
gateway: { url: "wss://remote.example", token: "" },
focused: {},
avatars: {},
},
localGatewayDefaults: null,
}),
schedulePatch,
flushPending: async () => {},
};
const Probe = () => {
const state = useGatewayConnection(coordinator);
return createElement(
"div",
null,
createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl),
createElement(
"button",
{
type: "button",
onClick: () => state.setToken("manual-token"),
"data-testid": "setToken",
},
"token"
)
);
};
render(createElement(Probe));
await waitFor(() => {
expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example");
});
expect(schedulePatch).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("setToken"));
await waitFor(() => {
expect(schedulePatch).toHaveBeenCalled();
});
expect(schedulePatch).toHaveBeenLastCalledWith(
{ gateway: { url: "wss://remote.example", token: "manual-token" } },
400
);
});
});
@@ -181,6 +181,7 @@ describe("useRuntimeSyncController", () => {
beforeEach(() => {
vi.useFakeTimers();
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
mockedRunHistorySyncOperation.mockReset();
mockedRunHistorySyncOperation.mockResolvedValue([]);
mockedExecuteHistorySyncCommands.mockReset();
@@ -192,6 +193,7 @@ describe("useRuntimeSyncController", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
});
it("runs reconcile immediately and every 3000ms while connected then cleans up", async () => {
@@ -365,4 +367,50 @@ describe("useRuntimeSyncController", () => {
expect(inFlightSeen).toEqual([false, true, false]);
});
it("uses domain runtime APIs when NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE is enabled", async () => {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/runtime/summary")) {
return new Response(
JSON.stringify({
enabled: true,
summary: { status: "connected", reason: null, asOf: null, outboxHead: 0 },
freshness: { source: "controlplane", stale: false, asOf: null, reason: null },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (url.includes("/api/runtime/agents/")) {
return new Response(JSON.stringify({ enabled: true, entries: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
const ctx = renderController({
focusedAgentId: "agent-1",
focusedAgentRunning: true,
});
await act(async () => {
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledWith("/api/runtime/summary", expect.anything());
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/runtime/agents/agent-1/history"),
expect.anything()
);
expect(ctx.call).not.toHaveBeenCalledWith("status", {});
vi.unstubAllGlobals();
ctx.unmount();
});
});