diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index c9279cebd058..d145af619fe9 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -227,7 +227,10 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ policy: { configGuard: "validate", loadPlugins: "never", networkProxy: "bypass" }, }), ), - { commandPath: ["gateway", "diagnostics"], exact: true, policy: { networkProxy: "bypass" } }, + { + commandPath: ["gateway", "diagnostics"], + policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" }, + }, { commandPath: ["gateway", "discover"], exact: true, policy: { networkProxy: "bypass" } }, { commandPath: ["gateway", "health"], diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 83d147dc69ac..a79c60253f23 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -294,6 +294,7 @@ describe("command-path-policy", () => { ["skills"], ["skills", "list"], ["skills", "check"], + ["gateway", "diagnostics", "export"], ["gateway", "stability"], ["gateway", "usage-cost"], ])("keeps read-only cold path %s out of startup config and plugins", (...commandPath) => { diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 1c9ff3273848..61d07c8168bb 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -47,6 +47,7 @@ describe("command-startup-policy", () => { ["hooks", "check"], ["memory", "search"], ["memory", "status"], + ["gateway", "diagnostics", "export"], ["gateway", "stability"], ["gateway", "usage-cost"], ]) { diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index 7c11f468d7fb..fa4334b8c0bc 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -322,6 +322,7 @@ describe("probeGatewayStatus", () => { tlsFingerprint: "abc123", method: "status", timeoutMs: 5_000, + sharedStateMode: "read-only", configPath: "/tmp/openclaw-daemon/openclaw.json", }); }); @@ -368,6 +369,7 @@ describe("probeGatewayStatus", () => { config, method: "status", timeoutMs: 30_000, + sharedStateMode: "read-only", }); }); @@ -413,6 +415,7 @@ describe("probeGatewayStatus", () => { tlsFingerprint: undefined, method: "status", timeoutMs: 5_000, + sharedStateMode: "read-only", }); }); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index b2c32b767094..c7916d0541fd 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -123,6 +123,7 @@ export async function probeGatewayStatus(opts: { ...(allowRpcConfigCredentials && opts.config ? { config: opts.config } : {}), method: "status", timeoutMs: opts.timeoutMs, + sharedStateMode: "read-only", ...(opts.configPath ? { configPath: opts.configPath } : {}), }); statusRuntimeVersion = readRuntimeVersionFromStatusPayload(statusPayload); diff --git a/src/cli/gateway-backed-exit.process.test.ts b/src/cli/gateway-backed-exit.process.test.ts index c095d5eef63d..722f445940aa 100644 --- a/src/cli/gateway-backed-exit.process.test.ts +++ b/src/cli/gateway-backed-exit.process.test.ts @@ -7,6 +7,7 @@ import type { AddressInfo } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { isLoopbackIpAddress, isPrivateOrLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { afterEach, describe, expect, it } from "vitest"; import { WebSocketServer } from "ws"; import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; @@ -23,6 +24,10 @@ import { storeOriginDeviceToken, } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import { + pickMatchingExternalInterfaceAddress, + readNetworkInterfaces, +} from "../infra/network-interfaces.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { getFreePort } from "../test-utils/ports.js"; @@ -31,6 +36,13 @@ const execFileAsync = promisify(execFile); const activeChildren = new Set(); const activeServers = new Set(); const UNREACHABLE_GATEWAY_URL = "ws://127.0.0.1:9"; +const EMPTY_STABILITY_SNAPSHOT = { + capacity: 100, + count: 0, + dropped: 0, + events: [], + summary: { byType: {} }, +}; afterEach(async () => { await Promise.all( @@ -179,6 +191,72 @@ async function startNodePairingGateway( return { calls, url: `ws://127.0.0.1:${address.port}` }; } +async function startGatewayStabilityRpcServer( + token: string, + issuedDeviceToken: string, +): Promise<{ + authTokens: Array; + calls: string[]; + url: string; +}> { + const authTokens: Array = []; + const calls: string[] = []; + const wss = new WebSocketServer({ host: "0.0.0.0", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id) { + return; + } + if (frame.method === "connect") { + expect(frame.params?.auth?.token).toBe(token); + authTokens.push(frame.params?.auth?.token); + sendMinimalGatewayResponse( + ws, + frame.id, + buildMinimalGatewayHelloOkPayload({ + methods: ["diagnostics.stability", "status"], + auth: { + role: "operator", + scopes: ["operator.admin"], + deviceToken: issuedDeviceToken, + }, + }), + ); + return; + } + if (typeof frame.method !== "string") { + return; + } + calls.push(frame.method); + if (frame.method === "diagnostics.stability") { + sendMinimalGatewayResponse(ws, frame.id, EMPTY_STABILITY_SNAPSHOT); + return; + } + if (frame.method === "status") { + sendMinimalGatewayResponse(ws, frame.id, { + runtimeVersion: "2026.8.17-test", + status: "ok", + }); + } + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + // A private non-loopback target keeps shared-secret auth from bypassing device identity. + const host = pickMatchingExternalInterfaceAddress(readNetworkInterfaces(), { + family: "IPv4", + matches: (candidate) => + isPrivateOrLoopbackIpAddress(candidate) && !isLoopbackIpAddress(candidate), + }); + if (!host) { + throw new Error("test host has no non-loopback private IPv4 address"); + } + return { authTokens, calls, url: `ws://${host}:${address.port}` }; +} + async function snapshotDirectoryContents(root: string): Promise> { const snapshot: Record = {}; const visit = async (directory: string): Promise => { @@ -466,6 +544,188 @@ describe("gateway-backed CLI process exit", () => { ).toBe(storedToken); }, 30_000); + it("calls a reachable Gateway with explicit auth without creating shared state", async () => { + const root = tempDirs.make("openclaw-gateway-call-explicit-auth-"); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const token = "configured-token"; + const gateway = await startGatewayStabilityRpcServer(token, "issued-device-token"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ gateway: { mode: "remote", remote: { url: gateway.url, token } } }), + ); + expect(await snapshotSharedStateArtifacts(stateDir)).toEqual({}); + + const result = await runIsolatedGatewayCli({ + args: ["gateway", "call", "diagnostics.stability", "--json"], + root, + stateDir, + configPath, + }); + + expect(result, result.stderr).toMatchObject({ code: 0, signal: null, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual(EMPTY_STABILITY_SNAPSHOT); + expect(gateway.authTokens).toEqual([token]); + expect(gateway.calls).toEqual(["diagnostics.stability"]); + expect(await snapshotSharedStateArtifacts(stateDir)).toEqual({}); + }, 30_000); + + it("calls a reachable Gateway with stored auth without changing shared state", async () => { + const root = tempDirs.make("openclaw-gateway-call-stored-auth-"); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const storedToken = "stored-device-token"; + const gateway = await startGatewayStabilityRpcServer(storedToken, "issued-device-token"); + const stateEnv = { + ...process.env, + HOME: root, + OPENCLAW_HOME: root, + OPENCLAW_STATE_DIR: stateDir, + }; + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ gateway: { mode: "remote", remote: { url: gateway.url } } }), + ); + const identity = loadOrCreateDeviceIdentity({ env: stateEnv }); + storeOriginDeviceToken({ + gatewayScope: gatewayOriginScope(gateway.url), + deviceId: identity.deviceId, + role: "operator", + token: storedToken, + scopes: ["operator.admin"], + env: stateEnv, + }); + closeOpenClawStateDatabaseForTest(); + const before = await snapshotSharedStateArtifacts(stateDir); + + const result = await runIsolatedGatewayCli({ + args: ["gateway", "call", "diagnostics.stability", "--json"], + root, + stateDir, + configPath, + }); + + expect(result, result.stderr).toMatchObject({ code: 0, signal: null, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual(EMPTY_STABILITY_SNAPSHOT); + expect(gateway.authTokens).toEqual([storedToken]); + expect(gateway.calls).toEqual(["diagnostics.stability"]); + expect( + loadOriginDeviceTokenReadOnly({ + gatewayScope: gatewayOriginScope(gateway.url), + deviceId: identity.deviceId, + role: "operator", + env: stateEnv, + })?.token, + ).toBe(storedToken); + expect(await snapshotSharedStateArtifacts(stateDir)).toEqual(before); + }, 30_000); + + it.each([ + { label: "absent", seeded: false }, + { label: "seeded", seeded: true }, + ])( + "requires a reachable status RPC without changing $label shared state", + async ({ label, seeded }) => { + const root = tempDirs.make(`openclaw-gateway-status-${label}-`); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const token = "configured-token"; + const gateway = await startGatewayStabilityRpcServer(token, "issued-device-token"); + const stateEnv = { + ...process.env, + HOME: root, + OPENCLAW_HOME: root, + OPENCLAW_STATE_DIR: stateDir, + }; + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ gateway: { mode: "remote", remote: { url: gateway.url, token } } }), + ); + if (seeded) { + const identity = loadOrCreateDeviceIdentity({ env: stateEnv }); + storeOriginDeviceToken({ + gatewayScope: gatewayOriginScope(gateway.url), + deviceId: identity.deviceId, + role: "operator", + token, + scopes: ["operator.admin"], + env: stateEnv, + }); + closeOpenClawStateDatabaseForTest(); + } + const before = await snapshotSharedStateArtifacts(stateDir); + expect(Object.keys(before).includes("openclaw.sqlite")).toBe(seeded); + + const result = await runIsolatedGatewayCli({ + args: [ + "gateway", + "status", + "--url", + gateway.url, + "--token", + token, + "--require-rpc", + "--json", + "--timeout", + "2000", + ], + root, + stateDir, + configPath, + }); + + expect(result, result.stderr).toMatchObject({ code: 0, signal: null, stderr: "" }); + expect(JSON.parse(result.stdout)).toMatchObject({ + rpc: { ok: true, kind: "read" }, + }); + expect(gateway.calls).toEqual(["status"]); + expect(await snapshotSharedStateArtifacts(stateDir)).toEqual(before); + }, + 30_000, + ); + + it.each([ + { label: "absent", seeded: false }, + { label: "seeded", seeded: true }, + ])( + "exports diagnostics without changing $label shared state", + async ({ label, seeded }) => { + const fixture = await prepareUnreachableGatewayCliFixture({ + label: `gateway-diagnostics-export-${label}`, + seeded, + }); + const outputPath = path.join(fixture.root, "diagnostics.zip"); + const before = await snapshotSharedStateArtifacts(fixture.stateDir); + + const result = await runIsolatedGatewayCli({ + ...fixture, + args: [ + "gateway", + "diagnostics", + "export", + "--json", + "--no-stability-bundle", + "--output", + outputPath, + ], + }); + + expect(result, result.stderr).toMatchObject({ code: 0, signal: null, stderr: "" }); + const payload = JSON.parse(result.stdout) as { bytes?: unknown; path?: unknown }; + expect(payload.path).toBe(outputPath); + expect(payload.bytes).toEqual(expect.any(Number)); + expect(payload.bytes).toBeGreaterThan(0); + const outputStat = await fs.stat(outputPath); + expect(outputStat.isFile()).toBe(true); + expect(outputStat.size).toBe(payload.bytes); + expect(await snapshotSharedStateArtifacts(fixture.stateDir)).toEqual(before); + }, + 30_000, + ); + it("rejects invalid remote config before a node pairing mutation without opening state", async () => { const root = tempDirs.make("openclaw-node-pairing-invalid-config-"); const stateDir = path.join(root, "state"); diff --git a/src/cli/gateway-cli/health-route.test.ts b/src/cli/gateway-cli/health-route.test.ts index 60ac656b4b2f..2c58b1e9b6eb 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 readBestEffortHealthConfig = vi.fn(async () => ({})); + const readNonObservingHealthConfig = 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, - readBestEffortHealthConfig, + readNonObservingHealthConfig, emitReachableGatewayAuthDiagnostic: emitReachableGatewayAuthDiagnostic as never, formatGatewayAuthErrorJson: formatGatewayAuthErrorJson as never, formatGatewayClientRequestErrorJson: formatGatewayClientRequestErrorJson as never, @@ -44,7 +44,7 @@ describe("runGatewayHealthJsonRoute", () => { { defaultTimeoutMs: 10_000, sharedStateMode: "read-only" }, ); expect(runtime.writeJson).toHaveBeenCalledWith({ ok: true, durationMs: 6 }, 2); - expect(readBestEffortHealthConfig).not.toHaveBeenCalled(); + expect(readNonObservingHealthConfig).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 readBestEffortHealthConfig = vi.fn(async () => ({ + const readNonObservingHealthConfig = vi.fn(async () => ({ gateway: { auth: { mode: "token" as const } }, })); @@ -64,7 +64,7 @@ describe("runGatewayHealthJsonRoute", () => { localPortOverride: 19083, }, runtime as never, - { callGateway, readBestEffortHealthConfig }, + { callGateway, readNonObservingHealthConfig }, ); expect(callGateway).toHaveBeenCalledWith( @@ -93,7 +93,7 @@ describe("runGatewayHealthJsonRoute", () => { runtime as never, { callGateway, - readBestEffortHealthConfig: vi.fn(async () => { + readNonObservingHealthConfig: vi.fn(async () => { throw error; }), }, @@ -115,7 +115,7 @@ describe("runGatewayHealthJsonRoute", () => { await runGatewayHealthJsonRoute({ rpc: { json: true, timeout: "10000" } }, runtime as never, { callGateway, - readBestEffortHealthConfig: async () => ({}), + readNonObservingHealthConfig: 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, - readBestEffortHealthConfig: async () => ({}), + readNonObservingHealthConfig: 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 c0ed54f57d14..81b82afed9c9 100644 --- a/src/cli/gateway-cli/health-route.ts +++ b/src/cli/gateway-cli/health-route.ts @@ -13,7 +13,7 @@ type GatewayHealthJsonRouteArgs = { type GatewayHealthRouteDependencies = { callGateway?: typeof import("../gateway-rpc.js").callGatewayFromCliWithTransport; - readBestEffortHealthConfig?: typeof import("../../commands/health.js").readBestEffortHealthConfig; + readNonObservingHealthConfig?: typeof import("../../commands/health.js").readNonObservingHealthConfig; emitReachableGatewayAuthDiagnostic?: typeof import("../../commands/health.js").emitReachableGatewayAuthDiagnostic; formatGatewayAuthErrorJson?: typeof import("../../gateway/call.js").formatGatewayAuthErrorJson; formatGatewayClientRequestErrorJson?: typeof import("../../gateway/call.js").formatGatewayClientRequestErrorJson; @@ -27,10 +27,10 @@ async function resolveRouteRpcOptions( if (args.localPortOverride === undefined) { return args.rpc; } - const readBestEffortHealthConfig = - deps.readBestEffortHealthConfig ?? - (await import("../../commands/health.js")).readBestEffortHealthConfig; - const config = await readBestEffortHealthConfig(); + const readNonObservingHealthConfig = + deps.readNonObservingHealthConfig ?? + (await import("../../commands/health.js")).readNonObservingHealthConfig; + const config = await readNonObservingHealthConfig(); return { ...args.rpc, localPortOverride: args.localPortOverride, @@ -70,7 +70,7 @@ export async function runGatewayHealthJsonRoute( return; } const [healthModule, callModule] = await Promise.all([ - deps.emitReachableGatewayAuthDiagnostic && deps.readBestEffortHealthConfig + deps.emitReachableGatewayAuthDiagnostic && deps.readNonObservingHealthConfig ? undefined : import("../../commands/health.js"), deps.formatGatewayAuthErrorJson && @@ -81,14 +81,14 @@ export async function runGatewayHealthJsonRoute( ]); const emitReachableGatewayAuthDiagnostic = deps.emitReachableGatewayAuthDiagnostic ?? healthModule?.emitReachableGatewayAuthDiagnostic; - const readBestEffortHealthConfig = - deps.readBestEffortHealthConfig ?? healthModule?.readBestEffortHealthConfig; - if (!emitReachableGatewayAuthDiagnostic || !readBestEffortHealthConfig) { + const readNonObservingHealthConfig = + deps.readNonObservingHealthConfig ?? healthModule?.readNonObservingHealthConfig; + if (!emitReachableGatewayAuthDiagnostic || !readNonObservingHealthConfig) { throw error; } const handled = await emitReachableGatewayAuthDiagnostic({ error, - config: rpc.config ?? (await readBestEffortHealthConfig()), + config: rpc.config ?? (await readNonObservingHealthConfig()), 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 add87a046ab1..195ea418b730 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -85,7 +85,7 @@ vi.mock("../../commands/health.js", () => ({ emitReachableGatewayAuthDiagnostic: (params: unknown) => mocks.emitReachableGatewayAuthDiagnostic(params), formatHealthChannelLines: () => mocks.formatHealthChannelLines(), - readBestEffortHealthConfig: async () => ({}), + readNonObservingHealthConfig: 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 ed6a9f6b4cae..855004c07cf8 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -114,7 +114,7 @@ function gatewayCallOpts(cmd: Command, defaultTimeoutMs = DEFAULT_GATEWAY_RPC_TI .option("--json", "Output JSON", false); } -async function callGatewayCli(method: string, opts: GatewayRpcOpts, params?: unknown) { +async function callGatewayReadOnlyCli(method: string, opts: GatewayRpcOpts, params?: unknown) { return await callGatewayFromCliWithTransport(method, opts, params, { defaultTimeoutMs: DEFAULT_GATEWAY_RPC_TIMEOUT_MS, sharedStateMode: "read-only", @@ -513,7 +513,7 @@ async function writeSupportExportFromCli(opts: { deep: false, }); }, - readHealthSnapshot: async () => await callGatewayCli("health", rpc), + readHealthSnapshot: async () => await callGatewayReadOnlyCli("health", rpc), }); if (opts.json) { defaultRuntime.writeJson(result); @@ -589,7 +589,7 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie : opts; const rpcOpts = await resolveGatewayRpcOptionsWithLocalPort(callOpts, command); const params = parseGatewayCallParams(String(opts.params ?? "{}")); - const result = await callGatewayCli(method, rpcOpts, params); + const result = await callGatewayReadOnlyCli(method, rpcOpts, params); if (rpcOpts.json) { defaultRuntime.writeJson(result); return; @@ -624,7 +624,7 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie waitSeconds: opts.wait, json: Boolean(rpcOpts.json), }, - { callGateway: callGatewayCli, runtime: defaultRuntime }, + { callGateway: callGatewayReadOnlyCli, runtime: defaultRuntime }, ); }, "Gateway suspend failed", @@ -645,7 +645,7 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie const rpcOpts = await resolveGatewayRpcOptionsWithLocalPort(opts, command); await runGatewayResume( { rpcOpts, suspensionId: String(suspensionId), json: Boolean(rpcOpts.json) }, - { callGateway: callGatewayCli, runtime: defaultRuntime }, + { callGateway: callGatewayReadOnlyCli, runtime: defaultRuntime }, ); }, "Gateway resume failed", @@ -672,7 +672,7 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie if (agentId && opts.allAgents) { throw new Error("Use --agent or --all-agents, not both"); } - const summary = (await callGatewayCli("usage.cost", rpcOpts, { + const summary = (await callGatewayReadOnlyCli("usage.cost", rpcOpts, { days, ...(agentId ? { agentId } : {}), ...(opts.allAgents ? { agentScope: "all" } : {}), @@ -703,14 +703,14 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie const rpcOpts = await resolveGatewayRpcOptionsWithLocalPort(opts, command); let result: unknown; try { - result = await callGatewayCli("health", rpcOpts); + result = await callGatewayReadOnlyCli("health", rpcOpts); } catch (error) { - const { emitReachableGatewayAuthDiagnostic, readBestEffortHealthConfig } = await ( + const { emitReachableGatewayAuthDiagnostic, readNonObservingHealthConfig } = await ( deps.loadGatewayHealthModule ?? loadGatewayHealthModule )(); const handled = await emitReachableGatewayAuthDiagnostic({ error, - config: rpcOpts.config ?? (await readBestEffortHealthConfig()), + config: rpcOpts.config ?? (await readNonObservingHealthConfig()), runtime: defaultRuntime, timeoutMs: parseGatewayRpcTimeoutOption(rpcOpts.timeout), token: rpcOpts.token, @@ -817,7 +817,7 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie return; } - const result = await callGatewayCli("diagnostics.stability", rpcOpts, { + const result = await callGatewayReadOnlyCli("diagnostics.stability", rpcOpts, { limit: query.limit, ...(query.type ? { type: query.type } : {}), ...(query.sinceSeq !== undefined ? { sinceSeq: query.sinceSeq } : {}), diff --git a/src/commands/health.ts b/src/commands/health.ts index d002ff7a5f7e..34c5d59fc4fe 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -249,7 +249,7 @@ export async function healthCommand( }, runtime: RuntimeEnv, ) { - const cfg = opts.config ?? (await readBestEffortHealthConfig()); + const cfg = opts.config ?? (await readNonObservingHealthConfig()); // Always query the running gateway; do not open a direct Baileys socket here. let summary: HealthSummary; try { @@ -563,7 +563,7 @@ export async function healthCommand( } } -export async function readBestEffortHealthConfig(): Promise { +export async function readNonObservingHealthConfig(): Promise { const { readConfigFileSnapshot } = await loadConfigRuntime(); const snapshot = await readConfigFileSnapshot({ observe: false, diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 7d476a776e98..e77ffdc9f129 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -49,7 +49,7 @@ const deviceIdentityState = vi.hoisted(() => ({ throwOnLoad: false, })); const loadOrCreateDeviceIdentityMock = vi.hoisted(() => vi.fn()); -const loadDeviceIdentityIfPresentReadOnlyMock = vi.hoisted(() => vi.fn()); +const loadDeviceIdentityIfPresentMock = vi.hoisted(() => vi.fn()); const loadDeviceAuthTokenMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => DeviceAuthEntry | null>(() => null), ); @@ -142,8 +142,8 @@ vi.mock("../infra/device-identity.js", async (importOriginal) => { } return deviceIdentityState.value; }, - loadDeviceIdentityIfPresentReadOnly: () => { - loadDeviceIdentityIfPresentReadOnlyMock(); + loadDeviceIdentityIfPresent: () => { + loadDeviceIdentityIfPresentMock(); if (deviceIdentityState.throwOnLoad) { throw new Error("read-only identity dir"); } @@ -342,7 +342,7 @@ function resetGatewayCallMocks() { gatewayClientStopAndWait = async () => {}; deviceIdentityState.throwOnLoad = false; loadOrCreateDeviceIdentityMock.mockReset(); - loadDeviceIdentityIfPresentReadOnlyMock.mockReset(); + loadDeviceIdentityIfPresentMock.mockReset(); loadDeviceAuthTokenMock.mockReset(); loadDeviceAuthTokenMock.mockReturnValue({ token: "paired-device-token", @@ -1044,7 +1044,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value); expect(lastClientOptions?.sharedStateMode).toBe("read-only"); - expect(loadDeviceIdentityIfPresentReadOnlyMock).toHaveBeenCalledOnce(); + expect(loadDeviceIdentityIfPresentMock).toHaveBeenCalledOnce(); expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled(); expect(loadOriginDeviceTokenReadOnlyMock).toHaveBeenCalledWith({ gatewayScope: "wss://remote.example:18789", diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 30e8db2d9325..dad1422299c0 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -39,7 +39,7 @@ import { loadOriginDeviceTokenReadOnly, } from "../infra/device-auth-store.js"; import { - loadDeviceIdentityIfPresentReadOnly, + loadDeviceIdentityIfPresent, loadOrCreateDeviceIdentity, type DeviceIdentity, } from "../infra/device-identity.js"; @@ -501,7 +501,7 @@ function shouldOmitDeviceIdentityForGatewayCall(params: { function resolveDeviceIdentityForGatewayCall(sharedStateMode?: "read-only"): DeviceIdentity | null { try { return sharedStateMode === "read-only" - ? loadDeviceIdentityIfPresentReadOnly() + ? loadDeviceIdentityIfPresent() : loadOrCreateDeviceIdentity(); } catch { // Read-only or restricted environments should still be able to call the diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 92fd89677ab6..404f6f0d099d 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -18,7 +18,7 @@ import { storeOriginDeviceToken, } from "../infra/device-auth-store.js"; import { - loadDeviceIdentityIfPresentReadOnly, + loadDeviceIdentityIfPresent, loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload, @@ -99,7 +99,7 @@ function createOpenClawGatewayClientHostDeps( ? { // Read-only is an authoritative lifecycle policy: caller overrides // must not restore identity creation or token writes behind it. - loadOrCreateDeviceIdentity: () => loadDeviceIdentityIfPresentReadOnly() ?? undefined, + loadOrCreateDeviceIdentity: () => loadDeviceIdentityIfPresent() ?? undefined, ...deviceAuthDeps, } : {}), diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index ed4e831b31e0..0bab5e5442bc 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -190,11 +190,11 @@ vi.mock("../infra/device-identity.js", () => ({ })); vi.mock("../infra/device-auth-store.js", () => ({ - loadDeviceAuthToken: (params: unknown) => { + loadDeviceAuthTokenReadOnly: (params: unknown) => { deviceIdentityState.tokenParams.push(params); return deviceIdentityState.cachedToken; }, - loadOriginDeviceToken: (params: unknown) => { + loadOriginDeviceTokenReadOnly: (params: unknown) => { deviceIdentityState.originTokenParams.push(params); return deviceIdentityState.cachedOriginToken; }, @@ -861,6 +861,7 @@ describe("probeGateway", () => { expect(success.ok).toBe(true); expect(lastGatewayClientOptions()?.url).toBe(url); expect(lastGatewayClientOptions()?.deviceIdentity).toEqual(deviceIdentityState.value); + expect(lastGatewayClientOptions()?.sharedStateMode).toBe("read-only"); setDeviceRequiredProbeMode(); gatewayClientState.options = null; diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 6ea1b89a42ea..0228f2ce2669 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -11,7 +11,10 @@ import { readMissingScopeError, type MissingScopeErrorDetails, } from "../../packages/gateway-protocol/src/gateway-error-details.js"; -import { loadDeviceAuthToken, loadOriginDeviceToken } from "../infra/device-auth-store.js"; +import { + loadDeviceAuthTokenReadOnly, + loadOriginDeviceTokenReadOnly, +} from "../infra/device-auth-store.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { SystemPresence } from "../infra/system-presence.js"; import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; @@ -291,13 +294,13 @@ export async function probeGateway(opts: { const cachedOperatorToken = opts.suppressStoredDeviceAuth ? null : deviceAuthScope - ? loadOriginDeviceToken({ + ? loadOriginDeviceTokenReadOnly({ gatewayScope: deviceAuthScope, deviceId: identity.deviceId, role: "operator", env: opts.env, }) - : loadDeviceAuthToken({ + : loadDeviceAuthTokenReadOnly({ deviceId: identity.deviceId, role: "operator", env: opts.env, @@ -411,6 +414,7 @@ export async function probeGateway(opts: { clientName: GATEWAY_CLIENT_NAMES.CLI, clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, + sharedStateMode: "read-only", instanceId, deviceIdentity, onConnectError: (err) => { diff --git a/src/infra/device-identity.test.ts b/src/infra/device-identity.test.ts index 632f269e87b9..262c1d45ca15 100644 --- a/src/infra/device-identity.test.ts +++ b/src/infra/device-identity.test.ts @@ -17,7 +17,6 @@ import type { DeviceIdentityStoreOptions } from "./device-identity-store.js"; import { deriveDeviceIdFromPublicKey, loadDeviceIdentityIfPresent, - loadDeviceIdentityIfPresentReadOnly, loadOrCreateDeviceIdentity, loadOrCreateProcessDeviceIdentity, normalizeDevicePublicKeyBase64Url, @@ -377,24 +376,6 @@ describe("device identity SQLite store", () => { }); }); - it("reads a missing database without creating coordinator artifacts", async () => { - await withTempDir("openclaw-device-identity-artifact-free-", async (rootDir) => { - const options = storeOptions(rootDir); - const coordinatorPaths = resolveDeviceIdentityCoordinatorPaths({ - databasePath: options.path!, - stateDir: rootDir, - temporaryDirectory: os.tmpdir(), - uid: typeof process.getuid === "function" ? process.getuid() : undefined, - }); - - expect(loadDeviceIdentityIfPresentReadOnly(options)).toBeNull(); - expect(fs.existsSync(options.path!)).toBe(false); - for (const coordinatorPath of coordinatorPaths) { - expect(fs.existsSync(coordinatorPath)).toBe(false); - } - }); - }); - it("reads an existing identity without changing canonical SQLite artifacts", async () => { await withTempDir("openclaw-device-identity-artifact-preserving-", async (rootDir) => { const options = storeOptions(rootDir); @@ -403,7 +384,7 @@ describe("device identity SQLite store", () => { const databaseDirectory = path.dirname(options.path!); const artifactsBeforeRead = fs.readdirSync(databaseDirectory).toSorted(); - expect(loadDeviceIdentityIfPresentReadOnly(options)).toEqual(created); + expect(loadDeviceIdentityIfPresent(options)).toEqual(created); expect(fs.readdirSync(databaseDirectory).toSorted()).toEqual(artifactsBeforeRead); }); }); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index fc61c42424d8..9243af4085a5 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -173,18 +173,6 @@ export function loadDeviceIdentityIfPresent( return null; } -/** Load a persisted identity without creating coordinator or shared-state artifacts. */ -export function loadDeviceIdentityIfPresentReadOnly( - options: DeviceIdentityStoreOptions = {}, -): DeviceIdentity | null { - const stored = readStoredDeviceIdentityReadOnly(options); - if (stored) { - return toDeviceIdentity(stored); - } - assertNoPendingLegacyIdentity(options); - return null; -} - /** Sign a UTF-8 payload with a PEM Ed25519 private key and return base64url bytes. */ export function signDevicePayload(privateKeyPem: string, payload: string): string { return signEd25519Payload(privateKeyPem, payload);