mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
6d04fe5305
commit
bbe22082b0
@@ -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"],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ describe("command-startup-policy", () => {
|
||||
["hooks", "check"],
|
||||
["memory", "search"],
|
||||
["memory", "status"],
|
||||
["gateway", "diagnostics", "export"],
|
||||
["gateway", "stability"],
|
||||
["gateway", "usage-cost"],
|
||||
]) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user