Skip to content

Commit 4f062d6

Browse files
committed
fix(webchat): unblock backend exec approvals
1 parent 712aa96 commit 4f062d6

17 files changed

Lines changed: 406 additions & 35 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai
6666
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
6767
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.
6868
- Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91.
69+
- WebChat/exec approvals: send `/approve ...` through the existing backend command path immediately while a run is blocked on approval, hydrate pending approval cards after reconnect, and add `openclaw approvals list --gateway` plus `openclaw sessions list --json` so operators can inspect stuck sessions without guessing. Thanks @vincentkoc.
6970
- Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-<uid>` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077.
7071
- Gateway/diagnostics: make stuck-session recovery outcome-driven and generation-guarded, add `diagnostics.stuckSessionAbortMs`, and emit structured recovery requested/completed events so stale or skipped recovery no longer looks like a successful abort.
7172
- Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y.

docs/cli/approvals.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ or `openclaw approvals set --node <id|name|ip>`.
6363
openclaw approvals get
6464
openclaw approvals get --node <id|name|ip>
6565
openclaw approvals get --gateway
66+
openclaw approvals list --gateway
6667
```
6768

6869
`openclaw approvals get` now shows the effective exec policy for local, gateway, and node targets:
@@ -78,6 +79,8 @@ Precedence is intentional:
7879
- `--node` combines the node host approvals file with gateway `tools.exec` policy, because both still apply at runtime
7980
- if gateway config is unavailable, the CLI falls back to the node approvals snapshot and notes that the final runtime policy could not be computed
8081

82+
`openclaw approvals list --gateway` lists pending runtime exec approval requests on the gateway. Use `get` for policy snapshots and allowlists; use `list` when an agent is waiting for an approval id.
83+
8184
## Replace approvals from a file
8285

8386
```bash

docs/cli/sessions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ openclaw sessions --active 120
3131
openclaw sessions --limit 25
3232
openclaw sessions --verbose
3333
openclaw sessions --json
34+
openclaw sessions list --json
3435
```
3536

3637
Scope selection:

docs/tools/exec-approvals.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ prompting even if session or config defaults request `ask: "on-miss"`.
2828
| Command | What it shows |
2929
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
3030
| `openclaw approvals get` / `--gateway` / `--node <id\|name\|ip>` | Requested policy, host policy sources, and the effective result. |
31+
| `openclaw approvals list --gateway` | Pending gateway runtime exec approval requests. |
3132
| `openclaw exec-policy show` | Local-machine merged view. |
3233
| `openclaw exec-policy set` / `preset` | Synchronize the local requested policy with the local host approvals file in one step. |
3334

src/cli/command-catalog.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
165165
policy: { ensureCliPath: false, networkProxy: "bypass" },
166166
route: { id: "sessions" },
167167
},
168+
{
169+
commandPath: ["sessions", "list"],
170+
exact: true,
171+
policy: { ensureCliPath: false, networkProxy: "bypass" },
172+
route: { id: "sessions" },
173+
},
168174
{
169175
commandPath: ["commitments"],
170176
policy: {

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

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,29 +24,31 @@ const mocks = vi.hoisted(() => {
2424
}),
2525
};
2626
return {
27-
callGatewayFromCli: vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
28-
if (method.endsWith(".get")) {
29-
if (method === "config.get") {
30-
return {
31-
config: {
32-
tools: {
33-
exec: {
34-
security: "full",
35-
ask: "off",
27+
callGatewayFromCli: vi.fn(
28+
async (method: string, _opts: unknown, params?: unknown): Promise<unknown> => {
29+
if (method.endsWith(".get")) {
30+
if (method === "config.get") {
31+
return {
32+
config: {
33+
tools: {
34+
exec: {
35+
security: "full",
36+
ask: "off",
37+
},
3638
},
3739
},
38-
},
40+
};
41+
}
42+
return {
43+
path: "/tmp/exec-approvals.json",
44+
exists: true,
45+
hash: "hash-1",
46+
file: { version: 1, agents: {} },
3947
};
4048
}
41-
return {
42-
path: "/tmp/exec-approvals.json",
43-
exists: true,
44-
hash: "hash-1",
45-
file: { version: 1, agents: {} },
46-
};
47-
}
48-
return { method, params };
49-
}),
49+
return { method, params };
50+
},
51+
),
5052
defaultRuntime,
5153
readBestEffortConfig,
5254
runtimeErrors,
@@ -170,6 +172,41 @@ describe("exec approvals CLI", () => {
170172
expect(runtimeErrors).toHaveLength(0);
171173
});
172174

175+
it("lists pending gateway exec approvals", async () => {
176+
callGatewayFromCli.mockImplementationOnce(async () => [
177+
{
178+
id: "approval-1",
179+
request: {
180+
command: "ls -la",
181+
agentId: "main",
182+
sessionKey: "agent:main:main",
183+
host: "gateway",
184+
},
185+
createdAtMs: 1000,
186+
expiresAtMs: Date.now() + 60_000,
187+
},
188+
]);
189+
190+
await runApprovalsCommand(["approvals", "list", "--gateway", "--json"]);
191+
192+
expect(callGatewayFromCli).toHaveBeenCalledWith(
193+
"exec.approval.list",
194+
expect.objectContaining({ timeout: "60000", gateway: true }),
195+
{},
196+
);
197+
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
198+
expect.objectContaining({
199+
count: 1,
200+
approvals: [
201+
expect.objectContaining({
202+
id: "approval-1",
203+
}),
204+
],
205+
}),
206+
0,
207+
);
208+
});
209+
173210
it("adds effective policy to json output", async () => {
174211
localSnapshot.file = {
175212
version: 1,

src/cli/exec-approvals-cli.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ type EffectivePolicyReport = {
4444
scopes: ExecPolicyScopeSnapshot[];
4545
note?: string;
4646
};
47+
type PendingExecApproval = {
48+
id?: string;
49+
request?: {
50+
command?: string;
51+
agentId?: string | null;
52+
sessionKey?: string | null;
53+
host?: string | null;
54+
nodeId?: string | null;
55+
};
56+
createdAtMs?: number;
57+
expiresAtMs?: number;
58+
};
4759
const APPROVALS_GET_DEFAULT_TIMEOUT_MS = 60_000;
4860

4961
type ExecApprovalsCliOpts = NodesRpcOpts & {
@@ -360,6 +372,49 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
360372
);
361373
}
362374

375+
function renderPendingApprovals(approvals: PendingExecApproval[]) {
376+
const rich = isRich();
377+
const heading = (text: string) => (rich ? theme.heading(text) : text);
378+
const muted = (text: string) => (rich ? theme.muted(text) : text);
379+
const now = Date.now();
380+
defaultRuntime.log(heading("Pending exec approvals"));
381+
if (approvals.length === 0) {
382+
defaultRuntime.log(muted("No pending exec approvals."));
383+
return;
384+
}
385+
const rows = approvals.map((approval) => {
386+
const request = approval.request ?? {};
387+
const expiresAtMs = typeof approval.expiresAtMs === "number" ? approval.expiresAtMs : null;
388+
return {
389+
ID: approval.id ?? "",
390+
Agent: request.agentId ?? "",
391+
Session: request.sessionKey ?? "",
392+
Host: request.nodeId ? `node:${request.nodeId}` : (request.host ?? ""),
393+
Expires: expiresAtMs ? formatTimeAgo(Math.max(0, expiresAtMs - now), { suffix: false }) : "",
394+
Command: request.command ?? "",
395+
};
396+
});
397+
defaultRuntime.log(
398+
renderTable({
399+
width: getTerminalTableWidth(),
400+
columns: [
401+
{ key: "ID", header: "ID", minWidth: 12 },
402+
{ key: "Agent", header: "Agent", minWidth: 8 },
403+
{ key: "Session", header: "Session", minWidth: 12 },
404+
{ key: "Host", header: "Host", minWidth: 8 },
405+
{ key: "Expires", header: "Expires", minWidth: 10 },
406+
{ key: "Command", header: "Command", minWidth: 24, flex: true },
407+
],
408+
rows,
409+
}).trimEnd(),
410+
);
411+
}
412+
413+
async function listPendingApprovals(opts: ExecApprovalsCliOpts): Promise<PendingExecApproval[]> {
414+
const result = await callGatewayFromCli("exec.approval.list", opts, {});
415+
return Array.isArray(result) ? (result as PendingExecApproval[]) : [];
416+
}
417+
363418
async function saveSnapshot(
364419
opts: ExecApprovalsCliOpts,
365420
nodeId: string | null,
@@ -521,6 +576,25 @@ export function registerExecApprovalsCli(program: Command) {
521576
});
522577
nodesCallOpts(getCmd, { timeoutMs: APPROVALS_GET_DEFAULT_TIMEOUT_MS });
523578

579+
const listCmd = approvals
580+
.command("list")
581+
.description("List pending exec approval requests from the gateway")
582+
.option("--gateway", "Force gateway approvals", false)
583+
.action(async (opts: ExecApprovalsCliOpts) => {
584+
try {
585+
const pending = await listPendingApprovals(opts);
586+
if (opts.json) {
587+
defaultRuntime.writeJson({ count: pending.length, approvals: pending }, 0);
588+
return;
589+
}
590+
renderPendingApprovals(pending);
591+
} catch (err) {
592+
defaultRuntime.error(formatCliError(err));
593+
defaultRuntime.exit(1);
594+
}
595+
});
596+
nodesCallOpts(listCmd, { timeoutMs: APPROVALS_GET_DEFAULT_TIMEOUT_MS });
597+
524598
const setCmd = approvals
525599
.command("set")
526600
.description("Replace exec approvals with a JSON file")

src/cli/program/build-program.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function buildProgram() {
1010
const program = new Command();
1111
program.enablePositionalOptions();
1212
// Preserve Commander-computed exit codes while still aborting parse flow.
13-
// Without this, commands like `openclaw sessions list` can print an error
13+
// Without this, unknown subcommands can print an error
1414
// but still report success when exits are intercepted.
1515
program.exitOverride((err) => {
1616
process.exitCode = typeof err.exitCode === "number" ? err.exitCode : 1;

src/cli/program/register.status-health-sessions.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,19 @@ describe("registerStatusHealthSessionsCommands", () => {
219219
);
220220
});
221221

222+
it("runs sessions list alias with forwarded options", async () => {
223+
await runCli(["sessions", "list", "--json", "--store", "/tmp/sessions.json", "--limit", "10"]);
224+
225+
expect(sessionsCommand).toHaveBeenCalledWith(
226+
expect.objectContaining({
227+
json: true,
228+
store: "/tmp/sessions.json",
229+
limit: "10",
230+
}),
231+
runtime,
232+
);
233+
});
234+
222235
it("runs sessions command with --all-agents forwarding", async () => {
223236
await runCli(["sessions", "--all-agents"]);
224237

src/cli/program/register.status-health-sessions.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,31 @@ function parseTimeoutMs(timeout: unknown): number | null | undefined {
3636
return parsed;
3737
}
3838

39+
type SessionsListOpts = {
40+
json?: boolean;
41+
verbose?: boolean;
42+
store?: string;
43+
agent?: string;
44+
allAgents?: boolean;
45+
active?: string;
46+
limit?: string;
47+
};
48+
49+
async function runSessionsList(opts: SessionsListOpts): Promise<void> {
50+
setVerbose(Boolean(opts.verbose));
51+
await sessionsCommand(
52+
{
53+
json: Boolean(opts.json),
54+
store: opts.store,
55+
agent: opts.agent,
56+
allAgents: Boolean(opts.allAgents),
57+
active: opts.active,
58+
limit: opts.limit,
59+
},
60+
defaultRuntime,
61+
);
62+
}
63+
3964
async function runWithVerboseAndTimeout(
4065
opts: { verbose?: boolean; debug?: boolean; timeout?: unknown },
4166
action: (params: { verbose: boolean; timeoutMs: number | undefined }) => Promise<void>,
@@ -142,6 +167,7 @@ export function registerStatusHealthSessionsCommands(program: Command) {
142167
["openclaw sessions --all-agents", "Aggregate sessions across agents."],
143168
["openclaw sessions --active 120", "Only last 2 hours."],
144169
["openclaw sessions --limit 25", "Show the newest 25 sessions."],
170+
["openclaw sessions list --json", "Machine-readable output."],
145171
["openclaw sessions --json", "Machine-readable output."],
146172
["openclaw sessions --store ./tmp/sessions.json", "Use a specific session store."],
147173
])}\n\n${theme.muted(
@@ -154,21 +180,41 @@ export function registerStatusHealthSessionsCommands(program: Command) {
154180
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/sessions", "docs.openclaw.ai/cli/sessions")}\n`,
155181
)
156182
.action(async (opts) => {
157-
setVerbose(Boolean(opts.verbose));
158-
await sessionsCommand(
159-
{
160-
json: Boolean(opts.json),
161-
store: opts.store as string | undefined,
162-
agent: opts.agent as string | undefined,
163-
allAgents: Boolean(opts.allAgents),
164-
active: opts.active as string | undefined,
165-
limit: opts.limit as string | undefined,
166-
},
167-
defaultRuntime,
168-
);
183+
await runSessionsList({
184+
json: Boolean(opts.json),
185+
verbose: Boolean(opts.verbose),
186+
store: opts.store as string | undefined,
187+
agent: opts.agent as string | undefined,
188+
allAgents: Boolean(opts.allAgents),
189+
active: opts.active as string | undefined,
190+
limit: opts.limit as string | undefined,
191+
});
169192
});
170193
sessionsCmd.enablePositionalOptions();
171194

195+
sessionsCmd
196+
.command("list")
197+
.description("List stored conversation sessions")
198+
.option("--json", "Output as JSON", false)
199+
.option("--verbose", "Verbose logging", false)
200+
.option("--store <path>", "Path to session store (default: resolved from config)")
201+
.option("--agent <id>", "Agent id to inspect (default: configured default agent)")
202+
.option("--all-agents", "Aggregate sessions across all configured agents", false)
203+
.option("--active <minutes>", "Only show sessions updated within the past N minutes")
204+
.option("--limit <count>", 'Max sessions to show (default: 100; use "all" for full output)')
205+
.action(async (opts, command) => {
206+
const parentOpts = command.parent?.opts() as SessionsListOpts | undefined;
207+
await runSessionsList({
208+
json: Boolean(opts.json || parentOpts?.json),
209+
verbose: Boolean(opts.verbose || parentOpts?.verbose),
210+
store: (opts.store as string | undefined) ?? parentOpts?.store,
211+
agent: (opts.agent as string | undefined) ?? parentOpts?.agent,
212+
allAgents: Boolean(opts.allAgents || parentOpts?.allAgents),
213+
active: (opts.active as string | undefined) ?? parentOpts?.active,
214+
limit: (opts.limit as string | undefined) ?? parentOpts?.limit,
215+
});
216+
});
217+
172218
sessionsCmd
173219
.command("cleanup")
174220
.description("Run session-store maintenance now")

0 commit comments

Comments
 (0)