diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index e8b8552e018..3f57b99569b 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -4,9 +4,19 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { expect } from "vitest"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + type DeviceIdentity, +} from "../infra/device-identity.js"; +import { + approveDevicePairing, + getPairedDevice, + requestDevicePairing, +} from "../infra/device-pairing.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; -import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; import { renderCatNoncePngBase64 } from "./live-image-probe.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; @@ -83,11 +93,13 @@ type CronListJob = NonNullable[number]; export type CliBackendLiveEnvSnapshot = { configPath?: string; + stateDir?: string; token?: string; skipChannels?: string; skipGmail?: string; skipCron?: string; skipCanvas?: string; + skipBrowserControl?: string; anthropicApiKey?: string; anthropicApiKeyOld?: string; }; @@ -228,6 +240,7 @@ function sleep(ms: number): Promise { export async function connectTestGatewayClient(params: { url: string; token: string; + deviceIdentity?: DeviceIdentity; }): Promise { const startedAt = Date.now(); let attempt = 0; @@ -260,6 +273,7 @@ async function connectClientOnce(params: { url: string; token: string; timeoutMs: number; + deviceIdentity?: DeviceIdentity; }): Promise { return await new Promise((resolve, reject) => { let done = false; @@ -291,6 +305,7 @@ async function connectClientOnce(params: { mode: "test", requestTimeoutMs: params.timeoutMs, connectChallengeTimeoutMs: params.timeoutMs, + deviceIdentity: params.deviceIdentity, onHelloOk: () => finish({ client }), onConnectError: (error) => finish({ error }), onClose: failWithClose, @@ -319,11 +334,13 @@ function isRetryableGatewayConnectError(error: Error): boolean { export function snapshotCliBackendLiveEnv(): CliBackendLiveEnvSnapshot { return { configPath: process.env.OPENCLAW_CONFIG_PATH, + stateDir: process.env.OPENCLAW_STATE_DIR, token: process.env.OPENCLAW_GATEWAY_TOKEN, skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, skipCron: process.env.OPENCLAW_SKIP_CRON, skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + skipBrowserControl: process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER, anthropicApiKey: process.env.ANTHROPIC_API_KEY, anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD, }; @@ -334,6 +351,7 @@ export function applyCliBackendLiveEnv(preservedEnv: ReadonlySet): void process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; process.env.OPENCLAW_SKIP_CRON = "1"; process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; if (!preservedEnv.has("ANTHROPIC_API_KEY")) { delete process.env.ANTHROPIC_API_KEY; } @@ -344,11 +362,13 @@ export function applyCliBackendLiveEnv(preservedEnv: ReadonlySet): void export function restoreCliBackendLiveEnv(snapshot: CliBackendLiveEnvSnapshot): void { restoreEnvVar("OPENCLAW_CONFIG_PATH", snapshot.configPath); + restoreEnvVar("OPENCLAW_STATE_DIR", snapshot.stateDir); restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", snapshot.token); restoreEnvVar("OPENCLAW_SKIP_CHANNELS", snapshot.skipChannels); restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", snapshot.skipGmail); restoreEnvVar("OPENCLAW_SKIP_CRON", snapshot.skipCron); restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", snapshot.skipCanvas); + restoreEnvVar("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", snapshot.skipBrowserControl); restoreEnvVar("ANTHROPIC_API_KEY", snapshot.anthropicApiKey); restoreEnvVar("ANTHROPIC_API_KEY_OLD", snapshot.anthropicApiKeyOld); } @@ -361,6 +381,44 @@ function restoreEnvVar(name: string, value: string | undefined): void { process.env[name] = value; } +export async function ensurePairedTestGatewayClientIdentity(): Promise { + const identity = loadOrCreateDeviceIdentity(); + const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const requiredScopes = ["operator.admin"]; + const paired = await getPairedDevice(identity.deviceId); + const pairedScopes = Array.isArray(paired?.approvedScopes) + ? paired.approvedScopes + : Array.isArray(paired?.scopes) + ? paired.scopes + : []; + if ( + paired?.publicKey === publicKey && + requiredScopes.every((scope) => pairedScopes.includes(scope)) + ) { + return identity; + } + const pairing = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey, + displayName: "vitest", + platform: process.platform, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes: requiredScopes, + silent: true, + }); + const approved = await approveDevicePairing(pairing.request.requestId, { + callerScopes: requiredScopes, + }); + if (approved?.status !== "approved") { + throw new Error( + `failed to pre-pair live test device: ${approved?.status ?? "missing-approval-result"}`, + ); + } + return identity; +} + export async function verifyCliBackendImageProbe(params: { client: GatewayClient; providerId: string; diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 1d26a254af6..be6215db9d8 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -10,6 +10,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { applyCliBackendLiveEnv, createBootstrapWorkspace, + ensurePairedTestGatewayClientIdentity, DEFAULT_CLAUDE_ARGS, DEFAULT_CLEAR_ENV, DEFAULT_CODEX_ARGS, @@ -32,11 +33,20 @@ import { extractPayloadText } from "./test-helpers.agent-results.js"; const LIVE = isLiveTestEnabled(); const CLI_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND); const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE); +const CLI_DEBUG = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG); const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-6"; const CLI_BACKEND_LIVE_TIMEOUT_MS = 420_000; +function logCliBackendLiveStep(step: string, details?: Record): void { + if (!CLI_DEBUG) { + return; + } + const suffix = details && Object.keys(details).length > 0 ? ` ${JSON.stringify(details)}` : ""; + console.error(`[gateway-cli-live] ${step}${suffix}`); +} + describeLive("gateway live (cli backend)", () => { it( "runs the agent pipeline against the local CLI backend", @@ -55,6 +65,7 @@ describeLive("gateway live (cli backend)", () => { const token = `test-${randomUUID()}`; process.env.OPENCLAW_GATEWAY_TOKEN = token; const port = await getFreeGatewayPort(); + logCliBackendLiveStep("env-ready", { port }); const rawModel = process.env.OPENCLAW_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL; const parsed = parseModelRef(rawModel, "claude-cli"); @@ -67,6 +78,7 @@ describeLive("gateway live (cli backend)", () => { const providerId = parsed.provider; const modelKey = `${providerId}/${parsed.model}`; const enableCliImageProbe = shouldRunCliImageProbe(providerId); + logCliBackendLiveStep("model-selected", { providerId, modelKey, enableCliImageProbe }); const providerDefaults = providerId === "claude-cli" ? { @@ -121,6 +133,9 @@ describeLive("gateway live (cli backend)", () => { } const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-cli-")); + const stateDir = path.join(tempDir, "state"); + await fs.mkdir(stateDir, { recursive: true }); + process.env.OPENCLAW_STATE_DIR = stateDir; const bootstrapWorkspace = providerId === "claude-cli" ? await createBootstrapWorkspace(tempDir) : null; const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0"; @@ -172,20 +187,31 @@ describeLive("gateway live (cli backend)", () => { const tempConfigPath = path.join(tempDir, "openclaw.json"); await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + const deviceIdentity = await ensurePairedTestGatewayClientIdentity(); + logCliBackendLiveStep("config-written", { + tempConfigPath, + stateDir, + cliCommand, + cliArgs, + }); const server = await startGatewayServer(port, { bind: "loopback", auth: { mode: "token", token }, controlUiEnabled: false, }); + logCliBackendLiveStep("server-started"); const client = await connectTestGatewayClient({ url: `ws://127.0.0.1:${port}`, token, + deviceIdentity, }); + logCliBackendLiveStep("client-connected"); try { const sessionKey = "agent:dev:live-cli-backend"; const nonce = randomBytes(3).toString("hex").toUpperCase(); + logCliBackendLiveStep("agent-request:start", { sessionKey, nonce }); const payload = await client.request( "agent", { @@ -202,6 +228,7 @@ describeLive("gateway live (cli backend)", () => { if (payload?.status !== "ok") { throw new Error(`agent status=${String(payload?.status)}`); } + logCliBackendLiveStep("agent-request:done", { status: payload?.status }); const text = extractPayloadText(payload?.result); if (providerId === "codex-cli") { @@ -220,6 +247,7 @@ describeLive("gateway live (cli backend)", () => { if (CLI_RESUME) { const resumeNonce = randomBytes(3).toString("hex").toUpperCase(); + logCliBackendLiveStep("agent-resume:start", { sessionKey, resumeNonce }); const resumePayload = await client.request( "agent", { @@ -236,6 +264,7 @@ describeLive("gateway live (cli backend)", () => { if (resumePayload?.status !== "ok") { throw new Error(`resume status=${String(resumePayload?.status)}`); } + logCliBackendLiveStep("agent-resume:done", { status: resumePayload?.status }); const resumeText = extractPayloadText(resumePayload?.result); if (providerId === "codex-cli") { expect(resumeText).toContain(`CLI-RESUME-${resumeNonce}`); @@ -247,6 +276,7 @@ describeLive("gateway live (cli backend)", () => { } if (enableCliImageProbe) { + logCliBackendLiveStep("image-probe:start", { sessionKey }); await verifyCliBackendImageProbe({ client, providerId, @@ -254,9 +284,11 @@ describeLive("gateway live (cli backend)", () => { tempDir, bootstrapWorkspace, }); + logCliBackendLiveStep("image-probe:done"); } if (providerId === "claude-cli") { + logCliBackendLiveStep("cron-mcp-probe:start", { sessionKey }); await verifyClaudeCliCronMcpProbe({ client, sessionKey, @@ -264,13 +296,16 @@ describeLive("gateway live (cli backend)", () => { token, env: process.env, }); + logCliBackendLiveStep("cron-mcp-probe:done"); } } finally { + logCliBackendLiveStep("cleanup:start"); clearRuntimeConfigSnapshot(); await client.stopAndWait(); await server.close(); await fs.rm(tempDir, { recursive: true, force: true }); restoreCliBackendLiveEnv(previousEnv); + logCliBackendLiveStep("cleanup:done"); } }, CLI_BACKEND_LIVE_TIMEOUT_MS, diff --git a/src/gateway/server.lazy.test.ts b/src/gateway/server.lazy.test.ts new file mode 100644 index 00000000000..59e4397c55b --- /dev/null +++ b/src/gateway/server.lazy.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const lazyState = vi.hoisted(() => ({ + loads: 0, + startCalls: [] as unknown[][], + resetCalls: 0, +})); + +vi.mock("./server.impl.js", () => { + lazyState.loads += 1; + return { + startGatewayServer: vi.fn(async (...args: unknown[]) => { + lazyState.startCalls.push(args); + return { close: vi.fn(async () => undefined) }; + }), + __resetModelCatalogCacheForTest: vi.fn(() => { + lazyState.resetCalls += 1; + }), + }; +}); + +describe("gateway server boundary", () => { + beforeEach(() => { + lazyState.loads = 0; + lazyState.startCalls = []; + lazyState.resetCalls = 0; + }); + + it("lazy-loads server.impl on demand", async () => { + const mod = await import("./server.js"); + + expect(lazyState.loads).toBe(0); + + await mod.__resetModelCatalogCacheForTest(); + expect(lazyState.loads).toBe(1); + expect(lazyState.resetCalls).toBe(1); + + await mod.startGatewayServer(4321, { bind: "loopback" }); + expect(lazyState.loads).toBe(1); + expect(lazyState.startCalls).toEqual([[4321, { bind: "loopback" }]]); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 96a87a2df6d..c4a00b3c97b 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1,3 +1,17 @@ export { truncateCloseReason } from "./server/close-reason.js"; export type { GatewayServer, GatewayServerOptions } from "./server.impl.js"; -export { __resetModelCatalogCacheForTest, startGatewayServer } from "./server.impl.js"; +async function loadServerImpl() { + return await import("./server.impl.js"); +} + +export async function startGatewayServer( + ...args: Parameters +): ReturnType { + const mod = await loadServerImpl(); + return await mod.startGatewayServer(...args); +} + +export async function __resetModelCatalogCacheForTest(): Promise { + const mod = await loadServerImpl(); + mod.__resetModelCatalogCacheForTest(); +} diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 6e3d0866db8..a39cc20df9e 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -348,7 +348,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { } resetAgentRunContextForTest(); const mod = await getServerModule(); - mod.__resetModelCatalogCacheForTest(); + await mod.__resetModelCatalogCacheForTest(); piSdkMock.enabled = false; piSdkMock.discoverCalls = 0; piSdkMock.models = [];