mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat: continue dashboard sessions from CLI URLs (#120893)
* feat(cli): ingest session targets * refactor(ui): remove gateway scope shim * docs(cli): document session targets * fix(cli): classify session target failures * fix(cli): keep session target result private * fix(cli): simplify timeout option warning * build: declare session URL contract dependency * fix(cli): parse bare session URL options symmetrically * fix(cli): preserve command-owned URL arguments * build: keep session URL contract build-only * fix: address session URL review findings * test: preserve session key mock exports * fix: keep session URL helpers internal * fix(tui): preserve URL agent for global sessions * fix(tui): keep URL agent input internal * fix(gateway): reconcile websocket protocol owner * fix(attach): preserve global session agent ownership * fix(attach): enforce global owner at grant boundary
This commit is contained in:
committed by
GitHub
parent
3bdfd60caa
commit
055a2dc6ce
+158
-111
@@ -3,14 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { constants as osConstants, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
callSessionTargetGateway,
|
||||
resolveSessionTarget,
|
||||
type SessionTargetGateway,
|
||||
} from "./session-target.js";
|
||||
|
||||
type AttachGrant = {
|
||||
sessionKey: string;
|
||||
@@ -34,7 +34,12 @@ export async function registerAttachCli(program: Command, _argv: string[] = proc
|
||||
program
|
||||
.command("attach")
|
||||
.description("Attach Claude Code to a gateway session with scoped MCP tools")
|
||||
.argument("[target]", "Control UI URL, host/agent/ref, short ref, or agent:... key")
|
||||
.option("--session <key>", "Gateway session key to bind (default: main session)")
|
||||
.option("--url <url>", "Gateway WebSocket URL")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (if required)")
|
||||
.option("--tls-fingerprint <sha256>", "Expected Gateway TLS certificate fingerprint")
|
||||
.option(
|
||||
"--ttl <ms>",
|
||||
"Grant TTL in positive base-10 integer milliseconds (default: gateway policy)",
|
||||
@@ -47,121 +52,163 @@ export async function registerAttachCli(program: Command, _argv: string[] = proc
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
"\nExamples:\n openclaw attach Attach Claude Code to the main session\n openclaw attach --session agent:main:telegram:123 --ttl 600000\n openclaw attach --print-config Set up the grant + config and print how to launch it yourself\n",
|
||||
"\nExamples:\n openclaw attach Attach Claude Code to the main session\n openclaw attach movies-a1166b81 Attach to a short session reference\n openclaw attach --session agent:main:telegram:123 --ttl 600000\n openclaw attach --print-config Set up the grant + config and print how to launch it yourself\n",
|
||||
)
|
||||
.action(async (opts: { session?: string; ttl?: string; bin: string; printConfig: boolean }) => {
|
||||
let ttlMs: number | undefined;
|
||||
if (opts.ttl !== undefined) {
|
||||
ttlMs = parseStrictPositiveInteger(opts.ttl);
|
||||
if (ttlMs === undefined) {
|
||||
defaultRuntime.error(
|
||||
`--ttl must be a positive integer of milliseconds. Got: ${JSON.stringify(opts.ttl)}`,
|
||||
);
|
||||
.action(
|
||||
async (
|
||||
target: string | undefined,
|
||||
opts: {
|
||||
session?: string;
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
tlsFingerprint?: string;
|
||||
ttl?: string;
|
||||
bin: string;
|
||||
printConfig: boolean;
|
||||
},
|
||||
) => {
|
||||
if (target && opts.session) {
|
||||
throw new Error("pass one session target: use either the positional target or --session");
|
||||
}
|
||||
let ttlMs: number | undefined;
|
||||
if (opts.ttl !== undefined) {
|
||||
ttlMs = parseStrictPositiveInteger(opts.ttl);
|
||||
if (ttlMs === undefined) {
|
||||
defaultRuntime.error(
|
||||
`--ttl must be a positive integer of milliseconds. Got: ${JSON.stringify(opts.ttl)}`,
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = target
|
||||
? await resolveSessionTarget({
|
||||
raw: target,
|
||||
gateway: {
|
||||
config: cfg,
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
tlsFingerprint: opts.tlsFingerprint,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const gateway: SessionTargetGateway = resolved?.gateway ?? {
|
||||
config: cfg,
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
tlsFingerprint: opts.tlsFingerprint,
|
||||
};
|
||||
const globalAgentId =
|
||||
resolved?.sessionKey === "global" && resolved.parsed.kind === "url"
|
||||
? resolved.parsed.agentId
|
||||
: undefined;
|
||||
const granted = (await callSessionTargetGateway({
|
||||
gateway,
|
||||
method: "attach.grant",
|
||||
request: {
|
||||
sessionKey: resolved?.sessionKey ?? opts.session,
|
||||
...(globalAgentId ? { agentId: globalAgentId } : {}),
|
||||
ttlMs,
|
||||
},
|
||||
requiredScope: "operator.admin",
|
||||
})) as Partial<AttachGrant> | null;
|
||||
if (
|
||||
!granted ||
|
||||
typeof granted.token !== "string" ||
|
||||
typeof granted.sessionKey !== "string" ||
|
||||
typeof granted.expiresAtMs !== "number" ||
|
||||
!Number.isFinite(granted.expiresAtMs) ||
|
||||
!granted.mcpConfig?.mcpServers ||
|
||||
typeof granted.env !== "object" ||
|
||||
granted.env === null
|
||||
) {
|
||||
defaultRuntime.error("attach.grant returned an unexpected response from the gateway.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const grant = granted as AttachGrant;
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const granted = (await callGateway({
|
||||
config: cfg,
|
||||
method: "attach.grant",
|
||||
params: { sessionKey: opts.session, ttlMs },
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
})) as Partial<AttachGrant> | null;
|
||||
if (
|
||||
!granted ||
|
||||
typeof granted.token !== "string" ||
|
||||
typeof granted.sessionKey !== "string" ||
|
||||
typeof granted.expiresAtMs !== "number" ||
|
||||
!Number.isFinite(granted.expiresAtMs) ||
|
||||
!granted.mcpConfig?.mcpServers ||
|
||||
typeof granted.env !== "object" ||
|
||||
granted.env === null
|
||||
) {
|
||||
defaultRuntime.error("attach.grant returned an unexpected response from the gateway.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const grant = granted as AttachGrant;
|
||||
const { path: configPath, cleanup } = writeClaudeMcpConfig(grant.mcpConfig);
|
||||
const expiresAt = new Date(grant.expiresAtMs).toISOString();
|
||||
const claudeArgs = ["--strict-mcp-config", "--mcp-config", configPath];
|
||||
|
||||
const { path: configPath, cleanup } = writeClaudeMcpConfig(grant.mcpConfig);
|
||||
const expiresAt = new Date(grant.expiresAtMs).toISOString();
|
||||
const claudeArgs = ["--strict-mcp-config", "--mcp-config", configPath];
|
||||
if (opts.printConfig) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sessionKey: grant.sessionKey,
|
||||
expiresAt,
|
||||
env: grant.env,
|
||||
configPath,
|
||||
launch: [opts.bin, ...claudeArgs],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
defaultRuntime.log(
|
||||
`Grant is live until ${expiresAt} and auto-expires; it is not revoked here. Launch with the env above, then delete ${configPath} when done.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let revokePromise: Promise<void> | undefined;
|
||||
const revokeOnce = () =>
|
||||
(revokePromise ??= (async () => {
|
||||
try {
|
||||
await callSessionTargetGateway({
|
||||
gateway,
|
||||
method: "attach.revoke",
|
||||
request: { token: grant.token },
|
||||
requiredScope: "operator.admin",
|
||||
});
|
||||
} catch (error) {
|
||||
defaultRuntime.error(
|
||||
`Warning: failed to revoke attach grant; it remains live until ${expiresAt}. ${String(error)}`,
|
||||
);
|
||||
}
|
||||
cleanup();
|
||||
})());
|
||||
|
||||
if (opts.printConfig) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sessionKey: grant.sessionKey,
|
||||
expiresAt,
|
||||
env: grant.env,
|
||||
configPath,
|
||||
launch: [opts.bin, ...claudeArgs],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
`Attaching Claude Code to session ${grant.sessionKey} (grant expires ${expiresAt})…`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
`Grant is live until ${expiresAt} and auto-expires; it is not revoked here. Launch with the env above, then delete ${configPath} when done.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const child = spawn(opts.bin, claudeArgs, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...grant.env },
|
||||
});
|
||||
|
||||
let revokePromise: Promise<void> | undefined;
|
||||
const revokeOnce = () =>
|
||||
(revokePromise ??= (async () => {
|
||||
try {
|
||||
await callGateway({
|
||||
config: cfg,
|
||||
method: "attach.revoke",
|
||||
params: { token: grant.token },
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
});
|
||||
} catch (error) {
|
||||
defaultRuntime.error(
|
||||
`Warning: failed to revoke attach grant; it remains live until ${expiresAt}. ${String(error)}`,
|
||||
);
|
||||
}
|
||||
cleanup();
|
||||
})());
|
||||
const onSigint = () => {};
|
||||
const onSigterm = () => child.kill("SIGTERM");
|
||||
const finish = (code: number) => {
|
||||
process.off("SIGINT", onSigint);
|
||||
process.off("SIGTERM", onSigterm);
|
||||
defaultRuntime.exit(code);
|
||||
};
|
||||
|
||||
defaultRuntime.log(
|
||||
`Attaching Claude Code to session ${grant.sessionKey} (grant expires ${expiresAt})…`,
|
||||
);
|
||||
const child = spawn(opts.bin, claudeArgs, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...grant.env },
|
||||
});
|
||||
|
||||
const onSigint = () => {};
|
||||
const onSigterm = () => child.kill("SIGTERM");
|
||||
const finish = (code: number) => {
|
||||
process.off("SIGINT", onSigint);
|
||||
process.off("SIGTERM", onSigterm);
|
||||
defaultRuntime.exit(code);
|
||||
};
|
||||
|
||||
child.on("error", (error) => {
|
||||
void (async () => {
|
||||
defaultRuntime.error(`Failed to launch '${opts.bin}': ${String(error)}`);
|
||||
await revokeOnce();
|
||||
finish(1);
|
||||
})();
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
void (async () => {
|
||||
await revokeOnce();
|
||||
const signalCode = signal
|
||||
? 128 + ((osConstants.signals as Record<string, number>)[signal] ?? 0)
|
||||
: null;
|
||||
finish(signalCode ?? code ?? 0);
|
||||
})();
|
||||
});
|
||||
process.on("SIGINT", onSigint);
|
||||
process.on("SIGTERM", onSigterm);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
void (async () => {
|
||||
defaultRuntime.error(`Failed to launch '${opts.bin}': ${String(error)}`);
|
||||
await revokeOnce();
|
||||
finish(1);
|
||||
})();
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
void (async () => {
|
||||
await revokeOnce();
|
||||
const signalCode = signal
|
||||
? 128 + ((osConstants.signals as Record<string, number>)[signal] ?? 0)
|
||||
: null;
|
||||
finish(signalCode ?? code ?? 0);
|
||||
})();
|
||||
});
|
||||
process.on("SIGINT", onSigint);
|
||||
process.on("SIGTERM", onSigterm);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user