mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
3b01ea7905
commit
d44f70eb4b
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user