mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
52cda537a4
commit
08b134324f
+69
-8
@@ -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
|
||||
|
||||
@@ -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
@@ -19,7 +19,7 @@ advances a milestone.
|
||||
| 1a | Naming: session copy revert | landed | #120667 |
|
||||
| 1b | Naming: devices consolidation | landed | #120689 |
|
||||
| 1c | Cleanup: node-pairing → device-pairing merge | landed | #120726 |
|
||||
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
|
||||
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664, #122870 |
|
||||
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | in progress | #120768, #122499 |
|
||||
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531, #122635, #122774 |
|
||||
| F | Real-wire session boundary harness | landed | #121212 |
|
||||
@@ -386,8 +386,27 @@ Independently mergeable PR series; 3–5 can interleave after 1c.
|
||||
|
||||
1. **1c naming cleanup**: finish nodes → devices in route ids, i18n keys,
|
||||
labels; `node-pairing.ts` facade merge. Before any new placement copy.
|
||||
2. **Continuation ergonomics** (in progress): `openclaw resume`, web
|
||||
"Continue in terminal".
|
||||
2. **Continuation ergonomics** (in progress): `openclaw resume` plus the web
|
||||
**Continue in terminal…** session action. The browser copies one
|
||||
credential-free command with one bounded, versioned, URL-safe handoff
|
||||
argument that encodes the exact qualified session key and selected Gateway
|
||||
WebSocket URL without shell-specific quoting. The key is agent-qualified and
|
||||
bounded to 512 user-perceived characters. Query-routed Gateway URLs are
|
||||
intentionally excluded because authentication and stored device scope are
|
||||
not query-aware; the UI never strips or copies the query and instead directs
|
||||
operators to a manually authenticated target or queryless configured URL. It
|
||||
never executes the CLI or delegates first-use authentication. Before attach,
|
||||
Resume asks the Gateway to resolve the key, uses only the returned canonical
|
||||
key, and rejects a missing or ambiguous handoff without starting another
|
||||
session. Resume may reuse only the current
|
||||
profile's auth, SecretRefs, and exact-origin device auth when the handoff URL
|
||||
byte-for-byte matches a target owned by the configured mode: local + Control
|
||||
UI base path or public origin + base path in local mode, and only the remote
|
||||
URL in remote mode. TLS pin reuse is limited to direct-local and
|
||||
configured remote identities; public origins inherit no local-listener pin.
|
||||
Ambient Gateway auth env fallback is suppressed for handoffs. Mismatches fail
|
||||
closed, terminal auth remains independent, and session ACLs stay
|
||||
authoritative.
|
||||
3. **`openclaw connect`**: verb + `oc-pair://` decoder + TLS pin in payload +
|
||||
`/j/<shortcode>` join route (reserved prefix, single-use, rate-limited) +
|
||||
shortcode mint + curl wrapper on the public site. Exit: a fresh machine
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user