diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index d9db1417b903..b6843d492451 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index d99ac38ab48e..8e2a460b7f50 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 1833f5059110..5e89dd788334 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"517a4d6efc324c135d1761b9d8e77ed573162bcc8b662372cebdfd7c5e3083c1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"6f8716be0843d82556326c47da8e9610aa9d4c45f7be35771f8f062025b1954b","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 203834274d5b..a38de2b73e8d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"007c23cfcc4af2cc62967750beabdb9825352b54afc3057c989c7e9179790ad0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"895ac29e8056362777291f51a27d021813de8f3a4acce30e57b1b82843cd27bc","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index bf8bc40c34e2..838300b9bde5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 9b88c0385485..c3cab6171e07 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"aaadbf677c001d81a784acbe65ce9acb4d76794be31fecff9d2c9b8d984ec37c","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"84ecd36a64b3aec85589103ef5e3e332ea0770651c0cc344090c6a3c609b6854","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 114a6844663d..1c66f9b4b8c1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"8bafa462130e5c1da30329c7face6a483957d4a0c3cc689b1632f1974420b69f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"c85d86fb7e98c9606a9ea4f44e99d97cf4e60708288b539c635ba2f6fc7d0f1a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 18108cd706e1..2dfd78f39d1b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"9e02fe7d5c9f80328c7f557420ee84687954f753dfd84b19e1469eb94b8dd168","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"abfb0bc418ade50fbceec696dbc19198d10e5596965e264d1202924f0f7d5761","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index dd26b93016a8..876e27b4f72e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 5e312e48516c..c278118d6d53 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"402dc7fb02d64dc390510abbf1eb240ccb807b1bf3e26ddaa582c9393bdfb57a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"f808b2dea87ef2f60c3d3867aabcade645ce918b4c273d1dcdb452e4b110aa0f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 05ecf007aa82..b4cdcf12a908 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"4b0344b65821cc88ea69e7d6e8ca37ddfd21d258035e6ba7d613748df743c761","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"29d5e02c1f225835cca47a07f807f317d77782e05bdce88b0d4a58d04d85b81b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 754a94714b19..9ce176ac1bcc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"ffbc6c5c809ed0ceb135ea42926e321edf4265eb96c80e0f9df536d7eb472811","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"8cf1f6a307496e0c44bc8c413d710bf069681fa506d5af3508f7ba437065c6e2","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 39d224aff695..5387a8558f87 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 6dcd8cb9bea4..ae96425e3ebe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"458825733f53d1c06d468287d7059d2400202572082a8300d7806c71856498c7","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"6c15ce9bac50287422269210a848b33a6cca4684b8f7801be93fbe2f04e0d45e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 71d11bee323e..c74d52df5245 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"0cbd356b5509eb2bd0f744987a48e34726525e8932fe5a6bb9c7eb5884b55329","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"66b685d9302b1e6bb5e348c0b164944e7f92087d480ca36ced0855316f6d9385","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index aa8b3e7e588c..d51603a969da 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"f662a31a8c5db27d767489160e9d443df45190429e8f4fb9f6e184d12bac3f19","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"2120a39bb107f406046340baa16e316e47341451d21f76d28f666d6b27fc4673","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 2c41811f6d01..7821ff91dfce 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index fc44f2286ac0..f30fd3b54b33 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"4962f66547dd18b573f4c3ee7ad08f35f220417559437ed2653ef1e3f07fb4be","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"af55168f90db0f1d2d55a38b116a8612710b90874418fc238827619b87a89691","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 6f05bba24054..67edf2b3a758 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"326217df400252a236ce5f5e1f98adfcbbec79d3a5d068cf44fc3b7a70e0debe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"eaf35330803af512853cf00050e5d6fe5e1e86f9997134e36146ea3cf6ba4595","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index ea66385f2eb5..fdb0b5e8a03b 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1742,5 +1742,9 @@ { "source": "Updating OpenClaw", "target": "更新 OpenClaw" + }, + { + "source": "Connect a machine", + "target": "连接机器" } ] diff --git a/docs/cli/connect.md b/docs/cli/connect.md new file mode 100644 index 000000000000..97d83e137dd5 --- /dev/null +++ b/docs/cli/connect.md @@ -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/ +``` + +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/ +``` + +Set the device name during enrollment when useful: + +```bash +npx openclaw connect https://gateway.example/j/ --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/ --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 ` accepts: + +- an `https:///j/` join URL; +- an `oc-pair://` 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/`. 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 `](/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. diff --git a/docs/cli/node.md b/docs/cli/node.md index e4b4980be671..a08ab1675eb3 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -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 --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) diff --git a/docs/docs.json b/docs/docs.json index cb8db8681770..3eb8a23d2175 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1857,6 +1857,7 @@ "cli/browser", "cli/cron", "cli/flows", + "cli/connect", "cli/node", "cli/nodes", "cli/sandbox", diff --git a/docs/plan/runners.md b/docs/plan/runners.md index b76e8e8aaf79..eef141375295 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -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 | — | diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index f0e876f6670d..4a080ef1eb1a 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -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( diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index cb24c5ab42fe..9074db0a0b62 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -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, diff --git a/src/cli/connect-cli.test.ts b/src/cli/connect-cli.test.ts new file mode 100644 index 000000000000..7932844e611c --- /dev/null +++ b/src/cli/connect-cli.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/cli/connect-cli.ts b/src/cli/connect-cli.ts new file mode 100644 index 000000000000..e3efe34cda4c --- /dev/null +++ b/src/cli/connect-cli.ts @@ -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; + +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/ 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 { + const expectedHost = normalizeHostname(target.hostname); + let release: () => Promise = 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 { + const joinTarget = parseJoinTarget(target); + return joinTarget ? await fetchJoinPayload(joinTarget) : decodePairingSetupCode(target); +} + +async function runConnectCommand(target: string, opts: ConnectCommandOptions): Promise { + 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("", "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 ", "Override the node display name") + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + ["openclaw connect oc-pair://", "Connect in the foreground."], + [ + "openclaw connect https://gateway.example/j/ --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); + } + }); +} diff --git a/src/cli/devices-cli.lazy.test.ts b/src/cli/devices-cli.lazy.test.ts index 3da61694f7a8..72373397f07c 100644 --- a/src/cli/devices-cli.lazy.test.ts +++ b/src/cli/devices-cli.lazy.test.ts @@ -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(), diff --git a/src/cli/devices-cli.runtime.ts b/src/cli/devices-cli.runtime.ts index 3363af9e2b48..ae9c78d60786 100644 --- a/src/cli/devices-cli.runtime.ts +++ b/src/cli/devices-cli.runtime.ts @@ -942,6 +942,30 @@ export async function runDevicesListCommand(opts: DevicesRpcOpts): Promise } } +export async function runDevicesJoinCodeCommand(opts: DevicesRpcOpts): Promise { + 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, diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index d1520a62a2f0..369ca83c7b27 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -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(() => {}); diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index e66002760aa3..429db891ba46 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -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") diff --git a/src/cli/node-cli/gateway-options.ts b/src/cli/node-cli/gateway-options.ts index e7d48040d4f8..290e6f17dccd 100644 --- a/src/cli/node-cli/gateway-options.ts +++ b/src/cli/node-cli/gateway-options.ts @@ -21,6 +21,8 @@ type NodePairGatewayOptions = { candidates: NodeHostGatewayConfig[]; }; +type PairingSetupPayload = ReturnType; + 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), ); diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index 34b816125c9c..a0071db9199d 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -161,6 +161,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ loadModule: () => import("../node-cli.js"), exportName: "registerNodeCli", }, + { + commandNames: ["connect"], + loadModule: () => import("../connect-cli.js"), + exportName: "registerConnectCli", + }, { commandNames: ["worker"], loadModule: () => import("../worker-cli.js"), diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index cbe707e8750e..76fa11da6092 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -128,6 +128,7 @@ const JSON_NOT_APPLICABLE = { "mcp serve", "node worker", "node run", + "connect", "worker", "fleet logs", "proxy start", diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index 8167fb4c29f5..f1d8a181cb51 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -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", diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts index 889978dab918..f3609d24753f 100644 --- a/src/gateway/auth-rate-limit.ts +++ b/src/gateway/auth-rate-limit.ts @@ -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"; diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index 22633919fca4..40f2d72ee64b 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -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", diff --git a/src/gateway/control-ui-routing.ts b/src/gateway/control-ui-routing.ts index 72cc7d96c59a..d52d145a89ee 100644 --- a/src/gateway/control-ui-routing.ts +++ b/src/gateway/control-ui-routing.ts @@ -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" }; diff --git a/src/gateway/device-pairing-join-http.ts b/src/gateway/device-pairing-join-http.ts new file mode 100644 index 000000000000..ec4d28731d4c --- /dev/null +++ b/src/gateway/device-pairing-join-http.ts @@ -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 { + 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; +} diff --git a/src/gateway/server-http.device-pairing-join.test.ts b/src/gateway/server-http.device-pairing-join.test.ts new file mode 100644 index 000000000000..ec297a315981 --- /dev/null +++ b/src/gateway/server-http.device-pairing-join.test.ts @@ -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>; +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 { + const response = await rpcReq(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 { + 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>(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" }); + }); +}); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 8c4e47e6529f..14773fa5202b 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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. diff --git a/src/gateway/server-methods/device-pair-setup.test.ts b/src/gateway/server-methods/device-pair-setup.test.ts index c3a65464f6a1..1278cb18f064 100644 --- a/src/gateway/server-methods/device-pair-setup.test.ts +++ b/src/gateway/server-methods/device-pair-setup.test.ts @@ -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"); diff --git a/src/gateway/server-methods/device-pair-setup.ts b/src/gateway/server-methods/device-pair-setup.ts index 477ac1ad527e..f7744d0127d3 100644 --- a/src/gateway/server-methods/device-pair-setup.ts +++ b/src/gateway/server-methods/device-pair-setup.ts @@ -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; 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 } : {}), diff --git a/src/gateway/server-runtime-state-prepare.ts b/src/gateway/server-runtime-state-prepare.ts index cd07dfb04381..1debfa0bdd00 100644 --- a/src/gateway/server-runtime-state-prepare.ts +++ b/src/gateway/server-runtime-state-prepare.ts @@ -401,6 +401,7 @@ export async function prepareGatewayKernelState(params: { strictTransportSecurityHeader, resolvedAuth, rateLimiter: authRateLimiter, + joinRateLimiter: browserAuthRateLimiter, isTerminalEnabled: terminalLaunchPolicy.isEnabled, gatewayTls, getResolvedAuth, diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 8cb2865c29b2..861afbf4a8e9 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -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, diff --git a/src/infra/device-pairing-join-code.ts b/src/infra/device-pairing-join-code.ts new file mode 100644 index 000000000000..cbda797cf294 --- /dev/null +++ b/src/infra/device-pairing-join-code.ts @@ -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; +type PairingSetupPayload = ReturnType; + +const initializedDatabases = new WeakSet(); + +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(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(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; + } +} diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index e796cdaf826f..9f43597206fe 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -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>[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( diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 13b17c34c1a1..e538f2041134 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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 { connectedGatewayProtocol = hello.protocol; retireOptionalPublications(); optionalPublicationStates = new Map(); + if (opts.stopAfterFirstConnect) { + void finish(0); + return; + } publishInventory(); }, onConnectError: (error) => { diff --git a/src/pairing/join-code.ts b/src/pairing/join-code.ts new file mode 100644 index 000000000000..26427b3868c3 --- /dev/null +++ b/src/pairing/join-code.ts @@ -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; +} diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index 7e0fa469d7d4..6f3f1dc78f3a 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -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", diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index c3eb963770c8..9b67fb257ead 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -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 ( diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 215ff9056e93..a106fa15843c 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -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; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index e794cd172d79..9a5cd1274b51 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -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,