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
This commit is contained in:
Peter Steinberger
2026-08-12 17:07:48 -07:00
committed by GitHub
parent 52cda537a4
commit 08b134324f
31 changed files with 2084 additions and 116 deletions
+69 -8
View File
@@ -15,6 +15,7 @@ the Gateway; `resume` selects it and opens the existing [TUI](/cli/tui).
```bash
openclaw resume
openclaw resume <query>
openclaw resume --handoff <payload>
```
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 <payload>` | (none) | Opaque session key and Gateway URL copied from the Control UI. |
| `--url <url>` | `gateway.remote.url` from config | Gateway WebSocket URL. |
| `--token <token>` | (none) | Gateway token if required. |
| `--password <pass>` | (none) | Gateway password if required. |
| `--tls-fingerprint <sha256>` | `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 <payload>` 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 <token>
# Opaque command copied from the Control UI
openclaw resume --handoff <payload>
```
## Related
+47 -2
View File
@@ -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
+22 -3
View File
@@ -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; 35 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/<shortcode>` join route (reserved prefix, single-use, rate-limited) +
shortcode mint + curl wrapper on the public site. Exit: a fresh machine
+15 -1
View File
@@ -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", () => {
+15 -3
View File
@@ -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;
}
}
+222 -47
View File
@@ -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<string, unknown> {
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<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,
});
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<void>((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<ReturnType<typeof connectResumeGateway>>["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,
});
+262 -1
View File
@@ -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 <payload>");
});
});
describe("real Gateway session boundary", () => {
let harness: Awaited<ReturnType<typeof startMinimalRealGateway>>;
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<typeof import("../tui/gateway-chat.js")>("../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" }));
+3 -1
View File
@@ -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 <payload>", "Opaque session handoff copied from the Control UI");
addTuiOptions(command)
.addHelpText(
"after",
+123
View File
@@ -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",
+100 -13
View File
@@ -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,
+13 -3
View File
@@ -105,6 +105,7 @@ export async function startMinimalRealGateway(
},
});
const sessionListRequests: Record<string, unknown>[] = [];
const sessionResolveRequests: Record<string, unknown>[] = [];
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<string, unknown>);
return await original(options);
return await originalList(options);
};
methods.coreGatewayHandlers["sessions.resolve"] = async (options) => {
sessionResolveRequests.push(options.params as Record<string, unknown>);
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 () =>
+193
View File
@@ -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.",
);
});
});
+127
View File
@@ -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();
}
}
+215
View File
@@ -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" });
+26
View File
@@ -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",
+36 -12
View File
@@ -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<SessionsResolveParams, "key" | "agentId" | "includeGlobal" | "allowMissing">
>;
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<GatewaySessionList>("sessions.list", opts ?? {});
}
async resolveSession(opts: HandoffSessionResolveParams): Promise<HandoffSessionResolveResult> {
return await this.client.request<HandoffSessionResolveResult>("sessions.resolve", opts);
}
async listAgents() {
return await this.client.request<GatewayAgentsList>("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 {
+1 -1
View File
@@ -31,7 +31,7 @@ export function renderConnectCommand(command: string) {
copyCommand(event);
}}
>
<code>${command}</code>
<code translate="no">${command}</code>
${renderCopyButton(command, copyLabel)}
</div>
</openclaw-tooltip>
@@ -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);
},
);
});
});
+12
View File
@@ -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",
+7
View File
@@ -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<string, ChatSessionSharingState>();
+2
View File
@@ -50,6 +50,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
private gatewayConnectionLifecycle?: ReturnType<typeof createGatewayConnectionLifecycle>;
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);
+20 -2
View File
@@ -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<Record<HeaderMenuActionKind, string>> = {
...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
@@ -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;
@@ -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<string, string>;
}
>("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 });
@@ -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<boolean> };
type MenuItemElement = HTMLElement & { checked: boolean; disabled: boolean; submenuOpen?: boolean };
@@ -46,7 +49,7 @@ async function mountMenu(
settings?: UiSettings;
panelActions?: HeaderMenuQuickAction[];
layoutActions?: HeaderMenuQuickAction[];
actionDisabledReasons?: Partial<Record<SessionMenuActionKind, string>>;
actionDisabledReasons?: Partial<Record<HeaderMenuActionKind, string>>;
forkDisabled?: boolean;
archiveAllowed?: boolean;
deleteAllowed?: boolean;
@@ -119,7 +122,14 @@ describe("chat header session menu", () => {
menu.querySelectorAll<MenuItemElement>(":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();
});
});
@@ -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<HeaderMenuAction["kind"], "open-in">;
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<SessionMenuActionKind, string>
Record<HeaderMenuActionKind, string>
> = {};
@property({ attribute: false }) forkDisabled = false;
@property({ attribute: false }) archiveAllowed = false;
@@ -47,11 +48,11 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) onSettingsChange: (patch: Partial<UiSettings>) => 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 {
<span class="session-menu__text">${t("sessionsView.forkSession")}</span>
${menuShortcutHint("f")}
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item"
value="continue-in-terminal"
?disabled=${this.actionDisabled("continue-in-terminal")}
title=${this.actionTitle("continue-in-terminal")}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.terminal}</span>
<span class="session-menu__text"
>${t("chat.sessionHeader.continueInTerminal.action")}</span
>
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
<wa-dropdown-item
class="session-menu__item"
@@ -0,0 +1,33 @@
import { html } from "lit";
import { renderConnectCommand } from "../../../components/connect-command.ts";
import "../../../components/modal-dialog.ts";
import { t } from "../../../i18n/index.ts";
export function renderContinueInTerminalDialog(params: { command: string; onClose: () => void }) {
const title = t("chat.sessionHeader.continueInTerminal.title");
const description = t("chat.sessionHeader.continueInTerminal.description");
return html`
<openclaw-modal-dialog
class="continue-in-terminal-dialog"
label=${title}
description=${description}
@modal-cancel=${params.onClose}
>
<section class="exec-approval-card continue-in-terminal-dialog__card">
<header class="continue-in-terminal-dialog__header">
<h2>${title}</h2>
<p>${description}</p>
</header>
${renderConnectCommand(params.command)}
<p class="continue-in-terminal-dialog__note">
${t("chat.sessionHeader.continueInTerminal.authNote")}
</p>
<footer class="exec-approval-actions">
<button type="button" class="btn primary" @click=${params.onClose}>
${t("common.close")}
</button>
</footer>
</section>
</openclaw-modal-dialog>
`;
}
@@ -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" });
});
});
@@ -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" };
}
}
+1 -11
View File
@@ -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;
+28
View File
@@ -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)