fix(gateway): keep diagnostic probes non-mutating

This commit is contained in:
Peter Steinberger
2026-04-25 23:02:16 +01:00
parent bd796d1c85
commit 8d08e86f42
7 changed files with 196 additions and 17 deletions

View File

@@ -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.

View File

@@ -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`):

View File

@@ -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<void> {
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({

View File

@@ -18,8 +18,14 @@ const gatewayClientState = vi.hoisted(() => ({
}));
const deviceIdentityState = vi.hoisted(() => ({
value: { id: "test-device-identity" } as Record<string, unknown>,
value: { deviceId: "test-device-identity" } as Record<string, unknown>,
throwOnLoad: false,
cachedToken: {
token: "cached-operator-token",
role: "operator",
scopes: ["operator.read"],
updatedAtMs: 1,
} as Record<string, unknown> | 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",

View File

@@ -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;
}
})();

View File

@@ -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<string, unknown>;
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);

View File

@@ -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);