mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(gateway): keep diagnostic probes non-mutating
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user