fix(cli): keep gateway calls and diagnostics read-only (#125178)

* fix(cli): keep gateway diagnostics read-only

Direct gateway calls and diagnostic probes now preserve shared SQLite ownership instead of creating or mutating state during read-only operations.

Related: #101290
Follow-up to #125102

* fix(cli): keep required status probes read-only
This commit is contained in:
Peter Steinberger
2026-08-17 03:58:57 -07:00
committed by GitHub
parent 6d04fe5305
commit bbe22082b0
18 changed files with 321 additions and 78 deletions
+4 -1
View File
@@ -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"],
+1
View File
@@ -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) => {
+1
View File
@@ -47,6 +47,7 @@ describe("command-startup-policy", () => {
["hooks", "check"],
["memory", "search"],
["memory", "status"],
["gateway", "diagnostics", "export"],
["gateway", "stability"],
["gateway", "usage-cost"],
]) {
+3
View File
@@ -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",
});
});
+1
View File
@@ -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);
+260
View File
@@ -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<ChildProcessWithoutNullStreams>();
const activeServers = new Set<WebSocketServer>();
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<string | undefined>;
calls: string[];
url: string;
}> {
const authTokens: Array<string | undefined> = [];
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<Record<string, string>> {
const snapshot: Record<string, string> = {};
const visit = async (directory: string): Promise<void> => {
@@ -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");
+8 -8
View File
@@ -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,
+10 -10
View File
@@ -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,
@@ -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", () => ({
+10 -10
View File
@@ -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 } : {}),
+2 -2
View File
@@ -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<OpenClawConfig> {
export async function readNonObservingHealthConfig(): Promise<OpenClawConfig> {
const { readConfigFileSnapshot } = await loadConfigRuntime();
const snapshot = await readConfigFileSnapshot({
observe: false,
+5 -5
View File
@@ -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",
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
}
: {}),
+3 -2
View File
@@ -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;
+7 -3
View File
@@ -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) => {
+1 -20
View File
@@ -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);
});
});
-12
View File
@@ -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);