From 08b134324f0a79458784c5311c0690211ecbcfd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 17:07:48 -0700 Subject: [PATCH] feat: continue web sessions in the terminal (#122870) * feat: continue sessions in terminal Add a credential-free Control UI continuation command and allow openclaw resume to reuse current-profile authentication only for byte-exact configured Gateway targets. * fix(gateway): separate public origin TLS ownership Allow exact public-origin resume targets to reuse local authentication without inheriting the direct local listener certificate fingerprint. * fix(gateway): scope exact targets to gateway mode Prevent remote profiles from reusing dormant local Gateway authentication for explicit loopback or public-origin targets. * fix(cli): encode terminal resume handoffs Replace shell-specific quoting with a strict credential-free base64url handoff, gate configured auth reuse to validated handoffs, and skip unused session discovery. * fix(gateway): isolate handoff auth identity Suppress ambient Gateway auth fallback for validated handoffs while preserving explicit credentials, configured SecretRefs, stored device auth, and exact-target TLS ownership. * fix(cli): harden terminal resume handoffs * fix(cli): parse terminal handoff outcomes * fix(cli): bind handoffs to resolved agent * test(ui): align terminal continuation proof * docs(plan): track terminal continuation * refactor(ui): keep terminal handoff result local --- docs/cli/resume.md | 77 ++++- docs/concepts/session-attachment.md | 49 +++- docs/plan/runners.md | 25 +- packages/terminal-core/src/safe-text.test.ts | 16 +- packages/terminal-core/src/safe-text.ts | 18 +- src/cli/resume-cli.runtime.ts | 269 +++++++++++++++--- src/cli/resume-cli.test.ts | 263 ++++++++++++++++- src/cli/resume-cli.ts | 4 +- src/gateway/client-bootstrap.test.ts | 123 ++++++++ src/gateway/client-bootstrap.ts | 113 +++++++- src/gateway/minimal-gateway.test-helpers.ts | 16 +- src/shared/resume-handoff.test.ts | 193 +++++++++++++ src/shared/resume-handoff.ts | 127 +++++++++ src/tui/gateway-chat.connection.test.ts | 215 ++++++++++++++ src/tui/gateway-chat.test.ts | 26 ++ src/tui/gateway-chat.ts | 48 +++- ui/src/components/connect-command.ts | 2 +- .../e2e/chat-continue-in-terminal.e2e.test.ts | 112 ++++++++ ui/src/i18n/locales/en.ts | 12 + ui/src/pages/chat/chat-pane-base.ts | 7 + ui/src/pages/chat/chat-pane-context.ts | 2 + ui/src/pages/chat/chat-pane-header.ts | 22 +- ui/src/pages/chat/chat-pane-session-menu.ts | 78 +++++ ui/src/pages/chat/chat-pane-terminal.test.ts | 100 +++++++ .../chat-header-session-menu.test.ts | 41 ++- .../components/chat-header-session-menu.ts | 24 +- .../components/continue-in-terminal-dialog.ts | 33 +++ .../chat/continue-in-terminal-command.test.ts | 98 +++++++ .../chat/continue-in-terminal-command.ts | 47 +++ ui/src/pages/chat/workspace-conflict.ts | 12 +- ui/src/styles/chat/split-view.css | 28 ++ 31 files changed, 2084 insertions(+), 116 deletions(-) create mode 100644 src/shared/resume-handoff.test.ts create mode 100644 src/shared/resume-handoff.ts create mode 100644 ui/src/e2e/chat-continue-in-terminal.e2e.test.ts create mode 100644 ui/src/pages/chat/components/continue-in-terminal-dialog.ts create mode 100644 ui/src/pages/chat/continue-in-terminal-command.test.ts create mode 100644 ui/src/pages/chat/continue-in-terminal-command.ts diff --git a/docs/cli/resume.md b/docs/cli/resume.md index 66018682d841..5bd13bc40a4b 100644 --- a/docs/cli/resume.md +++ b/docs/cli/resume.md @@ -15,6 +15,7 @@ the Gateway; `resume` selects it and opens the existing [TUI](/cli/tui). ```bash openclaw resume openclaw resume +openclaw resume --handoff ``` With no query, OpenClaw displays up to 50 sessions active in the last seven @@ -33,23 +34,80 @@ status 1. If no recent session matches, it suggests the picker and | Flag | Default | Description | | ---------------------------- | -------------------------------- | ------------------------------------------------------------------- | +| `--handoff ` | (none) | Opaque session key and Gateway URL copied from the Control UI. | | `--url ` | `gateway.remote.url` from config | Gateway WebSocket URL. | | `--token ` | (none) | Gateway token if required. | | `--password ` | (none) | Gateway password if required. | | `--tls-fingerprint ` | `gateway.remote.tlsFingerprint` | Expected TLS certificate fingerprint for a pinned `wss://` Gateway. | -`resume` uses the same Gateway URL, authentication, and TLS resolution as -[`openclaw tui`](/cli/tui). It never starts a Gateway automatically. If the -configured Gateway is unavailable, start or repair it and rerun the command. +`--handoff` cannot be combined with a positional query or `--url` because it +authoritatively supplies both. You can combine it with `--token`, `--password`, +and `--tls-fingerprint`; those explicit authentication values keep their normal +highest priority. + +`resume` never starts a Gateway automatically. If the configured Gateway is +unavailable, start or repair it and rerun the command. `resume` resolves configured Gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec`/`store` providers). -Gateway target precedence is explicit `--url`, then `OPENCLAW_GATEWAY_URL`, -then `gateway.remote.url` when `gateway.mode` is `remote`, then the local -loopback Gateway. For that local Gateway, `OPENCLAW_GATEWAY_PORT` takes -precedence over the active port recorded by a running Gateway, which takes -precedence over the configured or default `gateway.port`. +When present, `--handoff` supplies the target Gateway URL. Otherwise, Gateway +target precedence is explicit `--url`, then `OPENCLAW_GATEWAY_URL`, then +`gateway.remote.url` when `gateway.mode` is `remote`, then the local loopback +Gateway. For that local Gateway, `OPENCLAW_GATEWAY_PORT` takes precedence over +the active port recorded by a running Gateway, which takes precedence over the +configured or default `gateway.port`. + +An explicit target normally requires an explicit `--token` or `--password`; +OpenClaw does not borrow credentials or a TLS pin from a different configured +target. `resume` has one narrow exception for a handoff copied from the Control +UI: when its Gateway URL byte-for-byte matches a canonical target of the current +profile, it may reuse that profile's configured interactive auth, SecretRef, +and stored exact-origin device auth. In local mode, the eligible targets are the +current local target with `gateway.controlUi.basePath` and `gateway.publicOrigin` +converted to WebSocket form with that base path. In remote mode, only the exact +`gateway.remote.url` is eligible. TLS pin ownership is narrower: an exact +direct-local target may reuse the +local Gateway certificate fingerprint, and an exact configured remote target +may reuse `gateway.remote.tlsFingerprint`; a public-origin target never inherits +the local listener's pin. Pass `--tls-fingerprint` explicitly when that public +origin needs a pin. A host, port, path, profile, query, or fragment mismatch +fails closed under the normal explicit-target policy. OpenClaw never scans +other profiles for a match. Handoff connections also ignore ambient +`OPENCLAW_GATEWAY_TOKEN` and `OPENCLAW_GATEWAY_PASSWORD` fallback, so shell +credentials for another Gateway cannot cross into the selected target. Explicit +flags and credentials owned by an exact configured target remain eligible. + +## Continue from the Control UI + +Open the selected session's header menu and choose **Continue in terminal…**. +The dialog shows one copyable `openclaw resume --handoff ` command. +The opaque payload is versioned, bounded, and encoded with an unpadded URL-safe +base64 alphabet, so the command needs no quoting and is safe to paste in common +POSIX shells, PowerShell, and `cmd.exe`. The encoded argument is limited to 4096 +characters; inside it, the agent-qualified session key is limited to 512 +user-perceived characters and the Gateway URL to 2048 characters. It contains +only the exact qualified session key and selected Gateway WebSocket URL, +including any Control UI base path. It contains no token, password, device +credential, or bootstrap credential, and the browser does not execute it. + +The Control UI does not offer this command when the selected Gateway URL uses a +query string. Gateway authentication and stored device scope are origin-based, +not query-aware, so OpenClaw never strips or copies that query into a +credential-free handoff. Use a manually authenticated CLI target with explicit +`--token` or `--password`, or configure a queryless Gateway URL. + +Run the command in an already configured OpenClaw terminal. The terminal +authenticates independently. Before opening the TUI, `resume` asks that Gateway +to resolve the qualified key and uses the returned canonical key. A deleted or +stale session stops with guidance to copy a fresh command; it never starts a new +session. The Gateway's session access controls remain authoritative. This flow +continues an existing session; it does not delegate first-use authentication +from the browser. + +If OpenClaw reports an invalid `--handoff` payload, return to the session's +Control UI menu and copy a fresh command. Do not edit or reuse a truncated +payload. ## Examples @@ -65,6 +123,9 @@ openclaw resume bugfix # Remote Gateway override openclaw resume bugfix --url wss://gateway.example.com --token + +# Opaque command copied from the Control UI +openclaw resume --handoff ``` ## Related diff --git a/docs/concepts/session-attachment.md b/docs/concepts/session-attachment.md index a7df837ae0c1..b5e2a5d1b482 100644 --- a/docs/concepts/session-attachment.md +++ b/docs/concepts/session-attachment.md @@ -99,7 +99,31 @@ separately when first pairing with a Gateway origin. ### Continue in the terminal -For Gateway-backed continuation, pass the URL or reference to `openclaw tui`: +From the Control UI, open the session header menu and choose **Continue in +terminal…**. The dialog copies a credential-free `openclaw resume` command with +one opaque, versioned handoff argument. The argument encodes only the exact +agent-qualified session key and selected Gateway WebSocket URL. The key is +bounded to 512 user-perceived characters. Its URL-safe alphabet needs no shell +quoting, so the command is safe to paste in common POSIX shells, PowerShell, and +`cmd.exe`. Run it in an OpenClaw CLI profile that is already configured for that +Gateway; the terminal authenticates independently. The Gateway canonicalizes +the key before the TUI attaches, and a missing session produces recovery +guidance instead of creating another session. The session ACL still applies. + +Query-routed Gateway URLs cannot produce this credential-free command because +Gateway authentication and stored device scope are not query-aware. The Control +UI does not strip or copy the query. Use a manually authenticated CLI target +with explicit `--token` or `--password`, or configure a queryless Gateway URL. + +You can also choose or query a recent session directly: + +```bash +openclaw resume +openclaw resume agent:main:deploy-monitor +``` + +For Gateway-backed continuation from a URL or short reference, pass the target +to `openclaw tui`: ```bash openclaw tui https://claw.example.com/dashboard/main/deploy-monitor-6db92d48 @@ -136,7 +160,21 @@ launch options. A URL or gateway shorthand authoritatively selects one normalized Gateway origin. OpenClaw never reuses configured credentials or a stored device token -from another origin for that target. +from another origin for that target. The credential-free command copied by +**Continue in terminal…** has a narrower rule: `openclaw resume` may reuse the +current CLI profile only when its explicit WebSocket URL byte-for-byte matches +that profile's mode: local and public-origin targets are eligible only in local +mode, while only `gateway.remote.url` is eligible in remote mode. It never +searches other profiles, and any host, port, or path mismatch returns to the +normal explicit-credential requirement. Exact direct-local targets may reuse +the local listener's certificate fingerprint, and exact configured remote +targets may reuse the configured remote pin. A public-origin target does not +inherit the local listener's pin; pass `--tls-fingerprint` explicitly if that +proxy origin needs one. The payload contains no credentials; explicit `--token`, +`--password`, or `--tls-fingerprint` values supplied beside the handoff still +take priority. Handoff resolution suppresses ambient +`OPENCLAW_GATEWAY_TOKEN` and `OPENCLAW_GATEWAY_PASSWORD` fallback while keeping +those explicit values and exact-target configured credentials eligible. On first contact: @@ -150,6 +188,13 @@ On first contact: 4. Later connections to the same origin can use the stored device token. An explicit `--token` or `--password` always wins for the entire connection. +The Control UI continuation command does not perform these first-contact steps +or carry their credentials. Configure or pair the terminal independently before +using it. If the CLI rejects an invalid or truncated handoff, copy a fresh +command from the Control UI instead of editing the opaque argument. If the +session was deleted after the command was copied, return to the Control UI and +copy a command from an available session. + Revoke or remove the device from the same Gateway's **Devices** page when that client should no longer connect. Tokens do not cross origins. Read-only probes through an SSH tunnel also suppress stored device auth because the loopback diff --git a/docs/plan/runners.md b/docs/plan/runners.md index d691aa65d9b8..a9540c887af3 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -19,7 +19,7 @@ advances a milestone. | 1a | Naming: session copy revert | landed | #120667 | | 1b | Naming: devices consolidation | landed | #120689 | | 1c | Cleanup: node-pairing → device-pairing merge | landed | #120726 | -| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 | +| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664, #122870 | | 3 | `openclaw connect` one-paste onboarding + `/j/` join route | in progress | #120768, #122499 | | 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531, #122635, #122774 | | F | Real-wire session boundary harness | landed | #121212 | @@ -386,8 +386,27 @@ Independently mergeable PR series; 3–5 can interleave after 1c. 1. **1c naming cleanup**: finish nodes → devices in route ids, i18n keys, labels; `node-pairing.ts` facade merge. Before any new placement copy. -2. **Continuation ergonomics** (in progress): `openclaw resume`, web - "Continue in terminal". +2. **Continuation ergonomics** (in progress): `openclaw resume` plus the web + **Continue in terminal…** session action. The browser copies one + credential-free command with one bounded, versioned, URL-safe handoff + argument that encodes the exact qualified session key and selected Gateway + WebSocket URL without shell-specific quoting. The key is agent-qualified and + bounded to 512 user-perceived characters. Query-routed Gateway URLs are + intentionally excluded because authentication and stored device scope are + not query-aware; the UI never strips or copies the query and instead directs + operators to a manually authenticated target or queryless configured URL. It + never executes the CLI or delegates first-use authentication. Before attach, + Resume asks the Gateway to resolve the key, uses only the returned canonical + key, and rejects a missing or ambiguous handoff without starting another + session. Resume may reuse only the current + profile's auth, SecretRefs, and exact-origin device auth when the handoff URL + byte-for-byte matches a target owned by the configured mode: local + Control + UI base path or public origin + base path in local mode, and only the remote + URL in remote mode. TLS pin reuse is limited to direct-local and + configured remote identities; public origins inherit no local-listener pin. + Ambient Gateway auth env fallback is suppressed for handoffs. Mismatches fail + closed, terminal auth remains independent, and session ACLs stay + authoritative. 3. **`openclaw connect`**: verb + `oc-pair://` decoder + TLS pin in payload + `/j/` join route (reserved prefix, single-use, rate-limited) + shortcode mint + curl wrapper on the public site. Exit: a fresh machine diff --git a/packages/terminal-core/src/safe-text.test.ts b/packages/terminal-core/src/safe-text.test.ts index 93e33d213da3..aa6bb306e7cf 100644 --- a/packages/terminal-core/src/safe-text.test.ts +++ b/packages/terminal-core/src/safe-text.test.ts @@ -1,6 +1,20 @@ // Terminal Core tests cover safe text behavior. import { describe, expect, it } from "vitest"; -import { sanitizeTerminalText } from "./safe-text.js"; +import { hasTerminalControl, sanitizeTerminalText } from "./safe-text.js"; + +describe("hasTerminalControl", () => { + it.each([ + ["C0", "safe\u0000text"], + ["DEL", "safe\u007ftext"], + ["C1", "safe\u0085text"], + ])("detects %s controls", (_name, input) => { + expect(hasTerminalControl(input)).toBe(true); + }); + + it("allows printable shell metacharacters and Unicode", () => { + expect(hasTerminalControl(`'"$&;|<>^()%![]{}\\\`-%PATH%-��`)).toBe(false); + }); +}); describe("sanitizeTerminalText", () => { it("removes C1 control characters", () => { diff --git a/packages/terminal-core/src/safe-text.ts b/packages/terminal-core/src/safe-text.ts index 9a1ab6d2c8ee..70ff0a9a4fd7 100644 --- a/packages/terminal-core/src/safe-text.ts +++ b/packages/terminal-core/src/safe-text.ts @@ -1,6 +1,20 @@ // Terminal Core module implements safe text behavior. import { stripAnsi } from "./ansi.js"; +/** Return whether text contains C0 or C1 terminal control characters. */ +export function hasTerminalControl(input: string): boolean { + for (const char of input) { + const codePoint = char.codePointAt(0); + if ( + codePoint !== undefined && + (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) + ) { + return true; + } + } + return false; +} + /** * Normalize untrusted text for single-line terminal/log rendering. */ @@ -11,9 +25,7 @@ export function sanitizeTerminalText(input: string): string { .replace(/\t/g, "\\t"); let sanitized = ""; for (const char of normalized) { - const code = char.charCodeAt(0); - const isControl = (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f); - if (!isControl) { + if (!hasTerminalControl(char)) { sanitized += char; } } diff --git a/src/cli/resume-cli.runtime.ts b/src/cli/resume-cli.runtime.ts index ac62b55c7007..b8f58a7037b5 100644 --- a/src/cli/resume-cli.runtime.ts +++ b/src/cli/resume-cli.runtime.ts @@ -1,9 +1,12 @@ // Resolves recent Gateway sessions and attaches the existing TUI to the selected key. import { cancel, isCancel } from "@clack/prompts"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { ErrorShape } from "../../packages/gateway-protocol/src/frame-guards.js"; import { selectStyled } from "../../packages/terminal-core/src/prompt-select-styled.js"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; +import { decodeResumeHandoff } from "../shared/resume-handoff.js"; import type { TuiSessionList } from "../tui/tui-backend.js"; import { buildSessionChoices, @@ -16,6 +19,100 @@ import type { ResumeCliOptions } from "./resume-cli.js"; const RESUME_INTERACTIVE_TERMINAL_GUIDANCE = "Attaching to a session requires an interactive terminal. Re-run `openclaw resume [query]` from an interactive terminal."; +const RESUME_HANDOFF_MISSING = + "This session is no longer available. Copy a fresh command from the Control UI."; +const RESUME_HANDOFF_UNRESOLVED = + "Could not resolve the session handoff. Copy a fresh command from the Control UI."; + +type ParsedHandoffSessionResolveResult = + | { kind: "success"; key: string; agentId: string } + | { kind: "missing" } + | { + kind: "ambiguous"; + candidates: Array<{ key: string; agentId: string; displayName?: string }>; + } + | { kind: "error"; error: ErrorShape } + | { kind: "malformed" }; + +function hasExactKeys( + value: unknown, + requiredKeys: readonly string[], + optionalKeys: readonly string[] = [], +): value is Record { + if (!isRecord(value)) { + return false; + } + const keys = Object.keys(value); + return ( + requiredKeys.every((key) => Object.hasOwn(value, key)) && + keys.every((key) => requiredKeys.includes(key) || optionalKeys.includes(key)) + ); +} + +function isHandoffSessionCandidate( + value: unknown, +): value is { key: string; agentId: string; displayName?: string } { + return ( + hasExactKeys(value, ["key", "agentId"], ["displayName"]) && + typeof value.key === "string" && + value.key.length > 0 && + typeof value.agentId === "string" && + value.agentId.length > 0 && + (!Object.hasOwn(value, "displayName") || typeof value.displayName === "string") + ); +} + +function isHandoffErrorShape(value: unknown): value is ErrorShape { + if ( + !hasExactKeys(value, ["code", "message"], ["details", "retryable", "retryAfterMs"]) || + typeof value.code !== "string" || + value.code.length === 0 || + typeof value.message !== "string" || + value.message.length === 0 || + (Object.hasOwn(value, "retryable") && typeof value.retryable !== "boolean") + ) { + return false; + } + return ( + !Object.hasOwn(value, "retryAfterMs") || + (typeof value.retryAfterMs === "number" && + Number.isInteger(value.retryAfterMs) && + value.retryAfterMs >= 0) + ); +} + +function parseHandoffSessionResolveResult(value: unknown): ParsedHandoffSessionResolveResult { + if ( + hasExactKeys(value, ["ok", "key", "agentId"]) && + value.ok === true && + typeof value.key === "string" && + value.key.length > 0 && + typeof value.agentId === "string" && + value.agentId.length > 0 + ) { + return { kind: "success", key: value.key, agentId: value.agentId }; + } + if (hasExactKeys(value, ["ok", "missing"]) && value.ok === true && value.missing === true) { + return { kind: "missing" }; + } + if ( + hasExactKeys(value, ["ok", "ambiguous", "candidates"]) && + value.ok === true && + value.ambiguous === true && + Array.isArray(value.candidates) && + value.candidates.every(isHandoffSessionCandidate) + ) { + return { kind: "ambiguous", candidates: value.candidates }; + } + if ( + hasExactKeys(value, ["ok", "error"]) && + value.ok === false && + isHandoffErrorShape(value.error) + ) { + return { kind: "error", error: value.error }; + } + return { kind: "malformed" }; +} function requireInteractiveResumeTerminal() { if (!process.stdin.isTTY || !process.stdout.isTTY) { @@ -23,12 +120,35 @@ function requireInteractiveResumeTerminal() { } } -async function fetchResumeSessions( - opts: ResumeCliOptions, - options: { agentId?: string; includeGlobal?: boolean } = {}, -) { +async function formatResumeConnectionError(error: unknown): Promise { + const [{ formatTuiErrorMessage }, { resolveGatewayDisconnectState }] = await Promise.all([ + import("../tui/tui-formatters.js"), + import("../tui/tui.js"), + ]); + const details = + error && typeof error === "object" && "details" in error ? error.details : undefined; + const state = resolveGatewayDisconnectState({ + reason: formatTuiErrorMessage(error), + details, + }); + return new Error( + [ + state.connectionStatus, + state.remediation ?? + "Ensure the Gateway is running and your --url/--token/--password are correct.", + ].join("\n"), + { cause: error }, + ); +} + +async function connectResumeGateway(opts: ResumeCliOptions, handoffTarget: boolean) { const { GatewayChatClient } = await import("../tui/gateway-chat.js"); - const client = await GatewayChatClient.connect(opts); + const client = await GatewayChatClient.connect({ + ...opts, + ...(handoffTarget + ? { allowConfiguredAuthForExactTarget: true, suppressEnvAuthFallback: true } + : {}), + }); try { await new Promise((resolve, reject) => { let settled = false; @@ -45,26 +165,59 @@ async function fetchResumeSessions( finish(() => reject(new Error(reason || "Gateway connection closed"))); client.start(); }); - return await loadRecentSessions(client, options); + return client; } catch (error) { - const [{ formatTuiErrorMessage }, { resolveGatewayDisconnectState }] = await Promise.all([ - import("../tui/tui-formatters.js"), - import("../tui/tui.js"), - ]); - const details = - error && typeof error === "object" && "details" in error ? error.details : undefined; - const state = resolveGatewayDisconnectState({ - reason: formatTuiErrorMessage(error), - details, - }); - throw new Error( - [ - state.connectionStatus, - state.remediation ?? - "Ensure the Gateway is running and your --url/--token/--password are correct.", - ].join("\n"), - { cause: error }, - ); + await client.stop(); + throw await formatResumeConnectionError(error); + } +} + +async function resolveHandoffConnection( + opts: ResumeCliOptions, + handoff: { sessionKey: string; agentId: string }, +) { + const client = await connectResumeGateway(opts, true); + try { + let result: unknown; + try { + result = await client.resolveSession({ + key: handoff.sessionKey, + agentId: handoff.agentId, + includeGlobal: true, + allowMissing: true, + }); + } catch { + throw new Error(RESUME_HANDOFF_UNRESOLVED); + } + const parsed = parseHandoffSessionResolveResult(result); + if (parsed.kind === "success") { + const canonicalKeyOwner = parseAgentSessionKey(parsed.key)?.agentId; + if (parsed.agentId !== handoff.agentId || canonicalKeyOwner !== parsed.agentId) { + throw new Error(RESUME_HANDOFF_UNRESOLVED); + } + return { connection: client.connection, sessionKey: parsed.key }; + } + if (parsed.kind === "missing") { + throw new Error(RESUME_HANDOFF_MISSING); + } + throw new Error(RESUME_HANDOFF_UNRESOLVED); + } finally { + await client.stop(); + } +} + +async function fetchResumeSessions( + opts: ResumeCliOptions, + options: { agentId?: string; includeGlobal?: boolean } = {}, +) { + const client = await connectResumeGateway(opts, false); + try { + return { + connection: client.connection, + sessions: await loadRecentSessions(client, options), + }; + } catch (error) { + throw await formatResumeConnectionError(error); } finally { await client.stop(); } @@ -129,38 +282,60 @@ function resolveExplicitGlobalSessionKey( /** Resolve or select one session and run the existing Gateway-backed TUI. */ export async function runResumeCommand(query: string | undefined, opts: ResumeCliOptions) { + const { handoff: encodedHandoff, ...connectionOptions } = opts; + if (encodedHandoff !== undefined && (query !== undefined || opts.url !== undefined)) { + throw new Error("--handoff cannot be combined with a positional query or --url."); + } + const handoff = encodedHandoff === undefined ? undefined : decodeResumeHandoff(encodedHandoff); requireInteractiveResumeTerminal(); - const trimmedQuery = query?.trim(); - const explicitGlobalSession = resolveExplicitGlobalSessionKey(trimmedQuery); - const sessions = await fetchResumeSessions( - opts, - explicitGlobalSession - ? { agentId: explicitGlobalSession.agentId, includeGlobal: true } - : undefined, - ); + const resolvedQuery = query?.trim(); + const explicitGlobalSession = resolveExplicitGlobalSessionKey(resolvedQuery); + let connection: Awaited>["connection"]; let sessionKey: string | null; - if (explicitGlobalSession) { - sessionKey = explicitGlobalSession.key; - } else if (trimmedQuery) { - const resolution = resolveResumeSession(sessions, trimmedQuery); - if (resolution.kind !== "match") { - reportResumeFailure(trimmedQuery, resolution); - defaultRuntime.exit(1); - return; - } - sessionKey = resolution.session.value; + if (handoff) { + const parsed = parseAgentSessionKey(handoff.sessionKey)!; + const resolved = await resolveHandoffConnection( + { + ...connectionOptions, + url: handoff.gatewayUrl, + }, + { sessionKey: handoff.sessionKey, agentId: parsed.agentId }, + ); + connection = resolved.connection; + sessionKey = resolved.sessionKey; } else { - sessionKey = await promptResumeSession(sessions); + const discovery = await fetchResumeSessions( + connectionOptions, + explicitGlobalSession + ? { agentId: explicitGlobalSession.agentId, includeGlobal: true } + : undefined, + ); + connection = discovery.connection; + if (explicitGlobalSession) { + sessionKey = explicitGlobalSession.key; + } else if (resolvedQuery) { + const resolution = resolveResumeSession(discovery.sessions, resolvedQuery); + if (resolution.kind !== "match") { + reportResumeFailure(resolvedQuery, resolution); + defaultRuntime.exit(1); + return; + } + sessionKey = resolution.session.value; + } else { + sessionKey = await promptResumeSession(discovery.sessions); + } } if (!sessionKey) { return; } const { runTui } = await import("../tui/tui.js"); await runTui({ - url: opts.url, - token: opts.token, - password: opts.password, - tlsFingerprint: opts.tlsFingerprint, + boundGateway: { + url: handoff?.gatewayUrl ?? connection.url, + ...(connection.token ? { token: connection.token } : {}), + ...(connection.password ? { password: connection.password } : {}), + ...(connection.tlsFingerprint ? { tlsFingerprint: connection.tlsFingerprint } : {}), + }, session: sessionKey, forceProcessExitOnReturn: true, }); diff --git a/src/cli/resume-cli.test.ts b/src/cli/resume-cli.test.ts index 439a7154a45f..5841f6222d97 100644 --- a/src/cli/resume-cli.test.ts +++ b/src/cli/resume-cli.test.ts @@ -1,3 +1,4 @@ +import { Command } from "commander"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { DeviceAuthTokenRecord } from "../../packages/gateway-client/src/client.js"; import { @@ -6,8 +7,10 @@ import { } from "../../packages/gateway-protocol/src/client-info.js"; import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; import { startMinimalRealGateway } from "../gateway/minimal-gateway.test-helpers.js"; +import { encodeResumeHandoff } from "../shared/resume-handoff.js"; import type { TuiSessionList } from "../tui/tui-backend.js"; import { resolveResumeSession } from "../tui/tui-session-picker.js"; +import { registerResumeCli } from "./resume-cli.js"; import { runResumeCommand } from "./resume-cli.runtime.js"; const mocks = vi.hoisted(() => ({ @@ -47,9 +50,23 @@ const ttyDescriptors = [process.stdin, process.stdout].map( (stream) => [stream, Object.getOwnPropertyDescriptor(stream, "isTTY")] as const, ); -function createGatewayClient(rows: SessionRow[]) { +function createGatewayClient( + rows: SessionRow[], + connection: { + url: string; + token?: string; + password?: string; + tlsFingerprint?: string; + } = { + url: "wss://resolved.example/control", + token: "resolved-token", + tlsFingerprint: "sha256:resolved-pin", + }, +) { const client = { + connection, listSessions: vi.fn().mockResolvedValue({ sessions: rows }), + resolveSession: vi.fn(), onConnected: undefined as (() => void) | undefined, onConnectError: undefined as ((error: Error) => void) | undefined, onDisconnected: undefined as ((reason: string) => void) | undefined, @@ -137,6 +154,193 @@ describe("resolveResumeSession", () => { }); describe("runResumeCommand", () => { + it.each([ + ["malformed", "not+base64url"], + ["oversized", "A".repeat(4097)], + ])("rejects a %s handoff before Gateway discovery or the TUI", async (_name, handoff) => { + await expect(runResumeCommand(undefined, { handoff })).rejects.toThrow( + "Invalid --handoff payload. Copy a fresh command from the Control UI.", + ); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.runTui).not.toHaveBeenCalled(); + }); + + it.each([ + ["a positional query", "agent:main:other", undefined], + ["an explicit URL", undefined, "wss://other.example/ws"], + ])("rejects a handoff combined with %s", async (_name, query, url) => { + const handoff = encodeResumeHandoff({ + sessionKey: "agent:main:alpha", + gatewayUrl: "wss://gateway.example/openclaw", + }); + + await expect(runResumeCommand(query, { handoff, ...(url ? { url } : {}) })).rejects.toThrow( + "--handoff cannot be combined with a positional query or --url.", + ); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.runTui).not.toHaveBeenCalled(); + }); + + it("passes an exact handoff target and explicit auth directly into the bound TUI", async () => { + const sessionKey = "agent:main: hostile-'\"$&;|<>^()%![]{}\\`-%PATH% "; + const url = "wss://gateway.example/openclaw/$&;=()+,![]{}'`/%25PATH%25"; + const handoff = encodeResumeHandoff({ sessionKey, gatewayUrl: url }); + const client = createGatewayClient([], { + url: "wss://normalized.example/different-path", + token: "explicit-token", + password: "explicit-password", + tlsFingerprint: "sha256:explicit-pin", + }); + client.resolveSession.mockResolvedValue({ ok: true, key: sessionKey, agentId: "main" }); + + await runResumeCommand(undefined, { + handoff, + token: "explicit-token", + password: "explicit-password", + tlsFingerprint: "sha256:explicit-pin", + }); + + expect(mocks.connect).toHaveBeenCalledWith({ + url, + token: "explicit-token", + password: "explicit-password", + tlsFingerprint: "sha256:explicit-pin", + allowConfiguredAuthForExactTarget: true, + suppressEnvAuthFallback: true, + }); + expect(client.resolveSession).toHaveBeenCalledExactlyOnceWith({ + key: sessionKey, + agentId: "main", + includeGlobal: true, + allowMissing: true, + }); + expect(client.listSessions).not.toHaveBeenCalled(); + expect(mocks.runTui).toHaveBeenCalledWith({ + boundGateway: { + url, + token: "explicit-token", + password: "explicit-password", + tlsFingerprint: "sha256:explicit-pin", + }, + session: sessionKey, + forceProcessExitOnReturn: true, + }); + }); + + it.each([ + ["missing", { ok: true, missing: true }, "This session is no longer available."], + [ + "ambiguous", + { + ok: true, + ambiguous: true, + candidates: [{ key: "agent:main:one", agentId: "main", displayName: "One" }], + }, + "Could not resolve the session handoff.", + ], + [ + "domain error", + { ok: false, error: { code: "INVALID_REQUEST", message: "invalid handoff" } }, + "Could not resolve the session handoff.", + ], + ["projected missing", { ok: false }, "Could not resolve the session handoff."], + [ + "projected ambiguity", + { ok: false, candidates: [{ key: "agent:main:one" }] }, + "Could not resolve the session handoff.", + ], + ["malformed success", { ok: true }, "Could not resolve the session handoff."], + [ + "old success without agent ownership", + { ok: true, key: "agent:main:alpha" }, + "Could not resolve the session handoff.", + ], + [ + "extra success field", + { ok: true, key: "agent:main:alpha", agentId: "main", extra: true }, + "Could not resolve the session handoff.", + ], + [ + "empty success owner", + { ok: true, key: "agent:main:alpha", agentId: "" }, + "Could not resolve the session handoff.", + ], + [ + "old ambiguity candidate without agent ownership", + { ok: true, ambiguous: true, candidates: [{ key: "agent:main:one" }] }, + "Could not resolve the session handoff.", + ], + [ + "malformed candidate", + { + ok: true, + ambiguous: true, + candidates: [{ key: "agent:main:one", agentId: "main", extra: true }], + }, + "Could not resolve the session handoff.", + ], + [ + "mismatched returned agent", + { ok: true, key: "agent:main:alpha", agentId: "work" }, + "Could not resolve the session handoff.", + ], + [ + "unqualified canonical key", + { ok: true, key: "alpha", agentId: "main" }, + "Could not resolve the session handoff.", + ], + [ + "mismatched canonical key owner", + { ok: true, key: "agent:work:alpha", agentId: "main" }, + "Could not resolve the session handoff.", + ], + [ + "malformed error", + { + ok: false, + error: { code: "INVALID_REQUEST", message: "invalid handoff", retryAfterMs: -1 }, + }, + "Could not resolve the session handoff.", + ], + [ + "extra error field", + { + ok: false, + error: { code: "INVALID_REQUEST", message: "invalid handoff", extra: true }, + }, + "Could not resolve the session handoff.", + ], + ])( + "rejects a %s handoff resolution without discovery or TUI launch", + async (_name, result, message) => { + const handoff = encodeResumeHandoff({ + sessionKey: "agent:main:alpha", + gatewayUrl: "wss://gateway.example/openclaw", + }); + const client = createGatewayClient([]); + client.resolveSession.mockResolvedValue(result); + + await expect(runResumeCommand(undefined, { handoff })).rejects.toThrow(message); + expect(client.listSessions).not.toHaveBeenCalled(); + expect(mocks.runTui).not.toHaveBeenCalled(); + }, + ); + + it("rejects a handoff resolution RPC error without exposing it or launching the TUI", async () => { + const handoff = encodeResumeHandoff({ + sessionKey: "agent:main:alpha", + gatewayUrl: "wss://gateway.example/openclaw", + }); + const client = createGatewayClient([]); + client.resolveSession.mockRejectedValue(new Error("sensitive upstream details")); + + await expect(runResumeCommand(undefined, { handoff })).rejects.toThrow( + "Could not resolve the session handoff. Copy a fresh command from the Control UI.", + ); + expect(client.listSessions).not.toHaveBeenCalled(); + expect(mocks.runTui).not.toHaveBeenCalled(); + }); + it("excludes the bare global session from query resolution", async () => { const client = createGatewayClient([]); @@ -167,12 +371,39 @@ describe("runResumeCommand", () => { ); expect(mocks.runTui).toHaveBeenCalledWith( expect.objectContaining({ + boundGateway: { + url: "wss://resolved.example/control", + token: "resolved-token", + tlsFingerprint: "sha256:resolved-pin", + }, session: "agent:main:alpha", forceProcessExitOnReturn: true, }), ); }); + it("resolves the resume connection once and hands it to the TUI as bound", async () => { + createGatewayClient([ + { key: "agent:main:alpha", displayName: "Alpha planning", label: "roadmap" }, + ]); + + await runResumeCommand("agent:main:alpha", { url: "wss://gateway.example/control" }); + + expect(mocks.connect).toHaveBeenCalledWith({ + url: "wss://gateway.example/control", + }); + expect(mocks.runTui).toHaveBeenCalledWith( + expect.objectContaining({ + boundGateway: { + url: "wss://resolved.example/control", + token: "resolved-token", + tlsFingerprint: "sha256:resolved-pin", + }, + session: "agent:main:alpha", + }), + ); + }); + it("rejects a non-interactive queried resume before connecting or launching the TUI", async () => { Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); @@ -185,12 +416,22 @@ describe("runResumeCommand", () => { }); }); +describe("resume command registration", () => { + it("documents the additive opaque handoff option", () => { + const program = new Command().name("openclaw"); + registerResumeCli(program); + + expect(program.commands[0]?.helpInformation()).toContain("--handoff "); + }); +}); + describe("real Gateway session boundary", () => { let harness: Awaited>; beforeAll(async () => { harness = await startMinimalRealGateway([ { agentId: "work", key: "agent:work:global", visibility: "shared" }, + { agentId: "main", key: "agent:main:alpha" }, ]); }); @@ -210,6 +451,26 @@ describe("real Gateway session boundary", () => { ); }); + it("canonicalizes a case-variant handoff through the real sessions.resolve boundary", async () => { + const { GatewayChatClient } = + await vi.importActual("../tui/gateway-chat.js"); + mocks.connect.mockImplementation((options) => GatewayChatClient.connect(options)); + const rawSessionKey = "Agent:Main:ALPHA"; + const handoff = encodeResumeHandoff({ sessionKey: rawSessionKey, gatewayUrl: harness.url }); + + await runResumeCommand(undefined, { handoff, token: harness.token }); + + expect(harness.sessionResolveRequests).toContainEqual({ + key: rawSessionKey, + agentId: "main", + includeGlobal: true, + allowMissing: true, + }); + expect(mocks.runTui).toHaveBeenCalledWith( + expect.objectContaining({ session: "agent:main:alpha", forceProcessExitOnReturn: true }), + ); + }); + it("accepts a bootstrap-signed identity and rejects a mismatched signature", async () => { await expect(harness.connectBootstrap()).resolves.toMatchObject({ ok: true }); expect(harness.hellos).toContainEqual(expect.objectContaining({ type: "hello-ok" })); diff --git a/src/cli/resume-cli.ts b/src/cli/resume-cli.ts index 017dcc144f5a..160e2c9d7b7b 100644 --- a/src/cli/resume-cli.ts +++ b/src/cli/resume-cli.ts @@ -6,6 +6,7 @@ import { defaultRuntime } from "../runtime.js"; import { addTuiOptions } from "./tui-cli-options.js"; export type ResumeCliOptions = { + handoff?: string; url?: string; token?: string; password?: string; @@ -17,7 +18,8 @@ export function registerResumeCli(program: Command) { const command = program .command("resume") .description("Resume a recent Gateway session in the TUI") - .argument("[query]", "Session key, display name, or label"); + .argument("[query]", "Session key, display name, or label") + .option("--handoff ", "Opaque session handoff copied from the Control UI"); addTuiOptions(command) .addHelpText( "after", diff --git a/src/gateway/client-bootstrap.test.ts b/src/gateway/client-bootstrap.test.ts index e9fbcb36a7b5..ea59a79753ee 100644 --- a/src/gateway/client-bootstrap.test.ts +++ b/src/gateway/client-bootstrap.test.ts @@ -131,6 +131,129 @@ describe("resolveGatewayClientBootstrap", () => { expect(mockState.loadGatewayTlsRuntime).toHaveBeenCalledWith(tlsConfig); }); + it("reuses local auth without pinning an exact public-origin target to the local certificate", async () => { + const publicUrl = "wss://gateway.example/openclaw"; + const tlsConfig = { enabled: true }; + mockState.buildGatewayConnectionDetails + .mockReturnValueOnce({ + url: publicUrl, + urlSource: "cli --url", + message: `Gateway target: ${publicUrl}`, + }) + .mockReturnValueOnce({ + url: "wss://127.0.0.1:18789", + urlSource: "local loopback", + message: "Gateway target: wss://127.0.0.1:18789", + }); + mockState.loadGatewayTlsRuntime.mockResolvedValue({ + enabled: true, + required: true, + fingerprintSha256: "sha256:local", + }); + + const result = await resolveGatewayClientBootstrap({ + config: { + gateway: { + mode: "local", + publicOrigin: "https://gateway.example", + controlUi: { basePath: "/openclaw" }, + tls: tlsConfig, + auth: { mode: "token", token: "configured-token" }, + }, + } as never, + gatewayUrl: publicUrl, + authPolicy: "interactive", + allowConfiguredAuthForExactTarget: true, + env: process.env, + }); + + expect(result.auth.token).toBe("configured-token"); + expect(result.tlsFingerprint).toBeUndefined(); + expect(mockState.loadGatewayTlsRuntime).not.toHaveBeenCalled(); + }); + + it("retains the local certificate pin for an exact direct-local target", async () => { + const localUrl = "wss://127.0.0.1:18789/openclaw"; + const tlsConfig = { enabled: true }; + mockState.buildGatewayConnectionDetails + .mockReturnValueOnce({ + url: localUrl, + urlSource: "cli --url", + message: `Gateway target: ${localUrl}`, + }) + .mockReturnValueOnce({ + url: "wss://127.0.0.1:18789", + urlSource: "local loopback", + message: "Gateway target: wss://127.0.0.1:18789", + }); + mockState.loadGatewayTlsRuntime.mockResolvedValue({ + enabled: true, + required: true, + fingerprintSha256: "sha256:local", + }); + + const result = await resolveGatewayClientBootstrap({ + config: { + gateway: { + mode: "local", + controlUi: { basePath: "/openclaw" }, + tls: tlsConfig, + auth: { mode: "token", token: "configured-token" }, + }, + } as never, + gatewayUrl: localUrl, + explicitAuth: { token: "explicit-token" }, + authPolicy: "interactive", + allowConfiguredAuthForExactTarget: true, + env: process.env, + }); + + expect(result.auth.token).toBe("explicit-token"); + expect(result.tlsFingerprint).toBe("sha256:local"); + expect(mockState.loadGatewayTlsRuntime).toHaveBeenCalledWith(tlsConfig); + }); + + it("prefers direct-local TLS ownership when publicOrigin resolves to the same URL", async () => { + const localUrl = "wss://127.0.0.1:18789/openclaw"; + const tlsConfig = { enabled: true }; + mockState.buildGatewayConnectionDetails + .mockReturnValueOnce({ + url: localUrl, + urlSource: "cli --url", + message: `Gateway target: ${localUrl}`, + }) + .mockReturnValueOnce({ + url: "wss://127.0.0.1:18789", + urlSource: "local loopback", + message: "Gateway target: wss://127.0.0.1:18789", + }); + mockState.loadGatewayTlsRuntime.mockResolvedValue({ + enabled: true, + required: true, + fingerprintSha256: "sha256:local", + }); + + const result = await resolveGatewayClientBootstrap({ + config: { + gateway: { + mode: "local", + publicOrigin: "https://127.0.0.1:18789", + controlUi: { basePath: "/openclaw" }, + tls: tlsConfig, + auth: { mode: "token", token: "configured-token" }, + }, + } as never, + gatewayUrl: localUrl, + authPolicy: "interactive", + allowConfiguredAuthForExactTarget: true, + env: process.env, + }); + + expect(result.auth.token).toBe("configured-token"); + expect(result.tlsFingerprint).toBe("sha256:local"); + expect(mockState.loadGatewayTlsRuntime).toHaveBeenCalledWith(tlsConfig); + }); + it.each([ { url: "wss://gateway.example/ws", diff --git a/src/gateway/client-bootstrap.ts b/src/gateway/client-bootstrap.ts index c0e08080d5eb..e7d5bc106fb4 100644 --- a/src/gateway/client-bootstrap.ts +++ b/src/gateway/client-bootstrap.ts @@ -1,6 +1,7 @@ // Gateway client bootstrap resolver. // Collects URL, auth, and handshake settings before constructing a GatewayClient. import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; +import { resolveGatewayPublicOrigin } from "../config/gateway-public-origin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import { @@ -11,6 +12,7 @@ import { buildGatewayConnectionDetailsWithResolvers, type GatewayConnectionDetails, } from "./connection-details.js"; +import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveGatewayCredentialsWithSecretInputs } from "./credentials-secret-inputs.js"; import { resolveExplicitGatewayAuth, @@ -83,6 +85,70 @@ export function ensureExplicitGatewayAuth(params: { type GatewayClientBootstrapAuthPolicy = "default" | "interactive" | "probe"; +type ConfiguredGatewayTargetIdentity = { + authSurface: "local" | "remote"; + tlsSource?: "local loopback" | "config gateway.remote.url"; +}; + +function appendControlUiBasePath(url: string, basePath: string): string { + return `${url}${normalizeControlUiBasePath(basePath)}`; +} + +function resolveExactConfiguredGatewayTarget(params: { + buildConnectionDetails: (options: { + config: OpenClawConfig; + ignoreEnvUrlOverride?: boolean; + localPortOverride?: number; + }) => GatewayConnectionDetails; + config: OpenClawConfig; + explicitUrl: string; + localPortOverride?: number; +}): ConfiguredGatewayTargetIdentity | undefined { + const candidates: Array<{ + target: string; + identity: ConfiguredGatewayTargetIdentity; + }> = []; + if (params.config.gateway?.mode === "remote") { + const remoteUrl = trimToUndefined(params.config.gateway.remote?.url); + if (remoteUrl) { + candidates.push({ + target: remoteUrl, + identity: { authSurface: "remote", tlsSource: "config gateway.remote.url" }, + }); + } + } else { + const localGateway = { ...params.config.gateway, mode: "local" as const }; + delete localGateway.remote; + const localUrl = params.buildConnectionDetails({ + config: { ...params.config, gateway: localGateway }, + ignoreEnvUrlOverride: true, + ...(params.localPortOverride !== undefined + ? { localPortOverride: params.localPortOverride } + : {}), + }).url; + const basePath = params.config.gateway?.controlUi?.basePath ?? ""; + candidates.push({ + target: appendControlUiBasePath(localUrl, basePath), + identity: { authSurface: "local", tlsSource: "local loopback" }, + }); + const publicOrigin = resolveGatewayPublicOrigin(params.config); + if (publicOrigin) { + candidates.push({ + target: appendControlUiBasePath( + publicOrigin.replace(/^https:/u, "wss:").replace(/^http:/u, "ws:"), + basePath, + ), + // A public reverse proxy may terminate a different certificate than the + // direct local listener, so local auth ownership does not imply a TLS pin. + identity: { authSurface: "local" }, + }); + } + } + // Direct-local is listed before publicOrigin so an identical URL retains + // the local listener's TLS identity instead of becoming ambiguous. + return candidates.find(({ target }) => target === params.explicitUrl)?.identity; +} + /** Resolve the only URL overrides allowed to displace configured Gateway targets. */ export function resolveGatewayUrlOverride(params: { gatewayUrl?: string; @@ -110,6 +176,10 @@ export async function resolveGatewayClientBootstrap(params: { explicitAuth?: ExplicitGatewayAuth; env?: NodeJS.ProcessEnv; authPolicy?: GatewayClientBootstrapAuthPolicy; + /** Permit current-profile auth only after bootstrap proves an exact configured target match. */ + allowConfiguredAuthForExactTarget?: boolean; + /** Ignore ambient shared-auth fallback while still resolving configured SecretRefs. */ + suppressEnvAuthFallback?: boolean; modeOverride?: GatewayCredentialMode; ignoreEnvUrlOverride?: boolean; localPortOverride?: number; @@ -168,36 +238,52 @@ export async function resolveGatewayClientBootstrap(params: { }); const detectedUrlOverrideSource = resolveGatewayUrlOverrideSource(connection.urlSource); const urlOverrideSource = urlOverride.source ?? detectedUrlOverrideSource; + const configuredTarget = + params.allowConfiguredAuthForExactTarget && urlOverrideSource === "cli" + ? resolveExactConfiguredGatewayTarget({ + buildConnectionDetails, + config: params.config, + explicitUrl: connection.url, + ...(params.localPortOverride !== undefined + ? { localPortOverride: params.localPortOverride } + : {}), + }) + : undefined; + const tlsUrlSource = configuredTarget?.tlsSource ?? connection.urlSource; const tlsFingerprint = params.resolveTlsFingerprint ? await params.resolveTlsFingerprint({ config: params.config, url: connection.url, - urlSource: connection.urlSource, + urlSource: tlsUrlSource, explicitTlsFingerprint: params.explicitTlsFingerprint, }) : await resolveGatewayConnectionTlsFingerprint({ config: params.config, url: connection.url, - urlSource: connection.urlSource, + urlSource: tlsUrlSource, explicitTlsFingerprint: params.explicitTlsFingerprint, loadGatewayTlsRuntime, }); // Only direct CLI/env URL overrides should constrain token/password fallback. Config-derived // remote URLs are canonical config, not a caller override. const surface = - params.modeOverride ?? (params.config.gateway?.mode === "remote" ? "remote" : "local"); + configuredTarget?.authSurface ?? + params.modeOverride ?? + (params.config.gateway?.mode === "remote" ? "remote" : "local"); let auth: { token?: string; password?: string; failureReason?: string }; if (params.skipImplicitAuth) { auth = explicitAuth; - } else if (urlOverrideSource) { - auth = await resolveGatewayCredentialsWithSecretInputs({ - config: params.config, - explicitAuth, - env, - urlOverride: connection.url, - urlOverrideSource, - modeOverride: params.modeOverride, - }); + } else if (urlOverrideSource && !configuredTarget) { + auth = params.suppressEnvAuthFallback + ? explicitAuth + : await resolveGatewayCredentialsWithSecretInputs({ + config: params.config, + explicitAuth, + env, + urlOverride: connection.url, + urlOverrideSource, + modeOverride: params.modeOverride, + }); } else if (params.authPolicy === "probe") { auth = await resolveGatewayProbeSurfaceAuth({ config: params.config, env, surface }); } else if (params.authPolicy === "interactive") { @@ -205,6 +291,7 @@ export async function resolveGatewayClientBootstrap(params: { config: params.config, env, explicitAuth, + suppressEnvAuthFallback: params.suppressEnvAuthFallback, surface, }); } else { @@ -221,7 +308,7 @@ export async function resolveGatewayClientBootstrap(params: { urlOverrideSource || params.config.gateway?.mode === "remote" ? gatewayOriginScope(connection.url) : undefined; - if (params.overrideAuthErrorHint) { + if (params.overrideAuthErrorHint && !configuredTarget) { ensureExplicitGatewayAuth({ urlOverride: urlOverrideSource ? connection.url : undefined, urlOverrideSource, diff --git a/src/gateway/minimal-gateway.test-helpers.ts b/src/gateway/minimal-gateway.test-helpers.ts index 2e641e01aaf4..5dca40c9467a 100644 --- a/src/gateway/minimal-gateway.test-helpers.ts +++ b/src/gateway/minimal-gateway.test-helpers.ts @@ -105,6 +105,7 @@ export async function startMinimalRealGateway( }, }); const sessionListRequests: Record[] = []; + const sessionResolveRequests: Record[] = []; const hellos: unknown[] = []; const connectFailures: unknown[] = []; const clients: WebSocket[] = []; @@ -115,10 +116,15 @@ export async function startMinimalRealGateway( } const startServer = async () => { const methods = await import("./server-methods.js"); - const original = methods.coreGatewayHandlers["sessions.list"]!; + const originalList = methods.coreGatewayHandlers["sessions.list"]!; + const originalResolve = methods.coreGatewayHandlers["sessions.resolve"]!; methods.coreGatewayHandlers["sessions.list"] = async (options) => { sessionListRequests.push(options.params as Record); - return await original(options); + return await originalList(options); + }; + methods.coreGatewayHandlers["sessions.resolve"] = async (options) => { + sessionResolveRequests.push(options.params as Record); + return await originalResolve(options); }; const gateway = await import("./server.js"); return await gateway @@ -128,7 +134,10 @@ export async function startMinimalRealGateway( controlUiEnabled: false, sidecarStartup: "defer", }) - .finally(() => (methods.coreGatewayHandlers["sessions.list"] = original)); + .finally(() => { + methods.coreGatewayHandlers["sessions.list"] = originalList; + methods.coreGatewayHandlers["sessions.resolve"] = originalResolve; + }); }; try { for (const session of sessions) { @@ -151,6 +160,7 @@ export async function startMinimalRealGateway( url: `ws://127.0.0.1:${port}`, token, sessionListRequests, + sessionResolveRequests, hellos, connectFailures, issueNodeBootstrapToken: async () => diff --git a/src/shared/resume-handoff.test.ts b/src/shared/resume-handoff.test.ts new file mode 100644 index 000000000000..38fff0c45271 --- /dev/null +++ b/src/shared/resume-handoff.test.ts @@ -0,0 +1,193 @@ +// Shared contract tests run in Node using only the browser-compatible globals used in production. +import { describe, expect, it } from "vitest"; +import { decodeResumeHandoff, encodeResumeHandoff } from "./resume-handoff.js"; + +const gatewayUrl = "wss://gateway.example/openclaw"; +const maxEncodedLength = 4096; + +function encodeBytes(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +function encodeJson(value: unknown): string { + return encodeBytes(new TextEncoder().encode(JSON.stringify(value))); +} + +function decodeText(encoded: string): string { + const standard = encoded.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(`${standard}${"=".repeat((4 - (standard.length % 4)) % 4)}`); + return new TextDecoder().decode(Uint8Array.from(binary, (character) => character.charCodeAt(0))); +} + +describe("resume handoff contract", () => { + it("round-trips hostile cross-shell fields through only the inert base64url alphabet", () => { + const sessionKey = "agent:runner:hostile-'\"$&;|<>^()%![]{}\\`-%PATH%-��"; + const hostileGatewayUrl = "wss://gateway.example/openclaw/$&;=()+,![]{}'`/%25PATH%25/%E2%98%83"; + + const encoded = encodeResumeHandoff({ sessionKey, gatewayUrl: hostileGatewayUrl }); + + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encoded).not.toContain("="); + expect(decodeText(encoded)).toBe( + JSON.stringify({ version: 1, sessionKey, gatewayUrl: hostileGatewayUrl }), + ); + expect(decodeResumeHandoff(encoded)).toEqual({ + version: 1, + sessionKey, + gatewayUrl: hostileGatewayUrl, + }); + }); + + it.each<[string, string]>([ + ["astral emoji", `agent:main:${"🦀".repeat(300)}`], + ["combining clusters", `agent:main:${"e\u0301".repeat(300)}`], + ["ZWJ clusters", `agent:main:${"a\u200Db".repeat(300)}`], + ])("round-trips 300 %s clusters", (_name, sessionKey) => { + const encoded = encodeResumeHandoff({ sessionKey, gatewayUrl }); + + expect(decodeResumeHandoff(encoded)).toEqual({ version: 1, sessionKey, gatewayUrl }); + }); + + it.each(["WSS://gateway.example/openclaw", "WsS://gateway.example/openclaw"])( + "preserves a mixed-case WebSocket scheme: %s", + (mixedCaseGatewayUrl) => { + const sessionKey = "agent:main:mixed-case-scheme"; + const encoded = encodeResumeHandoff({ sessionKey, gatewayUrl: mixedCaseGatewayUrl }); + + expect(decodeResumeHandoff(encoded)).toEqual({ + version: 1, + sessionKey, + gatewayUrl: mixedCaseGatewayUrl, + }); + }, + ); + + it.each<[string, string]>([ + ["malformed alphabet", "not+base64url"], + ["padding", "Zg=="], + ["noncanonical encoding", "Zh"], + ["invalid UTF-8", encodeBytes(Uint8Array.from([0xc3, 0x28]))], + ["invalid JSON", encodeBytes(new TextEncoder().encode("not json"))], + ["array", encodeJson([1, "agent:main:alpha", gatewayUrl])], + ["null", encodeJson(null)], + ["missing field", encodeJson({ version: 1, sessionKey: "agent:main:alpha" })], + [ + "extra field", + encodeJson({ version: 1, sessionKey: "agent:main:alpha", gatewayUrl, token: "nope" }), + ], + ["wrong version", encodeJson({ version: 2, sessionKey: "agent:main:alpha", gatewayUrl })], + [ + "wrong version type", + encodeJson({ version: "1", sessionKey: "agent:main:alpha", gatewayUrl }), + ], + ["wrong key type", encodeJson({ version: 1, sessionKey: 42, gatewayUrl })], + ["wrong URL type", encodeJson({ version: 1, sessionKey: "agent:main:alpha", gatewayUrl: 42 })], + ["empty key", encodeJson({ version: 1, sessionKey: "", gatewayUrl })], + ["key C0 control", encodeJson({ version: 1, sessionKey: "agent:main:bad\nkey", gatewayUrl })], + [ + "key C1 control", + encodeJson({ version: 1, sessionKey: "agent:main:bad\u0085key", gatewayUrl }), + ], + [ + "URL C0 control", + encodeJson({ version: 1, sessionKey: "agent:main:alpha", gatewayUrl: `${gatewayUrl}\u0000` }), + ], + [ + "non-WebSocket URL", + encodeJson({ + version: 1, + sessionKey: "agent:main:alpha", + gatewayUrl: "https://gateway.example", + }), + ], + [ + "invalid URL", + encodeJson({ version: 1, sessionKey: "agent:main:alpha", gatewayUrl: "wss://[invalid" }), + ], + [ + "URL userinfo", + encodeJson({ + version: 1, + sessionKey: "agent:main:alpha", + gatewayUrl: "wss://user@gateway.example/ws", + }), + ], + [ + "empty URL userinfo", + encodeJson({ + version: 1, + sessionKey: "agent:main:alpha", + gatewayUrl: "wss://@gateway.example/ws", + }), + ], + [ + "URL query", + encodeJson({ + version: 1, + sessionKey: "agent:main:alpha", + gatewayUrl: "wss://gateway.example/ws?x=1", + }), + ], + [ + "URL fragment", + encodeJson({ + version: 1, + sessionKey: "agent:main:alpha", + gatewayUrl: "wss://gateway.example/ws#x", + }), + ], + ["encoded payload over limit", "A".repeat(maxEncodedLength + 1)], + [ + "session key over grapheme limit", + encodeJson({ version: 1, sessionKey: `agent:main:${"s".repeat(502)}`, gatewayUrl }), + ], + ...["main", "global", "agent::x", "agent:a:"].map((sessionKey): [string, string] => [ + `invalid qualified key ${sessionKey}`, + encodeJson({ version: 1, sessionKey, gatewayUrl }), + ]), + [ + "Gateway URL over limit", + encodeJson({ + version: 1, + sessionKey: "agent:main:alpha", + gatewayUrl: `wss://gateway.example/${"u".repeat(2049 - "wss://gateway.example/".length)}`, + }), + ], + ])("rejects %s", (_name, encoded) => { + expect(() => decodeResumeHandoff(encoded)).toThrow( + "Invalid --handoff payload. Copy a fresh command from the Control UI.", + ); + }); + + it.each<[string, { sessionKey: string; gatewayUrl: string }]>([ + ["empty key", { sessionKey: "", gatewayUrl }], + [ + "session key over grapheme limit", + { sessionKey: `agent:main:${"s".repeat(502)}`, gatewayUrl }, + ], + ...["main", "global", "agent::x", "agent:a:"].map( + (sessionKey): [string, { sessionKey: string; gatewayUrl: string }] => [ + `invalid qualified key ${sessionKey}`, + { sessionKey, gatewayUrl }, + ], + ), + ["key control", { sessionKey: "agent:main:bad\u0085key", gatewayUrl }], + ["empty URL", { sessionKey: "agent:main:alpha", gatewayUrl: "" }], + [ + "Gateway URL over limit", + { + sessionKey: "agent:main:alpha", + gatewayUrl: `wss://gateway.example/${"u".repeat(2049 - "wss://gateway.example/".length)}`, + }, + ], + ["URL query", { sessionKey: "agent:main:alpha", gatewayUrl: `${gatewayUrl}?x=1` }], + ])("refuses to encode %s", (_name, input) => { + expect(() => encodeResumeHandoff(input)).toThrow( + "Invalid --handoff payload. Copy a fresh command from the Control UI.", + ); + }); +}); diff --git a/src/shared/resume-handoff.ts b/src/shared/resume-handoff.ts new file mode 100644 index 000000000000..b5ad678330e3 --- /dev/null +++ b/src/shared/resume-handoff.ts @@ -0,0 +1,127 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { Guard } from "typebox/guard"; +import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../packages/gateway-protocol/src/schema/primitives.js"; +import { hasTerminalControl } from "../../packages/terminal-core/src/safe-text.js"; +import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; + +const RESUME_HANDOFF_MAX_ENCODED_LENGTH = 4096; +const RESUME_HANDOFF_MAX_GATEWAY_URL_LENGTH = 2048; +const RESUME_HANDOFF_KEYS = ["version", "sessionKey", "gatewayUrl"] as const; +const RESUME_HANDOFF_ERROR = "Invalid --handoff payload. Copy a fresh command from the Control UI."; + +type ResumeHandoff = { + version: 1; + sessionKey: string; + gatewayUrl: string; +}; + +function invalidResumeHandoff(): never { + throw new Error(RESUME_HANDOFF_ERROR); +} + +function validateGatewayUrl(gatewayUrl: string): void { + if ( + gatewayUrl.length === 0 || + gatewayUrl.length > RESUME_HANDOFF_MAX_GATEWAY_URL_LENGTH || + hasTerminalControl(gatewayUrl) + ) { + invalidResumeHandoff(); + } + let parsed: URL; + try { + parsed = new URL(gatewayUrl); + } catch { + invalidResumeHandoff(); + } + const authority = gatewayUrl.slice(gatewayUrl.indexOf("://") + 3).split("/", 1)[0] ?? ""; + if ( + (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") || + gatewayUrl.includes("?") || + gatewayUrl.includes("#") || + authority.includes("@") || + parsed.username.length > 0 || + parsed.password.length > 0 + ) { + invalidResumeHandoff(); + } +} + +function validateResumeHandoffFields(sessionKey: string, gatewayUrl: string): void { + if ( + sessionKey.length === 0 || + !Guard.IsMaxLength(sessionKey, CHAT_SEND_SESSION_KEY_MAX_LENGTH) || + hasTerminalControl(sessionKey) || + parseAgentSessionKey(sessionKey) === null + ) { + invalidResumeHandoff(); + } + validateGatewayUrl(gatewayUrl); +} + +function encodeBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +function decodeBase64Url(encoded: string): Uint8Array { + const standard = encoded.replaceAll("-", "+").replaceAll("_", "/"); + const paddingLength = (4 - (standard.length % 4)) % 4; + const binary = atob(`${standard}${"=".repeat(paddingLength)}`); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +export function encodeResumeHandoff(input: { sessionKey: string; gatewayUrl: string }): string { + validateResumeHandoffFields(input.sessionKey, input.gatewayUrl); + const payload: ResumeHandoff = { + version: 1, + sessionKey: input.sessionKey, + gatewayUrl: input.gatewayUrl, + }; + const encoded = encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload))); + if (encoded.length > RESUME_HANDOFF_MAX_ENCODED_LENGTH) { + invalidResumeHandoff(); + } + return encoded; +} + +export function decodeResumeHandoff(encoded: string): ResumeHandoff { + try { + if ( + encoded.length === 0 || + encoded.length > RESUME_HANDOFF_MAX_ENCODED_LENGTH || + !/^[A-Za-z0-9_-]+$/u.test(encoded) + ) { + invalidResumeHandoff(); + } + const bytes = decodeBase64Url(encoded); + if (encodeBase64Url(bytes) !== encoded) { + invalidResumeHandoff(); + } + const json = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + const payload: unknown = JSON.parse(json); + if (!isRecord(payload)) { + invalidResumeHandoff(); + } + const keys = Object.keys(payload); + if ( + keys.length !== RESUME_HANDOFF_KEYS.length || + !RESUME_HANDOFF_KEYS.every((key) => Object.hasOwn(payload, key)) || + payload.version !== 1 || + typeof payload.sessionKey !== "string" || + typeof payload.gatewayUrl !== "string" + ) { + invalidResumeHandoff(); + } + validateResumeHandoffFields(payload.sessionKey, payload.gatewayUrl); + return { + version: 1, + sessionKey: payload.sessionKey, + gatewayUrl: payload.gatewayUrl, + }; + } catch { + return invalidResumeHandoff(); + } +} diff --git a/src/tui/gateway-chat.connection.test.ts b/src/tui/gateway-chat.connection.test.ts index 9a9411943896..0aecc20b09fc 100644 --- a/src/tui/gateway-chat.connection.test.ts +++ b/src/tui/gateway-chat.connection.test.ts @@ -218,6 +218,221 @@ describe("resolveGatewayConnection", () => { }); }); + it("reuses local interactive auth for an exact resume target with the active port and base path", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + port: 18789, + controlUi: { basePath: "/control" }, + auth: { token: "configured-token" }, + }, + }); + readActiveGatewayLockPortMock.mockResolvedValue(48789); + + await expect( + resolveGatewayConnection({ + url: "ws://127.0.0.1:48789/control", + allowConfiguredAuthForExactTarget: true, + }), + ).resolves.toMatchObject({ + url: "ws://127.0.0.1:48789/control", + token: "configured-token", + }); + }); + + it("allows an exact configured resume target to use stored origin device auth", async () => { + loadConfig.mockReturnValue({ + gateway: { mode: "local", controlUi: { basePath: "/control" } }, + }); + loadDeviceIdentityIfPresentMock.mockReturnValue({ deviceId: "device-1" }); + loadOriginDeviceTokenMock.mockImplementation(({ gatewayScope }: { gatewayScope: string }) => + gatewayScope === "ws://127.0.0.1:18789/control" + ? { token: "stored-origin-token", scopes: ["operator.read"] } + : null, + ); + + await expect( + resolveGatewayConnection({ + url: "ws://127.0.0.1:18789/control", + allowConfiguredAuthForExactTarget: true, + }), + ).resolves.toMatchObject({ + deviceAuthScope: "ws://127.0.0.1:18789/control", + token: undefined, + password: undefined, + }); + }); + + it("suppresses ambient Gateway auth fallback for an exact handoff target", async () => { + loadConfig.mockReturnValue({ + gateway: { mode: "local", controlUi: { basePath: "/control" } }, + }); + loadDeviceIdentityIfPresentMock.mockReturnValue({ deviceId: "device-1" }); + loadOriginDeviceTokenMock.mockImplementation(({ gatewayScope }: { gatewayScope: string }) => + gatewayScope === "ws://127.0.0.1:18789/control" + ? { token: "stored-origin-token", scopes: ["operator.read"] } + : null, + ); + + await withEnvAsync( + { + OPENCLAW_GATEWAY_URL: "wss://gateway-b.example/ws", + OPENCLAW_GATEWAY_TOKEN: "gateway-b-token", + }, + async () => { + const result = await resolveGatewayConnection({ + url: "ws://127.0.0.1:18789/control", + allowConfiguredAuthForExactTarget: true, + suppressEnvAuthFallback: true, + }); + + expect(result).toMatchObject({ + deviceAuthScope: "ws://127.0.0.1:18789/control", + token: undefined, + password: undefined, + }); + }, + ); + }); + + it("reuses local SecretRef auth for an exact public-origin resume target", async () => { + loadConfig.mockReturnValue({ + secrets: { providers: { default: { source: "env" } } }, + gateway: { + mode: "local", + publicOrigin: "HTTPS://Gateway.Example/", + controlUi: { basePath: "/openclaw" }, + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "PROFILE_GATEWAY_TOKEN" }, + }, + }, + }); + + await withEnvAsync( + { + PROFILE_GATEWAY_TOKEN: "resolved-profile-token", + OPENCLAW_GATEWAY_TOKEN: "unrelated-ambient-token", + }, + async () => { + const result = await resolveGatewayConnection({ + url: "wss://gateway.example/openclaw", + allowConfiguredAuthForExactTarget: true, + suppressEnvAuthFallback: true, + }); + + expect(result).toMatchObject({ + url: "wss://gateway.example/openclaw", + token: "resolved-profile-token", + }); + expect(result.tlsFingerprint).toBeUndefined(); + }, + ); + }); + + it("keeps the remote TLS pin when explicit auth overrides exact-target credentials", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + remote: { + url: "wss://remote.example/gateway", + password: "configured-remote-password", // pragma: allowlist secret + tlsFingerprint: "sha256:configured-remote-pin", + }, + }, + }); + + await expect( + resolveGatewayConnection({ + url: "wss://remote.example/gateway", + password: "explicit-password", // pragma: allowlist secret + allowConfiguredAuthForExactTarget: true, + }), + ).resolves.toMatchObject({ + password: "explicit-password", + tlsFingerprint: "sha256:configured-remote-pin", + url: "wss://remote.example/gateway", + }); + }); + + it("does not resolve local auth for an explicit loopback target in remote mode", async () => { + await withModeExecProviderFixture( + "remote-loopback", + async ({ tokenMarker, passwordMarker, providers }) => { + loadConfig.mockReturnValue({ + secrets: { providers }, + gateway: { + mode: "remote", + auth: { + mode: "token", + token: { source: "exec", provider: "tokenprovider", id: "TOKEN_SECRET" }, + }, + remote: { url: "wss://remote.example/gateway", token: "remote-token" }, + }, + }); + + await expect( + resolveGatewayConnection({ + url: "ws://127.0.0.1:18789", + allowConfiguredAuthForExactTarget: true, + }), + ).rejects.toThrow(/pass --token or --password once to request pairing/i); + expect(await fileExists(tokenMarker)).toBe(false); + expect(await fileExists(passwordMarker)).toBe(false); + }, + ); + }); + + it("uses only the configured remote identity when publicOrigin matches in remote mode", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + publicOrigin: "https://gateway.example", + controlUi: { basePath: "/gateway" }, + auth: { token: "local-token" }, + remote: { url: "wss://gateway.example/gateway", token: "remote-token" }, + }, + }); + + await expect( + resolveGatewayConnection({ + url: "wss://gateway.example/gateway", + allowConfiguredAuthForExactTarget: true, + }), + ).resolves.toMatchObject({ token: "remote-token" }); + }); + + it.each([ + ["host", "wss://other.example/gateway"], + ["port", "wss://remote.example:444/gateway"], + ["path", "wss://remote.example/other"], + ["query", "wss://remote.example/gateway?mode=resume"], + ["fragment", "wss://remote.example/gateway#resume"], + ])("fails closed on an exact resume target %s mismatch", async (_part, url) => { + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + remote: { + url: "wss://remote.example/gateway", + token: "configured-remote-token", + tlsFingerprint: "sha256:configured-remote-pin", + }, + }, + }); + + await expect( + resolveGatewayConnection({ url, allowConfiguredAuthForExactTarget: true }), + ).rejects.toThrow(/pass --token or --password once to request pairing/i); + const explicit = await resolveGatewayConnection({ + url, + token: "explicit-token", + allowConfiguredAuthForExactTarget: true, + }); + expect(explicit.token).toBe("explicit-token"); + expect(explicit.tlsFingerprint).toBeUndefined(); + }); + it("allows a url override with an exact-origin stored device credential", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local" } }); loadDeviceIdentityIfPresentMock.mockReturnValue({ deviceId: "device-1" }); diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index da453680ec30..64ca7a72cff0 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -219,6 +219,32 @@ describe("GatewayChatClient", () => { }); }); + it("resolves a handoff key through the exact sessions.resolve wire contract", async () => { + const client = new GatewayChatClient({ + url: "ws://127.0.0.1:18789", + token: "test-token", + }); + const request = vi + .fn() + .mockResolvedValue({ ok: true, key: "agent:main:alpha", agentId: "main" }); + (client as unknown as { client: { request: typeof request } }).client.request = request; + + await expect( + client.resolveSession({ + key: "Agent:Main:ALPHA", + agentId: "main", + includeGlobal: true, + allowMissing: true, + }), + ).resolves.toEqual({ ok: true, key: "agent:main:alpha", agentId: "main" }); + expect(request).toHaveBeenCalledExactlyOnceWith("sessions.resolve", { + key: "Agent:Main:ALPHA", + agentId: "main", + includeGlobal: true, + allowMissing: true, + }); + }); + it("preserves side runs for session-scoped TUI aborts", async () => { const client = new GatewayChatClient({ url: "ws://127.0.0.1:18789", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 394f98fcc07e..7b033333965d 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -11,6 +11,7 @@ import { ConnectErrorDetailCodes, readConnectErrorDetailCode, } from "../../packages/gateway-protocol/src/connect-error-details.js"; +import type { ErrorShape } from "../../packages/gateway-protocol/src/frame-guards.js"; import { type HelloOk, GATEWAY_SERVER_CAPS, @@ -21,6 +22,7 @@ import { type CommandsListResult, type EnvironmentsListResult, type SessionsListParams, + type SessionsResolveParams, type SessionsPatchResult, type SessionsPatchParams, type TaskSuggestionsAcceptResult, @@ -64,6 +66,8 @@ type GatewayConnectionOptions = { token?: string; password?: string; tlsFingerprint?: string; + allowConfiguredAuthForExactTarget?: boolean; + suppressEnvAuthFallback?: boolean; }; type GatewayEvent = TuiEvent; @@ -150,6 +154,18 @@ function isLegacySucceedsParentError(err: unknown): boolean { type GatewaySessionList = TuiSessionList; type GatewayAgentsList = TuiAgentsList; type GatewayModelChoice = TuiModelChoice; +type HandoffSessionResolveParams = Required< + Pick +>; +type HandoffSessionResolveResult = + | { ok: true; key: string; agentId: string } + | { ok: true; missing: true } + | { + ok: true; + ambiguous: true; + candidates: Array<{ key: string; agentId: string; displayName?: string }>; + } + | { ok: false; error: ErrorShape }; export class GatewayChatClient implements TuiBackend { private client: GatewayClient; @@ -363,6 +379,10 @@ export class GatewayChatClient implements TuiBackend { return await this.client.request("sessions.list", opts ?? {}); } + async resolveSession(opts: HandoffSessionResolveParams): Promise { + return await this.client.request("sessions.resolve", opts); + } + async listAgents() { return await this.client.request("agents.list", {}); } @@ -543,9 +563,15 @@ async function resolveGatewayConnection( const hasExplicitGatewayTarget = Boolean( urlOverride.url || env.OPENCLAW_GATEWAY_PORT?.trim() || isRemoteMode, ); - const activeLocalGatewayPort = hasExplicitGatewayTarget - ? undefined - : await readActiveGatewayLockPort(); + const resumeMayMatchLocalTarget = + opts.allowConfiguredAuthForExactTarget === true && + urlOverride.source === "cli" && + !isRemoteMode && + !env.OPENCLAW_GATEWAY_PORT?.trim(); + const activeLocalGatewayPort = + !hasExplicitGatewayTarget || resumeMayMatchLocalTarget + ? await readActiveGatewayLockPort() + : undefined; if ( !urlOverride.source && gatewayAuthMode !== "none" && @@ -564,25 +590,23 @@ async function resolveGatewayConnection( explicitAuth, env, authPolicy: "interactive", + allowConfiguredAuthForExactTarget: opts.allowConfiguredAuthForExactTarget, + suppressEnvAuthFallback: opts.suppressEnvAuthFallback, ...(activeLocalGatewayPort ? { localPortOverride: activeLocalGatewayPort } : {}), explicitTlsFingerprint: opts.tlsFingerprint, allowStoredOriginAuth: hasStoredOriginDeviceAuth, overrideAuthErrorHint: "Fix: pass --token or --password once to request pairing, approve it in that gateway's Control UI (Settings -> Devices), then retry with the same credential so OpenClaw can store the device token.", buildConnectionDetails: buildGatewayConnectionDetails, - resolveTlsFingerprint: async ({ urlSource, explicitTlsFingerprint }) => - explicitTlsFingerprint ?? - (urlSource === "config gateway.remote.url" - ? normalizeOptionalString(config.gateway?.remote?.tlsFingerprint) - : undefined), }); const hasStoredOriginAuth = Boolean( bootstrap.deviceAuthScope && hasStoredOriginDeviceAuth(bootstrap.deviceAuthScope), ); - if ( - bootstrap.authFailureReason && - (bootstrap.authFailureReason !== "Missing gateway auth credentials." || !hasStoredOriginAuth) - ) { + const missingSharedAuth = + bootstrap.authFailureReason === "Missing gateway auth credentials." || + bootstrap.authFailureReason === "Missing gateway auth token." || + bootstrap.authFailureReason === "Missing gateway auth password."; + if (bootstrap.authFailureReason && (!missingSharedAuth || !hasStoredOriginAuth)) { throwGatewayAuthResolutionError(bootstrap.authFailureReason); } return { diff --git a/ui/src/components/connect-command.ts b/ui/src/components/connect-command.ts index 3289ba6357dd..4e9745a7bf37 100644 --- a/ui/src/components/connect-command.ts +++ b/ui/src/components/connect-command.ts @@ -31,7 +31,7 @@ export function renderConnectCommand(command: string) { copyCommand(event); }} > - ${command} + ${command} ${renderCopyButton(command, copyLabel)} diff --git a/ui/src/e2e/chat-continue-in-terminal.e2e.test.ts b/ui/src/e2e/chat-continue-in-terminal.e2e.test.ts new file mode 100644 index 000000000000..68a82c3870bf --- /dev/null +++ b/ui/src/e2e/chat-continue-in-terminal.e2e.test.ts @@ -0,0 +1,112 @@ +import { mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { decodeResumeHandoff } from "../../../src/shared/resume-handoff.js"; +import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI continue in terminal mocked Gateway E2E", + startServerBeforeBrowser: true, + unavailableMessage: (executablePath) => + `Playwright Chromium is not installed at ${executablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, +}); + +const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/continue-in-terminal"); +const basePath = "/nested/$&;=()+,![]{}'`/%25PATH%25"; +const agentId = "runner"; +const sessionKey = `agent:${agentId}:main-'"$&;|<>^()%![]{}\\\`-%PATH%`; + +function sessionsListResponse() { + return { + count: 1, + defaults: { contextTokens: null, model: "gpt-5.5", modelProvider: "openai" }, + path: "", + sessions: [ + { + agentId, + key: sessionKey, + kind: "direct", + label: "Terminal continuation", + updatedAt: Date.now(), + }, + ], + ts: Date.now(), + }; +} + +suite.define(() => { + it("shows, copies, and retires a credential-free exact continuation command", async () => { + await rm(artifactDir, { recursive: true, force: true }); + await mkdir(artifactDir, { recursive: true }); + await suite.withPage( + { + locale: "en-US", + serviceWorkers: "block", + viewport: { width: 1440, height: 900 }, + }, + async ({ context, page }) => { + const gateway = await installMockGateway(page, { + basePath, + historyMessages: [ + { + content: [{ type: "text", text: "Ready for terminal continuation." }], + role: "assistant", + timestamp: Date.now(), + }, + ], + methodResponses: { "sessions.list": sessionsListResponse() }, + sessionKey, + }); + const pageUrl = new URL(suite.server.baseUrl); + const gatewayUrl = `ws://${pageUrl.host}${basePath}`; + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: pageUrl.origin, + }); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey)); + const activePane = page.locator("openclaw-chat-pane.chat-pane-cache__pane--active"); + await activePane.getByText("Ready for terminal continuation.").waitFor({ timeout: 10_000 }); + + const menuTrigger = activePane.getByRole("button", { + name: "Actions for Terminal continuation", + }); + await menuTrigger.click(); + const dropdown = menuTrigger.locator("xpath=ancestor::wa-dropdown"); + const action = dropdown.getByText("Continue in terminal…", { exact: true }); + expect(await dropdown.evaluate((element) => (element as { open?: boolean }).open)).toBe( + true, + ); + await action.waitFor({ state: "visible" }); + await page.screenshot({ path: path.join(artifactDir, "01-menu.png"), fullPage: true }); + await action.click(); + + const dialog = page.locator("openclaw-modal-dialog.continue-in-terminal-dialog"); + await dialog.waitFor({ state: "visible" }); + await action.waitFor({ state: "hidden" }); + const command = (await dialog.locator("code").textContent()) ?? ""; + expect(command).toMatch(/^openclaw resume --handoff [A-Za-z0-9_-]+$/u); + const encoded = command.slice("openclaw resume --handoff ".length); + expect(decodeResumeHandoff(encoded)).toEqual({ + version: 1, + sessionKey, + gatewayUrl, + }); + expect(await dialog.textContent()).not.toMatch(/--token|--password|bootstrap/i); + await page.screenshot({ path: path.join(artifactDir, "02-modal.png"), fullPage: true }); + await dialog.getByRole("button", { name: "Copy command", exact: true }).click(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(command); + + await dialog.getByRole("button", { name: "Close" }).click(); + await menuTrigger.click(); + await action.click(); + await dialog.waitFor({ state: "visible" }); + const socketCount = await gateway.getSocketCount(); + await gateway.closeLatest(1001, "continue-in-terminal reconnect proof"); + await dialog.waitFor({ state: "detached", timeout: 10_000 }); + await expect + .poll(() => gateway.getSocketCount(), { timeout: 15_000 }) + .toBeGreaterThan(socketCount); + }, + ); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 156e3f3c719d..61460286973d 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4616,6 +4616,18 @@ export const en: TranslationMap = { openParent: "Open parent session {title}", panels: "Panels", layout: "Layout", + continueInTerminal: { + action: "Continue in terminal…", + title: "Continue in terminal", + description: + "Copy this command to continue the current session. It is safe to paste in common terminals and shells.", + authNote: + "The command contains no credentials. The terminal authenticates independently, and the session's access controls still apply.", + disconnected: "Connect to the Gateway to continue this session in a terminal.", + queryRouted: + "Query-routed Gateway URLs cannot create credential-free continuation commands because authentication and stored device scope are not query-aware. Use a manually authenticated CLI target or a queryless configured Gateway URL.", + unavailable: "This session or Gateway address cannot be continued in a terminal.", + }, workspaceAria: "Workspace actions for {workspace}", revealFinder: "Reveal in Finder", revealFileExplorer: "Reveal in File Explorer", diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 3227e3826967..45e52d70fae1 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -42,6 +42,7 @@ import type { ChatHistoryPagination } from "./chat-history-pagination.ts"; import { sendSessionObserverVisibility } from "./chat-observer.ts"; import { boardChatDockLayout, + type ChatPaneConnectionScope, type ChatPageContext, type PaneSessionChangeOptions, } from "./chat-pane-shared.ts"; @@ -160,6 +161,12 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @litState() protected headerRenameValue = ""; @litState() protected headerPlatform: string | null = null; @litState() protected headerCopiedAction: ChatPaneHeaderAction | null = null; + protected continueInTerminalDialog: { + qualifiedSessionKey: string; + selectedGatewayUrl: string; + clientGatewayUrl: string; + scope: ChatPaneConnectionScope; + } | null = null; @litState() protected headerPlacementReclaimingKey: string | null = null; @litState() protected presencePayload: PresencePayload | undefined; @litState() protected sessionSharingStates = new Map(); diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index 5c6a3a7a95d5..0fe01565612a 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -50,6 +50,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { private gatewayConnectionLifecycle?: ReturnType; override disconnectedCallback() { + this.continueInTerminalDialog = null; this.gatewayConnectionLifecycle?.dispose(); this.gatewayConnectionLifecycle = undefined; super.disconnectedCallback(); @@ -205,6 +206,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { this.presencePayload = presence ? { presence } : undefined; } if (sourceChanged) { + this.continueInTerminalDialog = null; this.cancelHeaderRename(); cancelChatScroll(state); releaseChatMediaResourceSubscriber(state.requestUpdate); diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index ba202759aa81..28deb8a45520 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -35,6 +35,7 @@ import { isChatRunWorking } from "./components/chat-composer.ts"; import "./components/chat-header-session-menu.ts"; import type { HeaderMenuAction, + HeaderMenuActionKind, HeaderMenuQuickAction, } from "./components/chat-header-session-menu.ts"; import { @@ -51,6 +52,7 @@ import { type SessionWorkspaceProps, } from "./components/chat-session-workspace.ts"; import { renderChatTerminalButton } from "./components/chat-terminal-button.ts"; +import { renderContinueInTerminalDialog } from "./components/continue-in-terminal-dialog.ts"; import type { SessionDiscussionPanelConfig } from "./components/session-discussion-panel.ts"; import { hasAbortableSessionRun } from "./run-lifecycle.ts"; import { @@ -175,12 +177,21 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { }); const archiveAllowed = Boolean(row && canArchiveSessionRow(row, configuredMainKey)); const deleteAllowed = Boolean(row && canDeleteSessionRows([row], configuredMainKey)); - const actionDisabledReasons = row + const sessionActionDisabledReasons = row ? sessionMenuReasons({ snapshot: this.context.gateway.snapshot, session: row, }) : {}; + const continueInTerminalDisabledReason = row + ? this.continueInTerminalDisabledReason(row) + : undefined; + const actionDisabledReasons: Partial> = { + ...sessionActionDisabledReasons, + ...(continueInTerminalDisabledReason + ? { "continue-in-terminal": continueInTerminalDisabledReason } + : {}), + }; const desktopPanelAvailable = isDesktopPanelAvailable(this.context.gateway.snapshot); const openDesktopPanel = () => window.dispatchEvent( @@ -318,7 +329,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { reclaimingKey: this.headerPlacementReclaimingKey, row, }); - return renderChatPaneHeader({ + const header = renderChatPaneHeader({ paneId: this.paneId, narrow: this.narrow, mergedChrome: this.mergedChrome, @@ -499,6 +510,13 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { onSplitRight: this.onSplitRight, onClosePane: this.onClosePane, }); + const continueCommand = this.currentContinueInTerminalCommand(row); + return html`${header}${continueCommand + ? renderContinueInTerminalDialog({ + command: continueCommand, + onClose: () => this.closeContinueInTerminalDialog(), + }) + : nothing}`; } // Probe once per session activation; transient failures stay uncached so the diff --git a/ui/src/pages/chat/chat-pane-session-menu.ts b/ui/src/pages/chat/chat-pane-session-menu.ts index c84c85392c72..5635038c5d58 100644 --- a/ui/src/pages/chat/chat-pane-session-menu.ts +++ b/ui/src/pages/chat/chat-pane-session-menu.ts @@ -22,6 +22,7 @@ import { headerPlatformByClient } from "./chat-pane-shared.ts"; import { patchChatSessionLabel } from "./chat-state-route.ts"; import type { HeaderMenuAction } from "./components/chat-header-session-menu.ts"; import type { ChatPaneHeaderAction } from "./components/chat-pane-header.ts"; +import { buildContinueInTerminalCommand } from "./continue-in-terminal-command.ts"; export abstract class ChatPaneSessionMenu extends ChatPaneContext { private headerSessionOperationsLoad: Promise< @@ -62,6 +63,10 @@ export abstract class ChatPaneSessionMenu extends ChatPaneContext { this.beginHeaderRename(row); return; } + if (action.kind === "continue-in-terminal") { + this.openContinueInTerminalDialog(row); + return; + } const scope = this.captureHeaderSessionActionScope(); if (!scope) { this.publishHeaderError(t("sessionsView.actionRequiresConnection")); @@ -125,6 +130,79 @@ export abstract class ChatPaneSessionMenu extends ChatPaneContext { } } + private resolveContinueInTerminalCommand(row: GatewaySessionRow, client: GatewayBrowserClient) { + return buildContinueInTerminalCommand({ + gatewayUrl: client.gatewayUrl, + sessionKey: row.key, + rowAgentId: row.agentId, + selectedAgentId: this.context.agentSelection.state.selectedId ?? undefined, + }); + } + + protected continueInTerminalDisabledReason(row: GatewaySessionRow): string | undefined { + const gateway = this.context.gateway; + const client = gateway.snapshot.client; + if (gateway.snapshot.phase !== "connected" || !client) { + return t("chat.sessionHeader.continueInTerminal.disconnected"); + } + const result = this.resolveContinueInTerminalCommand(row, client); + if (result.ok) { + return undefined; + } + return t( + result.reason === "query-routed" + ? "chat.sessionHeader.continueInTerminal.queryRouted" + : "chat.sessionHeader.continueInTerminal.unavailable", + ); + } + + private openContinueInTerminalDialog(row: GatewaySessionRow): void { + const scope = this.captureConnectionScope(); + if (!scope) { + return; + } + const result = this.resolveContinueInTerminalCommand(row, scope.client); + if (!result.ok) { + return; + } + this.continueInTerminalDialog = { + qualifiedSessionKey: result.qualifiedSessionKey, + selectedGatewayUrl: this.context.gateway.connection.gatewayUrl, + clientGatewayUrl: scope.client.gatewayUrl, + scope, + }; + this.requestUpdate(); + } + + protected closeContinueInTerminalDialog(): void { + if (!this.continueInTerminalDialog) { + return; + } + this.continueInTerminalDialog = null; + this.requestUpdate(); + } + + protected currentContinueInTerminalCommand(row: GatewaySessionRow | undefined): string | null { + const dialog = this.continueInTerminalDialog; + const gateway = this.context.gateway; + const client = gateway.snapshot.client; + if (!dialog) { + return null; + } + const result = row && client ? this.resolveContinueInTerminalCommand(row, client) : null; + if ( + !this.isConnectionScopeCurrent(dialog.scope) || + !result?.ok || + result.qualifiedSessionKey !== dialog.qualifiedSessionKey || + gateway.connection.gatewayUrl !== dialog.selectedGatewayUrl || + client?.gatewayUrl !== dialog.clientGatewayUrl + ) { + this.continueInTerminalDialog = null; + return null; + } + return result.command; + } + private captureHeaderSessionActionScope(): SidebarSessionMutationScope | null { const gateway = this.context.gateway; const client = gateway.snapshot.client; diff --git a/ui/src/pages/chat/chat-pane-terminal.test.ts b/ui/src/pages/chat/chat-pane-terminal.test.ts index 9c77bc3d7574..8f9027d1f050 100644 --- a/ui/src/pages/chat/chat-pane-terminal.test.ts +++ b/ui/src/pages/chat/chat-pane-terminal.test.ts @@ -2,6 +2,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import { decodeResumeHandoff } from "../../../../src/shared/resume-handoff.js"; import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; import { @@ -27,6 +28,105 @@ function desktopHello(methods: string[], scopes: string[]): GatewayHelloOk { } describe("chat pane terminal action", () => { + it.each(["session", "owner", "target", "client", "reconnect"] as const)( + "closes terminal continuation after a %s ownership change", + async (change) => { + const client = { gatewayUrl: "wss://gateway.example/control" } as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const row = { + key: "bare-session", + agentId: "row-agent", + kind: "direct", + updatedAt: 0, + } satisfies GatewaySessionRow; + const replacementRow = { ...row, key: "other-session" }; + const container = document.createElement("div"); + const paint = (selected: GatewaySessionRow) => + render( + pane.renderPaneHeader( + createSessionWorkspaceProps(state), + createBackgroundTasksProps(state), + selected, + false, + undefined, + false, + ), + container, + ); + + await pane.handleHeaderSessionAction({ kind: "continue-in-terminal" }, row); + paint(row); + const command = + container.querySelector(".continue-in-terminal-dialog .login-gate__command code") + ?.textContent ?? ""; + expect(command).toMatch(/^openclaw resume --handoff [A-Za-z0-9_-]+$/u); + expect(decodeResumeHandoff(command.slice("openclaw resume --handoff ".length))).toEqual({ + version: 1, + sessionKey: "agent:row-agent:bare-session", + gatewayUrl: "wss://gateway.example/control", + }); + + if (change === "owner") { + paint({ ...row, agentId: "other-agent" }); + } else if (change === "target") { + pane.context.gateway.connection.gatewayUrl = "wss://other.example/control"; + paint(row); + pane.context.gateway.connection.gatewayUrl = "ws://example.test"; + } else if (change === "client") { + pane.context.gateway.snapshot.client = { + gatewayUrl: "wss://replacement.example/control", + } as GatewayBrowserClient; + paint(row); + pane.context.gateway.snapshot.client = client; + } else if (change === "reconnect") { + pane.connectionGeneration += 1; + paint(row); + pane.connectionGeneration -= 1; + } else { + paint(replacementRow); + } + + expect(container.querySelector("openclaw-modal-dialog")).toBeNull(); + paint(row); + expect(container.querySelector("openclaw-modal-dialog")).toBeNull(); + }, + ); + + it("disables terminal continuation with query-specific guidance", () => { + const client = { + gatewayUrl: "wss://gateway.example/control?route=alpha", + } as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const row = { + key: "main", + agentId: "alpha", + kind: "direct", + updatedAt: 0, + } satisfies GatewaySessionRow; + const container = document.createElement("div"); + + render( + pane.renderPaneHeader( + createSessionWorkspaceProps(state), + createBackgroundTasksProps(state), + row, + false, + undefined, + false, + ), + container, + ); + + const menu = container.querySelector< + HTMLElement & { + actionDisabledReasons: Record; + } + >("openclaw-chat-header-session-menu"); + expect(menu?.actionDisabledReasons["continue-in-terminal"]).toBe( + "Query-routed Gateway URLs cannot create credential-free continuation commands because authentication and stored device scope are not query-aware. Use a manually authenticated CLI target or a queryless configured Gateway URL.", + ); + }); + it("renders only when available and opens the terminal dock", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); diff --git a/ui/src/pages/chat/components/chat-header-session-menu.test.ts b/ui/src/pages/chat/components/chat-header-session-menu.test.ts index c6a317c4fbb5..41f0adb05877 100644 --- a/ui/src/pages/chat/components/chat-header-session-menu.test.ts +++ b/ui/src/pages/chat/components/chat-header-session-menu.test.ts @@ -4,9 +4,12 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { UiSettings } from "../../../app/settings.ts"; import { icons } from "../../../components/icons.ts"; -import type { SessionMenuActionKind } from "../../../components/session-menu.ts"; import "./chat-header-session-menu.ts"; -import type { HeaderMenuAction, HeaderMenuQuickAction } from "./chat-header-session-menu.ts"; +import type { + HeaderMenuAction, + HeaderMenuActionKind, + HeaderMenuQuickAction, +} from "./chat-header-session-menu.ts"; type HeaderMenuElement = HTMLElement & { updateComplete: Promise }; type MenuItemElement = HTMLElement & { checked: boolean; disabled: boolean; submenuOpen?: boolean }; @@ -46,7 +49,7 @@ async function mountMenu( settings?: UiSettings; panelActions?: HeaderMenuQuickAction[]; layoutActions?: HeaderMenuQuickAction[]; - actionDisabledReasons?: Partial>; + actionDisabledReasons?: Partial>; forkDisabled?: boolean; archiveAllowed?: boolean; deleteAllowed?: boolean; @@ -119,7 +122,14 @@ describe("chat header session menu", () => { menu.querySelectorAll(":scope > wa-dropdown > wa-dropdown-item"), ).map(itemLabel); - expect(labels).toEqual(["Rename…", "View", "Fork", "Archive session", "Delete…"]); + expect(labels).toEqual([ + "Rename…", + "View", + "Fork", + "Continue in terminal…", + "Archive session", + "Delete…", + ]); expect( menu.querySelector(".chat-header-session-menu__trigger")?.getAttribute("aria-label"), ).toBe("Actions for Test session"); @@ -287,4 +297,27 @@ describe("chat header session menu", () => { ); expect(onAction).not.toHaveBeenCalled(); }); + + it("emits terminal continuation only while the current Gateway is connected", async () => { + const onAction = vi.fn<(action: HeaderMenuAction) => void>(); + const connected = await mountMenu({ onAction }); + + expect(item(connected, "Continue in terminal…").disabled).toBe(false); + const dropdown = connected.querySelector("wa-dropdown") as HTMLElement & { open: boolean }; + dropdown.open = true; + select(connected, "continue-in-terminal"); + expect(dropdown.open).toBe(false); + expect(onAction).toHaveBeenCalledWith({ kind: "continue-in-terminal" }); + + const disconnected = await mountMenu({ + actionDisabledReasons: { "continue-in-terminal": "Gateway disconnected." }, + onAction, + }); + const disabledAction = item(disconnected, "Continue in terminal…"); + expect(disabledAction.disabled).toBe(true); + expect(disabledAction.getAttribute("title")).toBe("Gateway disconnected."); + onAction.mockClear(); + select(disconnected, "continue-in-terminal"); + expect(onAction).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/pages/chat/components/chat-header-session-menu.ts b/ui/src/pages/chat/components/chat-header-session-menu.ts index 106765ad9e1b..3810132c22f6 100644 --- a/ui/src/pages/chat/components/chat-header-session-menu.ts +++ b/ui/src/pages/chat/components/chat-header-session-menu.ts @@ -3,7 +3,6 @@ import { property } from "lit/decorators.js"; import type { UiSettings } from "../../../app/settings.ts"; import { icons } from "../../../components/icons.ts"; import { activateMenuShortcut, menuShortcutHint } from "../../../components/menu-shortcuts.ts"; -import type { SessionMenuActionKind } from "../../../components/session-menu.ts"; import "../../../components/web-awesome.ts"; import { t } from "../../../i18n/index.ts"; import { EDITOR_IDS, EDITOR_LABELS, type EditorId } from "../../../lib/editor-links.ts"; @@ -13,8 +12,10 @@ export type HeaderMenuAction = | { kind: "open-in"; editor: EditorId; path: string } | { kind: "rename" } | { kind: "fork" } + | { kind: "continue-in-terminal" } | { kind: "toggle-archived" } | { kind: "delete" }; +export type HeaderMenuActionKind = Exclude; export type HeaderMenuQuickAction = { id: string; @@ -38,7 +39,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) panelActions: HeaderMenuQuickAction[] = []; @property({ attribute: false }) layoutActions: HeaderMenuQuickAction[] = []; @property({ attribute: false }) actionDisabledReasons: Partial< - Record + Record > = {}; @property({ attribute: false }) forkDisabled = false; @property({ attribute: false }) archiveAllowed = false; @@ -47,11 +48,11 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) onSettingsChange: (patch: Partial) => void = () => {}; @property({ attribute: false }) onAction: (action: HeaderMenuAction) => void = () => {}; - private actionDisabled(kind: SessionMenuActionKind, extra = false): boolean { + private actionDisabled(kind: HeaderMenuActionKind, extra = false): boolean { return extra || Boolean(this.actionDisabledReasons[kind]); } - private actionTitle(kind: SessionMenuActionKind): string | typeof nothing { + private actionTitle(kind: HeaderMenuActionKind): string | typeof nothing { return this.actionDisabledReasons[kind] ?? nothing; } @@ -96,10 +97,14 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { if ( value === "rename" || value === "fork" || + value === "continue-in-terminal" || value === "toggle-archived" || value === "delete" ) { if (!this.actionDisabled(value, value === "fork" && this.forkDisabled)) { + if (value === "continue-in-terminal") { + (event.currentTarget as HTMLElement & { open: boolean }).open = false; + } this.onAction({ kind: value }); } } @@ -249,6 +254,17 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement { ${t("sessionsView.forkSession")} ${menuShortcutHint("f")} + + + ${t("chat.sessionHeader.continueInTerminal.action")} + void }) { + const title = t("chat.sessionHeader.continueInTerminal.title"); + const description = t("chat.sessionHeader.continueInTerminal.description"); + return html` + +
+
+

${title}

+

${description}

+
+ ${renderConnectCommand(params.command)} +

+ ${t("chat.sessionHeader.continueInTerminal.authNote")} +

+ +
+
+ `; +} diff --git a/ui/src/pages/chat/continue-in-terminal-command.test.ts b/ui/src/pages/chat/continue-in-terminal-command.test.ts new file mode 100644 index 000000000000..ca8e3ca3d6b1 --- /dev/null +++ b/ui/src/pages/chat/continue-in-terminal-command.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { decodeResumeHandoff } from "../../../../src/shared/resume-handoff.js"; +import { buildContinueInTerminalCommand } from "./continue-in-terminal-command.ts"; + +describe("buildContinueInTerminalCommand", () => { + it.each([ + { + name: "preserves a qualified key and the selected Gateway base path", + input: { + gatewayUrl: "wss://gateway.example/openclaw", + sessionKey: "Agent:Work:Case'Sensitive", + rowAgentId: "ignored", + selectedAgentId: "fallback", + }, + qualifiedKey: "Agent:Work:Case'Sensitive", + }, + { + name: "qualifies a bare key with the row agent", + input: { + gatewayUrl: "ws://127.0.0.1:18789/control/$&;=()+,![]{}'`/%25PATH%25", + sessionKey: "deploy-'\"$&;|<>^()%![]{}\\`-%PATH%", + rowAgentId: "build's agent", + selectedAgentId: "fallback", + }, + qualifiedKey: "agent:build's agent:deploy-'\"$&;|<>^()%![]{}\\`-%PATH%", + }, + { + name: "uses the selected agent only when the row agent is absent", + input: { + gatewayUrl: "wss://gateway.example/ws", + sessionKey: "main", + selectedAgentId: "selected", + }, + qualifiedKey: "agent:selected:main", + }, + ])("$name", ({ input, qualifiedKey }) => { + const result = buildContinueInTerminalCommand(input); + expect(result).toMatchObject({ ok: true, qualifiedSessionKey: qualifiedKey }); + if (!result.ok) { + throw new Error("expected a continuation command"); + } + expect(result.command).toMatch(/^openclaw resume --handoff [A-Za-z0-9_-]+$/u); + const encoded = result.command.slice("openclaw resume --handoff ".length); + expect(decodeResumeHandoff(encoded)).toEqual({ + version: 1, + sessionKey: qualifiedKey, + gatewayUrl: input.gatewayUrl, + }); + }); + + it("accepts and preserves a mixed-case WebSocket scheme", () => { + const result = buildContinueInTerminalCommand({ + gatewayUrl: "WsS://gateway.example/ws", + sessionKey: "main", + rowAgentId: "alpha", + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("expected a continuation command"); + } + const encoded = result.command.slice("openclaw resume --handoff ".length); + expect(decodeResumeHandoff(encoded)).toEqual({ + version: 1, + sessionKey: "agent:alpha:main", + gatewayUrl: "WsS://gateway.example/ws", + }); + }); + + it("distinguishes query-routed Gateway URLs from generic unavailability", () => { + expect( + buildContinueInTerminalCommand({ + gatewayUrl: "wss://gateway.example/ws?route=alpha", + sessionKey: "main", + rowAgentId: "alpha", + }), + ).toEqual({ ok: false, reason: "query-routed" }); + }); + + it.each([ + ["non-WebSocket protocol", { gatewayUrl: "https://gateway.example", sessionKey: "main" }], + ["URL userinfo", { gatewayUrl: "wss://user@gateway.example/ws", sessionKey: "main" }], + ["empty URL userinfo", { gatewayUrl: "wss://@gateway.example/ws", sessionKey: "main" }], + ["URL fragment", { gatewayUrl: "wss://gateway.example/ws#frag", sessionKey: "main" }], + ["URL C0 control", { gatewayUrl: "wss://gateway.example/ws\nnext", sessionKey: "main" }], + ["key C1 control", { gatewayUrl: "wss://gateway.example/ws", sessionKey: "bad\u0085key" }], + [ + "agent C0 control", + { + gatewayUrl: "wss://gateway.example/ws", + sessionKey: "main", + rowAgentId: "bad\u0000agent", + }, + ], + ])("rejects %s", (_name, input) => { + expect(buildContinueInTerminalCommand(input)).toEqual({ ok: false, reason: "unavailable" }); + }); +}); diff --git a/ui/src/pages/chat/continue-in-terminal-command.ts b/ui/src/pages/chat/continue-in-terminal-command.ts new file mode 100644 index 000000000000..ea704cd95ccf --- /dev/null +++ b/ui/src/pages/chat/continue-in-terminal-command.ts @@ -0,0 +1,47 @@ +import { encodeResumeHandoff } from "../../../../src/shared/resume-handoff.js"; +import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; + +type ContinueInTerminalCommandResult = + | { ok: true; command: string; qualifiedSessionKey: string } + | { ok: false; reason: "query-routed" | "unavailable" }; + +export function buildContinueInTerminalCommand(params: { + gatewayUrl: string; + sessionKey: string; + rowAgentId?: string; + selectedAgentId?: string; +}): ContinueInTerminalCommandResult { + const { gatewayUrl, sessionKey } = params; + let parsedGatewayUrl: URL; + try { + parsedGatewayUrl = new URL(gatewayUrl); + } catch { + return { ok: false, reason: "unavailable" }; + } + if (parsedGatewayUrl.hash) { + return { ok: false, reason: "unavailable" }; + } + if ( + (parsedGatewayUrl.protocol === "ws:" || parsedGatewayUrl.protocol === "wss:") && + parsedGatewayUrl.search + ) { + return { ok: false, reason: "query-routed" }; + } + let qualifiedKey = sessionKey; + if (!parseAgentSessionKey(sessionKey)) { + const agentId = params.rowAgentId || params.selectedAgentId; + if (!agentId) { + return { ok: false, reason: "unavailable" }; + } + qualifiedKey = `agent:${agentId}:${sessionKey}`; + } + try { + return { + ok: true, + command: `openclaw resume --handoff ${encodeResumeHandoff({ sessionKey: qualifiedKey, gatewayUrl })}`, + qualifiedSessionKey: qualifiedKey, + }; + } catch { + return { ok: false, reason: "unavailable" }; + } +} diff --git a/ui/src/pages/chat/workspace-conflict.ts b/ui/src/pages/chat/workspace-conflict.ts index ad60eb5d95a1..940afd2a5c96 100644 --- a/ui/src/pages/chat/workspace-conflict.ts +++ b/ui/src/pages/chat/workspace-conflict.ts @@ -1,4 +1,5 @@ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import { hasTerminalControl } from "../../../../packages/terminal-core/src/safe-text.js"; import type { GatewaySessionRow } from "../../api/types.ts"; const CLOUD_WORKSPACE_CONFLICT_TRANSCRIPT_TYPE = "cloud-workspace-conflict"; @@ -10,17 +11,6 @@ export type WorkspaceResultConflict = { totalCount?: number; }; -function hasTerminalControl(entryPath: string): boolean { - // Copied commands must not preserve terminal controls: bracketed-paste terminators - // can turn a displayed filename into executed shell input. - return Array.from(entryPath).some((character) => { - const codePoint = character.codePointAt(0); - return ( - codePoint !== undefined && (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) - ); - }); -} - function isWorkspaceConflictPath(entryPath: string): boolean { if (!entryPath || entryPath.startsWith("/") || entryPath.includes("\0")) { return false; diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index b5b873551ee4..6cb82008fb0b 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -713,6 +713,34 @@ openclaw-chat-pane { overflow-y: auto; } +.continue-in-terminal-dialog__card { + display: grid; + gap: 18px; + padding: 24px; +} + +.continue-in-terminal-dialog__header h2, +.continue-in-terminal-dialog__header p, +.continue-in-terminal-dialog__note { + margin: 0; +} + +.continue-in-terminal-dialog__header p, +.continue-in-terminal-dialog__note { + margin-block-start: 6px; + color: var(--muted); +} + +.continue-in-terminal-dialog .login-gate__command { + min-width: 0; +} + +.continue-in-terminal-dialog .login-gate__command code { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + /* Plain web shell chrome overlays the first pane header. Expanded navigation shows toggle + search; collapsed navigation adds new-thread between them. */ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-web-chrome)