From 055a2dc6ced54dbf0669e3d88fbb53593dfc66d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 16:44:25 -0700 Subject: [PATCH] feat: continue dashboard sessions from CLI URLs (#120893) * feat(cli): ingest session targets * refactor(ui): remove gateway scope shim * docs(cli): document session targets * fix(cli): classify session target failures * fix(cli): keep session target result private * fix(cli): simplify timeout option warning * build: declare session URL contract dependency * fix(cli): parse bare session URL options symmetrically * fix(cli): preserve command-owned URL arguments * build: keep session URL contract build-only * fix: address session URL review findings * test: preserve session key mock exports * fix: keep session URL helpers internal * fix(tui): preserve URL agent for global sessions * fix(tui): keep URL agent input internal * fix(gateway): reconcile websocket protocol owner * fix(attach): preserve global session agent ownership * fix(attach): enforce global owner at grant boundary --- .../.generated/plugin-sdk-api-baseline.sha256 | 20 +- docs/cli/attach.md | 33 +- docs/cli/tui.md | 49 +- package.json | 1 + pnpm-lock.yaml | 3 + scripts/openclaw-postpack.mjs | 2 + scripts/openclaw-prepack.ts | 2 + scripts/package-manifest.d.mts | 2 + scripts/package-manifest.mjs | 110 ++++ scripts/package-openclaw-for-docker.mts | 70 ++- scripts/test-projects.test-support.mts | 1 + src/cli/attach-cli.action.test.ts | 75 ++- src/cli/attach-cli.ts | 269 +++++---- src/cli/program.smoke.test.ts | 61 ++ src/cli/program.test-mocks.ts | 2 + src/cli/run-main.exit.test.ts | 160 +++++ src/cli/run-main.ts | 35 +- src/cli/session-ref.test.ts | 564 ++++++++++++++++++ src/cli/session-ref.ts | 280 +++++++++ src/cli/session-target.ts | 236 ++++++++ src/cli/tui-cli.ts | 128 ++-- src/commands/status.summary.test.ts | 18 +- src/config/sessions/main-session-key.ts | 11 + src/config/sessions/main-session.ts | 10 +- src/gateway/mcp-grant-store.test.ts | 12 + src/gateway/mcp-grant-store.ts | 5 + src/gateway/mcp-http.request.ts | 13 +- src/gateway/mcp-http.test.ts | 7 +- src/gateway/net.ts | 6 +- src/gateway/server-methods/attach.test.ts | 36 ++ src/gateway/server-methods/attach.ts | 7 +- src/gateway/websocket-protocol.ts | 4 + src/pairing/setup-code.ts | 2 +- src/tui/tui.test.ts | 13 + src/tui/tui.ts | 39 +- .../package-openclaw-for-docker.e2e.test.ts | 57 ++ test/openclaw-prepack.test.ts | 65 +- ui/src/app/gateway-scope.ts | 2 - ui/src/pages/new-session/preferences.ts | 4 +- 39 files changed, 2189 insertions(+), 225 deletions(-) create mode 100644 scripts/package-manifest.d.mts create mode 100644 scripts/package-manifest.mjs create mode 100644 src/cli/session-ref.test.ts create mode 100644 src/cli/session-ref.ts create mode 100644 src/cli/session-target.ts create mode 100644 src/config/sessions/main-session-key.ts create mode 100644 src/gateway/websocket-protocol.ts delete mode 100644 ui/src/app/gateway-scope.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 05c6b7476d2d..ff81b5782e4e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -3,8 +3,8 @@ 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id d768139934447ff3ecf15470dc1fe613d36509fc5b93fc47ef09d20829cefa57 module/account-resolution 4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives -66c80a448539e52493abe8bd0e6ca3152d2ff7318cf809fe16336f270d2cacdd module/agent-harness -0f254f026d4968ab796096225dfe8ca94655a6ef2d0435eb3c3acba831c0b219 module/agent-harness-runtime +0eeb85850d6056f815e231a7f0acbca6fb4115699407b823a9cf4437bf627e69 module/agent-harness +e1754867b9fbdd210a21c3d00c3aa5f161517bd2ed10c1601f52e3f39714699a module/agent-harness-runtime d6097cfa1b410f4b5267a56a7bd19c2a33fbaf6642683dae6aec68e998e48f6c module/agent-media-payload 3035368684499711a63540fbef74b4aae7d131073367a77a3179e3b9aef16ad8 module/agent-runtime b57a3cb274a9977c48c5387772df7707e50dde9a6132e532e7367a27225dc142 module/agent-scope-runtime @@ -26,7 +26,7 @@ afad33fdaada25984db53504dc6f665ff0478c12f68b79f36a39d28ceb13355e module/channel c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives a6cca5706f3aba6abb2178b175a0986d921ade98c2c05d2a54451e2fb7e16825 module/channel-config-schema 37925e2b8c74b4444a14ea85b831ab569df9d46efebe89714527ee638719c100 module/channel-contract -265ce5d0983e662e2d5b54b7254226a713463c020ccb96b87672409423e1b2a7 module/channel-core +c6427c4fea5bd0a7574af77850445624a80e9988f7129af224a489297af041a3 module/channel-core f4a9870d37f3b4e824bc7b0f634e4eb868ae7dd4c5a9693f0105a6677c2ff5f9 module/channel-dm-policy 3f78f6022fc2942f60bc07a554f57ef9f8763db773f10f6425fa940acdbe917a module/channel-entry-contract 47cf8765e76c151ae7d2991d41beca62922a8837c1521e10a3fd23f9992c2d7c module/channel-feedback @@ -60,16 +60,16 @@ ab86235fcfff7c7cf0021fafeca6e92afe2c257ccf6a4a38441e0d41532998ff module/config- 0d99f5cb8c4978ed760e5fb4e476543759fbd8fd5bf73cc50a50c1d550203826 module/config-mutation 1c79d1356d7f41c22a0e85ff43766fc839cf13734435a3dcf7a4739512d513bf module/config-runtime d9e5f2ae27e29a40a6d6084c4d59e4811b4669235faa6f0d6a4fbfdd62e51ca3 module/conversation-runtime -2b11c2f1fc406190b1b036316bd173a528c92bbc061b3da1a4e971cdd26c7c97 module/core +5bebdf2011850e732ef1f71f5bc7669b7ebc3c4ece653f48c5ee0e3aa4ebc369 module/core 2872c791a0b43fad1020a8f595e506d369bed6c57819733919858dddbd96fbaf module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap 21d86413166ef815581d606f678b6a216a1cc73ffe470b841f5bc4a131bff6df module/diagnostic-runtime 2af0c3b8867148d3deaf0b6d112743a42b206014ed1b5a5cf4ef939f1976d420 module/directory-runtime -b2fd699e080ec56989f0a8f21f4b93ae87842b70671b918d6733777c44c89b07 module/discord +a589c6a22e936d596cdca5ce8d97ea091fd8ddd8d9df9407b57bbf1d683c8e82 module/discord 39fe343ed2119de714757c365eef2ccec89c2c82a0876c60a4bef8ce469f8c8a module/error-runtime ce4f1602bf5b5de968ca97cede4f498b6ae709a0a59393b5ce9255cf0e6a9d5e module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -49c009813a6dd34091d2c1c73eefe210af7926c8c490d87484550c668b3e20ac module/gateway-runtime +c76bd967ef989feff27494df1e59aab2abef6e01aabf96c222b1ea8b153af1fc module/gateway-runtime 1b1c6bd5bfc0cfb0c5bb9bd97f8ac1928750cb87232a3415dd066cc21d7b48b7 module/group-access 4a9e9046f39dd661ef98ad9d37b0088eced2aacfe430fef2a93d4e009b297c3f module/health a35bf7d756fc781b860d131bac3710e4d3b2dd4403f7f98313899ddf80b564d3 module/hook-runtime @@ -89,7 +89,7 @@ f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-m 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store b7e71516842300c041d2423d822da0080881db6ddc6b9b9ce38cabd8d546676b module/media-understanding a206a1486f6a6bed3091795b324e95dd710b4b3b87a4a8f18788e6158a48a922 module/media-understanding-runtime -e78370b4f450b6b29751117c5cbd59f3ade9e6e7a12b754b3d805336d770ef69 module/meeting-runtime +ffc96fca1e036592ed6b640c4917e4773a168b3d88aab1a11de4a4b4df84ce30 module/meeting-runtime 3312468e2e8f3423b765fac6bb17944b800ea2c84acffeb64342c99040b2f482 module/memory-core-host-engine-foundation e3d2db75fab4b4a4d8a77f7db8fc2df2047678d0570385843c68a2478d2be4e5 module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets @@ -103,7 +103,7 @@ b31f5d86904097993a55377fed7973cd298b0e1f49cade7e37a1e28f6e724108 module/plugin- 29e893378475bf70f9265e4bb160860f4fb9783dbbd7d5849cde4d10d3450cb6 module/plugin-entry 61364b898140f866d23aa282251eb2cbbcaf233dd2c966f0d47ea82e1b31d8dc module/plugin-runtime 515ca993ecfdcb462f2e006816fdfc4dfd7752b30fda55b095bc1d443744a231 module/provider-auth -03764516436e159b9352c00377856873d34c41b44ef650fd21653de7643d1861 module/provider-catalog-runtime +a54b42a90c511494e32245f5f1477a65d7b5ca5aaa9993ddafe6132ae42be7c2 module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture 4a698efc36d896c4702de8df831e36b06e85c82aa30bf86c9b1066e6cad4b700 module/question-gateway-runtime 1171a76ea0485b36f77c9e12601c44a0e858043140e1d4669bb86d1f9e35930b module/reply-chunking @@ -133,7 +133,7 @@ fb0af0a51ba93e070d8862701c016375cf8576c47be1d25e20537ec86b0bcf73 module/setup 8ec6ca8a40d4117c669fbd0f20241d54e7e6954d7cff8c5b4afc67ec9626e216 module/skill-commands-runtime 0b09bb506ca970ee39b58953d9c1f0407942b2762f54d89a4cd82c35b9bc6c8b module/speech-settings 0b3de0b219e431d26298c83688ffe53c262d58a336ca910e29619b5d0447266e module/ssrf-policy -0be0326d5dfb54aea1cc12bd320de91553859aa055b8ec87c11e498812265ab8 module/ssrf-runtime +6639ba57aacbb620a2f6ff802ecd94fc90b1969292e0306598546b5b4b9261cc module/ssrf-runtime 3855f0a23281d21063762f5b3be2b7485c8bdb3b4cd1692de4f2a17d4eeae496 module/state-paths 21fb493eaeb6e46441863c27a3838b52fc13494fff8f7c90c12b72a3d373b16b module/status-helpers f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-coerce-runtime @@ -145,7 +145,7 @@ d05a2db4a97844950ff3bc30f90f07e7f45600b1219e08a19264174b399817dc module/text-ru dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results a5eef5a532c439b1489236711f4bc2721abe612a9db3911430e1344c239f9861 module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -6bbbbcfea4f3e2acf483aac46d4ad0cda70820bdcfec3bac30a3efe06e6d1331 module/webhook-ingress +60465458d5674539012dabb6bf7861783798fd80b8111948812f1f451d848fd0 module/webhook-ingress e3a199a9ce0b85d203e9e8a29b500db29c6b7af307e3145d0a311e29d598925b module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/cli/attach.md b/docs/cli/attach.md index ac0dcd3dfc4f..4960bafaa390 100644 --- a/docs/cli/attach.md +++ b/docs/cli/attach.md @@ -8,8 +8,19 @@ title: "Attach CLI" `openclaw attach` launches Claude Code with a strict temporary MCP config bound to one Gateway session. +```sh +openclaw attach [target] +``` + +`target` accepts a Control UI session URL, a compact `host/agent/ref`, a bare +short reference, or a literal `agent:...` session key. A URL or host target +authoritatively selects that Gateway; a bare reference uses the configured or +default Gateway. + ```sh openclaw attach +openclaw attach https://gateway.example/dashboard/main/movies-a1166b81 +openclaw attach movies-a1166b81 openclaw attach --session agent:main:telegram:123 --ttl 600000 openclaw attach --print-config ``` @@ -17,10 +28,30 @@ openclaw attach --print-config Options: - `--session ` binds the grant to a Gateway session. Defaults to the main session. +- `--url ` selects a Gateway for a bare reference or `--session` key. Do + not combine it with a URL target. +- `--token ` and `--password ` provide explicit Gateway auth. +- `--tls-fingerprint ` pins the Gateway TLS certificate. - `--ttl ` requests a positive grant TTL in milliseconds. The Gateway applies its own ceiling. - `--bin ` selects the Claude Code binary. Default: `claude`. - `--print-config` writes the temporary `.mcp.json`, prints the launch command and env, and leaves the grant live until TTL expiry (it does not spawn Claude Code or revoke the grant). +Pass either a positional target or `--session`, not both. Short references are +resolved before the scoped attach grant is minted; a missing session is never +created implicitly. + +A URL or host target never reuses configured credentials or +`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`. It uses the stored +device token for that exact Gateway origin, or explicit `--token`/`--password` +credentials. On first contact, pass one of those credentials once, approve the +pairing request in that Gateway's Control UI, and retry; see +[Devices](/cli/devices). Session URLs must stay credential-free: userinfo and +sensitive query or fragment parameters such as `token` and `password` are +rejected. + +Target resolution uses the same [session target error matrix](/cli/tui#session-target-errors) +as `openclaw tui`. + The bearer token is passed through environment variables, not argv. OpenClaw launches Claude Code with `--strict-mcp-config --mcp-config ` so ambient Claude MCP servers do not join the attached session. Normal launches (without `--print-config`) revoke the grant when the Claude Code process exits. -See also: [Gateway CLI](/cli/gateway), [MCP CLI](/cli/mcp), and [ACP CLI](/cli/acp). +See also: [Control UI URLs](/web/urls), [Devices](/cli/devices), [Gateway CLI](/cli/gateway), [MCP CLI](/cli/mcp), and [ACP CLI](/cli/acp). diff --git a/docs/cli/tui.md b/docs/cli/tui.md index 9ced5242f22f..5bfa186edcc1 100644 --- a/docs/cli/tui.md +++ b/docs/cli/tui.md @@ -13,6 +13,22 @@ title: "TUI" Open the terminal UI connected to the Gateway, or run it in local embedded mode. +```bash +openclaw tui [target] +``` + +`target` can be a Control UI session URL, a compact `host/agent/ref`, a bare +short reference such as `movies-a1166b81`, or a literal `agent:...` session key. +A URL or host target authoritatively selects that Gateway; a bare reference +uses the configured or default Gateway. You can also paste a Control UI URL +directly as `openclaw ` and place the TUI options after it, for example +`openclaw --token --deliver`. + +The bare-URL form accepts `--token`, `--password`, `--tls-fingerprint`, +`--deliver`, `--thinking`, `--message`, `--timeout-ms`, and `--history-limit`. +Use `openclaw tui ` when you need another TUI option; `--local`, `--url`, +and `--session` conflict with a session URL. + Related guide: [TUI](/web/tui) ## Options @@ -37,8 +53,22 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with ## Notes - `--local` cannot combine with `--url`, `--token`, `--password`, or `--tls-fingerprint`. -- `tui` resolves configured Gateway auth SecretRefs for token/password auth - when possible (`env`/`file`/`exec` providers). +- Pass only one Gateway target. A URL target cannot combine with `--url`, and + any positional target cannot combine with `--session` or local mode. +- A URL or host target never reuses configured credentials or + `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`. It uses the stored + device token for that exact Gateway origin, or explicit `--token`/`--password` + credentials. On first contact, pass one of those credentials once, approve + the pairing request in that Gateway's Control UI, and retry; see + [Devices](/cli/devices). +- Session URLs must stay credential-free. Userinfo and sensitive query or + fragment parameters such as `token` and `password` are rejected. +- Short references resolve through the Gateway. If a short reference is + ambiguous, the CLI prints candidate names and longer ID prefixes without + attaching to either session. +- With no URL/host target or explicit `--url`, `tui` resolves configured Gateway + auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` + providers). - With no explicit URL or port, `tui` follows the active local Gateway port recorded by the running Gateway. Explicit `--url`, `OPENCLAW_GATEWAY_URL`, `OPENCLAW_GATEWAY_PORT`, and remote Gateway config keep precedence. @@ -53,12 +83,25 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with - Session [goals](/tools/goal) appear in the footer and can be managed with `/goal`. +## Session target errors + +| Failure | Recovery | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| The Gateway predates short-link resolution | Copy the full session key from that Gateway's Control UI. | +| Session missing or short ref ambiguous | For the configured/local Gateway, run `openclaw sessions list`; for a URL/host target, choose a longer or full key in that Gateway's Control UI. | +| Gateway unreachable | The error names the selected origin. For a `*.ts.net` host, connect Tailscale and confirm the Gateway is reachable on the tailnet. | +| Stored device token revoked or rotated | Rotate it with `openclaw devices rotate --device --role operator`, then reconnect. | +| TLS certificate pin mismatch | The original TLS fingerprint error passes through unchanged; verify the configured or explicit pin before retrying. | + ## Examples ```bash openclaw chat openclaw tui --local openclaw tui +openclaw tui https://gateway.example/dashboard/main/movies-a1166b81 +openclaw https://gateway.example/dashboard/main/movies-a1166b81 --token +openclaw tui movies-a1166b81 openclaw tui --url ws://127.0.0.1:18789 --token openclaw tui --session main --deliver openclaw chat --message "Compare my config to the docs and tell me what to fix" @@ -96,4 +139,6 @@ rerun `openclaw config validate`. See [TUI](/web/tui) and - [CLI reference](/cli) - [TUI](/web/tui) +- [Control UI URLs](/web/urls) +- [Devices](/cli/devices) - [Goal](/tools/goal) diff --git a/package.json b/package.json index 84cc3af9ca4c..4947225634ff 100644 --- a/package.json +++ b/package.json @@ -2072,6 +2072,7 @@ "@lit/context": "1.1.6", "@lit/task": "1.0.3", "@mdx-js/mdx": "3.1.1", + "@openclaw/session-url-contract": "workspace:*", "@opentelemetry/sdk-node": "0.221.0", "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2d2938acd3e..de685cea4a97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: '@mdx-js/mdx': specifier: 3.1.1 version: 3.1.1(supports-color@10.2.2) + '@openclaw/session-url-contract': + specifier: workspace:* + version: link:packages/session-url-contract '@opentelemetry/sdk-node': specifier: 0.221.0 version: 0.221.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) diff --git a/scripts/openclaw-postpack.mjs b/scripts/openclaw-postpack.mjs index d9106ba41053..4ae6de40a969 100755 --- a/scripts/openclaw-postpack.mjs +++ b/scripts/openclaw-postpack.mjs @@ -5,9 +5,11 @@ import { fileURLToPath } from "node:url"; // Restores every source artifact temporarily rewritten for npm packaging. import { restorePackageChangelog } from "./package-changelog.mjs"; import { restorePackageDocsMap } from "./package-docs-map.mjs"; +import { restorePackageManifest } from "./package-manifest.mjs"; export async function restorePrepackArtifacts(cwd = process.cwd()) { await restorePackageChangelog(cwd); + await restorePackageManifest(cwd); // Release the lifecycle receipt only after every other source mutation settles. await restorePackageDocsMap(cwd); } diff --git a/scripts/openclaw-prepack.ts b/scripts/openclaw-prepack.ts index d657863b00ef..bf9b74dd5c8d 100644 --- a/scripts/openclaw-prepack.ts +++ b/scripts/openclaw-prepack.ts @@ -11,6 +11,7 @@ import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventor import { restorePrepackArtifacts } from "./openclaw-postpack.mjs"; import { preparePackageChangelog } from "./package-changelog.mjs"; import { preparePackageDocsMap } from "./package-docs-map.mjs"; +import { preparePackageManifest } from "./package-manifest.mjs"; import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mts"; const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu; const requiredPreparedPathGroups = [ @@ -291,6 +292,7 @@ export async function preparePrepackArtifacts(env: NodeJS.ProcessEnv = process.e // changelog is touched, so concurrent packs cannot restore each other's files. await preparePackageDocsMap(process.cwd()); try { + await preparePackageManifest(process.cwd()); await preparePackageChangelog(process.cwd(), { allowUnreleased: resolvePrepackAllowUnreleasedChangelog(env), }); diff --git a/scripts/package-manifest.d.mts b/scripts/package-manifest.d.mts new file mode 100644 index 000000000000..b604f3257448 --- /dev/null +++ b/scripts/package-manifest.d.mts @@ -0,0 +1,2 @@ +export function restorePackageManifest(cwd?: string): Promise; +export function preparePackageManifest(cwd?: string): Promise; diff --git a/scripts/package-manifest.mjs b/scripts/package-manifest.mjs new file mode 100644 index 000000000000..4e2b4df0db01 --- /dev/null +++ b/scripts/package-manifest.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +// Temporarily removes private workspace dependencies from the published manifest. +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PACKAGE_JSON_PATH = "package.json"; +const BACKUP_PATH = path.join(".artifacts", "package-manifest", "package.json.prepack-backup"); + +function preparedPackageManifest(content) { + const packageJson = JSON.parse(content); + const devDependencies = packageJson.devDependencies; + if (!devDependencies || typeof devDependencies !== "object" || Array.isArray(devDependencies)) { + return content; + } + const devDependencyEntries = Object.entries(devDependencies); + const publishedDevDependencyEntries = devDependencyEntries.filter( + ([, spec]) => typeof spec !== "string" || !spec.startsWith("workspace:"), + ); + if (publishedDevDependencyEntries.length === devDependencyEntries.length) { + return content; + } + if (publishedDevDependencyEntries.length === 0) { + delete packageJson.devDependencies; + } else { + packageJson.devDependencies = Object.fromEntries(publishedDevDependencyEntries); + } + return `${JSON.stringify(packageJson, null, 2)}\n`; +} + +/** Restore package.json after prepack removed private workspace dependencies. */ +export async function restorePackageManifest(cwd = process.cwd()) { + const backupPath = path.join(cwd, BACKUP_PATH); + if (!existsSync(backupPath)) { + return false; + } + const packageJsonPath = path.join(cwd, PACKAGE_JSON_PATH); + const [original, current] = await Promise.all([ + readFile(backupPath, "utf8"), + readFile(packageJsonPath, "utf8"), + ]); + if (current !== original && current !== preparedPackageManifest(original)) { + throw new Error( + `Refusing to restore ${PACKAGE_JSON_PATH} because it changed after prepack sanitized it.`, + ); + } + await writeFile(packageJsonPath, original, "utf8"); + await rm(backupPath, { force: true }); + return true; +} + +/** Remove private workspace dependencies while recording restorable source bytes. */ +export async function preparePackageManifest(cwd = process.cwd()) { + const packageJsonPath = path.join(cwd, PACKAGE_JSON_PATH); + const backupPath = path.join(cwd, BACKUP_PATH); + const original = await readFile(packageJsonPath, "utf8"); + const prepared = preparedPackageManifest(original); + if (prepared === original) { + return false; + } + await mkdir(path.dirname(backupPath), { recursive: true }); + try { + await writeFile(backupPath, original, { encoding: "utf8", flag: "wx" }); + } catch (error) { + if (error?.code === "EEXIST") { + throw new Error( + `Another package preparation owns ${PACKAGE_JSON_PATH}; wait for it to finish or run \`node scripts/openclaw-postpack.mjs\` after an interrupted pack.`, + { cause: error }, + ); + } + throw error; + } + try { + await writeFile(packageJsonPath, prepared, "utf8"); + } catch (error) { + try { + await restorePackageManifest(cwd); + } catch (restoreError) { + const failure = new Error( + `Writing ${PACKAGE_JSON_PATH} failed and its source state could not be restored.`, + { cause: error }, + ); + Object.assign(failure, { restoreError }); + throw failure; + } + throw error; + } + return true; +} + +async function main(argv = process.argv.slice(2)) { + if (argv.length !== 1 || (argv[0] !== "prepare" && argv[0] !== "restore")) { + console.error("Usage: node scripts/package-manifest.mjs "); + process.exitCode = 1; + return; + } + const changed = + argv[0] === "prepare" ? await preparePackageManifest() : await restorePackageManifest(); + console.error( + changed + ? `package-manifest: ${argv[0] === "prepare" ? "sanitized" : "restored"} package.json.` + : `package-manifest: no ${argv[0] === "prepare" ? "sanitation" : "cleanup"} needed.`, + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/package-openclaw-for-docker.mts b/scripts/package-openclaw-for-docker.mts index a44e5c13bbc4..d81d1d50ede8 100644 --- a/scripts/package-openclaw-for-docker.mts +++ b/scripts/package-openclaw-for-docker.mts @@ -60,6 +60,10 @@ type DocsMapLifecycle = { preparePackageDocsMap: (cwd: string) => Promise; restorePackageDocsMap: (cwd: string) => Promise; }; +type PackageManifestLifecycle = { + preparePackageManifest: (cwd: string) => Promise; + restorePackageManifest: (cwd: string) => Promise; +}; type PackageOptions = RunOptions & { allowUnreleasedChangelog?: unknown; extractAiRuntime?: (tarballPath: string, destination: string) => Promise; @@ -69,8 +73,10 @@ type PackageOptions = RunOptions & { prepareBundledAiRuntime?: typeof prepareBundledAiRuntimePackage; prepareChangelog?: (cwd: string) => Promise; prepareDocsMap?: (cwd: string) => Promise; + prepareManifest?: (cwd: string) => Promise; restoreChangelog?: (cwd: string) => Promise; restoreDocsMap?: (cwd: string) => Promise; + restoreManifest?: (cwd: string) => Promise; runCaptureImpl?: RunImpl; runImpl?: CommandRunner; }; @@ -84,6 +90,14 @@ function isDocsMapLifecycle(value: unknown): value is DocsMapLifecycle { ); } +function isPackageManifestLifecycle(value: unknown): value is PackageManifestLifecycle { + return ( + isRecord(value) && + typeof value.preparePackageManifest === "function" && + typeof value.restorePackageManifest === "function" + ); +} + function hasErrorCode(error: unknown, code: string) { return isRecord(error) && error.code === code; } @@ -779,15 +793,21 @@ export async function prepareBundledAiRuntimePackage( async function restorePackageSourceArtifacts( sourceDir: string, restoreDocsMap: (cwd: string) => Promise, + restoreManifest: (cwd: string) => Promise, restoreChangelog: (cwd: string) => Promise, ) { await restoreChangelog(sourceDir); + await restoreManifest(sourceDir); // Release the lifecycle receipt only after every other source mutation settles. await restoreDocsMap(sourceDir); } -async function loadSourceDocsMapLifecycle(sourceDir: string) { - const modulePath = path.join(sourceDir, "scripts", "package-docs-map.mjs"); +async function loadSourcePackageLifecycle( + sourceDir: string, + moduleName: string, + validate: (value: unknown) => boolean, +) { + const modulePath = path.join(sourceDir, "scripts", moduleName); try { await fs.access(modulePath); } catch (error) { @@ -797,13 +817,10 @@ async function loadSourceDocsMapLifecycle(sourceDir: string) { throw error; } const lifecycle: unknown = await import(pathToFileURL(modulePath).href); - if (!isDocsMapLifecycle(lifecycle)) { - throw new Error(`source package docs-map lifecycle is invalid: ${modulePath}`); + if (!validate(lifecycle)) { + throw new Error(`source package lifecycle is invalid: ${modulePath}`); } - return { - preparePackageDocsMap: lifecycle.preparePackageDocsMap, - restorePackageDocsMap: lifecycle.restorePackageDocsMap, - }; + return lifecycle; } function packagePreparationRestoreError(error: unknown, restoreError: unknown) { @@ -831,7 +848,11 @@ export async function packOpenClawPackageForDocker( const sourceDocsMapLifecycle = packageOptions.prepareDocsMap && packageOptions.restoreDocsMap ? null - : await loadSourceDocsMapLifecycle(sourcePath); + : ((await loadSourcePackageLifecycle( + sourcePath, + "package-docs-map.mjs", + isDocsMapLifecycle, + )) as DocsMapLifecycle | null); const prepareDocsMap = packageOptions.prepareDocsMap ?? sourceDocsMapLifecycle?.preparePackageDocsMap ?? @@ -840,6 +861,22 @@ export async function packOpenClawPackageForDocker( packageOptions.restoreDocsMap ?? sourceDocsMapLifecycle?.restorePackageDocsMap ?? (async () => false); + const sourceManifestLifecycle = + packageOptions.prepareManifest && packageOptions.restoreManifest + ? null + : ((await loadSourcePackageLifecycle( + sourcePath, + "package-manifest.mjs", + isPackageManifestLifecycle, + )) as PackageManifestLifecycle | null); + const prepareManifest = + packageOptions.prepareManifest ?? + sourceManifestLifecycle?.preparePackageManifest ?? + (async () => false); + const restoreManifest = + packageOptions.restoreManifest ?? + sourceManifestLifecycle?.restorePackageManifest ?? + (async () => false); const prepareBundledAiRuntime = packageOptions.prepareBundledAiRuntime ?? prepareBundledAiRuntimePackage; const packTool = packageOptions.pnpmPack ? "pnpm" : "npm"; @@ -850,10 +887,16 @@ export async function packOpenClawPackageForDocker( // This receipt is the package lifecycle lock; acquire it before touching CHANGELOG.md. await prepareDocsMap(sourcePath); try { + await prepareManifest(sourcePath); await prepareChangelog(sourcePath); } catch (error) { try { - await restorePackageSourceArtifacts(sourcePath, restoreDocsMap, restoreChangelog); + await restorePackageSourceArtifacts( + sourcePath, + restoreDocsMap, + restoreManifest, + restoreChangelog, + ); } catch (restoreError) { throw packagePreparationRestoreError(error, restoreError); } @@ -886,7 +929,12 @@ export async function packOpenClawPackageForDocker( try { await cleanupBundledAiRuntime(); } finally { - await restorePackageSourceArtifacts(sourcePath, restoreDocsMap, restoreChangelog); + await restorePackageSourceArtifacts( + sourcePath, + restoreDocsMap, + restoreManifest, + restoreChangelog, + ); } } // pnpm reports an absolute destination path. The directory was emptied before packing, diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 7bfdbceb7b51..ef2f180b45ff 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -2044,6 +2044,7 @@ const EXACT_TOOLING_TARGETS = new Map([ ["scripts/lib/failed-trailer.mts", ["run-oxlint", "run-tsgo", "run-vitest", "changed-lanes"]], ["scripts/docker-e2e-rerun.mts", ["docker-e2e-helper-cli"]], ["scripts/openclaw-postpack.mjs", [TOOLING_VITEST_CONFIG]], + ["scripts/package-manifest.mjs", ["test/openclaw-prepack.test.ts"]], ["scripts/openclaw-npm-prepublish-verify.ts", ["test/openclaw-npm-prepublish-verify.test.ts"]], ["scripts/lib/docker-e2e-scenarios.mts", [dockerE2e, pluginPrerelease]], ["scripts/e2e/kitchen-sink-rpc-walk.mts", ["kitchen-sink-rpc-walk", pluginPrerelease]], diff --git a/src/cli/attach-cli.action.test.ts b/src/cli/attach-cli.action.test.ts index 18a1da7a6694..e216183c32bf 100644 --- a/src/cli/attach-cli.action.test.ts +++ b/src/cli/attach-cli.action.test.ts @@ -9,6 +9,10 @@ const gatewayCalls: Array<{ method: string; params: Record; mode?: string; + url?: string; + token?: string; + useStoredDeviceAuth?: boolean; + requiredStoredDeviceAuthScopes?: string[]; hasDeviceIdentityKey: boolean; }> = []; @@ -21,13 +25,31 @@ function gatewayParams(params: unknown): Record { vi.mock("../gateway/call.js", () => ({ callGateway: vi.fn( - async (p: { method: string; params: Record; mode?: string }) => { + async (p: { + method: string; + params: Record; + mode?: string; + url?: string; + token?: string; + useStoredDeviceAuth?: boolean; + requiredStoredDeviceAuthScopes?: string[]; + }) => { gatewayCalls.push({ method: p.method, params: gatewayParams(p.params), mode: p.mode, + url: p.url, + token: p.token, + useStoredDeviceAuth: p.useStoredDeviceAuth, + requiredStoredDeviceAuthScopes: p.requiredStoredDeviceAuthScopes, hasDeviceIdentityKey: "deviceIdentity" in p, }); + if (p.method === "sessions.resolve") { + return { ok: true, key: "agent:ops:thread:resolved" }; + } + if (p.method === "agents.list") { + return { defaultId: "main", mainKey: "main", scope: "global", agents: [] }; + } if (p.method === "attach.grant") { const sessionKey = (p.params.sessionKey as string) ?? "agent:main:main"; return { @@ -49,6 +71,8 @@ vi.mock("../gateway/call.js", () => ({ return {}; }, ), + GatewayStoredDeviceAuthUnavailableError: class extends Error {}, + GatewayTransportError: class extends Error {}, })); const logs: string[] = []; @@ -111,6 +135,55 @@ describe("openclaw attach (action)", () => { expect(grant?.hasDeviceIdentityKey).toBe(false); }); + it("resolves a URL target before granting on the same origin", async () => { + await runAttach( + "https://gateway.example/base/dashboard/ops/movies-a1166b81", + "--token", + "explicit-token", + "--print-config", + ); + + const resolve = gatewayCalls.find((call) => call.method === "sessions.resolve"); + expect(resolve).toMatchObject({ + url: "wss://gateway.example/base", + token: "explicit-token", + useStoredDeviceAuth: true, + requiredStoredDeviceAuthScopes: ["operator.read"], + params: { shortId: "a1166b81", slugHint: "movies" }, + }); + expect(gatewayCalls.find((call) => call.method === "attach.grant")).toMatchObject({ + url: "wss://gateway.example/base", + token: "explicit-token", + useStoredDeviceAuth: true, + requiredStoredDeviceAuthScopes: ["operator.admin"], + params: { sessionKey: "agent:ops:thread:resolved" }, + }); + }); + + it("preserves a global-scope URL main session when granting attach access", async () => { + await runAttach( + "https://gateway.example/base/dashboard/ops", + "--token", + "explicit-token", + "--print-config", + ); + + expect(gatewayCalls.find((call) => call.method === "agents.list")).toMatchObject({ + url: "wss://gateway.example/base", + token: "explicit-token", + useStoredDeviceAuth: true, + requiredStoredDeviceAuthScopes: ["operator.read"], + params: {}, + }); + expect(gatewayCalls.find((call) => call.method === "attach.grant")).toMatchObject({ + url: "wss://gateway.example/base", + token: "explicit-token", + useStoredDeviceAuth: true, + requiredStoredDeviceAuthScopes: ["operator.admin"], + params: { sessionKey: "global", agentId: "ops" }, + }); + }); + it("rejects a non-positive --ttl before minting", async () => { await runAttach("--ttl", "-5", "--print-config"); expect(exitCode).toBe(1); diff --git a/src/cli/attach-cli.ts b/src/cli/attach-cli.ts index 137e75c1f8e9..c8edb9dd5747 100644 --- a/src/cli/attach-cli.ts +++ b/src/cli/attach-cli.ts @@ -3,14 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { constants as osConstants, tmpdir } from "node:os"; import { join } from "node:path"; import type { Command } from "commander"; -import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, -} from "../../packages/gateway-protocol/src/client-info.js"; import { getRuntimeConfig } from "../config/io.js"; -import { callGateway } from "../gateway/call.js"; import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { defaultRuntime } from "../runtime.js"; +import { + callSessionTargetGateway, + resolveSessionTarget, + type SessionTargetGateway, +} from "./session-target.js"; type AttachGrant = { sessionKey: string; @@ -34,7 +34,12 @@ export async function registerAttachCli(program: Command, _argv: string[] = proc program .command("attach") .description("Attach Claude Code to a gateway session with scoped MCP tools") + .argument("[target]", "Control UI URL, host/agent/ref, short ref, or agent:... key") .option("--session ", "Gateway session key to bind (default: main session)") + .option("--url ", "Gateway WebSocket URL") + .option("--token ", "Gateway token (if required)") + .option("--password ", "Gateway password (if required)") + .option("--tls-fingerprint ", "Expected Gateway TLS certificate fingerprint") .option( "--ttl ", "Grant TTL in positive base-10 integer milliseconds (default: gateway policy)", @@ -47,121 +52,163 @@ export async function registerAttachCli(program: Command, _argv: string[] = proc ) .addHelpText( "after", - "\nExamples:\n openclaw attach Attach Claude Code to the main session\n openclaw attach --session agent:main:telegram:123 --ttl 600000\n openclaw attach --print-config Set up the grant + config and print how to launch it yourself\n", + "\nExamples:\n openclaw attach Attach Claude Code to the main session\n openclaw attach movies-a1166b81 Attach to a short session reference\n openclaw attach --session agent:main:telegram:123 --ttl 600000\n openclaw attach --print-config Set up the grant + config and print how to launch it yourself\n", ) - .action(async (opts: { session?: string; ttl?: string; bin: string; printConfig: boolean }) => { - let ttlMs: number | undefined; - if (opts.ttl !== undefined) { - ttlMs = parseStrictPositiveInteger(opts.ttl); - if (ttlMs === undefined) { - defaultRuntime.error( - `--ttl must be a positive integer of milliseconds. Got: ${JSON.stringify(opts.ttl)}`, - ); + .action( + async ( + target: string | undefined, + opts: { + session?: string; + url?: string; + token?: string; + password?: string; + tlsFingerprint?: string; + ttl?: string; + bin: string; + printConfig: boolean; + }, + ) => { + if (target && opts.session) { + throw new Error("pass one session target: use either the positional target or --session"); + } + let ttlMs: number | undefined; + if (opts.ttl !== undefined) { + ttlMs = parseStrictPositiveInteger(opts.ttl); + if (ttlMs === undefined) { + defaultRuntime.error( + `--ttl must be a positive integer of milliseconds. Got: ${JSON.stringify(opts.ttl)}`, + ); + defaultRuntime.exit(1); + return; + } + } + + const cfg = getRuntimeConfig(); + const resolved = target + ? await resolveSessionTarget({ + raw: target, + gateway: { + config: cfg, + url: opts.url, + token: opts.token, + password: opts.password, + tlsFingerprint: opts.tlsFingerprint, + }, + }) + : undefined; + const gateway: SessionTargetGateway = resolved?.gateway ?? { + config: cfg, + url: opts.url, + token: opts.token, + password: opts.password, + tlsFingerprint: opts.tlsFingerprint, + }; + const globalAgentId = + resolved?.sessionKey === "global" && resolved.parsed.kind === "url" + ? resolved.parsed.agentId + : undefined; + const granted = (await callSessionTargetGateway({ + gateway, + method: "attach.grant", + request: { + sessionKey: resolved?.sessionKey ?? opts.session, + ...(globalAgentId ? { agentId: globalAgentId } : {}), + ttlMs, + }, + requiredScope: "operator.admin", + })) as Partial | null; + if ( + !granted || + typeof granted.token !== "string" || + typeof granted.sessionKey !== "string" || + typeof granted.expiresAtMs !== "number" || + !Number.isFinite(granted.expiresAtMs) || + !granted.mcpConfig?.mcpServers || + typeof granted.env !== "object" || + granted.env === null + ) { + defaultRuntime.error("attach.grant returned an unexpected response from the gateway."); defaultRuntime.exit(1); return; } - } + const grant = granted as AttachGrant; - const cfg = getRuntimeConfig(); - const granted = (await callGateway({ - config: cfg, - method: "attach.grant", - params: { sessionKey: opts.session, ttlMs }, - mode: GATEWAY_CLIENT_MODES.CLI, - clientName: GATEWAY_CLIENT_NAMES.CLI, - })) as Partial | null; - if ( - !granted || - typeof granted.token !== "string" || - typeof granted.sessionKey !== "string" || - typeof granted.expiresAtMs !== "number" || - !Number.isFinite(granted.expiresAtMs) || - !granted.mcpConfig?.mcpServers || - typeof granted.env !== "object" || - granted.env === null - ) { - defaultRuntime.error("attach.grant returned an unexpected response from the gateway."); - defaultRuntime.exit(1); - return; - } - const grant = granted as AttachGrant; + const { path: configPath, cleanup } = writeClaudeMcpConfig(grant.mcpConfig); + const expiresAt = new Date(grant.expiresAtMs).toISOString(); + const claudeArgs = ["--strict-mcp-config", "--mcp-config", configPath]; - const { path: configPath, cleanup } = writeClaudeMcpConfig(grant.mcpConfig); - const expiresAt = new Date(grant.expiresAtMs).toISOString(); - const claudeArgs = ["--strict-mcp-config", "--mcp-config", configPath]; + if (opts.printConfig) { + defaultRuntime.log( + JSON.stringify( + { + sessionKey: grant.sessionKey, + expiresAt, + env: grant.env, + configPath, + launch: [opts.bin, ...claudeArgs], + }, + null, + 2, + ), + ); + defaultRuntime.log( + `Grant is live until ${expiresAt} and auto-expires; it is not revoked here. Launch with the env above, then delete ${configPath} when done.`, + ); + return; + } + + let revokePromise: Promise | undefined; + const revokeOnce = () => + (revokePromise ??= (async () => { + try { + await callSessionTargetGateway({ + gateway, + method: "attach.revoke", + request: { token: grant.token }, + requiredScope: "operator.admin", + }); + } catch (error) { + defaultRuntime.error( + `Warning: failed to revoke attach grant; it remains live until ${expiresAt}. ${String(error)}`, + ); + } + cleanup(); + })()); - if (opts.printConfig) { defaultRuntime.log( - JSON.stringify( - { - sessionKey: grant.sessionKey, - expiresAt, - env: grant.env, - configPath, - launch: [opts.bin, ...claudeArgs], - }, - null, - 2, - ), + `Attaching Claude Code to session ${grant.sessionKey} (grant expires ${expiresAt})…`, ); - defaultRuntime.log( - `Grant is live until ${expiresAt} and auto-expires; it is not revoked here. Launch with the env above, then delete ${configPath} when done.`, - ); - return; - } + const child = spawn(opts.bin, claudeArgs, { + stdio: "inherit", + env: { ...process.env, ...grant.env }, + }); - let revokePromise: Promise | undefined; - const revokeOnce = () => - (revokePromise ??= (async () => { - try { - await callGateway({ - config: cfg, - method: "attach.revoke", - params: { token: grant.token }, - mode: GATEWAY_CLIENT_MODES.CLI, - clientName: GATEWAY_CLIENT_NAMES.CLI, - }); - } catch (error) { - defaultRuntime.error( - `Warning: failed to revoke attach grant; it remains live until ${expiresAt}. ${String(error)}`, - ); - } - cleanup(); - })()); + const onSigint = () => {}; + const onSigterm = () => child.kill("SIGTERM"); + const finish = (code: number) => { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + defaultRuntime.exit(code); + }; - defaultRuntime.log( - `Attaching Claude Code to session ${grant.sessionKey} (grant expires ${expiresAt})…`, - ); - const child = spawn(opts.bin, claudeArgs, { - stdio: "inherit", - env: { ...process.env, ...grant.env }, - }); - - const onSigint = () => {}; - const onSigterm = () => child.kill("SIGTERM"); - const finish = (code: number) => { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - defaultRuntime.exit(code); - }; - - child.on("error", (error) => { - void (async () => { - defaultRuntime.error(`Failed to launch '${opts.bin}': ${String(error)}`); - await revokeOnce(); - finish(1); - })(); - }); - child.on("exit", (code, signal) => { - void (async () => { - await revokeOnce(); - const signalCode = signal - ? 128 + ((osConstants.signals as Record)[signal] ?? 0) - : null; - finish(signalCode ?? code ?? 0); - })(); - }); - process.on("SIGINT", onSigint); - process.on("SIGTERM", onSigterm); - }); + child.on("error", (error) => { + void (async () => { + defaultRuntime.error(`Failed to launch '${opts.bin}': ${String(error)}`); + await revokeOnce(); + finish(1); + })(); + }); + child.on("exit", (code, signal) => { + void (async () => { + await revokeOnce(); + const signalCode = signal + ? 128 + ((osConstants.signals as Record)[signal] ?? 0) + : null; + finish(signalCode ?? code ?? 0); + })(); + }); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + }, + ); } diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 15c3c39812b1..4d2114d2e436 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildProgram } from "./program.js"; import { + callGateway, configureCommand, ensureConfigReady, runSystemAgentWithInference, @@ -66,6 +67,66 @@ describe("cli program (smoke)", () => { expect(options?.forceProcessExitOnReturn).toBe(true); }); + it("resolves a positional tui short reference before launch", async () => { + callGateway.mockResolvedValue({ ok: true, key: "agent:main:thread:resolved" }); + + await runProgram(["tui", "movies-a1166b81"]); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "sessions.resolve", + params: { shortId: "a1166b81", slugHint: "movies" }, + }), + ); + expect(firstMockArg(runTui)).toMatchObject({ + local: false, + session: "agent:main:thread:resolved", + }); + }); + + it("preserves a global-scope URL main session when launching tui", async () => { + callGateway.mockResolvedValue({ + defaultId: "main", + mainKey: "main", + scope: "global", + agents: [], + }); + + await runProgram(["tui", "https://gateway.example/dashboard/ops"]); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "agents.list", + params: {}, + }), + ); + expect(firstMockArg(runTui)).toMatchObject({ + local: false, + session: "global", + agentId: "ops", + }); + }); + + it("leaves tui agent inference unchanged without a URL agent", async () => { + await runProgram(["tui"]); + + expect(firstMockArg(runTui)).not.toHaveProperty("agentId"); + }); + + it("rejects a URL target combined with --url", async () => { + await expect( + runProgram([ + "tui", + "https://gateway.example/dashboard/main/movies-a1166b81", + "--url", + "wss://other.example", + ]), + ).rejects.toThrow("exit"); + + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("pass one target")); + expect(runTui).not.toHaveBeenCalled(); + }); + it("runs setup one-shot requests", async () => { await runProgram(["setup", "--message", "status"]); const options = firstMockArg(runSystemAgentWithInference) as { diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index fe0ca41e6f75..e603c852fcb7 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -94,6 +94,8 @@ vi.mock("../commands/system-agent-with-inference.js", () => ({ })); vi.mock("../gateway/call.js", () => ({ callGateway: programMocks.callGateway, + GatewayStoredDeviceAuthUnavailableError: class extends Error {}, + GatewayTransportError: class extends Error {}, randomIdempotencyKey: () => "idem-test", buildGatewayConnectionDetails: () => ({ url: "ws://127.0.0.1:1234", diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 422eeb863ea6..b26292ff5041 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -116,6 +116,9 @@ const readLocalOnboardingStateMock = vi.hoisted(() => const setupWizardCommandMock = vi.hoisted(() => vi.fn(async () => {})); const runRemoteGatewayInferenceOnboardingMock = vi.hoisted(() => vi.fn(async () => {})); const runTuiMock = vi.hoisted(() => vi.fn<(opts: unknown) => Promise>(async () => {})); +const runTuiCliActionMock = vi.hoisted(() => + vi.fn<(target: string | undefined, opts: unknown) => Promise>(async () => {}), +); const probeGatewayConfiguredModelMock = vi.hoisted(() => vi.fn< () => Promise<{ @@ -423,6 +426,10 @@ vi.mock("../tui/tui.js", () => ({ runTui: runTuiMock, })); +vi.mock("./tui-cli.js", () => ({ + runTuiCliAction: runTuiCliActionMock, +})); + vi.mock("./progress.js", () => ({ createCliProgress: createCliProgressMock, })); @@ -2707,6 +2714,159 @@ describe("runCli exit behavior", () => { expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); }); + it("routes a bare-root Control UI URL directly to the TUI action", async () => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + + await withInteractiveTty(() => runCli(["node", "openclaw", target])); + + expect(runTuiCliActionMock).toHaveBeenCalledWith(target, {}); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + }); + + it.each(["tui", "attach", "logs"])( + "leaves an explicit %s URL invocation on the Commander path", + async (command) => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + const argv = ["node", "openclaw", command, target]; + buildProgramMock.mockReturnValueOnce({ + commands: [{ name: () => command, aliases: () => [] }], + parseAsync: commanderParseAsyncMock, + }); + + await runCli(argv); + + expect(runTuiCliActionMock).not.toHaveBeenCalled(); + expect(buildProgramMock).toHaveBeenCalledTimes(1); + expect(commanderParseAsyncMock).toHaveBeenCalledWith(argv); + }, + ); + + it("leaves plugin-owned URL arguments on the plugin command path", async () => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + const argv = ["node", "openclaw", "googlemeet", target]; + buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync: commanderParseAsyncMock }); + + await runCli(argv); + + expect(runTuiCliActionMock).not.toHaveBeenCalled(); + expect(buildProgramMock).toHaveBeenCalledTimes(1); + expect(commanderParseAsyncMock).toHaveBeenCalledWith(argv); + }); + + it("does not steal a URL argument from an unowned command", async () => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + + await expect(runCli(["node", "openclaw", "unknown-owner", target])).rejects.toThrow( + "Unknown command: openclaw unknown-owner", + ); + + expect(runTuiCliActionMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: "after the URL", + args: [ + "https://gateway.example/dashboard/main/movies-a1166b81", + "--token", + "direct-token", + "--password=direct-password", + "--tls-fingerprint", + "sha256:direct", + "--deliver", + "--message", + "continue here", + ], + }, + { + label: "before the URL with split values", + args: [ + "--token", + "direct-token", + "--password", + "direct-password", + "--tls-fingerprint", + "sha256:direct", + "https://gateway.example/dashboard/main/movies-a1166b81", + "--deliver", + "--message", + "continue here", + ], + }, + { + label: "before the URL with inline values", + args: [ + "--token=direct-token", + "--password=direct-password", + "--tls-fingerprint=sha256:direct", + "--message=continue here", + "https://gateway.example/dashboard/main/movies-a1166b81", + "--deliver", + ], + }, + ])("forwards bare-root TUI options $label without an environment handoff", async ({ args }) => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + await withEnvAsync( + { + OPENCLAW_GATEWAY_TOKEN: "ambient-token", + OPENCLAW_GATEWAY_PASSWORD: "ambient-password", + }, + () => withInteractiveTty(() => runCli(["node", "openclaw", ...args])), + ); + + expect(runTuiCliActionMock).toHaveBeenCalledWith(target, { + token: "direct-token", + password: "direct-password", + tlsFingerprint: "sha256:direct", + deliver: true, + message: "continue here", + }); + }); + + it.each([ + ["unknown inline option", ["--typo=do-not-print-me"]], + ["unknown split option", ["--typo", "do-not-print-me"]], + ["option terminator", ["--"]], + ])("rejects a pre-URL %s without reflecting values", async (_label, prefix) => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + let error: unknown; + try { + await runCli(["node", "openclaw", ...prefix, target]); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toContain("do-not-print-me"); + expect(runTuiCliActionMock).not.toHaveBeenCalled(); + }); + + it("rejects a missing pre-URL direct option value before command discovery", async () => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + + await expect(runCli(["node", "openclaw", "--token", target])).rejects.toThrow( + "--token requires a value", + ); + expect(runTuiCliActionMock).not.toHaveBeenCalled(); + }); + + it("does not claim a bare session ref as root-command sugar", async () => { + await expect(runCli(["node", "openclaw", "movies-a1166b81"])).rejects.toThrow( + "Unknown command: openclaw movies-a1166b81", + ); + + expect(runTuiCliActionMock).not.toHaveBeenCalled(); + }); + + it("does not claim host shorthand as root-command sugar", async () => { + await expect(runCli(["node", "openclaw", "gateway.example/main/a1166b81"])).rejects.toThrow( + "Unknown command: openclaw gateway.example/main/a1166b81", + ); + + expect(runTuiCliActionMock).not.toHaveBeenCalled(); + }); + it("suggests close known commands for unowned command roots before proxy startup", async () => { await expect(runCli(["node", "openclaw", "upate"])).rejects.toThrow( "Did you mean this?\n openclaw update", diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 184ed97f1617..51d077d88d0e 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -8,11 +8,8 @@ import type { Command as CommanderCommand, Option as CommanderOption } from "com import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { resolveStateDir } from "../config/paths.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; -import { - isLoopbackAddress, - isSecureWebSocketUrl, - normalizeWebSocketProtocol, -} from "../gateway/net.js"; +import { isLoopbackAddress, isSecureWebSocketUrl } from "../gateway/net.js"; +import { normalizeWebSocketProtocol } from "../gateway/websocket-protocol.js"; import { consumeRootOptionToken, FLAG_TERMINATOR, @@ -1195,6 +1192,12 @@ async function runCliWithPreparedOutputMode( if (shouldEnsureCliPath(normalizedArgv)) { ensureOpenClawCliOnPath(); } + // Cheap import gate only. Session-ref owns the authoritative URL/options parse. + const mayContainBareSessionUrl = normalizedArgv.slice(2).some((arg) => arg.includes("://")); + const bareSessionInvocation = + !isHelpOrVersionInvocation && mayContainBareSessionUrl + ? (await import("./session-ref.js")).parseBareSessionInvocation(normalizedArgv) + : null; // Activate operator-managed proxy routing for network-capable commands. // Local Gateway/control-plane commands keep direct loopback access while @@ -1240,6 +1243,7 @@ async function runCliWithPreparedOutputMode( } if ( !isHelpOrVersionInvocation && + !bareSessionInvocation && normalizedInvocation.primary && !isKnownBuiltInCommandRoot(normalizedInvocation.primary) ) { @@ -1308,9 +1312,11 @@ async function runCliWithPreparedOutputMode( }; if (!isHelpOrVersionInvocation && shouldStartProxyForCli(normalizedArgv)) { const config = await withConsoleLogsRoutedToStderr(readBestEffortCliConfig); - const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config }); - if (unownedPrimary) { - throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config })); + if (!bareSessionInvocation) { + const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config }); + if (unownedPrimary) { + throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config })); + } } await replaceStartedProxy(config?.proxy ?? undefined); } @@ -1358,6 +1364,19 @@ async function runCliWithPreparedOutputMode( // invocation can still fail validation and must honor the console style. await installConsoleCapture(); + if (bareSessionInvocation) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error( + "OpenClaw TUI needs an interactive TTY. Use `openclaw agent --local ...` for automation.", + ); + process.exitCode = 1; + return; + } + const { runTuiCliAction } = await import("./tui-cli.js"); + await runTuiCliAction(bareSessionInvocation.target, bareSessionInvocation.options); + return; + } + // Reject unowned command roots before help/version routing, so that // `openclaw --help` surfaces the same Unknown command error as // `openclaw ` instead of silently showing generic top-level help. diff --git a/src/cli/session-ref.test.ts b/src/cli/session-ref.test.ts new file mode 100644 index 000000000000..1e4f88bb8844 --- /dev/null +++ b/src/cli/session-ref.test.ts @@ -0,0 +1,564 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GatewayStoredDeviceAuthUnavailableError, GatewayTransportError } from "../gateway/call.js"; +import { GatewayClientRequestError } from "../gateway/client.js"; + +const callGatewayMock = vi.hoisted(() => vi.fn()); + +vi.mock("../gateway/call.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, callGateway: callGatewayMock }; +}); + +import { + parseBareSessionInvocation, + parseSessionTargetInput, + SessionTargetParseError, +} from "./session-ref.js"; +import { resolveSessionTarget } from "./session-target.js"; + +function gatewayTransportError(params: { + url: string; + message: string; + reason?: string; + kind?: "closed" | "timeout"; +}): GatewayTransportError { + return new GatewayTransportError({ + kind: params.kind ?? "closed", + message: params.message, + reason: params.reason, + connectionDetails: { + url: params.url, + urlSource: "cli --url", + message: `Gateway target: ${params.url}`, + }, + }); +} + +describe("session target parsing", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + delete process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS; + }); + + it.each([ + { + input: "https://Gateway.Example/dashboard/Ops/", + expected: { + kind: "url", + origin: "wss://gateway.example", + basePath: "", + agentId: "ops", + ref: { kind: "main" }, + }, + }, + { + input: "https://Gateway.Example/base/dashboard/Ops/movies-A1166B81/", + expected: { + kind: "url", + origin: "wss://gateway.example", + basePath: "/base", + agentId: "ops", + ref: { kind: "short", shortId: "a1166b81", slugHint: "movies" }, + }, + }, + { + input: "wss://gateway.example/base/chat/ops/telegram/123?view=compact#messages", + expected: { + kind: "url", + origin: "wss://gateway.example", + basePath: "/base", + agentId: "ops", + ref: { kind: "literal", sessionKey: "agent:ops:telegram:123" }, + }, + }, + { + input: "https://gateway.example/tenant/chat/dashboard/ops/movies-a1166b81", + expected: { + kind: "url", + origin: "wss://gateway.example", + basePath: "/tenant/chat", + agentId: "ops", + ref: { kind: "short", shortId: "a1166b81", slugHint: "movies" }, + }, + }, + { + input: "wss://gateway.example/dashboard/ops/~key/release-deadbeef", + expected: { + kind: "url", + origin: "wss://gateway.example", + basePath: "", + agentId: "ops", + ref: { kind: "literal", sessionKey: "agent:ops:release-deadbeef" }, + }, + }, + { + input: "Gateway.Example/Ops/movies-A1166B81/", + expected: { + kind: "url", + origin: "wss://gateway.example", + basePath: "", + agentId: "ops", + ref: { kind: "short", shortId: "a1166b81", slugHint: "movies" }, + }, + }, + { + input: "MOVIES-A1166B81", + expected: { + kind: "ref", + ref: { kind: "short", shortId: "a1166b81", slugHint: "MOVIES" }, + }, + }, + { + input: "A1166B81", + expected: { kind: "ref", ref: { kind: "short", shortId: "a1166b81" } }, + }, + { + input: "AGENT:Ops:Telegram:123", + expected: { + kind: "ref", + ref: { kind: "literal", sessionKey: "agent:ops:telegram:123" }, + }, + }, + ])("parses $input", ({ input, expected }) => { + expect(parseSessionTargetInput(input)).toEqual(expected); + }); + + it.each([ + "", + "not-a-session", + "deadbee", + "1234567890abcdef1234567890abcdef0", + "main", + "https://gateway.example/dashboard", + "https://gateway.example/DASHBOARD/main/deadbeef", + "https://gateway.example/dashboard/main/%zz", + "ftp://gateway.example/dashboard/main/deadbeef", + "gateway.example/main", + ])("rejects %j with the typed accepted-forms error", (input) => { + expect(() => parseSessionTargetInput(input)).toThrow(SessionTargetParseError); + expect(() => parseSessionTargetInput(input)).toThrow("Accepted session targets:"); + }); + + it("rejects credentials without echoing them", () => { + const secret = "do-not-print-me"; + let error: unknown; + try { + parseSessionTargetInput( + `https://user:${secret}@gateway.example/dashboard/main/movies-a1166b81`, + ); + } catch (caught) { + error = caught; + } + expect(String(error)).toContain("must not contain credentials"); + expect(String(error)).not.toContain(secret); + }); + + it("rejects credential query and fragment parameters without echoing them", () => { + for (const suffix of ["?token=do-not-print-me", "#password=do-not-print-me"]) { + let error: unknown; + try { + parseSessionTargetInput(`https://gateway.example/dashboard/main/movies-a1166b81${suffix}`); + } catch (caught) { + error = caught; + } + expect(String(error)).toContain("must not contain credentials"); + expect(String(error)).not.toContain("do-not-print-me"); + } + }); + + it("surfaces the canonical plaintext WebSocket security gate", () => { + expect(() => + parseSessionTargetInput("ws://gateway.example/dashboard/main/movies-a1166b81"), + ).toThrow("SECURITY ERROR: Gateway URL"); + }); +}); + +describe("bare-root session URL options", () => { + const target = "https://gateway.example/dashboard/main/movies-a1166b81"; + const argv = (...args: string[]) => ["node", "openclaw", ...args]; + + it.each([ + ["--token", "token"], + ["--password", "password"], + ["--tls-fingerprint", "tlsFingerprint"], + ["--thinking", "thinking"], + ["--message", "message"], + ["--timeout-ms", "timeoutMs"], + ["--history-limit", "historyLimit"], + ] as const)("parses %s symmetrically before and after the URL", (flag, key) => { + for (const args of [ + [flag, "sentinel", target], + [`${flag}=sentinel`, target], + [target, flag, "sentinel"], + [target, `${flag}=sentinel`], + ]) { + expect(parseBareSessionInvocation(argv(...args))).toEqual({ + target, + options: { [key]: "sentinel" }, + }); + } + }); + + it("parses boolean options on either side and preserves root globals", () => { + expect( + parseBareSessionInvocation( + argv( + "--no-color", + "--profile", + "work", + "--deliver", + target, + "--log-level=debug", + "--token=direct-token", + ), + ), + ).toEqual({ target, options: { deliver: true, token: "direct-token" } }); + expect(parseBareSessionInvocation(argv(target, "--deliver"))).toEqual({ + target, + options: { deliver: true }, + }); + }); + + it.each([ + ["bare ref", ["movies-a1166b81"]], + ["host shorthand", ["gateway.example/main/a1166b81"]], + ])("does not claim %s", (_label, args) => { + expect(parseBareSessionInvocation(argv(...args))).toBeNull(); + }); + + it.each(["tui", "attach", "logs", "googlemeet", "unowned-command"])( + "leaves an explicit %s command's URL argument to its owner", + (command) => { + expect(parseBareSessionInvocation(argv(command, target))).toBeNull(); + }, + ); + + it.each([ + ["split before", ["--token", target]], + ["split after", [target, "--token"]], + ["inline before", ["--token=", target]], + ["inline after", [target, "--token="]], + ])("rejects a missing value %s", (_label, args) => { + expect(() => parseBareSessionInvocation(argv(...args))).toThrow("--token requires a value"); + }); + + it.each([ + ["inline before", ["--typo=do-not-print-me", target]], + ["inline after", [target, "--typo=do-not-print-me"]], + ["split before", ["--typo", "do-not-print-me", target]], + ["split after", [target, "--typo", "do-not-print-me"]], + ])("rejects an unknown option %s without reflecting its value", (_label, args) => { + let error: unknown; + try { + parseBareSessionInvocation(argv(...args)); + } catch (caught) { + error = caught; + } + expect(String(error)).toContain("Unsupported bare session URL option: --typo"); + expect(String(error)).not.toContain("do-not-print-me"); + }); + + it.each([ + ["terminator before", ["--", target], "Unsupported bare session URL option: --"], + ["terminator after", [target, "--"], "Unsupported bare session URL option: --"], + ["extra after", [target, "do-not-print-me"], "Unexpected extra argument"], + ["second URL", [target, "https://secret.example/path"], "Unexpected extra argument"], + ])("rejects %s without reflecting extra values", (_label, args, expected) => { + let error: unknown; + try { + parseBareSessionInvocation(argv(...args)); + } catch (caught) { + error = caught; + } + expect(String(error)).toContain(expected); + expect(String(error)).not.toContain("do-not-print-me"); + expect(String(error)).not.toContain("secret.example"); + }); +}); + +describe("session target resolution", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("resolves a stale-agent URL short reference without scoping UUID lookup", async () => { + callGatewayMock.mockResolvedValue({ ok: true, key: "agent:research:thread:full-key" }); + + const result = await resolveSessionTarget({ + raw: "https://gateway.example/base/dashboard/ops/movies-a1166b81", + gateway: { token: "explicit-token" }, + }); + + expect(result.sessionKey).toBe("agent:research:thread:full-key"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "wss://gateway.example/base", + token: "explicit-token", + method: "sessions.resolve", + params: { shortId: "a1166b81", slugHint: "movies" }, + useStoredDeviceAuth: true, + requiredStoredDeviceAuthScopes: ["operator.read"], + }), + ); + }); + + it("resolves a bare literal key without forcing an explicit gateway", async () => { + callGatewayMock.mockResolvedValue({ ok: true, key: "agent:ops:telegram:123" }); + + await resolveSessionTarget({ raw: "agent:ops:telegram:123" }); + + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: undefined, + method: "sessions.resolve", + params: { key: "agent:ops:telegram:123" }, + }), + ); + expect(callGatewayMock.mock.calls[0]?.[0]).not.toHaveProperty("useStoredDeviceAuth"); + }); + + it("uses gateway-advertised routing for URL main sessions without requiring an existing row", async () => { + callGatewayMock.mockResolvedValue({ + defaultId: "main", + mainKey: "workspace", + scope: "per-sender", + agents: [], + }); + + const result = await resolveSessionTarget({ + raw: "https://gateway.example/dashboard/ops", + }); + + expect(result.sessionKey).toBe("agent:ops:workspace"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "wss://gateway.example", + method: "agents.list", + params: {}, + requiredStoredDeviceAuthScopes: ["operator.read"], + }), + ); + }); + + it("preserves the canonical global key for a global-scope URL main session", async () => { + callGatewayMock.mockResolvedValue({ + defaultId: "main", + mainKey: "main", + scope: "global", + agents: [], + }); + + const result = await resolveSessionTarget({ + raw: "https://gateway.example/dashboard/ops", + }); + + expect(result.sessionKey).toBe("global"); + }); + + it("rejects a second explicit URL", async () => { + await expect( + resolveSessionTarget({ + raw: "https://gateway.example/dashboard/main/movies-a1166b81", + gateway: { url: "wss://other.example" }, + }), + ).rejects.toThrow("pass one target"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("prints bounded ambiguity candidates without listing or describing", async () => { + callGatewayMock.mockResolvedValue({ + ok: false, + candidates: [ + { + key: "agent:main:thread:12345678-0aaa-4000-8000-000000000001", + displayName: "Alpha", + }, + { + key: "agent:main:thread:12345678-0bbb-4000-8000-000000000002", + displayName: "Beta", + }, + ], + }); + + await expect(resolveSessionTarget({ raw: "12345678" })).rejects.toThrow( + /Alpha\s+123456780aaa4000[\s\S]*Beta\s+123456780bbb4000/u, + ); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + }); + + it("reports old gateways without falling back to sessions.list", async () => { + callGatewayMock.mockRejectedValue( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "invalid sessions.resolve params: at root: unexpected property 'shortId'", + }), + ); + + await expect( + resolveSessionTarget({ + raw: "movies-a1166b81", + gateway: { url: "wss://gateway.example" }, + }), + ).rejects.toThrow( + "This gateway predates short-link resolution; pass the full session key. Choose a full session key from that gateway's Control UI (https://gateway.example).", + ); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + }); + + it("preserves not-found text and adds the sessions list recovery", async () => { + callGatewayMock.mockRejectedValue( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "No session found: a1166b81", + }), + ); + + await expect(resolveSessionTarget({ raw: "a1166b81" })).rejects.toThrow( + /No session found: a1166b81[\s\S]*openclaw sessions list/u, + ); + }); + + it("sends remote not-found recovery to the target Control UI, not the local session store", async () => { + callGatewayMock.mockRejectedValue( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "No session found: a1166b81", + }), + ); + + let error: unknown; + try { + await resolveSessionTarget({ raw: "gateway.example/main/a1166b81" }); + } catch (caught) { + error = caught; + } + expect(String(error)).toContain("that gateway's Control UI (https://gateway.example)"); + expect(String(error)).not.toContain("sessions list --url"); + }); + + it("turns structured pairing and revoked-token failures into actions", async () => { + callGatewayMock.mockRejectedValueOnce( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "connect failed", + details: { code: "PAIRING_REQUIRED" }, + }), + ); + await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toThrow( + "openclaw devices approve --latest", + ); + + callGatewayMock.mockRejectedValueOnce( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "connect failed", + details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" }, + }), + ); + await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toThrow( + "openclaw devices rotate --device --role operator", + ); + }); + + it("classifies legacy close reasons before adding reachability hints", async () => { + callGatewayMock.mockRejectedValueOnce( + gatewayTransportError({ + url: "wss://gateway.example", + message: "gateway closed (1008): pairing required", + reason: "pairing required", + }), + ); + let pairingError: unknown; + try { + await resolveSessionTarget({ raw: "gateway.example/main/a1166b81" }); + } catch (caught) { + pairingError = caught; + } + expect(String(pairingError)).toContain("openclaw devices approve --latest"); + expect(String(pairingError)).not.toContain("Could not reach gateway"); + + callGatewayMock.mockRejectedValueOnce( + gatewayTransportError({ + url: "wss://gateway.example", + message: "gateway closed (1008): device token mismatch", + reason: "device token mismatch", + }), + ); + let tokenError: unknown; + try { + await resolveSessionTarget({ raw: "gateway.example/main/a1166b81" }); + } catch (caught) { + tokenError = caught; + } + expect(String(tokenError)).toContain( + "openclaw devices rotate --device --role operator", + ); + expect(String(tokenError)).not.toContain("Could not reach gateway"); + }); + + it("explains how to bootstrap auth when no origin token exists", async () => { + callGatewayMock.mockRejectedValue( + new GatewayStoredDeviceAuthUnavailableError("No stored device auth"), + ); + + await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toThrow( + "Pass --token or --password once", + ); + }); + + it.each([ + { + code: "ECONNREFUSED", + target: "claw.example.ts.net/main/a1166b81", + expected: /Could not reach gateway wss:\/\/claw\.example\.ts\.net[\s\S]*Tailscale/u, + }, + { + code: "ENOTFOUND", + target: "gateway.example/main/a1166b81", + expected: /Could not reach gateway wss:\/\/gateway\.example[\s\S]*tailnet or SSH tunnel/u, + }, + ])("names unreachable origins for $code", async ({ code, target, expected }) => { + callGatewayMock.mockRejectedValue(Object.assign(new Error(`connect ${code}`), { code })); + + await expect(resolveSessionTarget({ raw: target })).rejects.toThrow(expected); + }); + + it("uses transport connection details for configured-remote bare refs", async () => { + callGatewayMock.mockRejectedValue( + gatewayTransportError({ + kind: "timeout", + url: "wss://claw.example.ts.net/base", + message: "gateway timeout after 10000ms", + }), + ); + + await expect( + resolveSessionTarget({ + raw: "a1166b81", + gateway: { + config: { + gateway: { + mode: "remote", + remote: { url: "wss://claw.example.ts.net/base" }, + }, + }, + }, + }), + ).rejects.toThrow( + /Could not reach gateway wss:\/\/claw\.example\.ts\.net\/base[\s\S]*Tailscale/u, + ); + }); + + it("does not mask TLS fingerprint mismatch errors", async () => { + const mismatch = gatewayTransportError({ + url: "wss://gateway.example", + message: "gateway tls fingerprint mismatch", + reason: "gateway tls fingerprint mismatch", + }); + callGatewayMock.mockRejectedValue(mismatch); + + await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toBe( + mismatch, + ); + }); +}); diff --git a/src/cli/session-ref.ts b/src/cli/session-ref.ts new file mode 100644 index 000000000000..7e7a0a62ee77 --- /dev/null +++ b/src/cli/session-ref.ts @@ -0,0 +1,280 @@ +import { isSensitiveUrlQueryParamName } from "@openclaw/net-policy/redact-sensitive-url"; +import { + type ControlUiSessionPathTarget, + parseControlUiSessionPath, +} from "@openclaw/session-url-contract/parse"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { buildGatewayConnectionDetailsWithResolvers } from "../gateway/connection-details.js"; +import { normalizeWebSocketProtocol } from "../gateway/websocket-protocol.js"; +import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; + +const SESSION_TARGET_HELP = + "Accepted session targets: https://host[/base]/{chat|dashboard}/[/], //, or a bare -, , or agent:... key."; + +export class SessionTargetParseError extends Error { + constructor() { + super(SESSION_TARGET_HELP); + this.name = "SessionTargetParseError"; + } +} + +export type SessionTargetRef = + | { kind: "main" } + | { kind: "short"; shortId: string; slugHint?: string } + | { kind: "literal"; sessionKey: string }; + +export type SessionTargetInput = + | { + kind: "url"; + origin: string; + basePath: string; + agentId: string; + ref: SessionTargetRef; + } + | { kind: "ref"; ref: Exclude }; + +const BARE_SESSION_TUI_VALUE_OPTIONS = { + "--token": "token", + "--password": "password", + "--tls-fingerprint": "tlsFingerprint", + "--thinking": "thinking", + "--message": "message", + "--timeout-ms": "timeoutMs", + "--history-limit": "historyLimit", +} as const; + +export type BareSessionTuiOptions = Partial< + Record< + (typeof BARE_SESSION_TUI_VALUE_OPTIONS)[keyof typeof BARE_SESSION_TUI_VALUE_OPTIONS], + string + > +> & { deliver?: boolean }; + +function refFromPathTarget(target: ControlUiSessionPathTarget): SessionTargetRef { + if (target.kind === "main") { + return { kind: "main" }; + } + if (target.kind === "short") { + return { + kind: "short", + shortId: target.shortId, + ...(target.slugHint ? { slugHint: target.slugHint } : {}), + }; + } + return { kind: "literal", sessionKey: target.sessionKey }; +} + +function parseControlPath(pathname: string): { + basePath: string; + target: ControlUiSessionPathTarget; +} { + const direct = parseControlUiSessionPath(pathname); + if (direct) { + return { basePath: "", target: direct }; + } + const segments = pathname.split("/"); + for (let index = segments.length - 1; index > 0; index -= 1) { + if (segments[index] !== "chat" && segments[index] !== "dashboard") { + continue; + } + const basePath = segments.slice(0, index).join("/"); + const target = parseControlUiSessionPath(pathname, basePath); + if (target) { + return { basePath, target }; + } + } + throw new SessionTargetParseError(); +} + +function rejectUrlCredentials(url: URL): void { + const fragmentParams = new URLSearchParams(url.hash.replace(/^#/u, "")); + const sensitiveParam = [...url.searchParams.keys(), ...fragmentParams.keys()].some( + isSensitiveUrlQueryParamName, + ); + if (url.username || url.password || sensitiveParam) { + throw new Error( + "Session URLs must not contain credentials. Pass --token or --password instead.", + ); + } +} + +function parseSessionUrl(raw: string): SessionTargetInput { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new SessionTargetParseError(); + } + rejectUrlCredentials(url); + if (!["http:", "https:", "ws:", "wss:"].includes(url.protocol)) { + throw new SessionTargetParseError(); + } + url.protocol = normalizeWebSocketProtocol(url.protocol); + const parsed = parseControlPath(url.pathname); + const gatewayUrl = `${url.origin}${parsed.basePath}`; + // Keep the established plaintext transport gate and its operator guidance canonical. + buildGatewayConnectionDetailsWithResolvers({ config: {}, url: gatewayUrl }); + return { + kind: "url", + origin: url.origin, + basePath: parsed.basePath, + agentId: parsed.target.agentId, + ref: refFromPathTarget(parsed.target), + }; +} + +function parseHostShorthand(raw: string): SessionTargetInput | null { + const normalized = raw.endsWith("/") ? raw.slice(0, -1) : raw; + const parts = normalized.split("/"); + if (parts.length !== 3 || parts.some((part) => !part)) { + return null; + } + let host: URL; + try { + host = new URL(`wss://${parts[0]}`); + } catch { + throw new SessionTargetParseError(); + } + rejectUrlCredentials(host); + if (host.pathname !== "/" || host.search || host.hash) { + throw new SessionTargetParseError(); + } + const target = parseControlUiSessionPath(`/dashboard/${parts[1]}/${parts[2]}`); + if (!target) { + throw new SessionTargetParseError(); + } + return { + kind: "url", + origin: host.origin, + basePath: "", + agentId: target.agentId, + ref: refFromPathTarget(target), + }; +} + +export function parseSessionTargetInput(raw: string): SessionTargetInput { + const value = raw.trim(); + if (!value) { + throw new SessionTargetParseError(); + } + if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(value)) { + return parseSessionUrl(value); + } + const agentKey = parseAgentSessionKey(value); + if (agentKey) { + return { + kind: "ref", + ref: { + kind: "literal", + sessionKey: `agent:${agentKey.agentId}:${agentKey.rest}`, + }, + }; + } + const shorthand = parseHostShorthand(value); + if (shorthand) { + return shorthand; + } + const short = parseControlUiSessionPath(`/dashboard/main/${value}`); + if (short?.kind === "short") { + return { + kind: "ref", + ref: { + kind: "short", + shortId: short.shortId, + ...(short.slugHint ? { slugHint: short.slugHint } : {}), + }, + }; + } + throw new SessionTargetParseError(); +} + +export type BareSessionInvocation = { + target: string; + options: BareSessionTuiOptions; +}; + +function isSessionUrlInputCandidate(raw: string): boolean { + return /^(?:https?|wss?):\/\//iu.test(raw.trim()); +} + +function findBareSessionUrlIndex(argv: readonly string[]): number { + for (let index = 2; index < argv.length; index += 1) { + const rootConsumed = consumeRootOptionToken(argv, index); + if (rootConsumed > 0) { + index += rootConsumed - 1; + continue; + } + const arg = argv[index]; + if (arg && isSessionUrlInputCandidate(arg)) { + return index; + } + } + return -1; +} + +function bareSessionOptionError(flag: string): Error { + return new Error( + `Unsupported bare session URL option: ${sanitizeTerminalText(flag)}. Use \`openclaw tui --help\` for the full option list.`, + ); +} + +/** Parse the complete bare-root URL invocation before generic command discovery can see secrets. */ +export function parseBareSessionInvocation(argv: readonly string[]): BareSessionInvocation | null { + const targetIndex = findBareSessionUrlIndex(argv); + if (targetIndex === -1) { + return null; + } + const options: BareSessionTuiOptions = {}; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg) { + continue; + } + if (index === targetIndex) { + continue; + } + if (arg === FLAG_TERMINATOR) { + throw bareSessionOptionError(FLAG_TERMINATOR); + } + const rootConsumed = consumeRootOptionToken(argv, index); + if (rootConsumed > 0) { + index += rootConsumed - 1; + continue; + } + if (arg === "--deliver") { + options.deliver = true; + continue; + } + const equalsIndex = arg.indexOf("="); + const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex); + const optionKey = + BARE_SESSION_TUI_VALUE_OPTIONS[flag as keyof typeof BARE_SESSION_TUI_VALUE_OPTIONS]; + if (!optionKey) { + if (!arg.startsWith("-")) { + // A positional before the URL is an explicit core/plugin command owner. + // Leave its URL argument untouched for Commander and plugin routing. + if (index < targetIndex) { + return null; + } + throw new Error( + "Unexpected extra argument for bare session URL. Use `openclaw tui --help` for the full option list.", + ); + } + throw bareSessionOptionError(flag); + } + const value = equalsIndex === -1 ? argv[index + 1] : arg.slice(equalsIndex + 1); + if ( + !value || + value === FLAG_TERMINATOR || + (equalsIndex === -1 && (index + 1 === targetIndex || value.startsWith("-"))) + ) { + throw new Error(`${flag} requires a value.`); + } + options[optionKey] = value; + if (equalsIndex === -1) { + index += 1; + } + } + return { target: argv[targetIndex] ?? "", options }; +} diff --git a/src/cli/session-target.ts b/src/cli/session-target.ts new file mode 100644 index 000000000000..e7c459a8b9e8 --- /dev/null +++ b/src/cli/session-target.ts @@ -0,0 +1,236 @@ +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../packages/gateway-protocol/src/client-info.js"; +import { classifyGatewayConnectFailure } from "../../packages/gateway-protocol/src/connect-error-details.js"; +import type { AgentsListResult } from "../../packages/gateway-protocol/src/index.js"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + callGateway, + GatewayStoredDeviceAuthUnavailableError, + GatewayTransportError, +} from "../gateway/call.js"; +import { GatewayClientRequestError } from "../gateway/client.js"; +import { projectGatewayUrlForDiagnostics } from "../gateway/connection-details.js"; +import { + parseSessionTargetInput, + SessionTargetParseError, + type SessionTargetInput, +} from "./session-ref.js"; + +export type SessionTargetGateway = { + config?: OpenClawConfig; + url?: string; + token?: string; + password?: string; + tlsFingerprint?: string; +}; + +type ResolvedSessionTarget = { + sessionKey: string; + gateway: SessionTargetGateway; + parsed: SessionTargetInput; +}; + +type SessionsResolveResult = + | { ok: true; key: string } + | { ok: false; candidates?: Array<{ key: string; displayName?: string }> }; + +function gatewayUrlForTarget(target: SessionTargetInput): string | undefined { + return target.kind === "url" ? `${target.origin}${target.basePath}` : undefined; +} + +export async function callSessionTargetGateway(params: { + gateway: SessionTargetGateway; + method: string; + request?: unknown; + requiredScope: "operator.read" | "operator.admin"; + shortRef?: boolean; +}): Promise { + const explicitUrl = params.gateway.url?.trim() || undefined; + try { + return await callGateway({ + config: params.gateway.config, + url: explicitUrl, + token: params.gateway.token, + password: params.gateway.password, + tlsFingerprint: params.gateway.tlsFingerprint, + method: params.method, + params: params.request, + mode: GATEWAY_CLIENT_MODES.CLI, + clientName: GATEWAY_CLIENT_NAMES.CLI, + ...(explicitUrl + ? { + useStoredDeviceAuth: true, + requiredStoredDeviceAuthScopes: [params.requiredScope], + } + : {}), + }); + } catch (error) { + throw shapeTargetError(error, explicitUrl, params.shortRef === true); + } +} + +function candidateId(key: string): string { + const uuid = key.match(/([0-9a-f]{8}-[0-9a-f-]{27})$/iu)?.[1]?.replaceAll("-", ""); + return (uuid ?? key).slice(0, 16); +} + +function formatAmbiguousCandidates( + candidates: Array<{ key: string; displayName?: string }>, + gatewayUrl: string | undefined, +): string { + const rows = candidates.map((candidate) => ({ + name: sanitizeTerminalText(candidate.displayName?.trim() || "(unnamed)") + .replace(/\s+/gu, " ") + .slice(0, 40), + id: candidateId(candidate.key), + })); + const width = Math.max("SESSION".length, ...rows.map((row) => row.name.length)); + return [ + "Session reference is ambiguous:", + `${"SESSION".padEnd(width)} ID PREFIX`, + ...rows.map((row) => `${row.name.padEnd(width)} ${row.id}`), + `Pass a longer reference. ${sessionsListHint(gatewayUrl)}`, + ].join("\n"); +} + +function sessionsListHint(gatewayUrl: string | undefined): string { + return gatewayUrl + ? `Choose a full session key from that gateway's Control UI (${controlUiBaseUrl(gatewayUrl)}).` + : "Run `openclaw sessions list` to choose a full session key."; +} + +function controlUiBaseUrl(gatewayUrl: string): string { + const url = new URL(gatewayUrl); + url.protocol = + url.protocol === "wss:" ? "https:" : url.protocol === "ws:" ? "http:" : url.protocol; + return sanitizeTerminalText(url.toString().replace(/\/$/u, "")); +} + +function isPriorGatewayShortIdRejection(error: unknown): boolean { + return ( + error instanceof GatewayClientRequestError && + error.gatewayCode === "INVALID_REQUEST" && + error.message.includes("invalid sessions.resolve params:") && + error.message.includes("unexpected property 'shortId'") + ); +} + +function unreachableTargetError(error: Error, gatewayUrl: string | undefined): Error { + if (!gatewayUrl) { + return error; + } + const hostname = new URL(gatewayUrl).hostname; + const displayGatewayUrl = projectGatewayUrlForDiagnostics(gatewayUrl); + const tailscaleHint = hostname.endsWith(".ts.net") + ? " For this .ts.net host, check that Tailscale is connected and the gateway is reachable on your tailnet." + : ""; + return new Error( + `${error.message}\nCould not reach gateway ${displayGatewayUrl}. Check whether the gateway is down and whether its tailnet or SSH tunnel is reachable.${tailscaleHint}`, + ); +} + +function shapeTargetError( + error: unknown, + gatewayUrl: string | undefined, + shortRef: boolean, +): Error { + if (shortRef && isPriorGatewayShortIdRejection(error)) { + return new Error( + `This gateway predates short-link resolution; pass the full session key. ${sessionsListHint(gatewayUrl)}`, + ); + } + if (error instanceof GatewayStoredDeviceAuthUnavailableError && gatewayUrl) { + return new Error( + `No stored device auth for ${gatewayUrl}. Pass --token or --password once, approve the pairing request in that gateway's Control UI (Settings > Devices), then retry.`, + ); + } + if (!(error instanceof Error)) { + return new Error(String(error)); + } + // A pin mismatch names the precise trust failure and must never be reclassified as transport. + if (/tls fingerprint/iu.test(error.message)) { + return error; + } + if ( + error instanceof GatewayClientRequestError && + error.gatewayCode === "INVALID_REQUEST" && + error.message.includes("No session found") + ) { + return new Error(`${error.message}\n${sessionsListHint(gatewayUrl)}`); + } + const failure = classifyGatewayConnectFailure({ + ...(error instanceof GatewayClientRequestError ? { details: error.details } : {}), + ...(error instanceof GatewayTransportError ? { reason: error.reason } : {}), + message: error.message, + }); + if (failure.kind === "unreachable") { + const effectiveGatewayUrl = + gatewayUrl ?? + (error instanceof GatewayTransportError ? error.connectionDetails.url : undefined); + return unreachableTargetError(error, effectiveGatewayUrl); + } + return failure.remediation ? new Error(`${failure.userMessage}\n${failure.remediation}`) : error; +} + +export async function resolveSessionTarget(params: { + raw: string; + gateway?: SessionTargetGateway; + requiredScope?: "operator.read" | "operator.admin"; +}): Promise { + const parsed = parseSessionTargetInput(params.raw); + const targetUrl = gatewayUrlForTarget(parsed); + if (targetUrl && params.gateway?.url) { + throw new Error("pass one target: use either the session URL or --url, not both"); + } + const gateway: SessionTargetGateway = { + ...params.gateway, + url: targetUrl ?? params.gateway?.url, + }; + if (parsed.ref.kind === "main") { + if (parsed.kind !== "url") { + throw new SessionTargetParseError(); + } + const agents = await callSessionTargetGateway({ + gateway, + method: "agents.list", + request: {}, + requiredScope: params.requiredScope ?? "operator.read", + }); + return { + parsed, + gateway, + sessionKey: resolveCanonicalMainSessionKey({ + agentId: parsed.agentId, + mainKey: agents.mainKey, + sessionScope: agents.scope, + }), + }; + } + + const ref = parsed.ref; + const request = + ref.kind === "short" + ? { + shortId: ref.shortId, + ...(ref.slugHint ? { slugHint: ref.slugHint } : {}), + } + : { key: ref.sessionKey }; + const result = await callSessionTargetGateway({ + gateway, + method: "sessions.resolve", + request, + requiredScope: params.requiredScope ?? "operator.read", + shortRef: ref.kind === "short", + }); + if (result.ok) { + return { parsed, gateway, sessionKey: result.key }; + } + if (result.candidates?.length) { + throw new Error(formatAmbiguousCandidates(result.candidates, gateway.url)); + } + throw new Error(`No session found.\n${sessionsListHint(gateway.url)}`); +} diff --git a/src/cli/tui-cli.ts b/src/cli/tui-cli.ts index 336f312d949a..9b49c82a8472 100644 --- a/src/cli/tui-cli.ts +++ b/src/cli/tui-cli.ts @@ -6,8 +6,96 @@ import { theme } from "../../packages/terminal-core/src/theme.js"; import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { defaultRuntime } from "../runtime.js"; import { parseTimeoutMs } from "./parse-timeout.js"; +import { resolveSessionTarget } from "./session-target.js"; import { addTuiOptions } from "./tui-cli-options.js"; +type TuiCliOptions = { + local?: boolean; + url?: string; + token?: string; + password?: string; + tlsFingerprint?: string; + session?: string; + deliver?: boolean; + thinking?: string; + message?: string; + timeoutMs?: string; + historyLimit?: string; +}; + +export async function runTuiCliAction( + target: string | undefined, + opts: TuiCliOptions, + invokedSubcommand = "tui", +): Promise { + const invokedAsLocalAlias = invokedSubcommand === "terminal" || invokedSubcommand === "chat"; + const isLocal = Boolean(opts.local) || invokedAsLocalAlias; + if (target && isLocal) { + throw new Error( + "a session target cannot be combined with --local, openclaw chat, or openclaw terminal", + ); + } + if (isLocal && (opts.url || opts.token || opts.password || opts.tlsFingerprint)) { + throw new Error( + "--local cannot be combined with --url, --token, --password, or --tls-fingerprint", + ); + } + if (target && opts.session) { + throw new Error("pass one session target: use either the positional target or --session"); + } + const timeoutMs = parseTimeoutMs(opts.timeoutMs); + if (opts.timeoutMs !== undefined && timeoutMs === undefined) { + defaultRuntime.error(`warning: invalid --timeout-ms "${opts.timeoutMs}"; ignoring`); + } + const historyLimit = parseStrictPositiveInteger(opts.historyLimit ?? "200"); + if (historyLimit === undefined) { + throw new Error("--history-limit must be a positive integer."); + } + if (!isLocal && historyLimit > CHAT_HISTORY_MAX_ENTRIES) { + throw new Error(`--history-limit must be at most ${CHAT_HISTORY_MAX_ENTRIES}.`); + } + + const resolved = target + ? await resolveSessionTarget({ + raw: target, + requiredScope: "operator.admin", + gateway: { + url: opts.url, + token: opts.token, + password: opts.password, + tlsFingerprint: opts.tlsFingerprint, + }, + }) + : undefined; + const { runTui } = await import("../tui/tui.js"); + await runTui({ + local: isLocal, + ...(resolved?.gateway.url + ? { + boundGateway: { + url: resolved.gateway.url, + token: resolved.gateway.token, + password: resolved.gateway.password, + tlsFingerprint: resolved.gateway.tlsFingerprint, + }, + } + : { + url: opts.url, + token: opts.token, + password: opts.password, + tlsFingerprint: opts.tlsFingerprint, + }), + session: resolved?.sessionKey ?? opts.session, + ...(resolved?.parsed.kind === "url" ? { agentId: resolved.parsed.agentId } : {}), + deliver: Boolean(opts.deliver), + thinking: opts.thinking, + message: opts.message, + timeoutMs, + historyLimit, + forceProcessExitOnReturn: true, + }); +} + /** Attach the `tui` command plus its `terminal`/`chat` aliases to the root CLI. */ export function registerTuiCli(program: Command) { const command = program @@ -15,6 +103,7 @@ export function registerTuiCli(program: Command) { .alias("terminal") .alias("chat") .description("Open a terminal UI connected to the Gateway") + .argument("[target]", "Control UI URL, host/agent/ref, short ref, or agent:... key") .option("--local", "Run against the local embedded agent runtime", false); addTuiOptions(command) .option("--session ", 'Session key (default: "main", or "global" when scope is global)') @@ -27,47 +116,12 @@ export function registerTuiCli(program: Command) { "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/tui", "docs.openclaw.ai/cli/tui")}\n`, ) - .action(async (opts, cmd) => { + .action(async (target: string | undefined, opts: TuiCliOptions, cmd: Command) => { try { // `cmd.name()` always returns the canonical subcommand name (`tui`). // Use the parsed parent args to see which alias the user actually typed. const invokedSubcommand = cmd.parent?.args[0]; - const invokedAsLocalAlias = - invokedSubcommand === "terminal" || invokedSubcommand === "chat"; - const isLocal = Boolean(opts.local) || invokedAsLocalAlias; - if (isLocal && (opts.url || opts.token || opts.password || opts.tlsFingerprint)) { - throw new Error( - "--local cannot be combined with --url, --token, --password, or --tls-fingerprint", - ); - } - const timeoutMs = parseTimeoutMs(opts.timeoutMs); - if (opts.timeoutMs !== undefined && timeoutMs === undefined) { - defaultRuntime.error( - `warning: invalid --timeout-ms "${String(opts.timeoutMs)}"; ignoring`, - ); - } - const historyLimit = parseStrictPositiveInteger(opts.historyLimit ?? "200"); - if (historyLimit === undefined) { - throw new Error("--history-limit must be a positive integer."); - } - if (!isLocal && historyLimit > CHAT_HISTORY_MAX_ENTRIES) { - throw new Error(`--history-limit must be at most ${CHAT_HISTORY_MAX_ENTRIES}.`); - } - const { runTui } = await import("../tui/tui.js"); - await runTui({ - local: isLocal, - url: opts.url as string | undefined, - token: opts.token as string | undefined, - password: opts.password as string | undefined, - tlsFingerprint: opts.tlsFingerprint as string | undefined, - session: opts.session as string | undefined, - deliver: Boolean(opts.deliver), - thinking: opts.thinking as string | undefined, - message: opts.message as string | undefined, - timeoutMs, - historyLimit, - forceProcessExitOnReturn: true, - }); + await runTuiCliAction(target, opts, invokedSubcommand); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 0da40003ccfd..08dac5e05bbe 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -171,12 +171,18 @@ vi.mock("../tasks/task-registry.maintenance.js", () => ({ getInspectableTaskAuditFindings: statusSummaryMocks.getInspectableTaskAuditFindings, })); -vi.mock("../routing/session-key.js", () => ({ - LEGACY_IMPLICIT_AGENT_ID: "main", - normalizeAgentId: vi.fn((value: string) => value), - normalizeMainKey: vi.fn((value?: string) => value ?? "main"), - parseAgentSessionKey: vi.fn(() => null), -})); +vi.mock("../routing/session-key.js", async () => { + const actual = await vi.importActual( + "../routing/session-key.js", + ); + return { + ...actual, + LEGACY_IMPLICIT_AGENT_ID: "main", + normalizeAgentId: vi.fn((value: string) => value), + normalizeMainKey: vi.fn((value?: string) => value ?? "main"), + parseAgentSessionKey: vi.fn(() => null), + }; +}); vi.mock("../version.js", async () => { const actual = await vi.importActual("../version.js"); diff --git a/src/config/sessions/main-session-key.ts b/src/config/sessions/main-session-key.ts new file mode 100644 index 000000000000..7524dfbfea97 --- /dev/null +++ b/src/config/sessions/main-session-key.ts @@ -0,0 +1,11 @@ +import { buildAgentMainSessionKey } from "../../routing/session-key.js"; +import type { SessionScope } from "./types.js"; + +/** Resolves the configured main session identity for one agent and session scope. */ +export function resolveCanonicalMainSessionKey(params: { + agentId: string; + mainKey?: string | undefined; + sessionScope?: SessionScope; +}): string { + return params.sessionScope === "global" ? "global" : buildAgentMainSessionKey(params); +} diff --git a/src/config/sessions/main-session.ts b/src/config/sessions/main-session.ts index 577626e0d019..827a65d15590 100644 --- a/src/config/sessions/main-session.ts +++ b/src/config/sessions/main-session.ts @@ -6,6 +6,7 @@ import { resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; import type { OpenClawConfig } from "../types.openclaw.js"; +import { resolveCanonicalMainSessionKey } from "./main-session-key.js"; import type { SessionScope } from "./types.js"; const FALLBACK_DEFAULT_AGENT_ID = "main"; @@ -18,10 +19,11 @@ function buildMainSessionKey(agentId: string, mainKey?: string): string { /** Resolves the configured main session key, honoring global session scope. */ export function resolveMainSessionKey(cfg: OpenClawConfig): string { - if (cfg?.session?.scope === "global") { - return "global"; - } - return buildMainSessionKey(resolveDefaultAgentId(cfg), cfg.session?.mainKey); + return resolveCanonicalMainSessionKey({ + agentId: resolveDefaultAgentId(cfg), + mainKey: cfg.session?.mainKey, + sessionScope: cfg.session?.scope, + }); } /** Stable fingerprint for the config values that canonicalize chat session keys. */ diff --git a/src/gateway/mcp-grant-store.test.ts b/src/gateway/mcp-grant-store.test.ts index 3b904feb45b5..600c29e9be7e 100644 --- a/src/gateway/mcp-grant-store.test.ts +++ b/src/gateway/mcp-grant-store.test.ts @@ -53,6 +53,18 @@ describe("mcp-grant-store", () => { expect(a.token).not.toBe(b.token); }); + it("binds a separate agent owner only to the canonical global session", () => { + const global = mintAttachGrant({ sessionKey: "global", agentId: " ops ", nowMs: T0 }); + const scoped = mintAttachGrant({ + sessionKey: "agent:main:telegram:1", + agentId: "ops", + nowMs: T0, + }); + + expect(global.agentId).toBe("ops"); + expect(scoped.agentId).toBeUndefined(); + }); + it("revokes by token", () => { const g = mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 }); expect(revokeAttachGrant(g.token)).toBe(true); diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts index d9b5c83b7e49..3352c66245f7 100644 --- a/src/gateway/mcp-grant-store.ts +++ b/src/gateway/mcp-grant-store.ts @@ -67,6 +67,8 @@ interface McpAttachGrant { readonly token: string; /** The openclaw session this grant is bound to; tool scope is resolved for this key. */ readonly sessionKey: string; + /** Explicit agent owner for canonical global sessions, whose key cannot encode one. */ + readonly agentId?: string; /** Absolute expiry (ms epoch). */ readonly expiresAtMs: number; /** Absolute mint time (ms epoch). */ @@ -119,6 +121,7 @@ function clampTtlMs(ttlMs: number | undefined): number { export function mintAttachGrant(params: { sessionKey: string; + agentId?: string; ttlMs?: number; nowMs?: number; }): McpAttachGrant { @@ -126,12 +129,14 @@ export function mintAttachGrant(params: { if (!sessionKey) { throw new Error("mintAttachGrant: sessionKey is required"); } + const agentId = sessionKey === "global" ? params.agentId?.trim() || undefined : undefined; const nowMs = params.nowMs ?? Date.now(); // Mint sweeps stale entries so abandoned grants do not accumulate. sweepExpiredAttachGrants(nowMs); const grant: McpAttachGrant = { token: crypto.randomBytes(32).toString("hex"), sessionKey, + ...(agentId ? { agentId } : {}), issuedAtMs: nowMs, expiresAtMs: nowMs + clampTtlMs(params.ttlMs), }; diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 324a42d6be87..ec4a6ca31386 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -62,6 +62,7 @@ type McpRequestContext = McpLoopbackRequestContext; type McpLoopbackRequestAuth = { senderIsOwner: boolean; boundSessionKey?: string; + boundAgentId?: string; boundContext?: McpLoopbackRequestContext; boundCaptureKey?: string; boundGrantToken?: string; @@ -149,7 +150,11 @@ function resolveMcpSender(params: { } const grant = grantToken ? resolveAttachGrant(grantToken) : undefined; if (grant) { - return { senderIsOwner: false, boundSessionKey: grant.sessionKey }; + return { + senderIsOwner: false, + boundSessionKey: grant.sessionKey, + ...(grant.agentId ? { boundAgentId: grant.agentId } : {}), + }; } return undefined; } @@ -279,6 +284,7 @@ export function validateMcpLoopbackRequest(params: { return { senderIsOwner: sender.senderIsOwner, boundSessionKey: sender.boundSessionKey, + boundAgentId: sender.boundAgentId, boundContext: sender.boundContext, boundCaptureKey: sender.boundCaptureKey, boundGrantToken: sender.boundGrantToken, @@ -416,11 +422,12 @@ export function resolveMcpRequestContext( // session, channel, capability, or ownership headers. return structuredClone(auth.boundContext); } - // Grant-authenticated callers get only their server-bound session; spoofable - // delivery/action headers stay reserved for the gateway-launched loopback client. + // Grant-authenticated callers get only their server-bound session and optional + // global-session agent owner; spoofable delivery/action headers stay reserved. if (auth.boundSessionKey) { return { sessionKey: auth.boundSessionKey, + agentId: auth.boundAgentId, sessionId: undefined, messageProvider: undefined, clientCaps: undefined, diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index ce68bf05df7c..6051aaf886a7 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -1141,8 +1141,8 @@ describe("mcp loopback server", () => { expect(getScopedToolsCall(1).clientCaps).toBeUndefined(); }); - it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => { - const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" }); + it("binds an attach grant's session owner and ignores ALL spoofed context headers", async () => { + const grant = mintAttachGrant({ sessionKey: "global", agentId: "ops" }); const port = await getFreePortBlockWithPermissionFallback({ offsets: [0], fallbackBase: 53_000, @@ -1168,7 +1168,8 @@ describe("mcp loopback server", () => { expect(response.status).toBe(200); const call = getScopedToolsCall(0); - expect(call.sessionKey).toBe("agent:main:attach-host"); + expect(call.sessionKey).toBe("global"); + expect(call.agentId).toBe("ops"); expect(call.senderIsOwner).toBe(false); expect(call.surface).toBe("loopback"); expect(call.messageProvider).toBeUndefined(); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index bbf5a2753181..bf2edd85e064 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -19,6 +19,7 @@ import { type NetworkInterfacesSnapshot, } from "../infra/network-interfaces.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; +import { normalizeWebSocketProtocol } from "./websocket-protocol.js"; /** Pick the primary non-internal IPv4 address, preferring common LAN interface names. */ export function pickPrimaryLanIPv4(): string | undefined { @@ -467,11 +468,6 @@ export function isPrivateOrLoopbackHost(host: string): boolean { return true; } -/** Normalize HTTP aliases accepted by WebSocket clients to their WebSocket protocol. */ -export function normalizeWebSocketProtocol(protocol: string): string { - return protocol === "https:" ? "wss:" : protocol === "http:" ? "ws:" : protocol; -} - function parseHostForAddressChecks( host: string, ): { isLocalhost: boolean; unbracketedHost: string } | null { diff --git a/src/gateway/server-methods/attach.test.ts b/src/gateway/server-methods/attach.test.ts index 1df03c51499b..9686a17e3415 100644 --- a/src/gateway/server-methods/attach.test.ts +++ b/src/gateway/server-methods/attach.test.ts @@ -62,6 +62,42 @@ describe("attach gateway methods", () => { expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method"); }); + it("preserves explicit ownership only for canonical global sessions", async () => { + const respond = vi.fn(); + await expectDefined( + attachHandlers["attach.grant"], + 'attachHandlers["attach.grant"] test invariant', + )({ + params: { sessionKey: "global", agentId: "ops" }, + respond, + context: { getRuntimeConfig: () => ({}) }, + } as unknown as GatewayRequestHandlerOptions); + + const grant = resolveAttachGrant( + (expectDefined(respond.mock.calls[0], "respond call invariant")[1] as { token: string }) + .token, + ); + expect(grant).toMatchObject({ sessionKey: "global", agentId: "ops" }); + + const scopedRespond = vi.fn(); + await expectDefined( + attachHandlers["attach.grant"], + 'attachHandlers["attach.grant"] test invariant', + )({ + params: { sessionKey: "agent:main:attach-method", agentId: "ops" }, + respond: scopedRespond, + context: { getRuntimeConfig: () => ({}) }, + } as unknown as GatewayRequestHandlerOptions); + const scopedGrant = resolveAttachGrant( + ( + expectDefined(scopedRespond.mock.calls[0], "scoped respond call invariant")[1] as { + token: string; + } + ).token, + ); + expect(scopedGrant?.agentId).toBeUndefined(); + }); + it("rejects attach grants for reserved harness sessions", async () => { const respond = vi.fn(); await expectDefined( diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts index d84914060aa8..1276e1f0fb27 100644 --- a/src/gateway/server-methods/attach.ts +++ b/src/gateway/server-methods/attach.ts @@ -33,6 +33,7 @@ export const attachHandlers: GatewayRequestHandlers = { const grantParams = paramRecord(params); const cfg = context.getRuntimeConfig(); const sessionKey = readString(grantParams, "sessionKey") ?? resolveMainSessionKey(cfg); + const agentId = sessionKey === "global" ? readString(grantParams, "agentId") : undefined; const harnessEntry = isAgentHarnessSessionKey(sessionKey) ? resolveSessionEntryAccessTarget({ cfg, sessionKey }).entry : undefined; @@ -57,7 +58,11 @@ export const attachHandlers: GatewayRequestHandlers = { ); return; } - const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(grantParams, "ttlMs") }); + const grant = mintAttachGrant({ + sessionKey, + ...(agentId ? { agentId } : {}), + ttlMs: readPositiveNumber(grantParams, "ttlMs"), + }); respond(true, { sessionKey: grant.sessionKey, token: grant.token, diff --git a/src/gateway/websocket-protocol.ts b/src/gateway/websocket-protocol.ts new file mode 100644 index 000000000000..b9270075dec4 --- /dev/null +++ b/src/gateway/websocket-protocol.ts @@ -0,0 +1,4 @@ +/** Map the HTTP aliases accepted by WebSocket clients onto their canonical schemes. */ +export function normalizeWebSocketProtocol(protocol: string): string { + return protocol === "https:" ? "wss:" : protocol === "http:" ? "ws:" : protocol; +} diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index f8e42be019be..06ec07357d81 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -17,7 +17,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; import { materializeGatewayAuthSecretRefs } from "../gateway/auth-config-utils.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; -import { normalizeWebSocketProtocol } from "../gateway/net.js"; +import { normalizeWebSocketProtocol } from "../gateway/websocket-protocol.js"; import { resolveAdvertisedLanHost } from "../infra/advertised-lan-host.js"; import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js"; import { diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index ab4d3fbfc66a..c9fe012a25d2 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -328,11 +328,24 @@ describe("resolveInitialTuiAgentId", () => { cfg, fallbackAgentId: "main", initialSessionInput: "agent:main:incident", + agentId: "ops", cwd: "/tmp/openclaw/projects/ops/src", }), ).toBe("main"); }); + it("keeps an explicit global-session agent ahead of workspace inference", () => { + expect( + resolveInitialTuiAgentId({ + cfg, + fallbackAgentId: "main", + initialSessionInput: "global", + agentId: "ops", + cwd: "/tmp/openclaw", + }), + ).toBe("ops"); + }); + it("falls back when cwd has no matching workspace", () => { expect( resolveInitialTuiAgentId({ diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 67c893ca3385..d02e7f637a61 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -15,6 +15,7 @@ import type { CommandEntry } from "../../packages/gateway-protocol/src/index.js" import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.shared.js"; import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; +import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js"; import { resolveCurrentOpenClawCliInvocation } from "../infra/openclaw-cli-invocation.js"; import { tryProcessCwd } from "../infra/safe-cwd.js"; import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js"; @@ -28,7 +29,6 @@ import { resolveTrustedWindowsCmdExe, } from "../process/windows-command.js"; import { - buildAgentMainSessionKey, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, @@ -91,6 +91,8 @@ const SESSION_SUBSCRIPTION_MAX_ATTEMPTS = 5; const SESSION_SUBSCRIPTION_RETRY_DELAY_MS = 25; type RunTuiOptions = TuiOptions & { + /** Explicit owner for a global session key, which cannot carry an agent prefix itself. */ + agentId?: string; backend?: TuiBackend; submitBurstWindowMs?: number; ctrlCExitWindowMs?: number; @@ -167,12 +169,10 @@ export function resolveTuiSessionKey(params: { }) { const trimmed = (params.raw ?? "").trim(); if (!trimmed) { - if (params.sessionScope === "global") { - return "global"; - } - return buildAgentMainSessionKey({ + return resolveCanonicalMainSessionKey({ agentId: params.currentAgentId, mainKey: params.sessionMainKey, + sessionScope: params.sessionScope, }); } const parsed = parseAgentSessionKey(trimmed); @@ -195,11 +195,12 @@ export function resolveInitialTuiAgentId(params: { cfg: OpenClawConfig; fallbackAgentId: string; initialSessionInput?: string; + agentId?: string; cwd?: string; }) { - const parsed = parseAgentSessionKey((params.initialSessionInput ?? "").trim()); - if (parsed?.agentId) { - return normalizeAgentId(parsed.agentId); + const explicitAgentId = resolveExplicitInitialTuiAgentId(params); + if (explicitAgentId) { + return explicitAgentId; } const cwd = params.cwd ?? tryProcessCwd(); @@ -211,6 +212,15 @@ export function resolveInitialTuiAgentId(params: { return normalizeAgentId(params.fallbackAgentId); } +function resolveExplicitInitialTuiAgentId(params: { + initialSessionInput?: string; + agentId?: string; +}): string | null { + const parsed = parseAgentSessionKey((params.initialSessionInput ?? "").trim()); + const explicitAgentId = parsed?.agentId ?? params.agentId?.trim(); + return explicitAgentId ? normalizeAgentId(explicitAgentId) : null; +} + export function resolveGatewayDisconnectState( input: { details?: unknown; @@ -598,10 +608,15 @@ export async function runTui(opts: RunTuiOptions): Promise { const sessionScope = (config.session?.scope ?? "per-sender") as SessionScope; const sessionMainKey = normalizeMainKey(config.session?.mainKey); const agentDefaultId = resolveDefaultAgentId(config); + const initialSessionAgentId = resolveExplicitInitialTuiAgentId({ + initialSessionInput, + agentId: opts.agentId, + }); let currentAgentId = resolveInitialTuiAgentId({ cfg: config, fallbackAgentId: agentDefaultId, initialSessionInput, + agentId: opts.agentId, }); const agentNames = new Map(); let currentSessionKey = ""; @@ -1248,14 +1263,6 @@ export async function runTui(opts: RunTuiOptions): Promise { }, }; - const initialSessionAgentId = (() => { - if (!initialSessionInput) { - return null; - } - const parsed = parseAgentSessionKey(initialSessionInput); - return parsed ? normalizeAgentId(parsed.agentId) : null; - })(); - const sessionActions = createSessionActions({ client, chatLog, diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index b855a6305aa8..6909ce138df9 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -473,6 +473,7 @@ describe("package-openclaw-for-docker", () => { const originalPackageJson = `${JSON.stringify( { dependencies: { "@openclaw/ai": "workspace:*", "dep-a": "1.2.3" }, + devDependencies: { "@openclaw/session-url-contract": "workspace:*" }, files: ["dist"], name: "openclaw", version: "2026.6.17", @@ -518,8 +519,10 @@ describe("package-openclaw-for-docker", () => { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { bundleDependencies: string[]; dependencies: Record; + devDependencies?: Record; }; expect(packageJson.dependencies["@openclaw/ai"]).toBe("2026.6.17"); + expect(packageJson.devDependencies?.["@openclaw/session-url-contract"]).toBe("workspace:*"); expect(packageJson.bundleDependencies).toContain("@openclaw/ai"); expect(fs.existsSync(path.join(installedAiPath, "original-marker"))).toBe(false); expect(fs.existsSync(path.join(installedAiPath, "runtime.js"))).toBe(true); @@ -540,6 +543,60 @@ describe("package-openclaw-for-docker", () => { } }); + it("reuses the source manifest lifecycle for ignore-scripts package artifacts", async () => { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-manifest-source-")); + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-manifest-output-")); + const scriptsDir = path.join(sourceDir, "scripts"); + const packageJsonPath = path.join(sourceDir, "package.json"); + const originalPackageJson = `${JSON.stringify( + { + devDependencies: { + "@openclaw/session-url-contract": "workspace:*", + vitest: "4.1.10", + }, + name: "openclaw", + version: "2026.8.1", + }, + null, + 2, + )}\n`; + fs.mkdirSync(scriptsDir); + fs.copyFileSync( + path.join(process.cwd(), "scripts", "package-manifest.mjs"), + path.join(scriptsDir, "package-manifest.mjs"), + ); + fs.writeFileSync(packageJsonPath, originalPackageJson); + + try { + const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { + ...skipDocsMapLifecycle, + prepareBundledAiRuntime: skipBundledAiRuntime, + prepareChangelog: async () => {}, + restoreChangelog: async () => {}, + runCaptureImpl: async () => { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + devDependencies?: Record; + }; + expect(packageJson.devDependencies).toEqual({ vitest: "4.1.10" }); + const packedPath = path.join(outputDir, "openclaw-2026.8.1.tgz"); + fs.writeFileSync(packedPath, "package"); + return `${path.basename(packedPath)}\n`; + }, + }); + + expect(tarball).toBe(path.join(outputDir, "openclaw-2026.8.1.tgz")); + expect(fs.readFileSync(packageJsonPath, "utf8")).toBe(originalPackageJson); + expect( + fs.existsSync( + path.join(sourceDir, ".artifacts", "package-manifest", "package.json.prepack-backup"), + ), + ).toBe(false); + } finally { + fs.rmSync(sourceDir, { recursive: true, force: true }); + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); + it("leaves pre-AI-workspace package sources unchanged", async () => { const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-legacy-source-")); const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-legacy-output-")); diff --git a/test/openclaw-prepack.test.ts b/test/openclaw-prepack.test.ts index d79b5769469d..d041d295f2aa 100644 --- a/test/openclaw-prepack.test.ts +++ b/test/openclaw-prepack.test.ts @@ -1,6 +1,13 @@ // OpenClaw prepack tests validate package prepack output. import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import * as tar from "tar"; import { afterEach, describe, expect, it } from "vitest"; @@ -123,6 +130,62 @@ describe("collectSourcePackWorkspaceDependencyErrors", () => { }), ).toHaveLength(2); }); + + it("omits build-only workspace dependencies from direct pnpm pack manifests", () => { + const rootDir = tempDirs.make("openclaw-direct-pack-manifest-"); + const packDir = path.join(rootDir, "pack"); + const extractDir = path.join(rootDir, "extract"); + const scriptsDir = path.join(rootDir, "scripts"); + const originalPackageJson = `${JSON.stringify( + { + name: "openclaw-direct-pack-manifest", + version: "2099.1.2-test.0", + scripts: { + prepack: "node scripts/package-manifest.mjs prepare", + postpack: "node scripts/package-manifest.mjs restore", + }, + devDependencies: { + "@openclaw/session-url-contract": "workspace:*", + vitest: "4.1.10", + }, + }, + null, + 2, + )}\n`; + mkdirSync(packDir); + mkdirSync(extractDir); + mkdirSync(scriptsDir); + writeFileSync(path.join(rootDir, "package.json"), originalPackageJson); + copyFileSync( + path.join(process.cwd(), "scripts", "package-manifest.mjs"), + path.join(scriptsDir, "package-manifest.mjs"), + ); + + const packed = spawnSync("pnpm", ["pack", "--silent", "--pack-destination", packDir], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + expect(packed.status, packed.stderr).toBe(0); + const tarballs = readdirSync(packDir).filter((entry) => entry.endsWith(".tgz")); + expect(tarballs).toHaveLength(1); + const tarballName = tarballs[0]; + if (!tarballName) { + throw new Error("pnpm pack did not produce the expected tarball"); + } + tar.x({ cwd: extractDir, file: path.join(packDir, tarballName), sync: true }); + + const packedPackageJson = JSON.parse( + readFileSync(path.join(extractDir, "package", "package.json"), "utf8"), + ) as { devDependencies?: Record }; + expect(packedPackageJson.devDependencies).toEqual({ vitest: "4.1.10" }); + expect(readFileSync(path.join(rootDir, "package.json"), "utf8")).toBe(originalPackageJson); + expect( + existsSync( + path.join(rootDir, ".artifacts", "package-manifest", "package.json.prepack-backup"), + ), + ).toBe(false); + }); }); describe("resolvePrepackAllowUnreleasedChangelog", () => { diff --git a/ui/src/app/gateway-scope.ts b/ui/src/app/gateway-scope.ts deleted file mode 100644 index cb116b407f71..000000000000 --- a/ui/src/app/gateway-scope.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Keep legacy UI imports on the package-owned normalization contract. -export { gatewayOriginScope as normalizeGatewayTokenScope } from "@openclaw/gateway-client/browser"; diff --git a/ui/src/pages/new-session/preferences.ts b/ui/src/pages/new-session/preferences.ts index 23b18c4415e1..dda7b349d511 100644 --- a/ui/src/pages/new-session/preferences.ts +++ b/ui/src/pages/new-session/preferences.ts @@ -1,4 +1,4 @@ -import { normalizeGatewayTokenScope } from "../../app/gateway-scope.ts"; +import { gatewayOriginScope } from "@openclaw/gateway-client/browser"; import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import { normalizeOptionalString } from "../../lib/string-coerce.ts"; import { getSafeLocalStorage } from "../../local-storage.ts"; @@ -18,7 +18,7 @@ type PersistedPreferences = { }; function storageKey(gatewayUrl: string): string { - return `${STORAGE_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; + return `${STORAGE_KEY_PREFIX}${gatewayOriginScope(gatewayUrl)}`; } function normalizePreference(value: unknown): NewSessionPreference | null {