mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
fb9a62e995
commit
ade3456dd4
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
{"contentHash":"402dc7fb02d64dc390510abbf1eb240ccb807b1bf3e26ddaa582c9393bdfb57a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"f808b2dea87ef2f60c3d3867aabcade645ce918b4c273d1dcdb452e4b110aa0f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
|
||||
@@ -1742,5 +1742,9 @@
|
||||
{
|
||||
"source": "Updating OpenClaw",
|
||||
"target": "更新 OpenClaw"
|
||||
},
|
||||
{
|
||||
"source": "Connect a machine",
|
||||
"target": "连接机器"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -1857,6 +1857,7 @@
|
||||
"cli/browser",
|
||||
"cli/cron",
|
||||
"cli/flows",
|
||||
"cli/connect",
|
||||
"cli/node",
|
||||
"cli/nodes",
|
||||
"cli/sandbox",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user