feat: add one-paste node onboarding (#122499)

* feat(pairing): add short join onboarding

* fix(gateway): isolate device join routing tests
This commit is contained in:
Peter Steinberger
2026-08-12 06:17:39 -07:00
committed by GitHub
parent fb9a62e995
commit ade3456dd4
54 changed files with 1014 additions and 25 deletions
@@ -17908,17 +17908,20 @@ public struct DevicePairSetupCodeParams: Codable, Sendable {
public let preferremoteurl: Bool?
public let includeqr: Bool?
public let bootstrapprofile: String?
public let joinurl: Bool?
public init(
publicurl: String? = nil,
preferremoteurl: Bool? = nil,
includeqr: Bool? = nil,
bootstrapprofile: String? = nil)
bootstrapprofile: String? = nil,
joinurl: Bool? = nil)
{
self.publicurl = publicurl
self.preferremoteurl = preferremoteurl
self.includeqr = includeqr
self.bootstrapprofile = bootstrapprofile
self.joinurl = joinurl
}
private enum CodingKeys: String, CodingKey {
@@ -17926,11 +17929,13 @@ public struct DevicePairSetupCodeParams: Codable, Sendable {
case preferremoteurl = "preferRemoteUrl"
case includeqr = "includeQr"
case bootstrapprofile = "bootstrapProfile"
case joinurl = "joinUrl"
}
}
public struct DevicePairSetupCodeResult: Codable, Sendable {
public let setupcode: String
public let joinurl: String?
public let qrdataurl: String?
public let gatewayurl: String
public let gatewayurls: [String]?
@@ -17942,6 +17947,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
public init(
setupcode: String,
joinurl: String? = nil,
qrdataurl: String? = nil,
gatewayurl: String,
gatewayurls: [String]? = nil,
@@ -17952,6 +17958,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
expiresatms: Int? = nil)
{
self.setupcode = setupcode
self.joinurl = joinurl
self.qrdataurl = qrdataurl
self.gatewayurl = gatewayurl
self.gatewayurls = gatewayurls
@@ -17964,6 +17971,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case setupcode = "setupCode"
case joinurl = "joinUrl"
case qrdataurl = "qrDataUrl"
case gatewayurl = "gatewayUrl"
case gatewayurls = "gatewayUrls"
@@ -1 +1 @@
{"contentHash":"e4a7ffbb2daf39077b19f28d0dbf9267bb645be42ce18e24161966e34b16bdc7","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"eefc72c42de4ca6c5259786aed66e2a209a616679c652d101e08ce250be6643a","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"517a4d6efc324c135d1761b9d8e77ed573162bcc8b662372cebdfd7c5e3083c1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"6f8716be0843d82556326c47da8e9610aa9d4c45f7be35771f8f062025b1954b","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"007c23cfcc4af2cc62967750beabdb9825352b54afc3057c989c7e9179790ad0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"895ac29e8056362777291f51a27d021813de8f3a4acce30e57b1b82843cd27bc","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"454a8b3a3ee15a1a691970f4b5cb15878b8f2f4e166d6600bf639f57292232f3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"a109615d9c3222bdbf04be69e222e4af4e314820c72b688c909fec6d0487bdc6","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"aaadbf677c001d81a784acbe65ce9acb4d76794be31fecff9d2c9b8d984ec37c","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
{"contentHash":"84ecd36a64b3aec85589103ef5e3e332ea0770651c0cc344090c6a3c609b6854","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
@@ -1 +1 @@
{"contentHash":"8bafa462130e5c1da30329c7face6a483957d4a0c3cc689b1632f1974420b69f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"c85d86fb7e98c9606a9ea4f44e99d97cf4e60708288b539c635ba2f6fc7d0f1a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"9e02fe7d5c9f80328c7f557420ee84687954f753dfd84b19e1469eb94b8dd168","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"abfb0bc418ade50fbceec696dbc19198d10e5596965e264d1202924f0f7d5761","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"a0b2dd06b690fe208c45c02e858934b527ed3c419f1a7ebd394423fc295cbff9","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"3a9f81af4a5140ef51cc73bf117138dd318dd89cb743e40eba2970792b1d9980","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"402dc7fb02d64dc390510abbf1eb240ccb807b1bf3e26ddaa582c9393bdfb57a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"f808b2dea87ef2f60c3d3867aabcade645ce918b4c273d1dcdb452e4b110aa0f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"4b0344b65821cc88ea69e7d6e8ca37ddfd21d258035e6ba7d613748df743c761","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"29d5e02c1f225835cca47a07f807f317d77782e05bdce88b0d4a58d04d85b81b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"ffbc6c5c809ed0ceb135ea42926e321edf4265eb96c80e0f9df536d7eb472811","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"8cf1f6a307496e0c44bc8c413d710bf069681fa506d5af3508f7ba437065c6e2","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"0ebc21dbb6435d83c96ddae60291abbf36a47292288533e1b921bf9501581895","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"0212bcd1e5e4740ed61cf0773e13ee51d777b64a5fd51c8fa3060398e5f129b2","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"458825733f53d1c06d468287d7059d2400202572082a8300d7806c71856498c7","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"6c15ce9bac50287422269210a848b33a6cca4684b8f7801be93fbe2f04e0d45e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"0cbd356b5509eb2bd0f744987a48e34726525e8932fe5a6bb9c7eb5884b55329","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"66b685d9302b1e6bb5e348c0b164944e7f92087d480ca36ced0855316f6d9385","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"f662a31a8c5db27d767489160e9d443df45190429e8f4fb9f6e184d12bac3f19","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"2120a39bb107f406046340baa16e316e47341451d21f76d28f666d6b27fc4673","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"d0c70224a6b22aef9680ea81d32d071b11a2533c885dc8665080c6c00255ea4d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"f127c5c3738201fb8972ab46c145a9001889c211fcde48c395c06818ea627bf8","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"4962f66547dd18b573f4c3ee7ad08f35f220417559437ed2653ef1e3f07fb4be","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"af55168f90db0f1d2d55a38b116a8612710b90874418fc238827619b87a89691","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"326217df400252a236ce5f5e1f98adfcbbec79d3a5d068cf44fc3b7a70e0debe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"eaf35330803af512853cf00050e5d6fe5e1e86f9997134e36146ea3cf6ba4595","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+4
View File
@@ -1742,5 +1742,9 @@
{
"source": "Updating OpenClaw",
"target": "更新 OpenClaw"
},
{
"source": "Connect a machine",
"target": "连接机器"
}
]
+100
View File
@@ -0,0 +1,100 @@
---
summary: "Connect a machine to an OpenClaw Gateway with one pasted command"
read_when:
- Pairing a new headless node with a Gateway
- Installing a node host from a join URL or setup code
title: "Connect"
---
# `openclaw connect`
Connect the current machine to an OpenClaw Gateway as a headless node. The
command redeems a short-lived bootstrap credential, saves the Gateway endpoint
in the existing node-host state, and runs the same runtime as
[`openclaw node run`](/cli/node).
## Create a join command
On the Gateway host, use admin credentials to mint a single-use join URL:
```bash
openclaw devices join-code
```
The command prints the URL and a pasteable command:
```bash
npx openclaw connect https://gateway.example/j/<shortcode>
```
The shortcode has 128 bits of entropy, expires with the setup credential after
about 10 minutes, and can be fetched exactly once. Mint another code if it
expires or has already been used.
## Connect in the foreground
Paste the printed command on the machine you want to connect:
```bash
npx openclaw connect https://gateway.example/j/<shortcode>
```
Set the device name during enrollment when useful:
```bash
npx openclaw connect https://gateway.example/j/<shortcode> --display-name "Build Node"
```
The node stays in the foreground until you stop it.
## Install as a service
Pass `--service` to redeem the bootstrap credential and install the node host as
the platform user service:
```bash
npx openclaw connect https://gateway.example/j/<shortcode> --service
```
OpenClaw completes the first authenticated connection before installing the
service. The short-lived bootstrap token is never stored in the service command
or node-host configuration; later starts use the durable paired-device token.
Use [`openclaw node status`](/cli/node#service-background) to inspect the
installed service.
## Accepted targets
`openclaw connect <target>` accepts:
- an `https://<gateway>/j/<shortcode>` join URL;
- an `oc-pair://<setup-code>` URL;
- a bare base64url setup code.
Join URLs must use HTTPS. Plain HTTP is accepted only for loopback Gateway URLs
such as `http://127.0.0.1/j/<shortcode>`. Direct setup codes can carry the
Gateway TLS certificate fingerprint, which lets the node host pin a self-signed
Gateway certificate after decoding the payload.
The payload determines the saved host, port, TLS mode, WebSocket context path,
and ordered fallback endpoints. No additional `openclaw.json` keys are created.
## Revocation behavior
A join code and a paired device have separate lifecycles:
- Burning or expiring a join code prevents another enrollment with that code.
- It does not disconnect or remove a node that already redeemed it.
- To revoke an enrolled machine, remove its paired device with
[`openclaw devices remove <deviceId>`](/cli/devices#openclaw-devices-remove-deviceid).
## Troubleshooting
If the join URL reports that it is missing or expired, mint a new one with
`openclaw devices join-code`. A used code intentionally returns the same result
as an unknown code.
If an HTTPS join URL uses a certificate the local machine does not trust, use
the direct `oc-pair://` or bare setup-code form that includes the TLS pin.
See [Node](/cli/node) for service management, explicit connection flags, node
state, and exec approval behavior.
+5
View File
@@ -70,6 +70,10 @@ Disable it on the node if needed:
## Run (foreground)
For one-paste onboarding, use [`openclaw connect`](/cli/connect). It accepts a
single-use join URL or the same setup code forms as `--pair`, then runs this
node-host runtime.
```bash
openclaw node run --host <gateway-host> --port 18789
```
@@ -281,4 +285,5 @@ created are rejected instead of changing what the node executes.
## Related
- [CLI reference](/cli)
- [Connect a machine](/cli/connect)
- [Nodes](/nodes)
+1
View File
@@ -1857,6 +1857,7 @@
"cli/browser",
"cli/cron",
"cli/flows",
"cli/connect",
"cli/node",
"cli/nodes",
"cli/sandbox",
+1 -1
View File
@@ -20,7 +20,7 @@ advances a milestone.
| 1b | Naming: devices consolidation | landed | #120689 |
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | in progress | #122499 |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531 |
| 5 | Public worker ingress path | not started | — |
| 6 | Node worker provider (device runners) | not started | — |
@@ -92,6 +92,7 @@ export const DevicePairSetupCodeParamsSchema = closedObject({
preferRemoteUrl: Type.Optional(Type.Boolean()),
includeQr: Type.Optional(Type.Boolean()),
bootstrapProfile: Type.Optional(Type.String({ enum: ["limited", "node"] })),
joinUrl: Type.Optional(Type.Literal(true)),
});
/**
@@ -102,6 +103,7 @@ export const DevicePairSetupCodeParamsSchema = closedObject({
*/
export const DevicePairSetupCodeResultSchema = closedObject({
setupCode: NonEmptyString,
joinUrl: Type.Optional(NonEmptyString),
qrDataUrl: Type.Optional(SetupCodeQrDataUrlSchema),
gatewayUrl: NonEmptyString,
gatewayUrls: Type.Optional(
+5
View File
@@ -475,6 +475,11 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
policy: { networkProxy: "default" },
},
{
commandPath: ["connect"],
exact: true,
policy: { networkProxy: "default" },
},
{
commandPath: ["worker"],
exact: true,
+127
View File
@@ -0,0 +1,127 @@
// Connect CLI tests cover accepted targets and handoff to the canonical node runtime.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { encodePairingSetupCode } from "../pairing/setup-code.js";
import { registerConnectCli } from "./connect-cli.js";
const mocks = vi.hoisted(() => ({
runNodeHost: vi.fn(),
runNodeDaemonInstall: vi.fn(),
fetchWithSsrFGuard: vi.fn(),
runtime: {
error: vi.fn(),
exit: vi.fn(),
},
}));
vi.mock("../node-host/runner.js", () => ({ runNodeHost: mocks.runNodeHost }));
vi.mock("./node-cli/daemon.js", () => ({
runNodeDaemonInstall: mocks.runNodeDaemonInstall,
}));
vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
}));
vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.runtime }));
const payload = {
url: "wss://192.168.1.20:8443/openclaw-gw",
urls: ["wss://192.168.1.20:8443/openclaw-gw", "wss://gateway.tailnet.example/tailnet-gw"],
bootstrapToken: "bootstrap-token",
tlsFingerprint: "ab".repeat(32),
};
function setupCode(): string {
return encodePairingSetupCode(payload);
}
async function runConnect(args: string[]): Promise<void> {
const program = new Command();
registerConnectCli(program);
await program.parseAsync(["connect", ...args], { from: "user" });
}
describe("connect cli", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.runNodeHost.mockResolvedValue(undefined);
mocks.runNodeDaemonInstall.mockResolvedValue(undefined);
mocks.runtime.exit.mockImplementation(() => {});
});
it.each([
{ name: "bare setup code", target: () => setupCode(), fetched: false },
{ name: "oc-pair wrapper", target: () => `oc-pair://${setupCode()}`, fetched: false },
{
name: "HTTPS join URL",
target: () => `https://gateway.example/openclaw-gw/j/${"a".repeat(22)}`,
fetched: true,
},
])("maps a $name into the existing node foreground runtime", async ({ target, fetched }) => {
if (fetched) {
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
}),
finalUrl: target(),
release: vi.fn().mockResolvedValue(undefined),
});
}
await runConnect([target(), "--display-name", "Build Node"]);
expect(mocks.runNodeHost).toHaveBeenCalledWith({
gatewayHost: "192.168.1.20",
gatewayPort: 8443,
gatewayTls: true,
gatewayTlsFingerprint: "ab".repeat(32),
gatewayContextPath: "/openclaw-gw",
gatewayCandidates: [
{
host: "192.168.1.20",
port: 8443,
contextPath: "/openclaw-gw",
tls: true,
tlsFingerprint: "ab".repeat(32),
},
{
host: "gateway.tailnet.example",
port: 443,
contextPath: "/tailnet-gw",
tls: true,
},
],
gatewayBootstrapToken: "bootstrap-token",
preferGatewayBootstrapToken: true,
displayName: "Build Node",
});
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(fetched ? 1 : 0);
expect(mocks.runNodeDaemonInstall).not.toHaveBeenCalled();
});
it("redeems before installing from the winning persisted endpoint", async () => {
await runConnect([setupCode(), "--service", "--display-name", "Service Node"]);
expect(mocks.runNodeHost).toHaveBeenCalledWith(
expect.objectContaining({
gatewayBootstrapToken: "bootstrap-token",
stopAfterFirstConnect: true,
}),
);
expect(mocks.runNodeDaemonInstall).toHaveBeenCalledWith({
displayName: "Service Node",
force: true,
});
});
it("refuses plain HTTP join URLs for non-loopback gateways", async () => {
await runConnect([`http://gateway.example/j/${"a".repeat(22)}`]);
expect(mocks.runtime.error).toHaveBeenCalledWith(
"Plain HTTP join URLs are allowed only for loopback gateways.",
);
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
expect(mocks.runNodeHost).not.toHaveBeenCalled();
});
});
+150
View File
@@ -0,0 +1,150 @@
// One-paste node onboarding from setup codes or single-use Gateway join URLs.
import type { Command } from "commander";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { isLoopbackHost } from "../gateway/net.js";
import { cancelUnreadResponseBody, readResponseWithLimit } from "../infra/http-body.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { normalizeHostname } from "../infra/net/hostname.js";
import { runNodeHost } from "../node-host/runner.js";
import { isDevicePairingJoinCode } from "../pairing/join-code.js";
import { decodePairingSetupCode, encodePairingSetupCode } from "../pairing/setup-code.js";
import { defaultRuntime } from "../runtime.js";
import { formatHelpExamples } from "./help-format.js";
import { runNodeDaemonInstall } from "./node-cli/daemon.js";
import { resolveNodePairGatewayPayload } from "./node-cli/gateway-options.js";
type ConnectCommandOptions = {
service?: boolean;
displayName?: string;
};
type PairingSetupPayload = ReturnType<typeof decodePairingSetupCode>;
const MAX_JOIN_PAYLOAD_BYTES = 24 * 1024;
const JOIN_FETCH_TIMEOUT_MS = 15_000;
function parseJoinTarget(target: string): URL | null {
let parsed: URL;
try {
parsed = new URL(target);
} catch {
return null;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return null;
}
const match = /(?:^|\/)j\/([^/]+)$/u.exec(parsed.pathname);
const shortcode = match?.[1] ?? "";
if (
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash ||
!isDevicePairingJoinCode(shortcode)
) {
throw new Error("Join URL must end with the exact /j/<shortcode> form.");
}
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
throw new Error("Plain HTTP join URLs are allowed only for loopback gateways.");
}
return parsed;
}
async function fetchJoinPayload(target: URL): Promise<PairingSetupPayload> {
const expectedHost = normalizeHostname(target.hostname);
let release: () => Promise<void> = async () => {};
try {
const guarded = await fetchWithSsrFGuard({
url: target.toString(),
auditContext: "openclaw-connect-join",
maxRedirects: 0,
requireHttps: target.protocol === "https:",
timeoutMs: JOIN_FETCH_TIMEOUT_MS,
policy: {
allowPrivateNetwork: true,
allowedHostnames: [expectedHost],
hostnameAllowlist: [expectedHost],
},
});
release = guarded.release;
const response = guarded.response;
if (!response.ok || !response.headers.get("content-type")?.startsWith("application/json")) {
await cancelUnreadResponseBody(response);
throw new Error("Gateway join code was not found or has expired.");
}
const body = await readResponseWithLimit(response, MAX_JOIN_PAYLOAD_BYTES);
let decoded: unknown;
try {
decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)) as unknown;
} catch {
throw new Error("Gateway returned an invalid pairing payload.");
}
return decodePairingSetupCode(encodePairingSetupCode(decoded as PairingSetupPayload));
} catch (error) {
if (error instanceof Error && error.message.startsWith("Gateway ")) {
throw error;
}
throw new Error("Could not fetch the Gateway join payload securely.", { cause: error });
} finally {
await release();
}
}
async function resolveConnectPayload(target: string): Promise<PairingSetupPayload> {
const joinTarget = parseJoinTarget(target);
return joinTarget ? await fetchJoinPayload(joinTarget) : decodePairingSetupCode(target);
}
async function runConnectCommand(target: string, opts: ConnectCommandOptions): Promise<void> {
const pair = resolveNodePairGatewayPayload(await resolveConnectPayload(target));
const nodeRunOptions = {
gatewayHost: pair.host,
gatewayPort: pair.port,
gatewayTls: pair.tls,
gatewayTlsFingerprint: pair.tlsFingerprint,
gatewayContextPath: pair.contextPath,
gatewayCandidates: pair.candidates,
gatewayBootstrapToken: pair.bootstrapToken,
preferGatewayBootstrapToken: true,
displayName: opts.displayName,
};
if (!opts.service) {
await runNodeHost(nodeRunOptions);
return;
}
// The first hello stores durable device auth and the winning endpoint before
// installation, so the service never persists the one-shot bootstrap bearer.
await runNodeHost({ ...nodeRunOptions, stopAfterFirstConnect: true });
await runNodeDaemonInstall({ displayName: opts.displayName, force: true });
}
export function registerConnectCli(program: Command): void {
program
.command("connect")
.description("Connect this machine to an OpenClaw Gateway as a node")
.argument("<target>", "oc-pair URL, setup code, or HTTPS Gateway join URL")
.option("--service", "Install and run the node host as an OS service", false)
.option("--display-name <name>", "Override the node display name")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw connect oc-pair://<setup-code>", "Connect in the foreground."],
[
"openclaw connect https://gateway.example/j/<code> --service",
"Install the node host service.",
],
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/connect", "docs.openclaw.ai/cli/connect")}\n`,
)
.action(async (target: string, opts: ConnectCommandOptions) => {
try {
await runConnectCommand(target, opts);
} catch (error) {
defaultRuntime.error(error instanceof Error ? error.message : String(error));
defaultRuntime.exit(1);
}
});
}
+2
View File
@@ -19,6 +19,7 @@ describe("devices cli lazy runtime boundary", () => {
return {
runDevicesApproveCommand: vi.fn(),
runDevicesClearCommand: vi.fn(),
runDevicesJoinCodeCommand: vi.fn(),
runDevicesListCommand: vi.fn(),
runDevicesRejectCommand: vi.fn(),
runDevicesRemoveCommand: vi.fn(),
@@ -52,6 +53,7 @@ describe("devices cli lazy runtime boundary", () => {
return {
runDevicesApproveCommand: vi.fn(),
runDevicesClearCommand: vi.fn(),
runDevicesJoinCodeCommand: vi.fn(),
runDevicesListCommand,
runDevicesRejectCommand: vi.fn(),
runDevicesRemoveCommand: vi.fn(),
+24
View File
@@ -942,6 +942,30 @@ export async function runDevicesListCommand(opts: DevicesRpcOpts): Promise<void>
}
}
export async function runDevicesJoinCodeCommand(opts: DevicesRpcOpts): Promise<void> {
const result = await callGatewayCli(
"device.pair.setupCode",
opts,
{
bootstrapProfile: "node",
includeQr: false,
joinUrl: true,
},
{ scopes: [ADMIN_SCOPE] },
);
const joinUrl = normalizeOptionalString((result as { joinUrl?: unknown }).joinUrl);
if (!joinUrl) {
throw new Error("Gateway did not return a device join URL.");
}
const command = `npx openclaw connect ${quoteCliArg(joinUrl)}`;
if (opts.json) {
defaultRuntime.writeJson({ joinUrl, command });
return;
}
defaultRuntime.log(joinUrl);
defaultRuntime.log(command);
}
export async function runDevicesRemoveCommand(
deviceId: string,
opts: DevicesRpcOpts,
+18
View File
@@ -1294,6 +1294,24 @@ describe("devices cli rename", () => {
});
});
describe("devices cli join-code", () => {
it("mints with admin scope and prints the pasteable command", async () => {
const joinUrl = `https://gateway.example/j/${"a".repeat(22)}`;
callGateway.mockResolvedValueOnce({ joinUrl, setupCode: "opaque" });
await runDevicesCommand(["join-code"]);
expectGatewayCall(0, {
method: "device.pair.setupCode",
params: { bootstrapProfile: "node", includeQr: false, joinUrl: true },
scopes: ["operator.admin"],
});
expect(readRuntimeOutput()).toContain(joinUrl);
expect(readRuntimeOutput()).toContain(`npx openclaw connect ${joinUrl}`);
expect(readRuntimeOutput()).not.toContain("opaque");
});
});
beforeEach(() => {
vi.clearAllMocks();
runtime.exit.mockImplementation(() => {});
+10
View File
@@ -49,6 +49,16 @@ export function registerDevicesCli(program: Command) {
}),
);
devicesCallOpts(
devices
.command("join-code")
.description("Mint a single-use node onboarding URL")
.action(async (opts: DevicesRpcOpts) => {
const { runDevicesJoinCodeCommand } = await loadDevicesRuntime();
await runDevicesJoinCodeCommand(opts);
}),
);
devicesCallOpts(
devices
.command("remove")
+9 -1
View File
@@ -21,6 +21,8 @@ type NodePairGatewayOptions = {
candidates: NodeHostGatewayConfig[];
};
type PairingSetupPayload = ReturnType<typeof decodePairingSetupCode>;
function gatewayConfigFromUrl(url: string, tlsFingerprint?: string): NodeHostGatewayConfig {
const parsed = new URL(url);
const tls = parsed.protocol === "wss:";
@@ -34,7 +36,13 @@ function gatewayConfigFromUrl(url: string, tlsFingerprint?: string): NodeHostGat
}
export function resolveNodePairGatewayOptions(input: string): NodePairGatewayOptions {
const payload = decodePairingSetupCode(input);
return resolveNodePairGatewayPayload(decodePairingSetupCode(input));
}
/** Project a validated pairing payload into the canonical node-host candidate list. */
export function resolveNodePairGatewayPayload(
payload: PairingSetupPayload,
): NodePairGatewayOptions {
const candidates = (payload.urls ?? [payload.url]).map((url) =>
gatewayConfigFromUrl(url, url === payload.url ? payload.tlsFingerprint : undefined),
);
+5
View File
@@ -161,6 +161,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
loadModule: () => import("../node-cli.js"),
exportName: "registerNodeCli",
},
{
commandNames: ["connect"],
loadModule: () => import("../connect-cli.js"),
exportName: "registerConnectCli",
},
{
commandNames: ["worker"],
loadModule: () => import("../worker-cli.js"),
@@ -128,6 +128,7 @@ const JSON_NOT_APPLICABLE = {
"mcp serve",
"node worker",
"node run",
"connect",
"worker",
"fleet logs",
"proxy start",
+5
View File
@@ -95,6 +95,11 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
description: "Run and manage the headless node host service",
hasSubcommands: true,
},
{
name: "connect",
description: "Connect this machine to an OpenClaw Gateway as a node",
hasSubcommands: false,
},
{
name: "worker",
description: "Run the restricted cloud worker runtime",
+3
View File
@@ -56,6 +56,9 @@ export const AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL = "node-reapproval";
// device signature can queue the bootstrap-pairing flow behind their
// requests, blocking legitimate node onboarding during the attack.
export const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token";
// Public join-code exchange burns SQLite state, so misses are serialized and
// throttled before they can queue unbounded writes behind the shared DB lock.
export const AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN = "device-join";
// Public watchOS challenge issuance is throttled separately from credential
// failures so challenge floods cannot displace legitimate device handshakes.
export const AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE = "watch-challenge";
+12
View File
@@ -249,6 +249,18 @@ describe("classifyControlUiRequest", () => {
method: "GET",
expected: { kind: "not-control-ui" as const },
},
{
name: "keeps the device join root outside the SPA catch-all",
pathname: "/j",
method: "GET",
expected: { kind: "not-control-ui" as const },
},
{
name: "keeps device join codes outside the SPA catch-all",
pathname: `/j/${"a".repeat(22)}`,
method: "GET",
expected: { kind: "not-control-ui" as const },
},
{
name: "keeps the OpenAI-compatible API root outside the SPA catch-all",
pathname: "/v1",
+3
View File
@@ -78,6 +78,9 @@ export function classifyControlUiRequest(params: {
if (pathname === "/api" || pathname.startsWith("/api/")) {
return { kind: "not-control-ui" };
}
if (pathname === "/j" || pathname.startsWith("/j/")) {
return { kind: "not-control-ui" };
}
// Disabled OpenAI-compatible endpoints must return 404, not the SPA HTML.
if (pathname === "/v1" || pathname.startsWith("/v1/")) {
return { kind: "not-control-ui" };
+58
View File
@@ -0,0 +1,58 @@
// Public single-use exchange for device-pairing join codes.
import type { IncomingMessage, ServerResponse } from "node:http";
import { redeemDevicePairingJoinCode } from "../infra/device-pairing-join-code.js";
import { isDevicePairingJoinCode } from "../pairing/join-code.js";
import { AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN, type AuthRateLimiter } from "./auth-rate-limit.js";
import { sendJson } from "./http-common.js";
import { withSerializedRateLimitAttempt } from "./rate-limit-attempt-serialization.js";
const NOT_FOUND_BODY = { error: "not_found" } as const;
function sendJoinNotFound(res: ServerResponse): void {
sendJson(res, 404, NOT_FOUND_BODY);
}
/** Handle the core-owned /j namespace before hooks, plugins, and the Control UI SPA. */
export async function handleDevicePairingJoinHttpRequest(params: {
req: IncomingMessage;
res: ServerResponse;
shortcode: string;
clientIp: string | undefined;
rateLimiter?: AuthRateLimiter;
}): Promise<boolean> {
const parsed = URL.parse(params.req.url ?? "/", "http://localhost");
params.res.setHeader("Cache-Control", "no-store");
await withSerializedRateLimitAttempt({
ip: params.clientIp,
scope: AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN,
run: async () => {
const rateCheck = params.rateLimiter?.check(
params.clientIp,
AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN,
);
if (rateCheck && !rateCheck.allowed) {
if (rateCheck.retryAfterMs > 0) {
params.res.setHeader("Retry-After", String(Math.ceil(rateCheck.retryAfterMs / 1000)));
}
sendJson(params.res, 429, { error: "rate_limited" });
return;
}
const validRequest =
params.req.method === "GET" && !parsed?.search && isDevicePairingJoinCode(params.shortcode);
const payload = validRequest
? redeemDevicePairingJoinCode({ shortcode: params.shortcode })
: null;
if (!payload) {
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN);
sendJoinNotFound(params.res);
return;
}
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN);
sendJson(params.res, 200, payload);
},
});
return true;
}
@@ -0,0 +1,115 @@
// Real Gateway lifecycle proof for admin mint -> public single-use join exchange.
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { WebSocket } from "ws";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { decodePairingSetupCode } from "../pairing/setup-code.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import {
connectReq,
createGatewaySuiteHarness,
installGatewayTestHooks,
rpcReq,
testState,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
type JoinSetupResult = {
setupCode: string;
joinUrl: string;
};
let harness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>;
let adminSocket: WebSocket;
beforeAll(async () => {
testState.gatewayAuth = {
mode: "token",
token: "secret",
rateLimit: {
maxAttempts: 2,
windowMs: 60_000,
lockoutMs: 60_000,
},
};
harness = await createGatewaySuiteHarness();
adminSocket = await harness.openWs();
const connected = await connectReq(adminSocket, {
token: "secret",
scopes: ["operator.admin"],
});
if (!connected.ok) {
throw new Error(`admin test client failed to connect: ${JSON.stringify(connected.error)}`);
}
});
afterAll(async () => {
adminSocket?.close();
await harness?.close();
});
async function mintJoinUrl(contextPath = ""): Promise<JoinSetupResult> {
const response = await rpcReq<JoinSetupResult>(adminSocket, "device.pair.setupCode", {
bootstrapProfile: "node",
includeQr: false,
joinUrl: true,
publicUrl: `ws://127.0.0.1:${harness.port}${contextPath}`,
});
if (!response.ok || !response.payload?.setupCode || !response.payload.joinUrl) {
throw new Error(`join-code mint failed: ${JSON.stringify(response.error)}`);
}
return response.payload;
}
function shortcodeFromUrl(joinUrl: string): string {
return new URL(joinUrl).pathname.split("/").at(-1) ?? "";
}
async function readJson(response: Response): Promise<unknown> {
return JSON.parse(await response.text()) as unknown;
}
describe("Gateway device join route", () => {
it("burns once, expires opaquely, and rate-limits misses on the real HTTP server", async () => {
const expired = await mintJoinUrl();
const expiredShortcode = shortcodeFromUrl(expired.joinUrl);
runOpenClawStateWriteTransaction(({ db }) => {
executeSqliteQuerySync(
db,
getNodeSqliteKysely<Pick<OpenClawStateKyselyDatabase, "device_pairing_join_codes">>(db)
.updateTable("device_pairing_join_codes")
.set({ expires_at_ms: 0 })
.where("shortcode", "=", expiredShortcode),
);
});
const expiredResponse = await fetch(expired.joinUrl);
expect(expiredResponse.status).toBe(404);
const opaqueNotFound = await readJson(expiredResponse);
expect(opaqueNotFound).toEqual({ error: "not_found" });
const live = await mintJoinUrl("/public-gateway");
const shortcode = shortcodeFromUrl(live.joinUrl);
expect(Buffer.from(shortcode, "base64url").byteLength).toBeGreaterThanOrEqual(16);
const first = await fetch(live.joinUrl);
expect(first.status).toBe(200);
expect(first.headers.get("content-type")).toContain("application/json");
expect(first.headers.get("cache-control")).toBe("no-store");
expect(await readJson(first)).toEqual(decodePairingSetupCode(live.setupCode));
const used = await fetch(live.joinUrl);
expect(used.status).toBe(404);
expect(await readJson(used)).toEqual(opaqueNotFound);
const unknownUrl = `http://127.0.0.1:${harness.port}/j/${"z".repeat(22)}`;
const unknown = await fetch(unknownUrl);
expect(unknown.status).toBe(404);
expect(await readJson(unknown)).toEqual(opaqueNotFound);
const limited = await fetch(unknownUrl);
expect(limited.status).toBe(429);
expect(await readJson(limited)).toEqual({ error: "rate_limited" });
});
});
+20
View File
@@ -18,6 +18,7 @@ import {
createDiagnosticTraceContext,
runWithDiagnosticTraceContext,
} from "../infra/diagnostic-trace-context.js";
import { parseDevicePairingJoinRequestPath } from "../pairing/join-code.js";
import {
getGatewaySuspendAdmissionPhase,
isGatewayRestartDraining,
@@ -131,6 +132,9 @@ const getSessionHistoryHttpModule = createLazyRuntimeModule(
const getSessionKillHttpModule = createLazyRuntimeModule(() => import("./session-kill-http.js"));
const getToolsInvokeHttpModule = createLazyRuntimeModule(() => import("./tools-invoke-http.js"));
const getUserProfilesHttpModule = createLazyRuntimeModule(() => import("./user-profiles-http.js"));
const getDevicePairingJoinHttpModule = createLazyRuntimeModule(
() => import("./device-pairing-join-http.js"),
);
const getPluginNodeCapabilityAuthModule = createLazyRuntimeModule(
() => import("./server/plugin-node-capability-auth.js"),
);
@@ -398,6 +402,8 @@ export function createGatewayHttpServer(opts: {
getResolvedAuth?: () => ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Strict limiter for the public join-code exchange, including loopback. */
joinRateLimiter?: AuthRateLimiter;
getReadiness?: ReadinessChecker;
getStartup?: StartupChecker;
getRuntimeConfig?: () => OpenClawConfig;
@@ -421,6 +427,7 @@ export function createGatewayHttpServer(opts: {
resolvePluginNodeCapabilityRoute,
resolvedAuth,
rateLimiter,
joinRateLimiter,
getReadiness,
getStartup,
} = opts;
@@ -550,6 +557,19 @@ export function createGatewayHttpServer(opts: {
run: GatewayHttpRequestStage["run"],
) => addRequestStage(name, enabled, run, true);
const devicePairingJoinShortcode = parseDevicePairingJoinRequestPath(scopedRequestPath);
if (devicePairingJoinShortcode !== null) {
addAdmittedStage("device-pairing-join", true, async () =>
(await getDevicePairingJoinHttpModule()).handleDevicePairingJoinHttpRequest({
req,
res,
shortcode: devicePairingJoinShortcode,
clientIp: resolveRequestClientIp(req, trustedProxies, allowRealIpFallback),
rateLimiter: joinRateLimiter,
}),
);
}
// Before hooks: an operator hooks.path of "/oauth" would otherwise claim
// this exact GET and 405 every provider redirect. The claim is exact-path
// and config-gated, so preceding hooks cannot shadow any hook route.
@@ -4,7 +4,8 @@
*/
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as devicePairingJoinCode from "../../infra/device-pairing-join-code.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const mocks = vi.hoisted(() => ({
@@ -71,6 +72,10 @@ describe("device.pair.setupCode", () => {
mocks.runCommandWithTimeout.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns the setup code, QR data URL, and only an auth label", async () => {
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
@@ -235,6 +240,46 @@ describe("device.pair.setupCode", () => {
);
});
it("mints from a secure fallback and preserves its public context path", async () => {
const resolution = {
...okResolution,
payload: {
url: "ws://192.168.1.20:18789/openclaw-gw",
urls: [
"ws://192.168.1.20:18789/openclaw-gw",
"wss://gateway.tailnet.example/public-gateway",
],
bootstrapToken: "boot-123",
expiresAtMs: 123_456,
},
};
mocks.resolvePairingSetupFromConfig.mockResolvedValue(resolution);
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
// Keep storage substitution test-local: this shard shares a non-isolated worker
// with the real mint/redeem test, where a leaked module mock creates unbacked codes.
const registerDevicePairingJoinCode = vi
.spyOn(devicePairingJoinCode, "registerDevicePairingJoinCode")
.mockReturnValue("a".repeat(22));
const { options, respond } = createOptions({ includeQr: false, joinUrl: true });
await expectDefined(
devicePairSetupHandlers["device.pair.setupCode"],
'devicePairSetupHandlers["device.pair.setupCode"] test invariant',
)(options);
expect(mocks.resolvePairingSetupFromConfig).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ bootstrapProfile: { roles: ["node"], scopes: [] } }),
);
expect(registerDevicePairingJoinCode).toHaveBeenCalledWith({
payload: resolution.payload,
expiresAtMs: resolution.expiresAtMs,
});
expect(respond.mock.calls[0]?.[1]).toMatchObject({
joinUrl: `https://gateway.tailnet.example/public-gateway/j/${"a".repeat(22)}`,
});
});
it("requests the limited mobile bootstrap profile when selected", async () => {
mocks.resolvePairingSetupFromConfig.mockResolvedValue(okResolution);
mocks.encodePairingSetupCode.mockReturnValue("SETUP-CODE-XYZ");
@@ -8,13 +8,19 @@ import {
validateDevicePairSetupCodeParams,
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { registerDevicePairingJoinCode } from "../../infra/device-pairing-join-code.js";
import { renderQrPngDataUrl } from "../../media/qr-image.js";
import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "../../pairing/setup-code.js";
import {
decodePairingSetupCode,
encodePairingSetupCode,
resolvePairingSetupFromConfig,
} from "../../pairing/setup-code.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import {
NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
} from "../../shared/device-bootstrap-profile.js";
import { isLoopbackHost } from "../net.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -24,12 +30,30 @@ import { assertValidParams } from "./validation.js";
// that case we omit the QR (the client can still render one from setupCode)
// rather than return a response that violates the protocol schema.
const MAX_QR_DATA_URL_LENGTH = 16_384;
type PairingSetupPayload = ReturnType<typeof decodePairingSetupCode>;
function readConfiguredDevicePairPublicUrl(config: OpenClawConfig): string | undefined {
const value = config.plugins?.entries?.["device-pair"]?.config?.["publicUrl"];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolveDevicePairingJoinBaseUrl(payload: PairingSetupPayload): URL {
for (const candidate of payload.urls ?? [payload.url]) {
const parsed = new URL(candidate);
if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
return parsed;
}
if (parsed.protocol === "ws:" && isLoopbackHost(parsed.hostname)) {
parsed.protocol = "http:";
return parsed;
}
}
throw new Error(
"Join URLs require a TLS gateway endpoint, except for loopback. Use the setup code directly for plaintext LAN pairing.",
);
}
/** Gateway handler for producing a device-pairing setup code + connect QR. */
export const devicePairSetupHandlers: GatewayRequestHandlers = {
"device.pair.setupCode": async ({ params, respond, context }) => {
@@ -44,6 +68,18 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
return;
}
try {
if (
params.joinUrl === true &&
params.bootstrapProfile !== undefined &&
params.bootstrapProfile !== "node"
) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "Join URLs require bootstrapProfile=node."),
);
return;
}
const config = context.getRuntimeConfig();
const requestPublicUrl = typeof params.publicUrl === "string" ? params.publicUrl : undefined;
const configuredPublicUrl =
@@ -54,10 +90,10 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
publicUrl,
preferRemoteUrl: params.preferRemoteUrl === true,
localTlsFingerprint: context.gatewayTlsFingerprint,
...(params.bootstrapProfile
...(params.joinUrl === true || params.bootstrapProfile
? {
bootstrapProfile:
params.bootstrapProfile === "node"
params.joinUrl === true || params.bootstrapProfile === "node"
? NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE
: PAIRING_SETUP_BOOTSTRAP_PROFILE,
}
@@ -71,6 +107,19 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
return;
}
const setupCode = encodePairingSetupCode(resolved.payload);
let joinUrl: string | undefined;
if (params.joinUrl === true) {
const parsedJoinUrl = resolveDevicePairingJoinBaseUrl(resolved.payload);
const shortcode = registerDevicePairingJoinCode({
payload: resolved.payload,
expiresAtMs: resolved.expiresAtMs,
});
const basePath = parsedJoinUrl.pathname.replace(/\/+$/u, "");
parsedJoinUrl.pathname = `${basePath}/j/${shortcode}`;
parsedJoinUrl.search = "";
parsedJoinUrl.hash = "";
joinUrl = parsedJoinUrl.toString();
}
// QR is on by default; callers that only need the code can opt out.
const includeQr = params.includeQr !== false;
// QR rendering is optional output; keep the usable setup code if encoding fails.
@@ -83,6 +132,7 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = {
true,
{
setupCode,
...(joinUrl ? { joinUrl } : {}),
...(qrDataUrl ? { qrDataUrl } : {}),
gatewayUrl: resolved.payload.url,
...(resolved.payload.urls ? { gatewayUrls: resolved.payload.urls } : {}),
@@ -401,6 +401,7 @@ export async function prepareGatewayKernelState(params: {
strictTransportSecurityHeader,
resolvedAuth,
rateLimiter: authRateLimiter,
joinRateLimiter: browserAuthRateLimiter,
isTerminalEnabled: terminalLaunchPolicy.isEnabled,
gatewayTls,
getResolvedAuth,
+2
View File
@@ -102,6 +102,7 @@ export async function createGatewayHttpTransport(params: {
getResolvedAuth: () => ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
joinRateLimiter?: AuthRateLimiter;
gatewayTls?: GatewayTlsRuntime;
hooksConfig: () => HooksConfigResolved | null;
getHookClientIpConfig: () => HookClientIpConfig;
@@ -282,6 +283,7 @@ export async function createGatewayHttpTransport(params: {
resolvedAuth: params.resolvedAuth,
getResolvedAuth: params.getResolvedAuth,
rateLimiter: params.rateLimiter,
joinRateLimiter: params.joinRateLimiter,
getReadiness: params.getReadiness,
getStartup: params.getStartup,
getRuntimeConfig: loadRuntimeConfig,
+108
View File
@@ -0,0 +1,108 @@
// Stores short-lived device onboarding join codes in shared SQLite state.
import type { DatabaseSync } from "node:sqlite";
import { DEVICE_PAIRING_JOIN_CODE_BYTES, isDevicePairingJoinCode } from "../pairing/join-code.js";
import { decodePairingSetupCode, encodePairingSetupCode } from "../pairing/setup-code.js";
import { ensureDevicePairingJoinCodeSchema } from "../state/openclaw-state-db-schema-additive.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
import { generateSecureToken } from "./secure-random.js";
type DevicePairingJoinCodeDatabase = Pick<OpenClawStateKyselyDatabase, "device_pairing_join_codes">;
type PairingSetupPayload = ReturnType<typeof decodePairingSetupCode>;
const initializedDatabases = new WeakSet<DatabaseSync>();
function ensureJoinCodeSchema(database: DatabaseSync): void {
if (initializedDatabases.has(database)) {
return;
}
ensureDevicePairingJoinCodeSchema(database);
initializedDatabases.add(database);
}
function validatePairingSetupPayload(payload: PairingSetupPayload): PairingSetupPayload {
return decodePairingSetupCode(encodePairingSetupCode(payload));
}
/** Register one setup payload under a random 128-bit shortcode. */
export function registerDevicePairingJoinCode(params: {
payload: PairingSetupPayload;
expiresAtMs: number;
database?: OpenClawStateDatabaseOptions;
}): string {
const createdAtMs = Date.now();
if (!Number.isSafeInteger(params.expiresAtMs) || params.expiresAtMs <= createdAtMs) {
throw new Error("Device pairing join code requires a future expiry.");
}
const payloadJson = JSON.stringify(validatePairingSetupPayload(params.payload));
const shortcode = generateSecureToken(DEVICE_PAIRING_JOIN_CODE_BYTES);
runOpenClawStateWriteTransaction(({ db }) => {
ensureJoinCodeSchema(db);
const kysely = getNodeSqliteKysely<DevicePairingJoinCodeDatabase>(db);
executeSqliteQuerySync(
db,
kysely.deleteFrom("device_pairing_join_codes").where("expires_at_ms", "<=", createdAtMs),
);
executeSqliteQuerySync(
db,
kysely.insertInto("device_pairing_join_codes").values({
shortcode,
payload_json: payloadJson,
created_at_ms: createdAtMs,
expires_at_ms: params.expiresAtMs,
}),
);
}, params.database);
return shortcode;
}
/** Atomically burn one live shortcode and return its validated setup payload. */
export function redeemDevicePairingJoinCode(params: {
shortcode: string;
database?: OpenClawStateDatabaseOptions;
}): PairingSetupPayload | null {
const shortcode = params.shortcode.trim();
if (!isDevicePairingJoinCode(shortcode)) {
return null;
}
const nowMs = Date.now();
const payloadJson = runOpenClawStateWriteTransaction(({ db }) => {
ensureJoinCodeSchema(db);
const kysely = getNodeSqliteKysely<DevicePairingJoinCodeDatabase>(db);
executeSqliteQuerySync(
db,
kysely.deleteFrom("device_pairing_join_codes").where("expires_at_ms", "<=", nowMs),
);
const row = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("device_pairing_join_codes")
.select("payload_json")
.where("shortcode", "=", shortcode),
);
executeSqliteQuerySync(
db,
kysely.deleteFrom("device_pairing_join_codes").where("shortcode", "=", shortcode),
);
return row?.payload_json;
}, params.database);
if (typeof payloadJson !== "string") {
return null;
}
try {
return decodePairingSetupCode(Buffer.from(payloadJson, "utf8").toString("base64url"), {
nowMs,
});
} catch {
return null;
}
}
+32
View File
@@ -333,6 +333,38 @@ describe("runNodeHost", () => {
}
});
it("stops the canonical runtime after a service enrollment hello", async () => {
mocks.useFakeRuntime = true;
mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({
ready: true,
aborted: false,
elapsedMs: 0,
});
const previousExitCode = process.exitCode;
try {
const running = runNodeHost({
gatewayHost: "gateway.example",
gatewayPort: 443,
gatewayTls: true,
gatewayBootstrapToken: "bootstrap-token",
preferGatewayBootstrapToken: true,
stopAfterFirstConnect: true,
});
await vi.waitFor(() => expect(lastCapturedOptions()?.onHelloOk).toBeTypeOf("function"));
lastCapturedOptions()?.onHelloOk?.({
protocol: 1,
features: { methods: [], events: [] },
} as unknown as Parameters<NonNullable<GatewayClientOptions["onHelloOk"]>>[0]);
await running;
expect(mocks.capturedGatewayClients[0]?.stop).toHaveBeenCalledOnce();
expect(mocks.activeRuntime.close).toHaveBeenCalledOnce();
expect(mocks.capturedGatewayClients[0]?.request).not.toHaveBeenCalled();
} finally {
process.exitCode = previousExitCode;
}
});
it("routes invoke input, cancellation, and connection close to the runtime", async () => {
mocks.useFakeRuntime = true;
await expect(runNodeHost({ gatewayHost: "127.0.0.1", gatewayPort: 18789 })).rejects.toThrow(
+6
View File
@@ -30,6 +30,8 @@ type NodeHostRunOptions = {
gatewayCandidates?: NodeHostGatewayConfig[];
gatewayBootstrapToken?: string;
preferGatewayBootstrapToken?: boolean;
/** Stop cleanly after the first authenticated hello (used before service install). */
stopAfterFirstConnect?: boolean;
/** Optional WebSocket context path (e.g. "/openclaw-gw"). */
gatewayContextPath?: string;
nodeId?: string;
@@ -509,6 +511,10 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
connectedGatewayProtocol = hello.protocol;
retireOptionalPublications();
optionalPublicationStates = new Map();
if (opts.stopAfterFirstConnect) {
void finish(0);
return;
}
publishInventory();
},
onConnectError: (error) => {
+22
View File
@@ -0,0 +1,22 @@
// Shared shape for the public device-pairing join shortcode.
export const DEVICE_PAIRING_JOIN_CODE_BYTES = 16;
const DEVICE_PAIRING_JOIN_CODE_RE = /^[A-Za-z0-9_-]{22}$/u;
export function isDevicePairingJoinCode(value: string): boolean {
return DEVICE_PAIRING_JOIN_CODE_RE.test(value);
}
export function parseDevicePairingJoinRequestPath(pathname: string): string | null {
// Public endpoints may include an advertised context path. The final /j namespace
// is the stable route contract; preserving only root /j would mint unusable URLs.
const markerIndex = pathname.lastIndexOf("/j");
if (markerIndex < 0) {
return null;
}
const routePath = pathname.slice(markerIndex);
if (routePath === "/j") {
return "";
}
return routePath.startsWith("/j/") ? routePath.slice(3) : null;
}
+1
View File
@@ -28,6 +28,7 @@ export const LAZY_ADDITIVE_STATE_TABLES = [
"projects",
"user_preferences",
"gateway_origin_device_tokens",
"device_pairing_join_codes",
"sidebar_sections",
"skill_workshop_proposal_events",
"skill_workshop_proposal_origin_runs",
@@ -24,6 +24,9 @@ const SECRET_STORE_SCHEMA_END =
const MCP_OAUTH_PENDING_SCHEMA_START =
"CREATE TABLE IF NOT EXISTS mcp_oauth_pending_authorizations (";
const MCP_OAUTH_PENDING_SCHEMA_END = "\n) STRICT;";
const DEVICE_PAIRING_JOIN_CODE_SCHEMA_START =
"CREATE TABLE IF NOT EXISTS device_pairing_join_codes (";
const DEVICE_PAIRING_JOIN_CODE_SCHEMA_END = "\n) STRICT;";
function secretStoreSchemaSql(): string {
const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SECRET_STORE_SCHEMA_START);
@@ -52,6 +55,24 @@ export function ensureMcpOAuthPendingSchema(database: DatabaseSync): void {
); // sqlite-allow-raw -- Canonical additive DDL only.
}
/** Lazily install the additive device join-code table on first mint or redemption. */
export function ensureDevicePairingJoinCodeSchema(database: DatabaseSync): void {
const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(DEVICE_PAIRING_JOIN_CODE_SCHEMA_START);
const endMarkerStart = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
DEVICE_PAIRING_JOIN_CODE_SCHEMA_END,
start,
);
if (start < 0 || endMarkerStart < start) {
throw new Error("OpenClaw device pairing join-code schema marker is missing.");
}
database.exec(
OPENCLAW_STATE_SCHEMA_SQL.slice(
start,
endMarkerStart + DEVICE_PAIRING_JOIN_CODE_SCHEMA_END.length,
),
); // sqlite-allow-raw -- Canonical additive DDL only.
}
export function ensureAgentDeletionJournalSchema(database: DatabaseSync): void {
database.exec(`
CREATE TABLE IF NOT EXISTS agent_deletion_journal (
+8
View File
@@ -581,6 +581,13 @@ export interface DeviceIdentities {
updated_at_ms: number;
}
export interface DevicePairingJoinCodes {
created_at_ms: number | null;
expires_at_ms: number | null;
payload_json: string | null;
shortcode: string | null;
}
export interface DevicePairingPaired {
approved_at_ms: number;
approved_scopes_json: string | null;
@@ -1714,6 +1721,7 @@ export interface DB {
device_auth_tokens: DeviceAuthTokens;
device_bootstrap_tokens: DeviceBootstrapTokens;
device_identities: DeviceIdentities;
device_pairing_join_codes: DevicePairingJoinCodes;
device_pairing_paired: DevicePairingPaired;
device_pairing_pending: DevicePairingPending;
diagnostic_events: DiagnosticEvents;
+7
View File
@@ -577,6 +577,13 @@ CREATE TABLE IF NOT EXISTS device_bootstrap_tokens (
CREATE INDEX IF NOT EXISTS idx_device_bootstrap_tokens_ts
ON device_bootstrap_tokens(ts);
CREATE TABLE IF NOT EXISTS device_pairing_join_codes (
shortcode TEXT,
payload_json TEXT,
created_at_ms INTEGER,
expires_at_ms INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS device_identities (
identity_key TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,