Skip to content

Commit 0381852

Browse files
fix: harden approvals get timeout handling (openclaw#66239) (thanks @neeravmakwana)
* fix(cli): harden approvals get timeout handling * fix(cli): sanitize approvals timeout notes * fix(cli): distill approvals timeout note --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent 0abe64a commit 0381852

3 files changed

Lines changed: 88 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
5959
- Outbound/relay-status: suppress internal relay-status placeholder payloads (`No channel reply.`, `Replied in-thread.`, `Replied in #...`, wiki-update status variants ending in `No channel reply.`) before channel delivery so internal housekeeping text does not leak to users.
6060
- Slack/doctor: add a dedicated doctor-contract sidecar so config warmup paths such as `openclaw cron` no longer fall back to Slack's broader contract surface, which could trigger Slack-related config-read crashes on affected setups. (#63192) Thanks @shhtheonlyperson.
6161
- Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.
62+
- CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.
6263

6364
## 2026.4.12
6465

src/cli/exec-approvals-cli.test.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,19 +141,32 @@ describe("exec approvals CLI", () => {
141141
expect(callGatewayFromCli).toHaveBeenNthCalledWith(
142142
1,
143143
"exec.approvals.get",
144-
expect.anything(),
144+
expect.objectContaining({ timeout: "60000" }),
145+
{},
146+
);
147+
expect(callGatewayFromCli).toHaveBeenNthCalledWith(
148+
2,
149+
"config.get",
150+
expect.objectContaining({ timeout: "60000" }),
145151
{},
146152
);
147-
expect(callGatewayFromCli).toHaveBeenNthCalledWith(2, "config.get", expect.anything(), {});
148153
expect(runtimeErrors).toHaveLength(0);
149154
callGatewayFromCli.mockClear();
150155

151156
await runApprovalsCommand(["approvals", "get", "--node", "macbook"]);
152157

153-
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
154-
nodeId: "node-1",
155-
});
156-
expect(callGatewayFromCli).toHaveBeenCalledWith("config.get", expect.anything(), {});
158+
expect(callGatewayFromCli).toHaveBeenCalledWith(
159+
"exec.approvals.node.get",
160+
expect.objectContaining({ timeout: "60000" }),
161+
{
162+
nodeId: "node-1",
163+
},
164+
);
165+
expect(callGatewayFromCli).toHaveBeenCalledWith(
166+
"config.get",
167+
expect.objectContaining({ timeout: "60000" }),
168+
{},
169+
);
157170
expect(runtimeErrors).toHaveLength(0);
158171
});
159172

@@ -346,6 +359,38 @@ describe("exec approvals CLI", () => {
346359
expect(runtimeErrors).toHaveLength(0);
347360
});
348361

362+
it("reports gateway config timeout explicitly", async () => {
363+
callGatewayFromCli.mockImplementation(
364+
async (method: string, _opts: unknown, params?: unknown) => {
365+
if (method === "config.get") {
366+
throw new Error("gateway timeout after 10000ms\u001b[2K\u0007\nRPC config.get");
367+
}
368+
if (method === "exec.approvals.get") {
369+
return {
370+
path: "/tmp/exec-approvals.json",
371+
exists: true,
372+
hash: "hash-1",
373+
file: { version: 1, agents: {} },
374+
};
375+
}
376+
return { method, params };
377+
},
378+
);
379+
380+
await runApprovalsCommand(["approvals", "get", "--gateway", "--timeout", "10000", "--json"]);
381+
382+
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
383+
expect.objectContaining({
384+
effectivePolicy: {
385+
note: "Config fetch timed out. Re-run with a higher --timeout to inspect Effective Policy.",
386+
scopes: [],
387+
},
388+
}),
389+
0,
390+
);
391+
expect(runtimeErrors).toHaveLength(0);
392+
});
393+
349394
it("keeps node approvals output when gateway config is unavailable", async () => {
350395
callGatewayFromCli.mockImplementation(
351396
async (method: string, _opts: unknown, params?: unknown) => {

src/cli/exec-approvals-cli.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
1717
import { defaultRuntime } from "../runtime.js";
1818
import { normalizeOptionalString } from "../shared/string-coerce.js";
19+
import { sanitizeForLog } from "../terminal/ansi.js";
1920
import { formatDocsLink } from "../terminal/links.js";
2021
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
2122
import { isRich, theme } from "../terminal/theme.js";
@@ -33,11 +34,16 @@ type ExecApprovalsSnapshot = {
3334
type ConfigSnapshotLike = {
3435
config?: OpenClawConfig;
3536
};
37+
type ConfigLoadResult = {
38+
config: OpenClawConfig | null;
39+
timedOut: boolean;
40+
};
3641
type ApprovalsTargetSource = "gateway" | "node" | "local";
3742
type EffectivePolicyReport = {
3843
scopes: ExecPolicyScopeSnapshot[];
3944
note?: string;
4045
};
46+
const APPROVALS_GET_DEFAULT_TIMEOUT_MS = 60_000;
4147

4248
type ExecApprovalsCliOpts = NodesRpcOpts & {
4349
node?: string;
@@ -159,59 +165,73 @@ async function saveSnapshotTargeted(params: {
159165

160166
function formatCliError(err: unknown): string {
161167
const msg = formatErrorMessage(err);
162-
return msg.includes("\n") ? msg.split("\n")[0] : msg;
168+
const firstLine = msg.includes("\n") ? msg.split("\n")[0] : msg;
169+
const safe = sanitizeForLog(firstLine);
170+
return safe.length > 300 ? `${safe.slice(0, 300)}...` : safe;
163171
}
164172

165173
async function loadConfigForApprovalsTarget(params: {
166174
opts: ExecApprovalsCliOpts;
167175
source: ApprovalsTargetSource;
168-
}): Promise<OpenClawConfig | null> {
176+
}): Promise<ConfigLoadResult> {
169177
try {
170178
if (params.source === "local") {
171-
return await readBestEffortConfig();
179+
return { config: await readBestEffortConfig(), timedOut: false };
172180
}
173181
const snapshot = (await callGatewayFromCli(
174182
"config.get",
175183
params.opts,
176184
{},
177185
)) as ConfigSnapshotLike;
178-
return snapshot.config && typeof snapshot.config === "object" ? snapshot.config : null;
179-
} catch {
180-
return null;
186+
return {
187+
config: snapshot.config && typeof snapshot.config === "object" ? snapshot.config : null,
188+
timedOut: false,
189+
};
190+
} catch (err) {
191+
return {
192+
config: null,
193+
timedOut: /^gateway timeout after \d+ms\b/i.test(formatCliError(err)),
194+
};
181195
}
182196
}
183197

184198
function buildEffectivePolicyReport(params: {
185-
cfg: OpenClawConfig | null;
199+
configLoad: ConfigLoadResult;
186200
source: ApprovalsTargetSource;
187201
approvals: ExecApprovalsFile;
188202
hostPath: string;
189203
}): EffectivePolicyReport {
204+
const cfg = params.configLoad.config;
205+
const timeoutNote = params.configLoad.timedOut
206+
? "Config fetch timed out. Re-run with a higher --timeout to inspect Effective Policy."
207+
: null;
190208
if (params.source === "node") {
191-
if (!params.cfg) {
209+
if (!cfg) {
192210
return {
193211
scopes: [],
194-
note: "Gateway config unavailable. Node output above shows host approvals state only, and final runtime policy still intersects with gateway tools.exec.",
212+
note:
213+
timeoutNote ??
214+
"Gateway config unavailable. Node output above shows host approvals state only, and final runtime policy still intersects with gateway tools.exec.",
195215
};
196216
}
197217
return {
198218
scopes: collectExecPolicyScopeSnapshots({
199-
cfg: params.cfg,
219+
cfg,
200220
approvals: params.approvals,
201221
hostPath: params.hostPath,
202222
}),
203223
note: "Effective exec policy is the node host approvals file intersected with gateway tools.exec policy.",
204224
};
205225
}
206-
if (!params.cfg) {
226+
if (!cfg) {
207227
return {
208228
scopes: [],
209-
note: "Config unavailable.",
229+
note: timeoutNote ?? "Config unavailable.",
210230
};
211231
}
212232
return {
213233
scopes: collectExecPolicyScopeSnapshots({
214-
cfg: params.cfg,
234+
cfg,
215235
approvals: params.approvals,
216236
hostPath: params.hostPath,
217237
}),
@@ -473,9 +493,9 @@ export function registerExecApprovalsCli(program: Command) {
473493
.action(async (opts: ExecApprovalsCliOpts) => {
474494
try {
475495
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
476-
const cfg = await loadConfigForApprovalsTarget({ opts, source });
496+
const configLoad = await loadConfigForApprovalsTarget({ opts, source });
477497
const effectivePolicy = buildEffectivePolicyReport({
478-
cfg,
498+
configLoad,
479499
source,
480500
approvals: snapshot.file,
481501
hostPath: snapshot.path,
@@ -498,7 +518,7 @@ export function registerExecApprovalsCli(program: Command) {
498518
defaultRuntime.exit(1);
499519
}
500520
});
501-
nodesCallOpts(getCmd);
521+
nodesCallOpts(getCmd, { timeoutMs: APPROVALS_GET_DEFAULT_TIMEOUT_MS });
502522

503523
const setCmd = approvals
504524
.command("set")

0 commit comments

Comments
 (0)