diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 5330ff2a5865..fa639988a725 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -244,6 +244,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ route: { id: "gateway-status" }, }, { commandPath: ["gateway", "call"], exact: true, policy: { networkProxy: "bypass" } }, + ...["suspend", "resume"].map( + (subcommand): CliCommandCatalogEntry => ({ + commandPath: ["gateway", subcommand], + exact: true, + policy: { configGuard: "validate", networkProxy: "bypass" }, + }), + ), { commandPath: ["gateway", "diagnostics"], exact: true, policy: { networkProxy: "bypass" } }, { commandPath: ["gateway", "discover"], exact: true, policy: { networkProxy: "bypass" } }, { diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 5dcf8013aea9..005498db502e 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -125,6 +125,15 @@ describe("command-path-policy", () => { } }); + it("keeps gateway suspension RPCs on non-observing config validation", () => { + for (const subcommand of ["suspend", "resume"]) { + expectResolvedPolicy(["gateway", subcommand], { + configGuard: "validate", + networkProxy: "bypass", + }); + } + }); + it("applies exact overrides after broader channel plugin rules", () => { expectResolvedPolicy(["channels", "send"], { loadPlugins: "always", diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index f0ac5a78336f..ad3461360461 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -81,6 +81,8 @@ describe("command-startup-policy", () => { ["nodes", "remove"], ["devices", "approve"], ["devices", "remove"], + ["gateway", "suspend"], + ["gateway", "resume"], ]) { expect(resolvePolicy({ commandPath })).toMatchObject({ skipConfigGuard: false, diff --git a/src/cli/gateway-backed-exit.process.test.ts b/src/cli/gateway-backed-exit.process.test.ts index 038648ea27a2..c095d5eef63d 100644 --- a/src/cli/gateway-backed-exit.process.test.ts +++ b/src/cli/gateway-backed-exit.process.test.ts @@ -23,7 +23,6 @@ import { storeOriginDeviceToken, } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; -import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { getFreePort } from "../test-utils/ports.js"; @@ -31,6 +30,7 @@ const tempDirs = useAutoCleanupTempDirTracker(afterEach); const execFileAsync = promisify(execFile); const activeChildren = new Set(); const activeServers = new Set(); +const UNREACHABLE_GATEWAY_URL = "ws://127.0.0.1:9"; afterEach(async () => { await Promise.all( @@ -191,8 +191,6 @@ async function snapshotDirectoryContents(root: string): Promise { - const database = openNodeSqliteDatabase(resolveImmutableSqliteFileUri(databasePath), { - readOnly: true, - }); +async function snapshotSharedStateArtifacts(stateDir: string): Promise> { + const sharedStateDir = path.join(stateDir, "state"); try { - const tables = database - .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name") - .all() as Array<{ name: string }>; - return Object.fromEntries( - tables.map(({ name }) => { - const quotedName = `"${name.replaceAll('"', '""')}"`; - const rows = database - .prepare(`SELECT * FROM ${quotedName}`) - .all() - .map((row) => - JSON.stringify(row, (_key, value: unknown) => - typeof value === "bigint" ? value.toString() : value, - ), - ) - .toSorted(); - return [name, rows]; - }), - ); - } finally { - database.close(); + return await snapshotDirectoryContents(sharedStateDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {}; + } + throw error; } } +async function prepareUnreachableGatewayCliFixture(params: { + label: string; + seeded: boolean; +}): Promise<{ root: string; stateDir: string; configPath: string }> { + const root = tempDirs.make(`openclaw-${params.label}-${params.seeded ? "seeded" : "absent"}-`); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + gateway: { + mode: "remote", + auth: { mode: "none" }, + remote: { url: UNREACHABLE_GATEWAY_URL }, + }, + }), + ); + if (params.seeded) { + const stateEnv = { + ...process.env, + HOME: root, + OPENCLAW_HOME: root, + OPENCLAW_STATE_DIR: stateDir, + }; + const identity = loadOrCreateDeviceIdentity({ env: stateEnv }); + storeOriginDeviceToken({ + gatewayScope: gatewayOriginScope(UNREACHABLE_GATEWAY_URL), + deviceId: identity.deviceId, + role: "operator", + token: "stored-device-token", + scopes: ["operator.admin"], + env: stateEnv, + }); + closeOpenClawStateDatabaseForTest(); + } + return { root, stateDir, configPath }; +} + +function expectUnreachableGatewayTransportFailure( + result: Awaited>, + output: "json" | "text", +): void { + expect(result).toMatchObject({ code: 1, signal: null }); + if (output === "json") { + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { + type: "gateway_transport_error", + kind: "closed", + message: expect.stringContaining("Gateway not reachable"), + }, + gateway: { url: UNREACHABLE_GATEWAY_URL }, + }); + return; + } + expect(result.stderr).toContain("Gateway not reachable"); + expect(result.stderr).toContain(UNREACHABLE_GATEWAY_URL); + expect(result.stderr).not.toContain("gateway timeout"); +} + async function runIsolatedGatewayCli(params: { args: string[]; root: string; @@ -294,6 +338,55 @@ async function runIsolatedGatewayCli(params: { } describe("gateway-backed CLI process exit", () => { + it.each([ + { + label: "root-health-json", + args: ["health", "--json", "--timeout", "250"], + output: "json" as const, + }, + { + label: "gateway-health-text", + args: ["gateway", "health", "--timeout", "250"], + output: "text" as const, + }, + { + label: "gateway-health-json", + args: ["gateway", "health", "--json", "--timeout", "250"], + output: "json" as const, + }, + { + label: "gateway-suspend-json", + args: ["gateway", "suspend", "--json", "--timeout", "250"], + output: "json" as const, + }, + { + label: "gateway-resume-json", + args: ["gateway", "resume", "suspension-1", "--json", "--timeout", "250"], + output: "json" as const, + }, + ])( + "leaves shared state byte-identical after unreachable $label", + async ({ label, args, output }) => { + const absent = await prepareUnreachableGatewayCliFixture({ label, seeded: false }); + expect(await snapshotSharedStateArtifacts(absent.stateDir)).toEqual({}); + + const absentResult = await runIsolatedGatewayCli({ ...absent, args }); + + expectUnreachableGatewayTransportFailure(absentResult, output); + expect(await snapshotSharedStateArtifacts(absent.stateDir)).toEqual({}); + + const seeded = await prepareUnreachableGatewayCliFixture({ label, seeded: true }); + const before = await snapshotSharedStateArtifacts(seeded.stateDir); + expect(Object.keys(before)).toContain("openclaw.sqlite"); + + const seededResult = await runIsolatedGatewayCli({ ...seeded, args }); + + expectUnreachableGatewayTransportFailure(seededResult, output); + expect(await snapshotSharedStateArtifacts(seeded.stateDir)).toEqual(before); + }, + 60_000, + ); + it("dispatches node pairing mutations without opening the writable state database", async () => { const root = tempDirs.make("openclaw-node-pairing-cli-"); const stateDir = path.join(root, "state"); @@ -350,9 +443,7 @@ describe("gateway-backed CLI process exit", () => { env: stateEnv, }); closeOpenClawStateDatabaseForTest(); - const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); const before = await snapshotDirectoryContents(stateDir); - const beforeTables = snapshotSqliteTables(databasePath); const result = await runIsolatedGatewayCli({ args: ["nodes", "approve", "request-1", "--json"], @@ -365,7 +456,6 @@ describe("gateway-backed CLI process exit", () => { expect(JSON.parse(result.stdout)).toEqual({ approved: true }); expect(gateway.calls).toEqual(["node.pair.list", "node.pair.approve"]); expect(await snapshotDirectoryContents(stateDir)).toEqual(before); - expect(snapshotSqliteTables(databasePath)).toEqual(beforeTables); expect( loadOriginDeviceTokenReadOnly({ gatewayScope: gatewayOriginScope(gateway.url), diff --git a/src/cli/gateway-cli/health-route.test.ts b/src/cli/gateway-cli/health-route.test.ts index 6106bc6a6bbf..60ac656b4b2f 100644 --- a/src/cli/gateway-cli/health-route.test.ts +++ b/src/cli/gateway-cli/health-route.test.ts @@ -16,7 +16,7 @@ describe("runGatewayHealthJsonRoute", () => { it("writes successful JSON without loading error-only dependencies", async () => { const runtime = createRuntime(); const callGateway = vi.fn(async () => ({ ok: true, durationMs: 6 })); - const readBestEffortConfig = vi.fn(async () => ({})); + const readBestEffortHealthConfig = vi.fn(async () => ({})); const emitReachableGatewayAuthDiagnostic = vi.fn(async () => false); const formatGatewayAuthErrorJson = vi.fn(); const formatGatewayClientRequestErrorJson = vi.fn(); @@ -29,7 +29,7 @@ describe("runGatewayHealthJsonRoute", () => { runtime as never, { callGateway, - readBestEffortConfig, + readBestEffortHealthConfig, emitReachableGatewayAuthDiagnostic: emitReachableGatewayAuthDiagnostic as never, formatGatewayAuthErrorJson: formatGatewayAuthErrorJson as never, formatGatewayClientRequestErrorJson: formatGatewayClientRequestErrorJson as never, @@ -41,10 +41,10 @@ describe("runGatewayHealthJsonRoute", () => { "health", { json: true, timeout: "10000" }, undefined, - { defaultTimeoutMs: 10_000 }, + { defaultTimeoutMs: 10_000, sharedStateMode: "read-only" }, ); expect(runtime.writeJson).toHaveBeenCalledWith({ ok: true, durationMs: 6 }, 2); - expect(readBestEffortConfig).not.toHaveBeenCalled(); + expect(readBestEffortHealthConfig).not.toHaveBeenCalled(); expect(emitReachableGatewayAuthDiagnostic).not.toHaveBeenCalled(); expect(formatGatewayAuthErrorJson).not.toHaveBeenCalled(); expect(formatGatewayClientRequestErrorJson).not.toHaveBeenCalled(); @@ -54,7 +54,7 @@ describe("runGatewayHealthJsonRoute", () => { it("projects a local port into the routed config", async () => { const runtime = createRuntime(); const callGateway = vi.fn(async () => ({ ok: true })); - const readBestEffortConfig = vi.fn(async () => ({ + const readBestEffortHealthConfig = vi.fn(async () => ({ gateway: { auth: { mode: "token" as const } }, })); @@ -64,7 +64,7 @@ describe("runGatewayHealthJsonRoute", () => { localPortOverride: 19083, }, runtime as never, - { callGateway, readBestEffortConfig }, + { callGateway, readBestEffortHealthConfig }, ); expect(callGateway).toHaveBeenCalledWith( @@ -76,7 +76,7 @@ describe("runGatewayHealthJsonRoute", () => { }, }), undefined, - { defaultTimeoutMs: 10_000 }, + { defaultTimeoutMs: 10_000, sharedStateMode: "read-only" }, ); }); @@ -93,7 +93,7 @@ describe("runGatewayHealthJsonRoute", () => { runtime as never, { callGateway, - readBestEffortConfig: vi.fn(async () => { + readBestEffortHealthConfig: vi.fn(async () => { throw error; }), }, @@ -115,7 +115,7 @@ describe("runGatewayHealthJsonRoute", () => { await runGatewayHealthJsonRoute({ rpc: { json: true, timeout: "10000" } }, runtime as never, { callGateway, - readBestEffortConfig: async () => ({}), + readBestEffortHealthConfig: async () => ({}), emitReachableGatewayAuthDiagnostic: vi.fn(async () => false) as never, formatGatewayAuthErrorJson: vi.fn(() => null) as never, formatGatewayClientRequestErrorJson: vi.fn(() => null) as never, @@ -145,7 +145,7 @@ describe("runGatewayHealthJsonRoute", () => { await runGatewayHealthJsonRoute({ rpc: { json: true, timeout: "10000" } }, runtime as never, { callGateway, - readBestEffortConfig: async () => ({}), + readBestEffortHealthConfig: async () => ({}), emitReachableGatewayAuthDiagnostic: vi.fn(async () => false) as never, formatGatewayAuthErrorJson: formatGatewayAuthErrorJson as never, formatGatewayClientRequestErrorJson: formatGatewayClientRequestErrorJson as never, diff --git a/src/cli/gateway-cli/health-route.ts b/src/cli/gateway-cli/health-route.ts index 576603b616b4..c0ed54f57d14 100644 --- a/src/cli/gateway-cli/health-route.ts +++ b/src/cli/gateway-cli/health-route.ts @@ -1,5 +1,4 @@ // Route-first machine-readable Gateway health command. -import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; @@ -14,7 +13,7 @@ type GatewayHealthJsonRouteArgs = { type GatewayHealthRouteDependencies = { callGateway?: typeof import("../gateway-rpc.js").callGatewayFromCliWithTransport; - readBestEffortConfig?: () => Promise; + readBestEffortHealthConfig?: typeof import("../../commands/health.js").readBestEffortHealthConfig; emitReachableGatewayAuthDiagnostic?: typeof import("../../commands/health.js").emitReachableGatewayAuthDiagnostic; formatGatewayAuthErrorJson?: typeof import("../../gateway/call.js").formatGatewayAuthErrorJson; formatGatewayClientRequestErrorJson?: typeof import("../../gateway/call.js").formatGatewayClientRequestErrorJson; @@ -28,10 +27,10 @@ async function resolveRouteRpcOptions( if (args.localPortOverride === undefined) { return args.rpc; } - const readBestEffortConfig = - deps.readBestEffortConfig ?? - (await import("../../config/read-best-effort-config.runtime.js")).readBestEffortConfig; - const config = await readBestEffortConfig(); + const readBestEffortHealthConfig = + deps.readBestEffortHealthConfig ?? + (await import("../../commands/health.js")).readBestEffortHealthConfig; + const config = await readBestEffortHealthConfig(); return { ...args.rpc, localPortOverride: args.localPortOverride, @@ -59,7 +58,10 @@ export async function runGatewayHealthJsonRoute( deps.callGateway ?? (await import("../gateway-rpc.js")).callGatewayFromCliWithTransport; writeRuntimeJson( runtime, - await callGateway("health", rpc, undefined, { defaultTimeoutMs: 10_000 }), + await callGateway("health", rpc, undefined, { + defaultTimeoutMs: 10_000, + sharedStateMode: "read-only", + }), ); } catch (error) { if (!rpc) { @@ -67,11 +69,10 @@ export async function runGatewayHealthJsonRoute( runtime.exit(1); return; } - const [healthModule, configModule, callModule] = await Promise.all([ - deps.emitReachableGatewayAuthDiagnostic ? undefined : import("../../commands/health.js"), - deps.readBestEffortConfig + const [healthModule, callModule] = await Promise.all([ + deps.emitReachableGatewayAuthDiagnostic && deps.readBestEffortHealthConfig ? undefined - : import("../../config/read-best-effort-config.runtime.js"), + : import("../../commands/health.js"), deps.formatGatewayAuthErrorJson && deps.formatGatewayClientRequestErrorJson && deps.formatGatewayTransportErrorJson @@ -80,13 +81,14 @@ export async function runGatewayHealthJsonRoute( ]); const emitReachableGatewayAuthDiagnostic = deps.emitReachableGatewayAuthDiagnostic ?? healthModule?.emitReachableGatewayAuthDiagnostic; - const readBestEffortConfig = deps.readBestEffortConfig ?? configModule?.readBestEffortConfig; - if (!emitReachableGatewayAuthDiagnostic || !readBestEffortConfig) { + const readBestEffortHealthConfig = + deps.readBestEffortHealthConfig ?? healthModule?.readBestEffortHealthConfig; + if (!emitReachableGatewayAuthDiagnostic || !readBestEffortHealthConfig) { throw error; } const handled = await emitReachableGatewayAuthDiagnostic({ error, - config: rpc.config ?? (await readBestEffortConfig()), + config: rpc.config ?? (await readBestEffortHealthConfig()), runtime, timeoutMs: Number(rpc.timeout ?? "10000"), token: rpc.token, diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index 6675bdb2dc9f..d8ce4bdcc245 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -85,6 +85,7 @@ vi.mock("../../commands/health.js", () => ({ emitReachableGatewayAuthDiagnostic: (params: unknown) => mocks.emitReachableGatewayAuthDiagnostic(params), formatHealthChannelLines: () => mocks.formatHealthChannelLines(), + readBestEffortHealthConfig: async () => ({}), })); vi.mock("../../config/read-best-effort-config.runtime.js", () => ({ diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 653a0ff93d08..e244a0321a3d 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -116,6 +116,7 @@ function gatewayCallOpts(cmd: Command, defaultTimeoutMs = DEFAULT_GATEWAY_RPC_TI async function callGatewayCli(method: string, opts: GatewayRpcOpts, params?: unknown) { return await callGatewayFromCliWithTransport(method, opts, params, { defaultTimeoutMs: DEFAULT_GATEWAY_RPC_TIMEOUT_MS, + sharedStateMode: "read-only", }); } @@ -696,14 +697,12 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie try { result = await callGatewayCli("health", rpcOpts); } catch (error) { - const [{ emitReachableGatewayAuthDiagnostic }, { readBestEffortConfig }] = - await Promise.all([ - (deps.loadGatewayHealthModule ?? loadGatewayHealthModule)(), - loadConfigModule(), - ]); + const { emitReachableGatewayAuthDiagnostic, readBestEffortHealthConfig } = await ( + deps.loadGatewayHealthModule ?? loadGatewayHealthModule + )(); const handled = await emitReachableGatewayAuthDiagnostic({ error, - config: rpcOpts.config ?? (await readBestEffortConfig()), + config: rpcOpts.config ?? (await readBestEffortHealthConfig()), runtime: defaultRuntime, timeoutMs: parseGatewayRpcTimeoutOption(rpcOpts.timeout), token: rpcOpts.token, diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 61ffadb0e3f3..78e744550cba 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -436,6 +436,7 @@ describe("healthCommand", () => { expect(gatewayRequest.token).toBe("setup-token"); expect(gatewayRequest.password).toBe("setup-password"); expect(gatewayRequest.ignoreEnvUrlOverride).toBe(true); + expect(gatewayRequest.sharedStateMode).toBe("read-only"); }); it("outputs JSON for gateway transport failures in JSON mode", async () => { diff --git a/src/commands/health.ts b/src/commands/health.ts index a38e4baa8c73..d002ff7a5f7e 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -267,6 +267,7 @@ export async function healthCommand( config: cfg, token: opts.token, password: opts.password, + sharedStateMode: "read-only", ignoreEnvUrlOverride: opts.ignoreEnvUrlOverride, localPortOverride: opts.localPortOverride, }), @@ -562,7 +563,11 @@ export async function healthCommand( } } -async function readBestEffortHealthConfig(): Promise { - const { readBestEffortConfig } = await loadConfigRuntime(); - return await readBestEffortConfig(); +export async function readBestEffortHealthConfig(): Promise { + const { readConfigFileSnapshot } = await loadConfigRuntime(); + const snapshot = await readConfigFileSnapshot({ + observe: false, + pluginValidation: "core-only", + }); + return snapshot.runtimeConfig ?? snapshot.config; }