Skip to content

Commit 942b449

Browse files
steipetevincentkoc
andauthored
fix(exec): keep pending approval warnings truthful (#101561)
Co-authored-by: Vincent Koc <[email protected]>
1 parent 036686a commit 942b449

8 files changed

Lines changed: 128 additions & 9 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26+
- **Exec approval prompts:** keep background-disabled fallback warnings out of pending gateway/node approvals and show them only after a command actually runs in the foreground. (#78184) Thanks @vincentkoc.
2627
- **Agent wait hard-timeout snapshots:** preserve canonical hard-timeout phase and timestamps when the outer `agent.wait` timer wins the retry-grace race, while leaving queue, draining, and restart-cancelled waits correctable. (#89367) Thanks @Pick-cat.
2728
- **Control UI typed approvals:** send `/approve` commands immediately through the authorized Gateway command path while an agent run is blocked instead of queueing the command behind that run. (#77672) Thanks @vincentkoc.
2829
- **Microsoft Teams Graph response bounds:** cap successful file-upload and chat JSON reads so oversized Microsoft Graph responses cannot be buffered without limit. (#97784) Thanks @Alix-007.

docs/tools/exec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ To hard-disable exec, deny it via tool policy (`tools.deny: ["exec"]` or per-age
168168

169169
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
170170

171-
When approvals are required, the exec tool returns immediately with `status: "approval-pending"` and an approval id. Once approved (or denied / timed out), the Gateway emits command progress and completion system events only for approved runs (`Exec running` / `Exec finished`). Denied or timed-out approvals are terminal and do not wake the agent session with a denial system event.
171+
When a human approval is required, node-host and non-native gateway flows return immediately with `status: "approval-pending"` and an approval id. Native chat and Web UI gateway flows can instead wait inline and return the final command result after approval. An `approval-pending` result means the command has not started, so foreground fallback warnings appear only if the approved command actually runs inline. Approved asynchronous runs emit command progress and completion system events (`Exec running` / `Exec finished`); denied or timed-out approvals are terminal and do not wake the agent session with a denial system event.
172172

173173
On channels with native approval cards/buttons, the agent should rely on that native UI first and only include a manual `/approve` command when the tool result explicitly says chat approvals are unavailable or manual approval is the only path.
174174

src/agents/bash-tools.exec-foreground-failures.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,26 @@ describe("exec foreground failures", () => {
136136
tempDirs.cleanup();
137137
});
138138

139+
it("keeps the background fallback warning when gateway exec actually runs inline", async () => {
140+
mockSuccessfulSpawn();
141+
const tool = createExecTool({
142+
host: "gateway",
143+
security: "full",
144+
ask: "off",
145+
allowBackground: false,
146+
});
147+
148+
const result = await tool.execute("call-background-disabled-foreground", {
149+
command: "echo ok",
150+
background: true,
151+
});
152+
153+
expect(result.details.status).toBe("completed");
154+
expect(requireTextContent(result)).toContain(
155+
"Warning: background execution is disabled; running synchronously.",
156+
);
157+
});
158+
139159
it("returns a failed text result when the default timeout is exceeded", async () => {
140160
const tool = createExecTool({
141161
security: "full",

src/agents/bash-tools.exec-host-node-phases.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
} from "../infra/system-run-command.js";
4040
import { addSafeTimeoutDelayGraceMs } from "../utils/timer-delay.js";
4141
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
42-
import { renderExecOutputText } from "./bash-tools.exec-output.js";
42+
import { renderExecUpdateText } from "./bash-tools.exec-output.js";
4343
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
4444
import type { AgentToolResult } from "./runtime/index.js";
4545
import { callGatewayTool } from "./tools/gateway.js";
@@ -225,6 +225,7 @@ export function formatNodeRunToolResult(params: {
225225
raw: unknown;
226226
startedAt: number;
227227
cwd: string | undefined;
228+
warnings?: string[];
228229
}): AgentToolResult<ExecToolDetails> {
229230
const payload =
230231
params.raw && typeof params.raw === "object"
@@ -241,7 +242,10 @@ export function formatNodeRunToolResult(params: {
241242
content: [
242243
{
243244
type: "text",
244-
text: renderExecOutputText(stdout || stderr || errorText),
245+
text: renderExecUpdateText({
246+
tailText: stdout || stderr || errorText,
247+
warnings: params.warnings ?? [],
248+
}),
245249
},
246250
],
247251
details: {
@@ -404,7 +408,12 @@ export async function invokeNodeSystemRunDirect(params: {
404408
notifyOnExit: params.request.notifyOnExit,
405409
}),
406410
);
407-
return formatNodeRunToolResult({ raw, startedAt, cwd: params.request.workdir });
411+
return formatNodeRunToolResult({
412+
raw,
413+
startedAt,
414+
cwd: params.request.workdir,
415+
warnings: [...params.request.warnings, ...(params.request.foregroundWarnings ?? [])],
416+
});
408417
}
409418

410419
/** Prepares a node-host system run using remote prepare support or local fallback. */

src/agents/bash-tools.exec-host-node.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
resolveExecApprovalUnavailableDecisions,
1515
} from "../infra/exec-approvals.js";
1616
import { defaultExecAutoReviewer, type ExecAutoReviewInput } from "../infra/exec-auto-review.js";
17+
import { tail } from "./bash-process-registry.js";
1718
import {
1819
buildExecApprovalRequesterContext,
1920
buildExecApprovalTurnSourceContext,
@@ -29,7 +30,6 @@ import {
2930
shouldSkipNodeApprovalPrepare,
3031
} from "./bash-tools.exec-host-node-phases.js";
3132
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
32-
import { tail } from "./bash-process-registry.js";
3333
import * as execHostShared from "./bash-tools.exec-host-shared.js";
3434
import {
3535
DEFAULT_NOTIFY_TAIL_CHARS,
@@ -483,5 +483,10 @@ export async function executeNodeHostCommand(
483483
scopes: APPROVED_NODE_INVOKE_SCOPES,
484484
})
485485
: await callGatewayTool("node.invoke", { timeoutMs: target.invokeTimeoutMs }, invoke);
486-
return formatNodeRunToolResult({ raw, startedAt, cwd: params.workdir });
486+
return formatNodeRunToolResult({
487+
raw,
488+
startedAt,
489+
cwd: params.workdir,
490+
warnings: [...params.warnings, ...(params.foregroundWarnings ?? [])],
491+
});
487492
}

src/agents/bash-tools.exec-host-node.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export type ExecuteNodeHostCommandParams = {
3838
defaultTimeoutSec: number;
3939
approvalRunningNoticeMs: number;
4040
warnings: string[];
41+
/** Warnings that apply only when the command runs inline, never while approval is pending. */
42+
foregroundWarnings?: string[];
4143
notifySessionKey?: string;
4244
notifyOnExit?: boolean;
4345
trustedSafeBinDirs?: ReadonlySet<string>;

src/agents/bash-tools.exec.approval-id.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,37 @@ describe("exec approvals", () => {
657657
expect(calls).toContain("node.invoke");
658658
});
659659

660+
it("keeps the background fallback warning when node exec actually runs inline", async () => {
661+
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
662+
if (method === "node.invoke") {
663+
const invoke = params as { command?: string };
664+
if (invoke.command === "system.run") {
665+
return { payload: { success: true, stdout: "node-ok" } };
666+
}
667+
}
668+
return { ok: true };
669+
});
670+
671+
const tool = createExecTool({
672+
host: "node",
673+
ask: "off",
674+
security: "full",
675+
allowBackground: false,
676+
approvalRunningNoticeMs: 0,
677+
});
678+
679+
const result = await tool.execute("call-node-background-disabled", {
680+
command: "echo ok",
681+
background: true,
682+
});
683+
684+
expect(result.details.status).toBe("completed");
685+
expect(getResultText(result)).toContain(
686+
"Warning: background execution is disabled; running synchronously.",
687+
);
688+
expect(getResultText(result)).toContain("node-ok");
689+
});
690+
660691
it("honors ask=off for elevated gateway exec without prompting", async () => {
661692
const calls: string[] = [];
662693
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
@@ -941,6 +972,49 @@ describe("exec approvals", () => {
941972
expect(calls).toContain("exec.approval.waitDecision");
942973
});
943974

975+
it.each(["gateway", "node"] as const)(
976+
"keeps background fallback warnings out of pending %s approvals",
977+
async (host) => {
978+
let approvalRequest: Record<string, unknown> | undefined;
979+
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
980+
if (method === "node.invoke") {
981+
const invoke = params as { command?: string };
982+
if (invoke.command === "system.run.prepare") {
983+
return buildPreparedSystemRunPayload(params);
984+
}
985+
}
986+
if (method === "exec.approvals.node.get") {
987+
return { file: { version: 1, agents: {} } };
988+
}
989+
if (method === "exec.approval.request") {
990+
approvalRequest = params as Record<string, unknown>;
991+
return acceptedApprovalResponse(params);
992+
}
993+
if (method === "exec.approval.waitDecision") {
994+
return { decision: "deny" };
995+
}
996+
return { ok: true };
997+
});
998+
999+
const tool = createExecTool({
1000+
host,
1001+
ask: "always",
1002+
security: "full",
1003+
allowBackground: false,
1004+
approvalRunningNoticeMs: 0,
1005+
});
1006+
1007+
const result = await tool.execute(`call-${host}-background-approval`, {
1008+
command: "echo ok",
1009+
background: true,
1010+
});
1011+
1012+
expect(result.details.status).toBe("approval-pending");
1013+
expect(getResultText(result)).not.toContain("background execution is disabled");
1014+
expect(approvalRequest?.warningText).toBeUndefined();
1015+
},
1016+
);
1017+
9441018
it("starts an internal agent follow-up after approved gateway exec completes without an external route", async () => {
9451019
const agentCalls: Array<Record<string, unknown>> = [];
9461020

src/agents/bash-tools.exec.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,9 +1583,10 @@ export function createExecTool(
15831583
let execCommandOverride: string | undefined;
15841584
const backgroundRequested = params.background === true;
15851585
const yieldRequested = typeof params.yieldMs === "number";
1586-
if (!allowBackground && (backgroundRequested || yieldRequested)) {
1587-
warnings.push("Warning: background execution is disabled; running synchronously.");
1588-
}
1586+
const foregroundFallbackWarning =
1587+
!allowBackground && (backgroundRequested || yieldRequested)
1588+
? "Warning: background execution is disabled; running synchronously."
1589+
: undefined;
15891590
const yieldWindow = allowBackground
15901591
? backgroundRequested
15911592
? 0
@@ -1885,6 +1886,7 @@ export function createExecTool(
18851886
defaultTimeoutSec,
18861887
approvalRunningNoticeMs,
18871888
warnings,
1889+
foregroundWarnings: foregroundFallbackWarning ? [foregroundFallbackWarning] : [],
18881890
notifySessionKey,
18891891
notifyOnExit,
18901892
trustedSafeBinDirs,
@@ -1947,6 +1949,12 @@ export function createExecTool(
19471949
}
19481950
}
19491951

1952+
// Pending approvals have not started the command. Add fallback warnings only
1953+
// after approval routing proves this call will execute in the foreground.
1954+
if (foregroundFallbackWarning) {
1955+
warnings.push(foregroundFallbackWarning);
1956+
}
1957+
19501958
const explicitTimeoutSec = typeof params.timeout === "number" ? params.timeout : null;
19511959
effectiveTimeout = explicitTimeoutSec ?? defaultTimeoutSec;
19521960
const usePty = params.pty === true && !sandbox;

0 commit comments

Comments
 (0)