feat(pairing): one-paste device pairing via oc-pair setup links (#120768)

* feat(pairing): one-paste device pairing via oc-pair setup links

Implements milestone 3 from docs/plan/runners.md.

* fix(pairing): sign bootstrap handshake, keep URL candidates, wire pairing countdown

* test(gateway): update client callsite guard

* fix(pairing): preserve setup URL context paths

* fix(ui): keep pairing help aligned with setup mode

* fix(pairing): isolate bootstrap credentials

* perf(ui): keep one-paste pairing within bundle budget

* refactor(pairing): isolate native pair URL prefix parsing

* fix(pairing): preserve candidate lifecycle state

* fix(pairing): retire shared credentials after bootstrap

* fix(pairing): apply rotated manifest through client owner

* test(pairing): prove bootstrap retirement across reconnect

* fix(pairing): preserve native gateway context paths

* fix(pairing): carry native context paths through reconnect

* fix(ios): preserve encoded gateway context path

* chore(plugin-sdk): refresh pairing API baselines
This commit is contained in:
Peter Steinberger
2026-08-12 02:01:33 -07:00
committed by GitHub
parent 3b01ea7905
commit d44f70eb4b
92 changed files with 2432 additions and 545 deletions
@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayClientOptions } from "../gateway/client.js";
import { createNodeHostGatewayCandidateConnection } from "./gateway-candidate-connection.js";
const mocks = vi.hoisted(() => ({
options: [] as GatewayClientOptions[],
clients: [] as Array<{
request: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
updateNodeManifest: ReturnType<typeof vi.fn>;
}>,
}));
vi.mock("../gateway/client.js", () => ({
GatewayClient: function GatewayClient(options: GatewayClientOptions) {
const client = {
request: vi.fn(async () => ({ url: options.url })),
start: vi.fn(),
stop: vi.fn(),
updateNodeManifest: vi.fn(),
};
mocks.options.push(options);
mocks.clients.push(client);
return client;
},
}));
const candidates = [
{ host: "192.168.1.20", port: 18789, contextPath: "/openclaw-gw", tls: false },
{ host: "gateway.tailnet.example", port: 443, tls: true },
];
function createConnection() {
const callbacks = {
onEvent: vi.fn(),
onHelloOk: vi.fn(),
onConnectError: vi.fn(),
onReconnectPaused: vi.fn(),
onClose: vi.fn(),
onWinningCandidate: vi.fn(),
};
return {
callbacks,
connection: createNodeHostGatewayCandidateConnection({
candidates,
clientOptions: {},
...callbacks,
}),
};
}
describe("gateway candidate connection", () => {
beforeEach(() => {
mocks.options.length = 0;
mocks.clients.length = 0;
vi.clearAllMocks();
});
it("rotates only before hello, fences stale callbacks, and forwards through the winner", async () => {
const { callbacks, connection } = createConnection();
connection.start();
expect(mocks.options[0]?.url).toBe("ws://192.168.1.20:18789/openclaw-gw");
expect(mocks.clients[0]?.start).toHaveBeenCalledOnce();
mocks.options[0]?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await vi.waitFor(() => expect(mocks.clients).toHaveLength(2));
expect(mocks.clients[0]?.stop).toHaveBeenCalledOnce();
expect(mocks.options[1]?.url).toBe("wss://gateway.tailnet.example:443");
expect(mocks.clients[1]?.start).toHaveBeenCalledOnce();
mocks.options[0]?.onEvent?.({ type: "event", event: "stale" });
mocks.options[0]?.onHelloOk?.({} as never);
mocks.options[0]?.onClose?.(1006, "stale close", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
expect(callbacks.onEvent).not.toHaveBeenCalled();
expect(callbacks.onHelloOk).not.toHaveBeenCalled();
expect(callbacks.onWinningCandidate).not.toHaveBeenCalled();
expect(mocks.clients).toHaveLength(2);
const activeEvent = { type: "event", event: "active" } as const;
mocks.options[1]?.onEvent?.(activeEvent);
mocks.options[1]?.onHelloOk?.({} as never);
mocks.options[1]?.onHelloOk?.({} as never);
expect(callbacks.onEvent).toHaveBeenCalledWith(activeEvent);
expect(callbacks.onWinningCandidate).toHaveBeenCalledOnce();
expect(callbacks.onWinningCandidate).toHaveBeenCalledWith(candidates[1]);
await connection.request("node.test", { active: true }, undefined);
connection.updateNodeManifest({ caps: ["mcp"], commands: ["mcp.tools.call.v1"] });
expect(mocks.clients[0]?.request).not.toHaveBeenCalled();
expect(mocks.clients[1]?.request).toHaveBeenCalledWith(
"node.test",
{ active: true },
undefined,
);
expect(mocks.clients[1]?.updateNodeManifest).toHaveBeenCalledWith({
caps: ["mcp"],
commands: ["mcp.tools.call.v1"],
});
});
it("does not rotate after the connect request was sent", async () => {
createConnection();
mocks.options[0]?.onClose?.(1008, "connect failed", {
phase: "pre-hello",
socketOpened: true,
transportValidated: true,
connectRequestSent: true,
transientPreHelloCleanClose: false,
});
await Promise.resolve();
expect(mocks.clients).toHaveLength(1);
});
it("promotes a candidate after hello instead of replaying setup auth on another endpoint", async () => {
const { callbacks } = createConnection();
mocks.options[0]?.onHelloOk?.({} as never);
mocks.options[0]?.onClose?.(1006, "later reconnect transport failure", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await Promise.resolve();
expect(callbacks.onWinningCandidate).toHaveBeenCalledWith(candidates[0]);
expect(mocks.clients).toHaveLength(1);
});
it("carries a pre-hello manifest update into the next candidate", async () => {
const { connection } = createConnection();
const manifest = { caps: ["mcp"], commands: ["mcp.tools.call.v1"] };
connection.updateNodeManifest(manifest);
mocks.options[0]?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await vi.waitFor(() => expect(mocks.clients).toHaveLength(2));
expect(mocks.clients[1]?.updateNodeManifest).toHaveBeenCalledWith(manifest);
});
it("does not create the queued candidate after stop", async () => {
const { connection } = createConnection();
mocks.options[0]?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
connection.stop();
await Promise.resolve();
expect(mocks.clients).toHaveLength(1);
});
});
@@ -0,0 +1,152 @@
import {
GatewayClient,
type GatewayClientCloseInfo,
type GatewayClientOptions,
type GatewayClientRequestOptions,
type GatewayReconnectPausedInfo,
} from "../gateway/client.js";
import type { NodeHostGatewayConfig } from "./config.js";
type GatewayCandidateEvent = Parameters<NonNullable<GatewayClientOptions["onEvent"]>>[0];
type GatewayCandidateHello = Parameters<NonNullable<GatewayClientOptions["onHelloOk"]>>[0];
type CandidateConnectionOptions = Omit<
GatewayClientOptions,
| "url"
| "tlsFingerprint"
| "onEvent"
| "onHelloOk"
| "onConnectError"
| "onReconnectPaused"
| "onClose"
>;
type GatewayCandidateConnectionParams = {
candidates: readonly NodeHostGatewayConfig[];
clientOptions: CandidateConnectionOptions;
onEvent: (event: GatewayCandidateEvent) => void;
onHelloOk: (hello: GatewayCandidateHello, url: string) => void;
onConnectError: (error: Error) => void;
onReconnectPaused: (info: GatewayReconnectPausedInfo) => void;
onClose: (code: number, reason: string, info?: GatewayClientCloseInfo) => void;
onWinningCandidate: (candidate: NodeHostGatewayConfig) => void;
};
function formatGatewayCandidateUrl(gateway: NodeHostGatewayConfig): string {
const host = gateway.host ?? "127.0.0.1";
const urlHost =
host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
const port = gateway.port ?? 18789;
const scheme = gateway.tls ? "wss" : "ws";
const contextPath = gateway.contextPath
? gateway.contextPath.startsWith("/")
? gateway.contextPath
: `/${gateway.contextPath}`
: "";
return `${scheme}://${urlHost}:${port}${contextPath}`;
}
function canTryNextGatewayCandidate(info: GatewayClientCloseInfo | undefined): boolean {
return info?.phase === "pre-hello" && info.connectRequestSent === false;
}
export function createNodeHostGatewayCandidateConnection(params: GatewayCandidateConnectionParams) {
if (params.candidates.length === 0) {
throw new Error("node host gateway candidate list cannot be empty");
}
let currentCandidateIndex = 0;
let stopped = false;
let winnerSelected = params.candidates.length === 1;
let latestManifest: { caps: string[]; commands: string[] } | undefined;
let currentClient = createCandidateClient(currentCandidateIndex);
function createCandidateClient(candidateIndex: number): GatewayClient {
const candidate = params.candidates[candidateIndex];
if (!candidate) {
throw new Error(`node host gateway candidate ${candidateIndex} is unavailable`);
}
const url = formatGatewayCandidateUrl(candidate);
const candidateClient = new GatewayClient({
...params.clientOptions,
url,
tlsFingerprint: candidate.tlsFingerprint,
onEvent: (event) => {
if (currentCandidateIndex === candidateIndex) {
params.onEvent(event);
}
},
onHelloOk: (hello) => {
if (currentCandidateIndex !== candidateIndex) {
return;
}
if (!winnerSelected) {
winnerSelected = true;
params.onWinningCandidate(candidate);
}
params.onHelloOk(hello, url);
},
onConnectError: (error) => {
if (currentCandidateIndex === candidateIndex) {
params.onConnectError(error);
}
},
onReconnectPaused: (info) => {
if (currentCandidateIndex === candidateIndex) {
params.onReconnectPaused(info);
}
},
onClose: (code, reason, info) => {
if (currentCandidateIndex !== candidateIndex) {
return;
}
params.onClose(code, reason, info);
const nextCandidateIndex = candidateIndex + 1;
if (
stopped ||
// A successful hello redeems setup credentials and promotes this
// endpoint. Its own reconnect path owns durable device auth from here.
winnerSelected ||
nextCandidateIndex >= params.candidates.length ||
!canTryNextGatewayCandidate(info)
) {
return;
}
currentCandidateIndex = nextCandidateIndex;
candidateClient.stop();
queueMicrotask(() => {
if (stopped || currentCandidateIndex !== nextCandidateIndex) {
return;
}
currentClient = createCandidateClient(nextCandidateIndex);
currentClient.start();
});
},
});
if (latestManifest) {
candidateClient.updateNodeManifest(latestManifest);
}
return candidateClient;
}
return {
start(): void {
currentClient.start();
},
stop(): void {
stopped = true;
currentClient.stop();
},
request<T = Record<string, unknown>>(
...requestArgs: [method: string, params?: unknown, options?: GatewayClientRequestOptions]
): Promise<T> {
return currentClient.request<T>(...requestArgs);
},
updateNodeManifest(manifest: { caps: string[]; commands: string[] }): void {
// Availability may change before the first hello. Every later candidate
// must start with the newest manifest rather than the constructor snapshot.
latestManifest = manifest;
currentClient.updateNodeManifest(manifest);
},
};
}
+88 -1
View File
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
capturedConfiguredGatewayConfigs: [] as Array<{ contextPath?: string }>,
capturedGatewayClients: [] as Array<{
request: Mock<(method: string, params?: unknown) => Promise<unknown>>;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
updateNodeManifest: ReturnType<typeof vi.fn>;
}>,
@@ -30,6 +31,9 @@ const mocks = vi.hoisted(() => ({
availabilityChanged: undefined as (() => void) | undefined,
normalizedPath: null as string | null,
resolvedExecutables: new Map<string, string>(),
runtimeClient: undefined as
| { request: (method: string, params?: unknown) => Promise<unknown> }
| undefined,
closeMcpManager: vi.fn(async () => undefined),
runStartupMigrations: vi.fn(async () => undefined),
configureNodeHost: vi.fn(async (params: Parameters<typeof configureNodeHost>[0]) => {
@@ -76,6 +80,7 @@ vi.mock("../gateway/client.js", async (importOriginal) => {
GatewayClient: function GatewayClient(opts: GatewayClientOptions) {
const client = {
request: vi.fn(async () => ({})),
start: vi.fn(),
stop: vi.fn(),
updateNodeManifest: vi.fn(),
};
@@ -171,7 +176,10 @@ vi.mock("./runtime.js", async (importOriginal) => {
return {
manifest: { caps: [], commands: [], pathEnv: process.env.PATH ?? "" },
initialInventory: { skills: [], pluginTools: [] },
start: () => mocks.activeRuntime,
start: (params) => {
mocks.runtimeClient = params.client;
return mocks.activeRuntime;
},
};
},
};
@@ -207,6 +215,7 @@ describe("runNodeHost", () => {
mocks.availabilityChanged = undefined;
mocks.normalizedPath = null;
mocks.resolvedExecutables.clear();
mocks.runtimeClient = undefined;
vi.clearAllMocks();
mocks.getRuntimeConfig.mockReturnValue({
gateway: { handshakeTimeoutMs: 1_000 },
@@ -246,6 +255,84 @@ describe("runNodeHost", () => {
},
);
it("passes a paired bootstrap credential with first-connect preference", async () => {
await expect(
runNodeHost({
gatewayHost: "gateway.example",
gatewayPort: 443,
gatewayTls: true,
gatewayBootstrapToken: "bootstrap-123",
preferGatewayBootstrapToken: true,
}),
).rejects.toThrow("event loop readiness timeout");
expect(lastCapturedOptions()).toMatchObject({
bootstrapToken: "bootstrap-123",
preferBootstrapToken: true,
});
expect(lastCapturedOptions()?.token).toBeUndefined();
expect(mocks.resolveGatewayCredentialsWithSecretInputs).not.toHaveBeenCalled();
});
it("persists the pairing candidate that completes the handshake", async () => {
mocks.useFakeRuntime = true;
mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({
ready: true,
aborted: false,
elapsedMs: 0,
});
const processOnceSpy = vi.spyOn(process, "once");
const previousExitCode = process.exitCode;
try {
const running = runNodeHost({
gatewayHost: "192.168.1.20",
gatewayPort: 18789,
gatewayBootstrapToken: "bootstrap-123",
preferGatewayBootstrapToken: true,
gatewayCandidates: [
{ host: "192.168.1.20", port: 18789, tls: false },
{ host: "gateway.tailnet.example", port: 443, tls: true },
],
});
await vi.waitFor(() => expect(mocks.capturedGatewayClients).toHaveLength(1));
const firstOptions = mocks.capturedGatewayClientOptions[0];
firstOptions?.onClose?.(1006, "transport unavailable", {
phase: "pre-hello",
socketOpened: false,
transportValidated: false,
connectRequestSent: false,
transientPreHelloCleanClose: false,
});
await vi.waitFor(() => expect(mocks.capturedGatewayClients).toHaveLength(2));
expect(mocks.capturedGatewayClientOptions[1]?.url).toBe("wss://gateway.tailnet.example:443");
mocks.capturedGatewayClientOptions[1]?.onHelloOk?.({} as never);
await vi.waitFor(() => expect(mocks.configureNodeHost).toHaveBeenCalledTimes(2));
expect(mocks.capturedConfiguredGatewayConfigs[1]).toEqual({
host: "gateway.tailnet.example",
port: 443,
tls: true,
});
await vi.waitFor(() =>
expect(processOnceSpy.mock.calls.some(([event]) => event === "SIGTERM")).toBe(true),
);
const onSigterm = processOnceSpy.mock.calls.find(([event]) => event === "SIGTERM")?.[1];
onSigterm?.("SIGTERM");
await running;
} finally {
for (const [event, listener] of processOnceSpy.mock.calls) {
if ((event === "SIGINT" || event === "SIGTERM") && typeof listener === "function") {
process.off(event, listener);
}
}
process.exitCode = previousExitCode;
processOnceSpy.mockRestore();
}
});
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(
+54 -47
View File
@@ -7,16 +7,13 @@ import {
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js";
import {
GatewayClient,
GatewayClientRequestError,
type GatewayReconnectPausedInfo,
} from "../gateway/client.js";
import { GatewayClientRequestError, type GatewayReconnectPausedInfo } from "../gateway/client.js";
import { resolveGatewayCredentialsWithSecretInputs } from "../gateway/credentials-secret-inputs.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
import { configureNodeHost, type NodeHostGatewayConfig } from "./config.js";
import { createNodeHostGatewayCandidateConnection } from "./gateway-candidate-connection.js";
import {
coerceNodeInvokeCancelPayload,
coerceNodeInvokeInputPayload,
@@ -30,6 +27,9 @@ type NodeHostRunOptions = {
gatewayPort: number;
gatewayTls?: boolean;
gatewayTlsFingerprint?: string;
gatewayCandidates?: NodeHostGatewayConfig[];
gatewayBootstrapToken?: string;
preferGatewayBootstrapToken?: boolean;
/** Optional WebSocket context path (e.g. "/openclaw-gw"). */
gatewayContextPath?: string;
nodeId?: string;
@@ -220,6 +220,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const nodeId = config.nodeId;
const displayName = config.displayName ?? fallbackDisplayName;
const gateway = config.gateway ?? plannedGateway;
const gatewayCandidates = opts.gatewayCandidates?.length ? opts.gatewayCandidates : [gateway];
const cfg = getRuntimeConfig();
const preparedRuntime = await prepareNodeHostRuntime({
@@ -228,22 +229,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
enableAgentRuns: true,
installedAppsSharingEnabled: config.installedAppsSharing,
});
const { token, password } = await resolveNodeHostGatewayCredentials({
config: cfg,
env: process.env,
});
const { token, password } = opts.preferGatewayBootstrapToken
? {}
: await resolveNodeHostGatewayCredentials({
config: cfg,
env: process.env,
});
const host = gateway.host ?? "127.0.0.1";
const urlHost =
host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
const port = gateway.port ?? 18789;
const scheme = gateway.tls ? "wss" : "ws";
const contextPath = gateway.contextPath
? gateway.contextPath.startsWith("/")
? gateway.contextPath
: `/${gateway.contextPath}`
: "";
const url = `${scheme}://${urlHost}:${port}${contextPath}`;
let inventory: NodeHostInventory = preparedRuntime.initialInventory;
let gatewayHelloReceived = false;
let gatewayConnectionGeneration = 0;
@@ -451,27 +443,42 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
);
};
const client = new GatewayClient({
url,
token: token || undefined,
password: password || undefined,
instanceId: nodeId,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: displayName,
clientVersion: VERSION,
platform: resolveNodeHostGatewayPlatform(process.platform),
deviceFamily: resolveNodeHostGatewayDeviceFamily(process.platform),
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
// Pair the built-in MCP command family up front. Server inventory is
// restart-scoped availability, not a capability upgrade requiring re-pairing.
caps: preparedRuntime.manifest.caps,
commands: preparedRuntime.manifest.commands,
pathEnv: preparedRuntime.manifest.pathEnv,
permissions: undefined,
deviceIdentity: loadOrCreateDeviceIdentity(),
tlsFingerprint: gateway.tlsFingerprint,
const persistWinningGateway = (winningGateway: NodeHostGatewayConfig) => {
void configureNodeHost({
nodeId,
displayName,
fallbackDisplayName,
gateway: winningGateway,
installedAppsSharing: config.installedAppsSharing,
}).catch((error: unknown) => {
writeStderrLine(`node host gateway endpoint persistence failed: ${String(error)}`);
});
};
const client = createNodeHostGatewayCandidateConnection({
candidates: gatewayCandidates,
clientOptions: {
token: token || undefined,
bootstrapToken: opts.gatewayBootstrapToken,
preferBootstrapToken: opts.preferGatewayBootstrapToken,
password: password || undefined,
instanceId: nodeId,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: displayName,
clientVersion: VERSION,
platform: resolveNodeHostGatewayPlatform(process.platform),
deviceFamily: resolveNodeHostGatewayDeviceFamily(process.platform),
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
// Pair the built-in MCP command family up front. Server inventory is
// restart-scoped availability, not a capability upgrade requiring re-pairing.
caps: preparedRuntime.manifest.caps,
commands: preparedRuntime.manifest.commands,
pathEnv: preparedRuntime.manifest.pathEnv,
permissions: undefined,
deviceIdentity: loadOrCreateDeviceIdentity(),
},
onEvent: (evt) => {
if (evt.event === "node.invoke.cancel") {
const payload = coerceNodeInvokeCancelPayload(evt.payload);
@@ -491,12 +498,11 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
return;
}
const payload = coerceNodeInvokePayload(evt.payload);
if (!payload) {
return;
if (payload) {
void activeRuntime.invoke(payload);
}
void activeRuntime.invoke(payload);
},
onHelloOk: (hello) => {
onHelloOk: (hello, url) => {
writeStderrLine(`node host gateway connected: ${url}`);
gatewayConnectionGeneration += 1;
gatewayHelloReceived = true;
@@ -505,9 +511,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
optionalPublicationStates = new Map();
publishInventory();
},
onConnectError: (err) => {
onConnectError: (error) => {
// keep retrying (handled by GatewayClient)
writeStderrLine(`node host gateway connect failed: ${err.message}`);
writeStderrLine(`node host gateway connect failed: ${error.message}`);
},
onReconnectPaused: (info) => {
handleNodeHostReconnectPaused(info, {
@@ -524,6 +530,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
activeRuntime.cancelAll();
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
},
onWinningCandidate: persistWinningGateway,
});
const activeRuntime = preparedRuntime.start({
client,