Skip to content

Commit 623f361

Browse files
committed
feat: add one-step gateway setup pairing
1 parent 640258d commit 623f361

15 files changed

Lines changed: 1240 additions & 13 deletions

docs/channels/pairing.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,12 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire
113113
1. In Telegram, message your bot: `/pair`
114114
2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram).
115115
3. On your phone, open the OpenClaw iOS app → Settings → Gateway.
116-
4. Scan the QR code or paste the setup code and connect.
117-
5. Back in Telegram: `/pair pending` (review request IDs, role, and scopes), then approve.
116+
4. Scan the QR code or paste the setup code and connect. For the built-in
117+
setup-code mobile baseline, the Gateway silently approves the fresh mobile
118+
device and its node pairing during the bootstrap handshake.
119+
5. Back in Telegram: `/pair pending` only if the app reports a pending repair or
120+
upgrade. Review request IDs, role, scopes, and capability changes before
121+
approving.
118122

119123
The setup code is a base64-encoded JSON payload that contains:
120124

@@ -127,26 +131,32 @@ That bootstrap token carries the built-in pairing bootstrap profile:
127131
`node` plus a bounded `operator` handoff
128132
- the handed-off `node` token stays `scopes: []`
129133
- the handed-off `operator` token is limited to `operator.approvals`,
130-
`operator.read`, and `operator.write`
134+
`operator.read`, `operator.talk.secrets`, and `operator.write`
131135
- `operator.admin` and `operator.pairing` are not granted by QR/setup-code
132136
bootstrap; they require a separate approved operator pairing or token flow
133137
- later token rotation/revocation remains bounded by both the device's approved
134138
role contract and the caller session's operator scopes
135139

136-
Treat the setup code like a password while it is valid.
140+
Treat the setup code like a password while it is valid. `openclaw qr` also
141+
prints an 8-character setup short code. The short code is a 5-minute, single-use
142+
pointer to the same setup-code payload on that Gateway. A QR/setup code carries
143+
the Gateway URL directly; a bare short code still requires the app to know which
144+
Gateway host to redeem against.
137145

138146
For Tailscale, public, or other remote mobile pairing, use Tailscale Serve/Funnel
139147
or another `wss://` Gateway URL. Plaintext `ws://` setup codes are accepted only
140148
for loopback, private LAN addresses, `.local` Bonjour hosts, and the Android
141149
emulator host. Tailnet CGNAT addresses, `.ts.net` names, and public hosts still
142150
fail closed before QR/setup-code issuance.
143151

144-
### Approve a node device
152+
### Approve a node device or upgrade
145153

146154
```bash
147155
openclaw devices list
148156
openclaw devices approve <requestId>
149157
openclaw devices reject <requestId>
158+
openclaw nodes pending
159+
openclaw nodes approve <requestId>
150160
```
151161

152162
When an explicit approval is denied because the approving paired-device session

docs/cli/qr.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,17 @@ openclaw qr --url wss://gateway.example/ws
2929
- `--password <password>`: override which gateway password the bootstrap flow authenticates against
3030
- `--setup-code-only`: print only setup code
3131
- `--no-ascii`: skip ASCII QR rendering
32-
- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`)
32+
- `--json`: emit JSON (`setupCode`, optional `shortCode`/`shortCodeExpiresAtMs`, `gatewayUrl`, `auth`, `urlSource`)
3333

3434
## Notes
3535

3636
- `--token` and `--password` are mutually exclusive.
3737
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
38+
- Normal QR/setup-code mobile onboarding is one step: scan the QR or paste the setup code, then the app connects with bootstrap auth and the Gateway silently approves the fresh baseline mobile device plus its node pairing.
39+
- `openclaw qr` also prints an 8-character setup short code. The short code is a 5-minute, single-use pointer to the same setup-code payload on that Gateway. A bare short code still needs the app to know which Gateway host to redeem against; the QR/setup code carries the Gateway URL directly.
3840
- Built-in setup-code bootstrap returns a primary `node` token with `scopes: []` plus a bounded `operator` handoff token for trusted mobile onboarding.
3941
- The handed-off operator token is limited to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`; `operator.admin` and `operator.pairing` require a separate approved operator pairing or token flow.
42+
- Auto-approval is limited to the fresh setup-code mobile baseline. Later role, scope, metadata, or node capability upgrades still create pending requests that require explicit approval.
4043
- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL.
4144
- With `--remote`, OpenClaw requires either `gateway.remote.url` or
4245
`gateway.tailscale.mode=serve|funnel`.
@@ -46,9 +49,10 @@ openclaw qr --url wss://gateway.example/ws
4649
- `gateway.auth.password` resolves when password auth can win (explicit `gateway.auth.mode="password"` or inferred mode with no winning token from auth/env).
4750
- If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs) and `gateway.auth.mode` is unset, setup-code resolution fails until mode is set explicitly.
4851
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
49-
- After scanning, approve device pairing with:
52+
- If onboarding reports a pending repair or upgrade instead of connecting, review it with:
5053
- `openclaw devices list`
51-
- `openclaw devices approve <requestId>`
54+
- `openclaw nodes pending`
55+
- `openclaw devices approve <requestId>` or `openclaw nodes approve <requestId>`
5256

5357
## Related
5458

docs/gateway/protocol.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,40 @@ a separate approved operator pairing or token flow. Clients should persist
177177
when the connect used bootstrap auth on trusted transport such as `wss://` or
178178
loopback/local pairing.
179179

180+
For current native onboarding, that same baseline setup-code bootstrap can also
181+
silently approve the fresh mobile device and its first node pairing. This is
182+
limited to the built-in setup profile and mobile client metadata. Later role,
183+
scope, device metadata, or node capability changes still create pending pairing
184+
requests for explicit review.
185+
186+
### Setup Short-Code Redeem
187+
188+
`POST /api/v1/pairing/setup-code/redeem` redeems the 8-character setup short
189+
code printed by `openclaw qr` into the canonical setup-code payload:
190+
191+
```json
192+
{ "code": "ABCD2345" }
193+
```
194+
195+
Success:
196+
197+
```json
198+
{
199+
"ok": true,
200+
"payload": {
201+
"url": "wss://gateway.example.com",
202+
"bootstrapToken": ""
203+
},
204+
"expiresAtMs": 1737264300000
205+
}
206+
```
207+
208+
The endpoint is intentionally pre-auth so a fresh mobile app can redeem a code.
209+
The short code is single-use, expires after 5 minutes, is rate-limited per
210+
client IP, and points only to the same setup-code payload that the QR/setup-code
211+
path would have carried. It does not expose the long-lived shared Gateway token
212+
or password by default.
213+
180214
### Node example
181215

182216
```json

src/cli/qr-cli.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({
1212
diagnostics: [] as string[],
1313
})),
1414
renderTerminal: vi.fn(async () => "ASCII-QR"),
15+
registerShortCode: vi.fn(() => ({
16+
ok: true as const,
17+
code: "ABCD2345",
18+
expiresAtMs: 456,
19+
authLabel: "token" as const,
20+
urlSource: "test",
21+
})),
1522
}));
1623
const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture();
1724
const runtimeLog = runtime.log;
@@ -32,6 +39,10 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWi
3239
vi.mock("../media/qr-terminal.ts", () => ({
3340
renderQrTerminal: mocks.renderTerminal,
3441
}));
42+
vi.mock("../pairing/setup-short-code.js", () => ({
43+
formatPairingSetupShortCode: (code: string) => `${code.slice(0, 4)}-${code.slice(4)}`,
44+
registerPairingSetupShortCode: mocks.registerShortCode,
45+
}));
3546
vi.mock("./command-secret-gateway.js", () => ({
3647
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
3748
}));
@@ -45,6 +56,7 @@ const loadConfig = mocks.loadConfig;
4556
const runCommandWithTimeout = mocks.runCommandWithTimeout;
4657
const resolveCommandSecretRefsViaGateway = mocks.resolveCommandSecretRefsViaGateway;
4758
const renderTerminal = mocks.renderTerminal;
59+
const registerShortCode = mocks.registerShortCode;
4860

4961
const { registerQrCli } = await import("./qr-cli.js");
5062

@@ -140,6 +152,8 @@ describe("registerQrCli", () => {
140152
const raw = calls[calls.length - 1]?.[0];
141153
return JSON.parse(typeof raw === "string" ? raw : "{}") as {
142154
setupCode?: string;
155+
shortCode?: string;
156+
shortCodeExpiresAtMs?: number;
143157
gatewayUrl?: string;
144158
auth?: string;
145159
urlSource?: string;
@@ -197,6 +211,7 @@ describe("registerQrCli", () => {
197211
});
198212
expect(runtime.log).toHaveBeenCalledWith(expected);
199213
expect(renderTerminal).not.toHaveBeenCalled();
214+
expect(registerShortCode).not.toHaveBeenCalled();
200215
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
201216
});
202217

@@ -215,8 +230,66 @@ describe("registerQrCli", () => {
215230
const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
216231
expect(output).toContain("Pairing QR");
217232
expect(output).toContain("ASCII-QR");
233+
expect(output).toContain("Short code:");
234+
expect(output).toContain("ABCD-2345");
218235
expect(output).toContain("Gateway:");
236+
expect(output).toContain("Fresh mobile setup auto-approves");
237+
expect(output).toContain("openclaw nodes pending");
219238
expect(output).toContain("openclaw devices approve <requestId>");
239+
expect(output).toContain("openclaw nodes approve <requestId>");
240+
expect(registerShortCode).toHaveBeenCalledWith({
241+
payload: {
242+
url: "ws://127.0.0.1:18789",
243+
bootstrapToken: "bootstrap-123",
244+
},
245+
authLabel: "token",
246+
urlSource: "gateway.bind=custom",
247+
});
248+
});
249+
250+
it("still prints the setup QR when short-code registration is unavailable", async () => {
251+
loadConfig.mockReturnValue({
252+
gateway: {
253+
bind: "custom",
254+
customBindHost: "127.0.0.1",
255+
auth: { mode: "token", token: "tok" },
256+
},
257+
});
258+
registerShortCode.mockImplementationOnce(() => {
259+
throw new Error("state unavailable");
260+
});
261+
262+
await runQr([]);
263+
264+
expect(renderTerminal).toHaveBeenCalledTimes(1);
265+
const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
266+
expect(output).toContain("Pairing QR");
267+
expect(output).toContain("ASCII-QR");
268+
expect(output).toContain("Short code:");
269+
expect(output).toContain("unavailable");
270+
expect(output).toContain("Setup code:");
271+
expect(output).toContain("Gateway:");
272+
});
273+
274+
it("omits unavailable short-code fields from json output", async () => {
275+
loadConfig.mockReturnValue({
276+
gateway: {
277+
bind: "custom",
278+
customBindHost: "127.0.0.1",
279+
auth: { mode: "token", token: "tok" },
280+
},
281+
});
282+
registerShortCode.mockImplementationOnce(() => {
283+
throw new Error("state unavailable");
284+
});
285+
286+
await runQr(["--json"]);
287+
288+
const payload = parseLastLoggedQrJson();
289+
expect(payload.setupCode).toBeTruthy();
290+
expect(payload.gatewayUrl).toBe("ws://127.0.0.1:18789");
291+
expect(payload.shortCode).toBeUndefined();
292+
expect(payload.shortCodeExpiresAtMs).toBeUndefined();
220293
});
221294

222295
it("fails fast for insecure remote mobile pairing setup urls", async () => {
@@ -482,8 +555,11 @@ describe("registerQrCli", () => {
482555

483556
const payload = parseLastLoggedQrJson();
484557
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
558+
expect(payload.shortCode).toBeUndefined();
559+
expect(payload.shortCodeExpiresAtMs).toBeUndefined();
485560
expect(payload.auth).toBe("token");
486561
expect(payload.urlSource).toBe("gateway.remote.url");
562+
expect(registerShortCode).not.toHaveBeenCalled();
487563
expect(runCommandWithTimeout).not.toHaveBeenCalled();
488564
});
489565

@@ -545,7 +621,10 @@ describe("registerQrCli", () => {
545621

546622
const payload = parseLastLoggedQrJson();
547623
expect(payload.gatewayUrl).toBe("wss://ts-host.tailnet.ts.net");
624+
expect(payload.shortCode).toBe("ABCD2345");
625+
expect(payload.shortCodeExpiresAtMs).toBe(456);
548626
expect(payload.auth).toBe("token");
549627
expect(payload.urlSource).toBe("gateway.tailscale.mode=serve");
628+
expect(registerShortCode).toHaveBeenCalled();
550629
});
551630
});

src/cli/qr-cli.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { trimToUndefined } from "../gateway/credentials.js";
99
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
1010
import { renderQrTerminal } from "../media/qr-terminal.ts";
1111
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
12+
import {
13+
formatPairingSetupShortCode,
14+
registerPairingSetupShortCode,
15+
type PairingSetupShortCodeIssueResult,
16+
} from "../pairing/setup-short-code.js";
1217
import { runCommandWithTimeout } from "../process/exec.js";
1318
import { defaultRuntime } from "../runtime.js";
1419
import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js";
@@ -28,6 +33,31 @@ type QrCliOptions = {
2833
function renderQrAscii(data: string): Promise<string> {
2934
return renderQrTerminal(data);
3035
}
36+
37+
function tryRegisterPairingSetupShortCode(
38+
params: Parameters<typeof registerPairingSetupShortCode>[0],
39+
): PairingSetupShortCodeIssueResult {
40+
try {
41+
return registerPairingSetupShortCode(params);
42+
} catch {
43+
return { ok: false, error: "short code unavailable" };
44+
}
45+
}
46+
47+
function shouldRegisterPairingSetupShortCode(params: {
48+
explicitUrl?: string;
49+
urlSource: string;
50+
}): boolean {
51+
if (params.explicitUrl) {
52+
return false;
53+
}
54+
return (
55+
params.urlSource.startsWith("gateway.bind=") ||
56+
params.urlSource.startsWith("gateway.tailscale.mode=") ||
57+
params.urlSource === "plugins.entries.device-pair.config.publicUrl"
58+
);
59+
}
60+
3161
function readDevicePairPublicUrlFromConfig(cfg: OpenClawConfig): string | undefined {
3262
const value = cfg.plugins?.entries?.["device-pair"]?.config?.["publicUrl"];
3363
if (typeof value !== "string") {
@@ -216,9 +246,22 @@ export function registerQrCli(program: Command) {
216246
return;
217247
}
218248

249+
const shortCode = shouldRegisterPairingSetupShortCode({
250+
explicitUrl,
251+
urlSource: resolved.urlSource,
252+
})
253+
? tryRegisterPairingSetupShortCode({
254+
payload: resolved.payload,
255+
authLabel: resolved.authLabel,
256+
urlSource: resolved.urlSource,
257+
})
258+
: ({ ok: false, error: "short code unavailable" } satisfies PairingSetupShortCodeIssueResult);
259+
219260
if (opts.json) {
220261
defaultRuntime.writeJson({
221262
setupCode,
263+
shortCode: shortCode.ok ? shortCode.code : undefined,
264+
shortCodeExpiresAtMs: shortCode.ok ? shortCode.expiresAtMs : undefined,
222265
gatewayUrl: resolved.payload.url,
223266
auth: resolved.authLabel,
224267
urlSource: resolved.urlSource,
@@ -238,14 +281,18 @@ export function registerQrCli(program: Command) {
238281
}
239282

240283
lines.push(
284+
`${theme.muted("Short code:")} ${shortCode.ok ? formatPairingSetupShortCode(shortCode.code) : "unavailable"}`,
241285
`${theme.muted("Setup code:")} ${setupCode}`,
242286
`${theme.muted("Gateway:")} ${resolved.payload.url}`,
243287
`${theme.muted("Auth:")} ${resolved.authLabel}`,
244288
`${theme.muted("Source:")} ${resolved.urlSource}`,
245289
"",
246-
"Approve after scan with:",
290+
"Fresh mobile setup auto-approves the baseline device and node.",
291+
"If an upgraded surface remains pending:",
247292
` ${theme.command("openclaw devices list")}`,
293+
` ${theme.command("openclaw nodes pending")}`,
248294
` ${theme.command("openclaw devices approve <requestId>")}`,
295+
` ${theme.command("openclaw nodes approve <requestId>")}`,
249296
);
250297

251298
defaultRuntime.log(lines.join("\n"));

src/gateway/auth-rate-limit.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL = "node-reapproval";
5555
// device signature can queue the bootstrap-pairing flow behind their
5656
// requests, blocking legitimate node onboarding during the attack.
5757
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
58+
export const AUTH_RATE_LIMIT_SCOPE_PAIRING_SETUP_SHORT_CODE = "pairing-setup-short-code";
5859
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
5960
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
6061
const IDENTITY_RATE_LIMIT_KEY_PREFIX = "identity:";

0 commit comments

Comments
 (0)