diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd5023c6b5..3f4c6f2060f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/gateway: keep diagnostic probes from creating first-time read-only device + pairings, while still reusing cached device tokens for detailed read probes. + Fixes #71766. Thanks @SunboZ. - CLI/agents: keep `agents bind`, `agents unbind`, and `agents bindings` on setup-safe channel metadata paths so they do not preload bundled plugin runtimes or stage runtime dependencies. Fixes #71743. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 0a9e9e9c8eb..3237ac2aab4 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -188,6 +188,9 @@ Notes: - `gateway status` stays available for diagnostics even when the local CLI config is missing or invalid. - Default `gateway status` proves service state, WebSocket connect, and the auth capability visible at handshake time. It does not prove read/write/admin operations. +- Diagnostic probes are non-mutating for first-time device auth: they reuse an + existing cached device token when one exists, but they do not create a new CLI + device identity or read-only device pairing record just to check status. - `gateway status` resolves configured auth SecretRefs for probe auth when possible. - If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. - If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. @@ -225,6 +228,8 @@ Interpretation: - `Capability: read-only|write-capable|admin-capable|pairing-pending|connect-only` reports what the probe could prove about auth. It is separate from reachability. - `Read probe: ok` means read-scope detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded. - `Read probe: limited - missing scope: operator.read` means connect succeeded but read-scope RPC is limited. This is reported as **degraded** reachability, not full failure. +- Like `gateway status`, probe reuses existing cached device auth but does not + create first-time device identity or pairing state. - Exit code is non-zero only when no probed target is reachable. JSON notes (`--json`): diff --git a/src/gateway/probe.auth.integration.test.ts b/src/gateway/probe.auth.integration.test.ts index eed4d1be8ac..28390409023 100644 --- a/src/gateway/probe.auth.integration.test.ts +++ b/src/gateway/probe.auth.integration.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js"; @@ -5,14 +7,57 @@ installGatewayTestHooks(); const { callGateway } = await import("./call.js"); const { probeGateway } = await import("./probe.js"); +const { storeDeviceAuthToken } = await import("../infra/device-auth-store.js"); +const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = + await import("../infra/device-identity.js"); +const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); + +function requireGatewayToken(): string { + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? "") + : ""; + expect(token).toBeTruthy(); + return token; +} + +function statePath(...parts: string[]): string { + const stateDir = process.env.OPENCLAW_STATE_DIR; + expect(stateDir).toBeTruthy(); + return path.join(stateDir ?? "", ...parts); +} + +async function seedCachedOperatorToken(scopes: string[]): Promise { + const identity = loadOrCreateDeviceIdentity(); + const pairing = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + displayName: "vitest probe", + platform: process.platform, + clientId: "test", + clientMode: "probe", + role: "operator", + scopes, + silent: true, + }); + const approved = await approveDevicePairing(pairing.request.requestId, { + callerScopes: scopes, + }); + expect(approved?.status).toBe("approved"); + const token = + approved?.status === "approved" ? (approved.device.tokens?.operator?.token ?? "") : ""; + expect(token).toBeTruthy(); + storeDeviceAuthToken({ + deviceId: identity.deviceId, + role: "operator", + token, + scopes, + }); +} describe("probeGateway auth integration", () => { it("keeps direct local authenticated status RPCs device-bound", async () => { - const token = - typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" - ? ((testState.gatewayAuth as { token?: string }).token ?? "") - : ""; - expect(token).toBeTruthy(); + const token = requireGatewayToken(); await withGatewayServer(async ({ port }) => { const status = await callGateway({ @@ -26,12 +71,30 @@ describe("probeGateway auth integration", () => { }); }); - it("keeps detail RPCs available for local authenticated probes", async () => { - const token = - typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" - ? ((testState.gatewayAuth as { token?: string }).token ?? "") - : ""; - expect(token).toBeTruthy(); + it("keeps first-time local authenticated probes non-mutating", async () => { + const token = requireGatewayToken(); + + await withGatewayServer(async ({ port }) => { + const result = await probeGateway({ + url: `ws://127.0.0.1:${port}`, + auth: { token }, + timeoutMs: 5_000, + }); + + expect(result.ok).toBe(false); + expect(result.health).toBeNull(); + expect(result.status).toBeNull(); + expect(result.configSnapshot).toBeNull(); + expect(result.auth.capability).toBe("connected_no_operator_scope"); + expect(fs.existsSync(statePath("devices", "paired.json"))).toBe(false); + expect(fs.existsSync(statePath("devices", "pending.json"))).toBe(false); + expect(fs.existsSync(statePath("identity", "device-auth.json"))).toBe(false); + }); + }); + + it("keeps detail RPCs available for local authenticated probes with cached device auth", async () => { + const token = requireGatewayToken(); + await seedCachedOperatorToken(["operator.read"]); await withGatewayServer(async ({ port }) => { const result = await probeGateway({ diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 871bf51e802..873e0d45989 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -18,8 +18,14 @@ const gatewayClientState = vi.hoisted(() => ({ })); const deviceIdentityState = vi.hoisted(() => ({ - value: { id: "test-device-identity" } as Record, + value: { deviceId: "test-device-identity" } as Record, throwOnLoad: false, + cachedToken: { + token: "cached-operator-token", + role: "operator", + scopes: ["operator.read"], + updatedAtMs: 1, + } as Record | null, })); class MockGatewayClientRequestError extends Error { @@ -100,6 +106,16 @@ vi.mock("../infra/device-identity.js", () => ({ } return deviceIdentityState.value; }, + loadDeviceIdentityIfPresent: () => { + if (deviceIdentityState.throwOnLoad) { + throw new Error("read-only identity dir"); + } + return deviceIdentityState.value; + }, +})); + +vi.mock("../infra/device-auth-store.js", () => ({ + loadDeviceAuthToken: () => deviceIdentityState.cachedToken, })); const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js"); @@ -107,6 +123,12 @@ const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js"); describe("probeGateway", () => { beforeEach(() => { deviceIdentityState.throwOnLoad = false; + deviceIdentityState.cachedToken = { + token: "cached-operator-token", + role: "operator", + scopes: ["operator.read"], + updatedAtMs: 1, + }; gatewayClientState.startMode = "hello"; gatewayClientState.close = { code: 1008, reason: "pairing required" }; gatewayClientState.helloAuth = { @@ -159,6 +181,19 @@ describe("probeGateway", () => { expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value); }); + it("does not create or attach a device identity for first-time authenticated probes", async () => { + deviceIdentityState.cachedToken = null; + + await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 1_000, + }); + + expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); + }); + it("keeps device identity disabled for unauthenticated loopback probes", async () => { await probeGateway({ url: "ws://127.0.0.1:18789", diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 8763bec5e9b..07b62b0437b 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { loadDeviceAuthToken } from "../infra/device-auth-store.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { SystemPresence } from "../infra/system-presence.js"; import { MAX_SAFE_TIMEOUT_DELAY_MS, resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; @@ -153,17 +154,26 @@ export async function probeGateway(opts: { } catch { return null; } - // Local authenticated probes should stay device-bound so read/detail RPCs - // are not scope-limited by the shared-auth scope stripping hardening. + // Keep probes non-mutating: only attach a device identity when this CLI + // already has a cached operator device token. Fresh diagnostics should not + // create a read-only pairing baseline that later blocks admin commands. if (isLoopbackHost(hostname) && !(opts.auth?.token || opts.auth?.password)) { return null; } try { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); - return loadOrCreateDeviceIdentity(); + const { loadDeviceIdentityIfPresent } = await import("../infra/device-identity.js"); + const identity = loadDeviceIdentityIfPresent(); + if (!identity) { + return null; + } + const cachedOperatorToken = loadDeviceAuthToken({ + deviceId: identity.deviceId, + role: "operator", + }); + return cachedOperatorToken ? identity : null; } catch { // Read-only or restricted environments should still be able to run - // token/password-auth detail probes without crashing on identity persistence. + // token/password-auth detail probes without mutating identity state. return null; } })(); diff --git a/src/infra/device-identity.test.ts b/src/infra/device-identity.test.ts index adcf77ab5e9..a7c72a99542 100644 --- a/src/infra/device-identity.test.ts +++ b/src/infra/device-identity.test.ts @@ -1,8 +1,10 @@ +import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-utils/temp-dir.js"; import { deriveDeviceIdFromPublicKey, + loadDeviceIdentityIfPresent, loadOrCreateDeviceIdentity, normalizeDevicePublicKeyBase64Url, publicKeyRawBase64UrlFromPem, @@ -20,6 +22,36 @@ async function withIdentity( } describe("device identity crypto helpers", () => { + it("loads an existing identity without creating a missing file", async () => { + await withTempDir("openclaw-device-identity-readonly-", async (dir) => { + const identityPath = path.join(dir, "identity", "device.json"); + + expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull(); + expect(fs.existsSync(identityPath)).toBe(false); + + const created = loadOrCreateDeviceIdentity(identityPath); + + expect(loadDeviceIdentityIfPresent(identityPath)).toEqual(created); + }); + }); + + it("does not repair mismatched stored device ids in read-only mode", async () => { + await withTempDir("openclaw-device-identity-readonly-", async (dir) => { + const identityPath = path.join(dir, "identity", "device.json"); + loadOrCreateDeviceIdentity(identityPath); + const stored = JSON.parse(fs.readFileSync(identityPath, "utf8")) as Record; + fs.writeFileSync( + identityPath, + `${JSON.stringify({ ...stored, deviceId: "mismatched" }, null, 2)}\n`, + "utf8", + ); + const before = fs.readFileSync(identityPath, "utf8"); + + expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull(); + expect(fs.readFileSync(identityPath, "utf8")).toBe(before); + }); + }); + it("derives the same canonical raw key and device id from pem and encoded public keys", async () => { await withIdentity((identity) => { const publicKeyRaw = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index b3579c394c8..53463f5dad5 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -122,6 +122,37 @@ export function loadOrCreateDeviceIdentity( return identity; } +export function loadDeviceIdentityIfPresent( + filePath: string = resolveDefaultIdentityPath(), +): DeviceIdentity | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as StoredIdentity; + if ( + parsed?.version !== 1 || + typeof parsed.deviceId !== "string" || + typeof parsed.publicKeyPem !== "string" || + typeof parsed.privateKeyPem !== "string" + ) { + return null; + } + const derivedId = fingerprintPublicKey(parsed.publicKeyPem); + if (!derivedId || derivedId !== parsed.deviceId) { + return null; + } + return { + deviceId: parsed.deviceId, + publicKeyPem: parsed.publicKeyPem, + privateKeyPem: parsed.privateKeyPem, + }; + } catch { + return null; + } +} + export function signDevicePayload(privateKeyPem: string, payload: string): string { const key = crypto.createPrivateKey(privateKeyPem); const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);