openclaw/test/gateway.multi.e2e.test.ts
Peter Steinberger 8f622108e0
feat(state): local client credential cutover (R0 PR1b) (#174599)
* feat(state): local client credential cutover (R0 PR1b)

Move client identities, tokens, and IPC credentials to the local credential owner with schema 29 migration and generation-bound rollback.

* fix(test): use local credentials in rebased restart fixtures

* test(state): reconcile credential fixtures with current main

* fix(gateway): preserve dispatch errors when failure recording conflicts

* fix(state): retain private-store attribution through rebase

* fix(ci): preserve explicit singleton worker limits

* fix(state): preserve credential restore and owner contracts

* fix(state): inspect installed PostgreSQL schema before capture

* fix(state): preserve updater custody in PostgreSQL inspection

* fix(state): qualify schema SQL and isolate bounded test assets

* test(worker): report relocated watcher startup failures

* fix(build): retain fs-safe worker entry effects

* test(gateway): await node result acknowledgements before teardown

* test(worktrees): enable diagnostics for eviction log capture

* fix(mantis): retain prepared native assets for offline candidates

* fix(state): validate native credential receipts consistently

Match the TypeScript receipt source contract in the native reader and cover malformed source witnesses. Record bounded emitted-worker diagnostics when the existing deployment proof fails.

* test(build): capture worker source across bundling stages

* test: keep declaration fixture writes inside owned paths

* test(codex): await rollout watcher delivery

* test: pin late lease proof to acquisition path

* test: preserve current completion notices after restart

* test: align credential capture attribution metadata

* refactor(infra): retain shared socket transport for credential cutover
2026-10-19 14:46:51 -07:00

382 lines
14 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expectDefined } from "@openclaw/normalization-core";
import { afterAll, describe, expect, it } from "vitest";
import { GatewayClient } from "../src/gateway/client.js";
import { requireGatewayRecord } from "../src/gateway/test-helpers.assertions.js";
import { buildMockOpenAiResponsesProvider } from "../src/gateway/test-openai-responses-model.js";
import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js";
import {
type GatewayInstance,
connectNode,
connectGatewayStatusClient,
postJson,
spawnGatewayInstance,
stopGatewayInstance,
waitForNodeStatus,
} from "./helpers/gateway-e2e-harness.js";
import { connectProofClient } from "./helpers/gateway-integration-fixtures.js";
import { createOpenClawTestInstance } from "./helpers/openclaw-test-instance.js";
import { waitForFile } from "./helpers/process-wait.js";
import { runQaGatewayFixture } from "./helpers/qa-gateway-cleanup.js";
const E2E_TIMEOUT_MS = 120_000;
const CLOCK_SHIFT_PRELOAD = `
import { existsSync, writeFileSync } from "node:fs";
const shiftPath = process.env.NODE_INVOKE_CLOCK_SHIFT_PATH;
const shiftReadyPath = process.env.NODE_INVOKE_CLOCK_SHIFT_READY_PATH;
const offsetMs = Number(process.env.NODE_INVOKE_CLOCK_SHIFT_MS ?? "0");
const originalNow = Date.now.bind(Date);
const timer = setInterval(() => {
if (!shiftPath || !existsSync(shiftPath)) {
return;
}
clearInterval(timer);
Date.now = () => originalNow() + offsetMs;
if (shiftReadyPath) {
writeFileSync(shiftReadyPath, "ready\\n");
}
}, 10);
timer.unref();
`;
async function settleGatewayCleanups(cleanups: Array<() => unknown>) {
const results = await Promise.allSettled(cleanups.map(async (cleanup) => await cleanup()));
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (errors.length === 1) {
throw errors[0];
}
if (errors.length > 1) {
throw new AggregateError(errors, "Multi-Gateway cleanup failed");
}
}
async function cleanupGateways(instances: GatewayInstance[], clients: GatewayClient[]) {
// Failed client cleanup must not release files their Gateway may still use.
let clientsJoined = false;
await runQaGatewayFixture(
async () => {
await settleGatewayCleanups(clients.map((client) => () => client.stopAndWait()));
clientsJoined = true;
},
() =>
settleGatewayCleanups(
instances.map(
(instance) => () =>
clientsJoined ? stopGatewayInstance(instance) : instance.stopGateway(),
),
),
);
}
describe("gateway multi-instance e2e", () => {
const instances: GatewayInstance[] = [];
const nodeClients: GatewayClient[] = [];
const acquisitions: Promise<unknown>[] = [];
afterAll(async () => {
// Join failed acquisitions' siblings before reading their retained owners.
await Promise.allSettled(acquisitions);
await cleanupGateways(instances, nodeClients);
});
it(
"spins up two gateways and exercises WS + HTTP + node pairing",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const spawnOwnedGateway = async (name: string) => {
const inst = await spawnGatewayInstance(name);
instances.push(inst);
return inst;
};
const gatewayAcquisitions = [spawnOwnedGateway("a"), spawnOwnedGateway("b")] as const;
acquisitions.push(...gatewayAcquisitions);
const gateways = await Promise.all(gatewayAcquisitions);
const hookResults = await Promise.all(
gateways.map((gateway, index) =>
postJson(
`http://127.0.0.1:${gateway.port}/hooks/wake`,
{ text: `wake ${index === 0 ? "a" : "b"}`, mode: "now" },
{ "x-openclaw-token": gateway.hookToken },
),
),
);
expect(hookResults).toMatchObject([
{ status: 200, json: { ok: true } },
{ status: 200, json: { ok: true } },
]);
const connectOwnedNode = async (inst: GatewayInstance, label: string) => {
const node = await connectNode(inst, label);
nodeClients.push(node.client);
return node;
};
const nodeAcquisitions = gateways.map((gateway, index) =>
connectOwnedNode(gateway, index === 0 ? "node-a" : "node-b"),
);
acquisitions.push(...nodeAcquisitions);
const nodes = await Promise.all(nodeAcquisitions);
await Promise.all(
gateways.map((gateway, index) => waitForNodeStatus(gateway, nodes[index]!.nodeId)),
);
},
);
it(
"preserves scheduler runtime across a scheduler-disabled Gateway edit",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const model = buildMockOpenAiResponsesProvider("http://127.0.0.1:9/v1", "scheduler-canary");
const manager = await createOpenClawTestInstance({
name: "cron-passive-manager",
config: {
cron: { enabled: false },
plugins: { enabled: false },
agents: {
defaults: {
model: { primary: model.modelRef },
models: { [model.modelRef]: { agentRuntime: { id: "openclaw" } } },
},
},
models: {
mode: "replace",
providers: {
[model.providerId]: { ...model.config, request: { allowPrivateNetwork: true } },
},
},
},
env: { OPENCLAW_SKIP_CRON: "0" },
});
let managerClient: GatewayClient | undefined;
await runQaGatewayFixture(
async () => {
// Creation captures a durable account; only the injected offline runner executes it.
await manager.state.writeAuthProfiles({
version: 1,
profiles: {
"openai:cron-passive-manager": {
type: "api_key",
provider: "openai",
key: "synthetic-cron-scheduler-key",
},
},
});
await manager.startGateway();
managerClient = await connectGatewayStatusClient(manager);
const canary = await managerClient.request<{ id: string }>("cron.add", {
name: "shared-store canary",
enabled: true,
schedule: { kind: "every", everyMs: 3_600_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
model: "openai/gpt-4.1@openai:cron-passive-manager",
message: "run canary",
toolsAllow: [],
},
delivery: { mode: "none" },
});
const target = await managerClient.request<{ id: string }>("cron.add", {
name: "shared-store edit target",
enabled: true,
schedule: { kind: "cron", expr: "0 6 * * *" },
sessionTarget: "main",
wakeMode: "now",
payload: { kind: "systemEvent", text: "edit target" },
});
await managerClient.request("cron.list", { includeDisabled: true });
// Exclusive cron custody requires stopping the Gateway before the offline scheduler.
await managerClient.stopAndWait();
managerClient = undefined;
await manager.stopGateway();
const scheduler = spawnSync(
process.execPath,
[
"--import",
path.join(process.cwd(), "scripts/tsx.mjs"),
"--input-type=module",
"--eval",
`
import { CronService } from "./src/cron/service.ts";
import { resolveCronJobsStorePath } from "./src/cron/store.ts";
import { toPublicCronJob } from "./src/cron/public-job.ts";
import { createTestGatewayScheduler } from "./src/test-utils/gateway-scheduler-clock.ts";
const cron = new CronService({
scheduler: createTestGatewayScheduler(),
nowMs: () => Date.now(),
cronEnabled: true,
storePath: resolveCronJobsStorePath(),
log: { debug() {}, info() {}, warn() {}, error() {} },
enqueueSystemEvent() {},
requestHeartbeat() {},
async runIsolatedAgentJob() { return { status: "ok", summary: "scheduler canary completed" }; },
});
try {
await cron.start();
const result = await cron.run(process.argv[1], "force");
if (!result.ok || !("ran" in result) || !result.ran) throw new Error(JSON.stringify(result));
console.log(JSON.stringify(toPublicCronJob(cron.getJob(process.argv[1]))));
} finally {
cron.stop();
}
`,
canary.id,
],
{ cwd: process.cwd(), env: manager.env, encoding: "utf8", timeout: E2E_TIMEOUT_MS },
);
expect(scheduler.stderr).toBe("");
expect(
scheduler.status,
scheduler.error?.message ?? scheduler.signal ?? scheduler.stderr,
).toBe(0);
const before = JSON.parse(scheduler.stdout) as {
state: { lastRunAtMs?: number; lastStatus?: string };
};
expect(before.state).toMatchObject({ lastRunAtMs: expect.any(Number), lastStatus: "ok" });
await manager.startGateway();
managerClient = await connectGatewayStatusClient(manager);
await managerClient.request("cron.update", {
id: target.id,
patch: { description: "updated through passive Gateway" },
});
const after = await managerClient.request<{ state: unknown }>("cron.get", {
id: canary.id,
});
expect(after.state).toEqual(before.state);
},
() => cleanupGateways([manager], managerClient ? [managerClient] : []),
);
},
);
it(
"keeps a real node.invoke timeout stable across a gateway wall-clock change",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const proofRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-node-invoke-proof-"));
const shiftPath = path.join(proofRoot, "shift");
const shiftReadyPath = path.join(proofRoot, "shift-ready");
const preloadPath = path.join(proofRoot, "clock-shift.mjs");
let node: GatewayClient | undefined;
let operator: GatewayClient | undefined;
let instance: GatewayInstance | undefined;
let responseWork = Promise.resolve();
await runQaGatewayFixture(
async () => {
await writeFile(preloadPath, CLOCK_SHIFT_PRELOAD, "utf8");
instance = await createOpenClawTestInstance({
name: "node-invoke-clock",
gatewayCommandPrefix: [process.execPath, "--import", pathToFileURL(preloadPath).href],
env: {
NODE_INVOKE_CLOCK_SHIFT_PATH: shiftPath,
NODE_INVOKE_CLOCK_SHIFT_READY_PATH: shiftReadyPath,
NODE_INVOKE_CLOCK_SHIFT_MS: "1000",
},
});
await instance.startGateway();
const nodeIdentity = loadOrCreateDeviceIdentity({
path: path.join(
instance.homeDir,
"proof-clients",
"node",
"client",
"credentials.sqlite",
),
});
const nodeId = nodeIdentity.deviceId;
node = await connectProofClient(instance, {
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "real-node-proof",
clientVersion: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: ["system.notify"],
deviceIdentity: nodeIdentity,
onEvent: (event) => {
if (event.event !== "node.invoke.request") {
return;
}
const payload = requireGatewayRecord(event.payload, "node invoke request");
expect(payload).toMatchObject({ id: expect.any(String), nodeId });
responseWork = responseWork.then(async () => {
await writeFile(shiftPath, "shift\n");
await waitForFile(shiftReadyPath, AbortSignal.timeout(5_000));
await new Promise<void>((resolve) => {
setTimeout(resolve, 50);
});
await expectDefined(node, "connected proof node").request("node.invoke.result", {
id: payload.id,
nodeId: payload.nodeId,
ok: true,
payloadJSON: JSON.stringify({ captured: true }),
});
});
void responseWork.catch(() => {});
},
});
operator = await connectProofClient(instance, {
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes: ["operator.admin", "operator.read", "operator.write", "operator.pairing"],
deviceIdentity: loadOrCreateDeviceIdentity({
path: path.join(
instance.homeDir,
"proof-clients",
"operator",
"client",
"credentials.sqlite",
),
}),
});
const pairing = await operator.request<{
pending: Array<{ nodeId: string }>;
paired: Array<{ nodeId: string; commands?: string[] }>;
}>("node.pair.list", {});
expect(pairing.pending.find((entry) => entry.nodeId === nodeId)).toBeUndefined();
expect(pairing.paired.find((entry) => entry.nodeId === nodeId)?.commands).toEqual([
"system.notify",
]);
await waitForNodeStatus(instance, nodeId);
const startedAt = performance.now();
const result = await operator.request(
"node.invoke",
{
nodeId,
command: "system.notify",
params: { quality: "low" },
timeoutMs: 500,
idempotencyKey: "real-node-invoke-clock-proof",
},
{ timeoutMs: 5_000 },
);
const elapsedMs = Math.round(performance.now() - startedAt);
expect(result).toMatchObject({ payload: { captured: true } });
expect(elapsedMs).toBeGreaterThanOrEqual(50);
expect(elapsedMs).toBeLessThan(500);
await responseWork;
},
() => responseWork,
async () => {
await cleanupGateways(
instance ? [instance] : [],
[operator, node].filter((client): client is GatewayClient => client !== undefined),
);
await rm(proofRoot, { recursive: true, force: true });
},
);
},
);
});