Files
openclaw/src/node-host/runner.credentials.test.ts
scotthuang 819fd9fbe9 fix(node-host): restart stale node host on version mismatch
Restart stale local node-host processes when they reconnect to a newer gateway with a released-version mismatch, so launchd/systemd can restart them with updated code instead of leaving old dynamic imports alive.

Adds gateway mismatch detail propagation, node-host terminal pause handling, and regression coverage for the GatewayClient reconnect-pause path.

Verification:
- node scripts/run-vitest.mjs run src/gateway/client.test.ts -t 'CLIENT_VERSION_MISMATCH' --reporter=verbose
- node scripts/run-vitest.mjs run src/gateway/server.node-version-mismatch.test.ts src/node-host/runner.credentials.test.ts src/gateway/client.test.ts --reporter=verbose
- /Users/steipete/Projects/agent-skills/skills/autoreview/scripts/autoreview --mode local
- Crabbox AWS run_292dcbfd78d9: focused GatewayClient mismatch regression plus server/node-host mismatch tests passed

Co-authored-by: scotthuang <scotthuang@tencent.com>
2026-05-27 08:25:02 +01:00

227 lines
6.8 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { ConnectErrorDetailCodes } from "../gateway/protocol/connect-error-details.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
handleNodeHostReconnectPaused,
resolveNodeHostGatewayCredentials,
shouldExitNodeHostOnReconnectPaused,
} from "./runner.js";
function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig {
return {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "remote",
remote: {
token: { source: "env", provider: "default", id: tokenId },
},
},
} as OpenClawConfig;
}
async function expectNoGatewayCredentials(
config: OpenClawConfig,
env: Record<string, string | undefined>,
) {
await withEnvAsync(env, async () => {
const credentials = await resolveNodeHostGatewayCredentials({ config });
expect(credentials.token).toBeUndefined();
expect(credentials.password).toBeUndefined();
});
}
describe("resolveNodeHostGatewayCredentials", () => {
it("does not inherit gateway.remote token in local mode", async () => {
const config = {
gateway: {
mode: "local",
remote: { token: "remote-only-token" },
},
} as OpenClawConfig;
await expectNoGatewayCredentials(config, {
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
});
});
it("ignores unresolved gateway.remote token refs in local mode", async () => {
const config = {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
remote: {
token: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_TOKEN" },
},
},
} as OpenClawConfig;
await expectNoGatewayCredentials(config, {
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
MISSING_REMOTE_GATEWAY_TOKEN: undefined,
});
});
it("resolves remote token SecretRef values", async () => {
const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN");
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
REMOTE_GATEWAY_TOKEN: "token-from-ref",
},
async () => {
const credentials = await resolveNodeHostGatewayCredentials({ config });
expect(credentials.token).toBe("token-from-ref");
},
);
});
it("prefers OPENCLAW_GATEWAY_TOKEN over configured refs", async () => {
const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN");
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: "token-from-env",
OPENCLAW_GATEWAY_PASSWORD: undefined,
REMOTE_GATEWAY_TOKEN: "token-from-ref",
},
async () => {
const credentials = await resolveNodeHostGatewayCredentials({ config });
expect(credentials.token).toBe("token-from-env");
},
);
});
it("throws when a configured remote token ref cannot resolve", async () => {
const config = createRemoteGatewayTokenRefConfig("MISSING_REMOTE_GATEWAY_TOKEN");
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
MISSING_REMOTE_GATEWAY_TOKEN: undefined,
},
async () => {
await expect(resolveNodeHostGatewayCredentials({ config })).rejects.toThrow(
"gateway.remote.token",
);
},
);
});
it("does not resolve remote password refs when token auth is already available", async () => {
const config = {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "remote",
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_PASSWORD" },
},
},
} as OpenClawConfig;
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
REMOTE_GATEWAY_TOKEN: "token-from-ref",
MISSING_REMOTE_GATEWAY_PASSWORD: undefined,
},
async () => {
const credentials = await resolveNodeHostGatewayCredentials({ config });
expect(credentials.token).toBe("token-from-ref");
expect(credentials.password).toBeUndefined();
},
);
});
});
describe("handleNodeHostReconnectPaused", () => {
it("exits for terminal credential pauses so service supervisors can restart", () => {
const lines: string[] = [];
const exit = vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never;
expect(() =>
handleNodeHostReconnectPaused(
{
code: 1008,
reason: "connect failed",
detailCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
},
{ writeLine: (line) => lines.push(line), exit },
),
).toThrow("exit 1");
expect(exit).toHaveBeenCalledWith(1);
expect(lines).toEqual([
"node host gateway reconnect paused after close (1008): connect failed detail=AUTH_TOKEN_MISMATCH; exiting for supervisor restart",
]);
});
it("keeps pairing pauses visible without exiting foreground approval flow", () => {
const lines: string[] = [];
const exit = vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never;
handleNodeHostReconnectPaused(
{
code: 1008,
reason: "connect failed",
detailCode: ConnectErrorDetailCodes.PAIRING_REQUIRED,
},
{ writeLine: (line) => lines.push(line), exit },
);
expect(shouldExitNodeHostOnReconnectPaused(ConnectErrorDetailCodes.PAIRING_REQUIRED)).toBe(
false,
);
expect(exit).not.toHaveBeenCalled();
expect(lines).toEqual([
"node host gateway reconnect paused after close (1008): connect failed detail=PAIRING_REQUIRED; waiting for operator action",
]);
});
it("exits for version mismatch so supervisor restarts with updated code", () => {
const lines: string[] = [];
const exit = vi.fn((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never;
expect(() =>
handleNodeHostReconnectPaused(
{
code: 1008,
reason: "client version mismatch",
detailCode: ConnectErrorDetailCodes.CLIENT_VERSION_MISMATCH,
},
{ writeLine: (line) => lines.push(line), exit },
),
).toThrow("exit 1");
expect(exit).toHaveBeenCalledWith(1);
expect(lines).toEqual([
"node host gateway reconnect paused after close (1008): client version mismatch detail=CLIENT_VERSION_MISMATCH; exiting for supervisor restart",
]);
});
});