fix(cli): keep offline gateway RPCs read-only (#125102)

Gateway health, suspend, and resume now preserve shared SQLite state when the target is unreachable.

Related: #101290

Follow-up to #123674
This commit is contained in:
Peter Steinberger
2026-08-16 23:29:30 -07:00
committed by GitHub
parent f77d2ec3a8
commit a3eb8787ff
10 changed files with 179 additions and 63 deletions
+7
View File
@@ -244,6 +244,13 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
route: { id: "gateway-status" },
},
{ commandPath: ["gateway", "call"], exact: true, policy: { networkProxy: "bypass" } },
...["suspend", "resume"].map(
(subcommand): CliCommandCatalogEntry => ({
commandPath: ["gateway", subcommand],
exact: true,
policy: { configGuard: "validate", networkProxy: "bypass" },
}),
),
{ commandPath: ["gateway", "diagnostics"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "discover"], exact: true, policy: { networkProxy: "bypass" } },
{
+9
View File
@@ -125,6 +125,15 @@ describe("command-path-policy", () => {
}
});
it("keeps gateway suspension RPCs on non-observing config validation", () => {
for (const subcommand of ["suspend", "resume"]) {
expectResolvedPolicy(["gateway", subcommand], {
configGuard: "validate",
networkProxy: "bypass",
});
}
});
it("applies exact overrides after broader channel plugin rules", () => {
expectResolvedPolicy(["channels", "send"], {
loadPlugins: "always",
+2
View File
@@ -81,6 +81,8 @@ describe("command-startup-policy", () => {
["nodes", "remove"],
["devices", "approve"],
["devices", "remove"],
["gateway", "suspend"],
["gateway", "resume"],
]) {
expect(resolvePolicy({ commandPath })).toMatchObject({
skipConfigGuard: false,
+120 -30
View File
@@ -23,7 +23,6 @@ import {
storeOriginDeviceToken,
} from "../infra/device-auth-store.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { getFreePort } from "../test-utils/ports.js";
@@ -31,6 +30,7 @@ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const execFileAsync = promisify(execFile);
const activeChildren = new Set<ChildProcessWithoutNullStreams>();
const activeServers = new Set<WebSocketServer>();
const UNREACHABLE_GATEWAY_URL = "ws://127.0.0.1:9";
afterEach(async () => {
await Promise.all(
@@ -191,8 +191,6 @@ async function snapshotDirectoryContents(root: string): Promise<Record<string, s
await visit(absolutePath);
} else if (stat.isSymbolicLink()) {
snapshot[relativePath] = `symlink:${await fs.readlink(absolutePath)}`;
} else if (name === "openclaw.sqlite") {
snapshot[relativePath] = "sqlite-database";
} else {
snapshot[relativePath] = `file:${createHash("sha256")
.update(await fs.readFile(absolutePath))
@@ -204,34 +202,80 @@ async function snapshotDirectoryContents(root: string): Promise<Record<string, s
return snapshot;
}
function snapshotSqliteTables(databasePath: string): Record<string, string[]> {
const database = openNodeSqliteDatabase(resolveImmutableSqliteFileUri(databasePath), {
readOnly: true,
});
async function snapshotSharedStateArtifacts(stateDir: string): Promise<Record<string, string>> {
const sharedStateDir = path.join(stateDir, "state");
try {
const tables = database
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name")
.all() as Array<{ name: string }>;
return Object.fromEntries(
tables.map(({ name }) => {
const quotedName = `"${name.replaceAll('"', '""')}"`;
const rows = database
.prepare(`SELECT * FROM ${quotedName}`)
.all()
.map((row) =>
JSON.stringify(row, (_key, value: unknown) =>
typeof value === "bigint" ? value.toString() : value,
),
)
.toSorted();
return [name, rows];
}),
);
} finally {
database.close();
return await snapshotDirectoryContents(sharedStateDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {};
}
throw error;
}
}
async function prepareUnreachableGatewayCliFixture(params: {
label: string;
seeded: boolean;
}): Promise<{ root: string; stateDir: string; configPath: string }> {
const root = tempDirs.make(`openclaw-${params.label}-${params.seeded ? "seeded" : "absent"}-`);
const stateDir = path.join(root, "state");
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
gateway: {
mode: "remote",
auth: { mode: "none" },
remote: { url: UNREACHABLE_GATEWAY_URL },
},
}),
);
if (params.seeded) {
const stateEnv = {
...process.env,
HOME: root,
OPENCLAW_HOME: root,
OPENCLAW_STATE_DIR: stateDir,
};
const identity = loadOrCreateDeviceIdentity({ env: stateEnv });
storeOriginDeviceToken({
gatewayScope: gatewayOriginScope(UNREACHABLE_GATEWAY_URL),
deviceId: identity.deviceId,
role: "operator",
token: "stored-device-token",
scopes: ["operator.admin"],
env: stateEnv,
});
closeOpenClawStateDatabaseForTest();
}
return { root, stateDir, configPath };
}
function expectUnreachableGatewayTransportFailure(
result: Awaited<ReturnType<typeof runIsolatedGatewayCli>>,
output: "json" | "text",
): void {
expect(result).toMatchObject({ code: 1, signal: null });
if (output === "json") {
expect(result.stderr).toBe("");
expect(JSON.parse(result.stdout)).toMatchObject({
ok: false,
error: {
type: "gateway_transport_error",
kind: "closed",
message: expect.stringContaining("Gateway not reachable"),
},
gateway: { url: UNREACHABLE_GATEWAY_URL },
});
return;
}
expect(result.stderr).toContain("Gateway not reachable");
expect(result.stderr).toContain(UNREACHABLE_GATEWAY_URL);
expect(result.stderr).not.toContain("gateway timeout");
}
async function runIsolatedGatewayCli(params: {
args: string[];
root: string;
@@ -294,6 +338,55 @@ async function runIsolatedGatewayCli(params: {
}
describe("gateway-backed CLI process exit", () => {
it.each([
{
label: "root-health-json",
args: ["health", "--json", "--timeout", "250"],
output: "json" as const,
},
{
label: "gateway-health-text",
args: ["gateway", "health", "--timeout", "250"],
output: "text" as const,
},
{
label: "gateway-health-json",
args: ["gateway", "health", "--json", "--timeout", "250"],
output: "json" as const,
},
{
label: "gateway-suspend-json",
args: ["gateway", "suspend", "--json", "--timeout", "250"],
output: "json" as const,
},
{
label: "gateway-resume-json",
args: ["gateway", "resume", "suspension-1", "--json", "--timeout", "250"],
output: "json" as const,
},
])(
"leaves shared state byte-identical after unreachable $label",
async ({ label, args, output }) => {
const absent = await prepareUnreachableGatewayCliFixture({ label, seeded: false });
expect(await snapshotSharedStateArtifacts(absent.stateDir)).toEqual({});
const absentResult = await runIsolatedGatewayCli({ ...absent, args });
expectUnreachableGatewayTransportFailure(absentResult, output);
expect(await snapshotSharedStateArtifacts(absent.stateDir)).toEqual({});
const seeded = await prepareUnreachableGatewayCliFixture({ label, seeded: true });
const before = await snapshotSharedStateArtifacts(seeded.stateDir);
expect(Object.keys(before)).toContain("openclaw.sqlite");
const seededResult = await runIsolatedGatewayCli({ ...seeded, args });
expectUnreachableGatewayTransportFailure(seededResult, output);
expect(await snapshotSharedStateArtifacts(seeded.stateDir)).toEqual(before);
},
60_000,
);
it("dispatches node pairing mutations without opening the writable state database", async () => {
const root = tempDirs.make("openclaw-node-pairing-cli-");
const stateDir = path.join(root, "state");
@@ -350,9 +443,7 @@ describe("gateway-backed CLI process exit", () => {
env: stateEnv,
});
closeOpenClawStateDatabaseForTest();
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
const before = await snapshotDirectoryContents(stateDir);
const beforeTables = snapshotSqliteTables(databasePath);
const result = await runIsolatedGatewayCli({
args: ["nodes", "approve", "request-1", "--json"],
@@ -365,7 +456,6 @@ describe("gateway-backed CLI process exit", () => {
expect(JSON.parse(result.stdout)).toEqual({ approved: true });
expect(gateway.calls).toEqual(["node.pair.list", "node.pair.approve"]);
expect(await snapshotDirectoryContents(stateDir)).toEqual(before);
expect(snapshotSqliteTables(databasePath)).toEqual(beforeTables);
expect(
loadOriginDeviceTokenReadOnly({
gatewayScope: gatewayOriginScope(gateway.url),
+10 -10
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 readBestEffortConfig = vi.fn(async () => ({}));
const readBestEffortHealthConfig = 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,
readBestEffortConfig,
readBestEffortHealthConfig,
emitReachableGatewayAuthDiagnostic: emitReachableGatewayAuthDiagnostic as never,
formatGatewayAuthErrorJson: formatGatewayAuthErrorJson as never,
formatGatewayClientRequestErrorJson: formatGatewayClientRequestErrorJson as never,
@@ -41,10 +41,10 @@ describe("runGatewayHealthJsonRoute", () => {
"health",
{ json: true, timeout: "10000" },
undefined,
{ defaultTimeoutMs: 10_000 },
{ defaultTimeoutMs: 10_000, sharedStateMode: "read-only" },
);
expect(runtime.writeJson).toHaveBeenCalledWith({ ok: true, durationMs: 6 }, 2);
expect(readBestEffortConfig).not.toHaveBeenCalled();
expect(readBestEffortHealthConfig).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 readBestEffortConfig = vi.fn(async () => ({
const readBestEffortHealthConfig = vi.fn(async () => ({
gateway: { auth: { mode: "token" as const } },
}));
@@ -64,7 +64,7 @@ describe("runGatewayHealthJsonRoute", () => {
localPortOverride: 19083,
},
runtime as never,
{ callGateway, readBestEffortConfig },
{ callGateway, readBestEffortHealthConfig },
);
expect(callGateway).toHaveBeenCalledWith(
@@ -76,7 +76,7 @@ describe("runGatewayHealthJsonRoute", () => {
},
}),
undefined,
{ defaultTimeoutMs: 10_000 },
{ defaultTimeoutMs: 10_000, sharedStateMode: "read-only" },
);
});
@@ -93,7 +93,7 @@ describe("runGatewayHealthJsonRoute", () => {
runtime as never,
{
callGateway,
readBestEffortConfig: vi.fn(async () => {
readBestEffortHealthConfig: vi.fn(async () => {
throw error;
}),
},
@@ -115,7 +115,7 @@ describe("runGatewayHealthJsonRoute", () => {
await runGatewayHealthJsonRoute({ rpc: { json: true, timeout: "10000" } }, runtime as never, {
callGateway,
readBestEffortConfig: async () => ({}),
readBestEffortHealthConfig: 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,
readBestEffortConfig: async () => ({}),
readBestEffortHealthConfig: async () => ({}),
emitReachableGatewayAuthDiagnostic: vi.fn(async () => false) as never,
formatGatewayAuthErrorJson: formatGatewayAuthErrorJson as never,
formatGatewayClientRequestErrorJson: formatGatewayClientRequestErrorJson as never,
+16 -14
View File
@@ -1,5 +1,4 @@
// Route-first machine-readable Gateway health command.
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
@@ -14,7 +13,7 @@ type GatewayHealthJsonRouteArgs = {
type GatewayHealthRouteDependencies = {
callGateway?: typeof import("../gateway-rpc.js").callGatewayFromCliWithTransport;
readBestEffortConfig?: () => Promise<OpenClawConfig>;
readBestEffortHealthConfig?: typeof import("../../commands/health.js").readBestEffortHealthConfig;
emitReachableGatewayAuthDiagnostic?: typeof import("../../commands/health.js").emitReachableGatewayAuthDiagnostic;
formatGatewayAuthErrorJson?: typeof import("../../gateway/call.js").formatGatewayAuthErrorJson;
formatGatewayClientRequestErrorJson?: typeof import("../../gateway/call.js").formatGatewayClientRequestErrorJson;
@@ -28,10 +27,10 @@ async function resolveRouteRpcOptions(
if (args.localPortOverride === undefined) {
return args.rpc;
}
const readBestEffortConfig =
deps.readBestEffortConfig ??
(await import("../../config/read-best-effort-config.runtime.js")).readBestEffortConfig;
const config = await readBestEffortConfig();
const readBestEffortHealthConfig =
deps.readBestEffortHealthConfig ??
(await import("../../commands/health.js")).readBestEffortHealthConfig;
const config = await readBestEffortHealthConfig();
return {
...args.rpc,
localPortOverride: args.localPortOverride,
@@ -59,7 +58,10 @@ export async function runGatewayHealthJsonRoute(
deps.callGateway ?? (await import("../gateway-rpc.js")).callGatewayFromCliWithTransport;
writeRuntimeJson(
runtime,
await callGateway("health", rpc, undefined, { defaultTimeoutMs: 10_000 }),
await callGateway("health", rpc, undefined, {
defaultTimeoutMs: 10_000,
sharedStateMode: "read-only",
}),
);
} catch (error) {
if (!rpc) {
@@ -67,11 +69,10 @@ export async function runGatewayHealthJsonRoute(
runtime.exit(1);
return;
}
const [healthModule, configModule, callModule] = await Promise.all([
deps.emitReachableGatewayAuthDiagnostic ? undefined : import("../../commands/health.js"),
deps.readBestEffortConfig
const [healthModule, callModule] = await Promise.all([
deps.emitReachableGatewayAuthDiagnostic && deps.readBestEffortHealthConfig
? undefined
: import("../../config/read-best-effort-config.runtime.js"),
: import("../../commands/health.js"),
deps.formatGatewayAuthErrorJson &&
deps.formatGatewayClientRequestErrorJson &&
deps.formatGatewayTransportErrorJson
@@ -80,13 +81,14 @@ export async function runGatewayHealthJsonRoute(
]);
const emitReachableGatewayAuthDiagnostic =
deps.emitReachableGatewayAuthDiagnostic ?? healthModule?.emitReachableGatewayAuthDiagnostic;
const readBestEffortConfig = deps.readBestEffortConfig ?? configModule?.readBestEffortConfig;
if (!emitReachableGatewayAuthDiagnostic || !readBestEffortConfig) {
const readBestEffortHealthConfig =
deps.readBestEffortHealthConfig ?? healthModule?.readBestEffortHealthConfig;
if (!emitReachableGatewayAuthDiagnostic || !readBestEffortHealthConfig) {
throw error;
}
const handled = await emitReachableGatewayAuthDiagnostic({
error,
config: rpc.config ?? (await readBestEffortConfig()),
config: rpc.config ?? (await readBestEffortHealthConfig()),
runtime,
timeoutMs: Number(rpc.timeout ?? "10000"),
token: rpc.token,
@@ -85,6 +85,7 @@ vi.mock("../../commands/health.js", () => ({
emitReachableGatewayAuthDiagnostic: (params: unknown) =>
mocks.emitReachableGatewayAuthDiagnostic(params),
formatHealthChannelLines: () => mocks.formatHealthChannelLines(),
readBestEffortHealthConfig: async () => ({}),
}));
vi.mock("../../config/read-best-effort-config.runtime.js", () => ({
+5 -6
View File
@@ -116,6 +116,7 @@ function gatewayCallOpts(cmd: Command, defaultTimeoutMs = DEFAULT_GATEWAY_RPC_TI
async function callGatewayCli(method: string, opts: GatewayRpcOpts, params?: unknown) {
return await callGatewayFromCliWithTransport(method, opts, params, {
defaultTimeoutMs: DEFAULT_GATEWAY_RPC_TIMEOUT_MS,
sharedStateMode: "read-only",
});
}
@@ -696,14 +697,12 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie
try {
result = await callGatewayCli("health", rpcOpts);
} catch (error) {
const [{ emitReachableGatewayAuthDiagnostic }, { readBestEffortConfig }] =
await Promise.all([
(deps.loadGatewayHealthModule ?? loadGatewayHealthModule)(),
loadConfigModule(),
]);
const { emitReachableGatewayAuthDiagnostic, readBestEffortHealthConfig } = await (
deps.loadGatewayHealthModule ?? loadGatewayHealthModule
)();
const handled = await emitReachableGatewayAuthDiagnostic({
error,
config: rpcOpts.config ?? (await readBestEffortConfig()),
config: rpcOpts.config ?? (await readBestEffortHealthConfig()),
runtime: defaultRuntime,
timeoutMs: parseGatewayRpcTimeoutOption(rpcOpts.timeout),
token: rpcOpts.token,
+1
View File
@@ -436,6 +436,7 @@ describe("healthCommand", () => {
expect(gatewayRequest.token).toBe("setup-token");
expect(gatewayRequest.password).toBe("setup-password");
expect(gatewayRequest.ignoreEnvUrlOverride).toBe(true);
expect(gatewayRequest.sharedStateMode).toBe("read-only");
});
it("outputs JSON for gateway transport failures in JSON mode", async () => {
+8 -3
View File
@@ -267,6 +267,7 @@ export async function healthCommand(
config: cfg,
token: opts.token,
password: opts.password,
sharedStateMode: "read-only",
ignoreEnvUrlOverride: opts.ignoreEnvUrlOverride,
localPortOverride: opts.localPortOverride,
}),
@@ -562,7 +563,11 @@ export async function healthCommand(
}
}
async function readBestEffortHealthConfig(): Promise<OpenClawConfig> {
const { readBestEffortConfig } = await loadConfigRuntime();
return await readBestEffortConfig();
export async function readBestEffortHealthConfig(): Promise<OpenClawConfig> {
const { readConfigFileSnapshot } = await loadConfigRuntime();
const snapshot = await readConfigFileSnapshot({
observe: false,
pluginValidation: "core-only",
});
return snapshot.runtimeConfig ?? snapshot.config;
}