Skip to content

Commit 6df7db9

Browse files
feat(cli): add attach launcher (#96454)
* feat(cli): openclaw attach — launch Claude Code bound to a gateway session with scoped MCP tools * fix(cli): use token-only MCP config for attach --------- Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent 55d31be commit 6df7db9

13 files changed

Lines changed: 511 additions & 25 deletions

docs/cli/attach.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
summary: "CLI reference for `openclaw attach` (launch Claude Code with a scoped Gateway MCP grant)"
3+
read_when:
4+
- You want Claude Code to use OpenClaw Gateway MCP tools
5+
- You need a temporary session-bound MCP grant for an external harness
6+
title: "Attach CLI"
7+
---
8+
9+
`openclaw attach` launches Claude Code with a strict temporary MCP config bound
10+
to one Gateway session.
11+
12+
```sh
13+
openclaw attach
14+
openclaw attach --session agent:main:telegram:123 --ttl 600000
15+
openclaw attach --print-config
16+
```
17+
18+
Options:
19+
20+
- `--session <key>` binds the grant to a Gateway session. Defaults to the main session.
21+
- `--ttl <ms>` requests a positive grant TTL in milliseconds. The Gateway applies its own ceiling.
22+
- `--bin <path>` selects the Claude Code binary. Defaults to `claude`.
23+
- `--print-config` writes the temporary `.mcp.json`, prints the launch command and env, and leaves the grant live until TTL expiry.
24+
25+
The bearer token is passed through environment variables, not argv. OpenClaw
26+
launches Claude Code with `--strict-mcp-config --mcp-config <path>` so ambient
27+
Claude MCP servers do not join the attached session. Normal launches revoke the
28+
grant when the Claude Code process exits.
29+
30+
See also: [Gateway CLI](/cli/gateway), [MCP CLI](/cli/mcp), and [ACP CLI](/cli/acp).

docs/cli/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Use the setup commands by intent:
2424
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2525
| Setup and onboarding | [`crestodian`](/cli/crestodian) · [`setup`](/cli/setup) · [`onboard`](/cli/onboard) · [`configure`](/cli/configure) · [`config`](/cli/config) · [`completion`](/cli/completion) · [`doctor`](/cli/doctor) · [`dashboard`](/cli/dashboard) |
2626
| Reset and uninstall | [`backup`](/cli/backup) · [`reset`](/cli/reset) · [`uninstall`](/cli/uninstall) · [`update`](/cli/update) |
27-
| Messaging and agents | [`message`](/cli/message) · [`agent`](/cli/agent) · [`agents`](/cli/agents) · [`acp`](/cli/acp) · [`mcp`](/cli/mcp) |
27+
| Messaging and agents | [`message`](/cli/message) · [`agent`](/cli/agent) · [`agents`](/cli/agents) · [`attach`](/cli/attach) · [`acp`](/cli/acp) · [`mcp`](/cli/mcp) |
2828
| Health and sessions | [`status`](/cli/status) · [`health`](/cli/health) · [`sessions`](/cli/sessions) |
2929
| Gateway and logs | [`gateway`](/cli/gateway) · [`logs`](/cli/logs) · [`system`](/cli/system) |
3030
| Models and inference | [`models`](/cli/models) · [`infer`](/cli/infer) · `capability` (alias for [`infer`](/cli/infer)) · [`memory`](/cli/memory) · [`commitments`](/cli/commitments) · [`wiki`](/cli/wiki) |
@@ -193,6 +193,7 @@ openclaw [--dev] [--profile <name>] <command>
193193
bind
194194
unbind
195195
set-identity
196+
attach
196197
acp
197198
mcp
198199
serve

docs/docs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1745,7 +1745,7 @@
17451745
},
17461746
{
17471747
"group": "Interfaces",
1748-
"pages": ["cli/dashboard", "cli/tui"]
1748+
"pages": ["cli/attach", "cli/dashboard", "cli/tui"]
17491749
},
17501750
{
17511751
"group": "Utility",

src/cli/attach-cli.action.test.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { EventEmitter } from "node:events";
2+
import { Command } from "commander";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const spawnedChild = Object.assign(new EventEmitter(), { kill: vi.fn() });
6+
vi.mock("node:child_process", () => ({ spawn: vi.fn(() => spawnedChild) }));
7+
8+
const gatewayCalls: Array<{
9+
method: string;
10+
params: Record<string, unknown>;
11+
mode?: string;
12+
hasDeviceIdentityKey: boolean;
13+
}> = [];
14+
vi.mock("../gateway/call.js", () => ({
15+
callGateway: vi.fn(
16+
async (p: { method: string; params: Record<string, unknown>; mode?: string }) => {
17+
gatewayCalls.push({
18+
method: p.method,
19+
params: p.params,
20+
mode: p.mode,
21+
hasDeviceIdentityKey: "deviceIdentity" in p,
22+
});
23+
if (p.method === "attach.grant") {
24+
const sessionKey = (p.params.sessionKey as string) ?? "agent:main:main";
25+
return {
26+
sessionKey,
27+
token: "tok-123",
28+
expiresAtMs: 2_000_000_000_000,
29+
mcpConfig: {
30+
mcpServers: {
31+
openclaw: {
32+
type: "http",
33+
url: "http://127.0.0.1:9999/mcp",
34+
headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}" },
35+
},
36+
},
37+
},
38+
env: { OPENCLAW_MCP_TOKEN: "tok-123" },
39+
};
40+
}
41+
return {};
42+
},
43+
),
44+
}));
45+
46+
const logs: string[] = [];
47+
let exitCode: number | undefined;
48+
vi.mock("../runtime.js", () => ({
49+
defaultRuntime: {
50+
log: (m: string) => logs.push(m),
51+
error: (m: string) => logs.push(`ERR:${m}`),
52+
exit: (c: number) => {
53+
exitCode = c;
54+
},
55+
},
56+
}));
57+
vi.mock("../config/io.js", () => ({ getRuntimeConfig: () => ({}) }));
58+
59+
import { callGateway } from "../gateway/call.js";
60+
import { registerAttachCli } from "./attach-cli.js";
61+
62+
async function runAttach(...args: string[]) {
63+
const program = new Command().name("openclaw").exitOverride();
64+
await registerAttachCli(program);
65+
await program.parseAsync(["node", "openclaw", "attach", ...args]);
66+
}
67+
const tick = () =>
68+
new Promise<void>((resolve) => {
69+
setImmediate(resolve);
70+
});
71+
72+
describe("openclaw attach (action)", () => {
73+
beforeEach(() => {
74+
gatewayCalls.length = 0;
75+
logs.length = 0;
76+
exitCode = undefined;
77+
spawnedChild.removeAllListeners();
78+
spawnedChild.kill.mockClear();
79+
});
80+
81+
it("--print-config: mints + writes config + prints launch, does NOT revoke or name a nonexistent command", async () => {
82+
await runAttach("--print-config", "--session", "agent:main:cli");
83+
expect(gatewayCalls.find((c) => c.method === "attach.grant")?.params.sessionKey).toBe(
84+
"agent:main:cli",
85+
);
86+
// setup mode leaves the grant live (no revoke) and must not point at a revoke command that does not exist
87+
expect(gatewayCalls.find((c) => c.method === "attach.revoke")).toBeUndefined();
88+
const out = logs.join("\n");
89+
expect(out).toContain("agent:main:cli");
90+
expect(out).toContain("--mcp-config");
91+
expect(out).toContain("--strict-mcp-config");
92+
expect(out).toContain("OPENCLAW_MCP_TOKEN");
93+
expect(out).not.toContain("attach.revoke");
94+
});
95+
96+
it("calls attach.grant in CLI mode with an auto-resolved device identity (operator.admin regression guard)", async () => {
97+
// Regression guard: attach.grant is operator.admin-scoped. mode BACKEND or an explicit
98+
// deviceIdentity:null drops the operator device identity → the gateway rejects with
99+
// "missing scope: operator.admin". This was a real bug found via a live-gateway proof.
100+
await runAttach("--print-config", "--session", "agent:main:cli");
101+
const grant = gatewayCalls.find((c) => c.method === "attach.grant");
102+
expect(grant?.mode).toBe("cli");
103+
expect(grant?.hasDeviceIdentityKey).toBe(false);
104+
});
105+
106+
it("rejects a non-positive --ttl before minting", async () => {
107+
await runAttach("--ttl", "-5", "--print-config");
108+
expect(exitCode).toBe(1);
109+
expect(gatewayCalls.find((c) => c.method === "attach.grant")).toBeUndefined();
110+
});
111+
112+
it("rejects an empty --ttl rather than silently defaulting", async () => {
113+
await runAttach("--ttl", "", "--print-config");
114+
expect(exitCode).toBe(1);
115+
expect(gatewayCalls.find((c) => c.method === "attach.grant")).toBeUndefined();
116+
});
117+
118+
it("passes a positive --ttl through to attach.grant", async () => {
119+
await runAttach("--ttl", "600000", "--print-config");
120+
expect(gatewayCalls.find((c) => c.method === "attach.grant")?.params.ttlMs).toBe(600_000);
121+
});
122+
123+
it("errors on a malformed attach.grant response instead of crashing", async () => {
124+
vi.mocked(callGateway).mockResolvedValueOnce({} as never);
125+
await runAttach("--print-config");
126+
expect(exitCode).toBe(1);
127+
});
128+
129+
it("spawns Claude Code and revokes the grant when the child exits", async () => {
130+
await runAttach("--session", "agent:main:spawn");
131+
expect(gatewayCalls.find((c) => c.method === "attach.grant")).toBeTruthy();
132+
const { spawn } = await import("node:child_process");
133+
expect(vi.mocked(spawn).mock.calls[0]?.[1]).toEqual([
134+
"--strict-mcp-config",
135+
"--mcp-config",
136+
expect.stringContaining(".mcp.json"),
137+
]);
138+
spawnedChild.emit("exit", 0, null);
139+
await tick();
140+
await tick();
141+
expect(gatewayCalls.find((c) => c.method === "attach.revoke")?.params.token).toBe("tok-123");
142+
expect(exitCode).toBe(0);
143+
});
144+
145+
it("revokes once and surfaces a launch failure when the child errors", async () => {
146+
await runAttach("--session", "agent:main:spawn-err");
147+
spawnedChild.emit("error", new Error("ENOENT"));
148+
await tick();
149+
await tick();
150+
expect(gatewayCalls.filter((c) => c.method === "attach.revoke")).toHaveLength(1);
151+
expect(exitCode).toBe(1);
152+
expect(logs.join("\n")).toContain("Failed to launch");
153+
});
154+
155+
it("warns when revoke fails but still exits with the child status", async () => {
156+
vi.mocked(callGateway).mockImplementationOnce(async (p) => {
157+
gatewayCalls.push({
158+
method: p.method,
159+
params: p.params,
160+
mode: p.mode,
161+
hasDeviceIdentityKey: "deviceIdentity" in p,
162+
});
163+
return {
164+
sessionKey: "agent:main:spawn",
165+
token: "tok-123",
166+
expiresAtMs: 2_000_000_000_000,
167+
mcpConfig: { mcpServers: { openclaw: {} } },
168+
env: { OPENCLAW_MCP_TOKEN: "tok-123" },
169+
} as never;
170+
});
171+
vi.mocked(callGateway).mockImplementationOnce(async (p) => {
172+
gatewayCalls.push({
173+
method: p.method,
174+
params: p.params,
175+
mode: p.mode,
176+
hasDeviceIdentityKey: "deviceIdentity" in p,
177+
});
178+
throw new Error("gateway down");
179+
});
180+
181+
await runAttach("--session", "agent:main:spawn");
182+
spawnedChild.emit("exit", 0, null);
183+
await tick();
184+
await tick();
185+
186+
expect(exitCode).toBe(0);
187+
expect(logs.join("\n")).toContain("failed to revoke attach grant");
188+
});
189+
190+
it("detaches its signal handlers after the child exits (no listener leak)", async () => {
191+
const baseInt = process.listenerCount("SIGINT");
192+
const baseTerm = process.listenerCount("SIGTERM");
193+
await runAttach("--session", "agent:main:spawn");
194+
expect(process.listenerCount("SIGINT")).toBe(baseInt + 1);
195+
spawnedChild.emit("exit", 0, null);
196+
await tick();
197+
await tick();
198+
expect(process.listenerCount("SIGINT")).toBe(baseInt);
199+
expect(process.listenerCount("SIGTERM")).toBe(baseTerm);
200+
});
201+
202+
it("errors on a grant with a non-numeric expiresAtMs instead of crashing on toISOString", async () => {
203+
vi.mocked(callGateway).mockResolvedValueOnce({
204+
sessionKey: "agent:main:x",
205+
token: "tok-123",
206+
expiresAtMs: "soon",
207+
mcpConfig: { mcpServers: { openclaw: {} } },
208+
env: {},
209+
} as never);
210+
await runAttach("--print-config");
211+
expect(exitCode).toBe(1);
212+
});
213+
});

src/cli/attach-cli.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
import { writeClaudeMcpConfig } from "./attach-cli.js";
4+
5+
const MCP_CONFIG = {
6+
mcpServers: {
7+
openclaw: {
8+
type: "http",
9+
url: "http://127.0.0.1:54321/mcp",
10+
headers: {
11+
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
12+
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
13+
},
14+
},
15+
},
16+
};
17+
18+
describe("writeClaudeMcpConfig", () => {
19+
it("writes the gateway mcpConfig verbatim to a .mcp.json (placeholders preserved for Claude env substitution)", () => {
20+
const { path, cleanup } = writeClaudeMcpConfig(MCP_CONFIG);
21+
try {
22+
expect(path.endsWith(".mcp.json")).toBe(true);
23+
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(MCP_CONFIG);
24+
} finally {
25+
cleanup();
26+
}
27+
});
28+
29+
it("cleanup removes the temp config", () => {
30+
const { path, cleanup } = writeClaudeMcpConfig(MCP_CONFIG);
31+
cleanup();
32+
expect(() => readFileSync(path, "utf8")).toThrow();
33+
});
34+
});

0 commit comments

Comments
 (0)