Skip to content

Commit 6ee5539

Browse files
bitloisteipete
authored andcommitted
Preserve elevated exec followup defaults
1 parent 48cfb77 commit 6ee5539

15 files changed

Lines changed: 366 additions & 2 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,7 @@ Docs: https://docs.openclaw.ai
12211221
- Memory-core: treat exhausted file watcher limits as non-fatal for builtin memory auto-sync while preserving fatal handling for unrelated disk-full errors. (#73357) Thanks @solodmd.
12221222
- Providers/Ollama: restore catalog context-window forwarding as `num_ctx` for native `/api/chat` requests; fixes tool selection and context truncation regressions on models with catalog entries (qwen3, llama3, gemma3, …) when no explicit `params.num_ctx` was configured. Fixes #76117. (#76181) Thanks @openperf.
12231223
- Plugins/install: pin npm plugin installs to the verified resolved version and reject package-lock version or integrity drift, so mutable tags cannot race integrity checks into accepting a different artifact. Thanks @Lucenx9.
1224+
- Exec approvals: preserve trusted elevated defaults across approved command follow-up runs so same-turn elevated `on`/`ask` commands request a fresh approval instead of reporting elevated as unavailable. Fixes #75832. Thanks @jameyedwards.
12241225

12251226
- Plugins/providers: preserve scoped cold-load fallback for enabled external manifest-contract capability providers missing from the startup registry, so providers such as Fish Audio can resolve on request without requiring `activation.onStartup` for correctness. (#76536) Thanks @Conan-Scott.
12261227
- Gateway/update: carry `continuationMessage` from `update.run` into successful restart sentinels so session-scoped self-updates can resume one follow-up turn after the Gateway restarts. Refs #71178. (#74362) Thanks @100menotu001, @HeilbronAILabs, and @artnking.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { randomUUID } from "node:crypto";
2+
import { normalizeOptionalString } from "../shared/string-coerce.js";
3+
import type { ExecElevatedDefaults } from "./bash-tools.exec-types.js";
4+
5+
const EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX = "exec-approval-followup:";
6+
const EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER = ":elevated:";
7+
const EXEC_APPROVAL_FOLLOWUP_ELEVATED_TTL_MS = 5 * 60 * 1000;
8+
9+
type ExecApprovalFollowupElevatedEntry = {
10+
sessionKey: string;
11+
bashElevated: ExecElevatedDefaults;
12+
expiresAtMs: number;
13+
};
14+
15+
const execApprovalFollowupElevatedDefaults = new Map<string, ExecApprovalFollowupElevatedEntry>();
16+
17+
function cloneExecElevatedDefaults(value: ExecElevatedDefaults): ExecElevatedDefaults {
18+
return {
19+
enabled: value.enabled,
20+
allowed: value.allowed,
21+
defaultLevel: value.defaultLevel,
22+
...(value.fullAccessAvailable !== undefined
23+
? { fullAccessAvailable: value.fullAccessAvailable }
24+
: {}),
25+
...(value.fullAccessBlockedReason !== undefined
26+
? { fullAccessBlockedReason: value.fullAccessBlockedReason }
27+
: {}),
28+
};
29+
}
30+
31+
function pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs: number): void {
32+
for (const [token, entry] of execApprovalFollowupElevatedDefaults) {
33+
if (entry.expiresAtMs <= nowMs) {
34+
execApprovalFollowupElevatedDefaults.delete(token);
35+
}
36+
}
37+
}
38+
39+
export function buildExecApprovalFollowupIdempotencyKey(params: {
40+
approvalId: string;
41+
execApprovalFollowupToken?: string;
42+
}): string {
43+
const base = `${EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX}${params.approvalId}`;
44+
return params.execApprovalFollowupToken
45+
? `${base}${EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER}${params.execApprovalFollowupToken}`
46+
: base;
47+
}
48+
49+
function parseExecApprovalFollowupToken(idempotencyKey: string): string | undefined {
50+
if (!idempotencyKey.startsWith(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX)) {
51+
return undefined;
52+
}
53+
const tokenMarker = idempotencyKey.lastIndexOf(EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER);
54+
if (tokenMarker < EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX.length) {
55+
return undefined;
56+
}
57+
return normalizeOptionalString(
58+
idempotencyKey.slice(tokenMarker + EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER.length),
59+
);
60+
}
61+
62+
export function registerExecApprovalFollowupElevatedDefaults(params: {
63+
sessionKey: string;
64+
bashElevated?: ExecElevatedDefaults;
65+
nowMs?: number;
66+
}): string | undefined {
67+
const sessionKey = normalizeOptionalString(params.sessionKey);
68+
if (!params.bashElevated || !sessionKey) {
69+
return undefined;
70+
}
71+
const nowMs = params.nowMs ?? Date.now();
72+
pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs);
73+
const token = randomUUID();
74+
execApprovalFollowupElevatedDefaults.set(token, {
75+
sessionKey,
76+
bashElevated: cloneExecElevatedDefaults(params.bashElevated),
77+
expiresAtMs: nowMs + EXEC_APPROVAL_FOLLOWUP_ELEVATED_TTL_MS,
78+
});
79+
return token;
80+
}
81+
82+
export function consumeExecApprovalFollowupElevatedDefaults(params: {
83+
token?: string;
84+
sessionKey?: string;
85+
nowMs?: number;
86+
}): ExecElevatedDefaults | undefined {
87+
const token = normalizeOptionalString(params.token);
88+
if (!token) {
89+
return undefined;
90+
}
91+
const nowMs = params.nowMs ?? Date.now();
92+
pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs);
93+
const entry = execApprovalFollowupElevatedDefaults.get(token);
94+
if (!entry) {
95+
return undefined;
96+
}
97+
if (entry.expiresAtMs <= nowMs) {
98+
execApprovalFollowupElevatedDefaults.delete(token);
99+
return undefined;
100+
}
101+
const sessionKey = normalizeOptionalString(params.sessionKey);
102+
if (entry.sessionKey !== sessionKey) {
103+
return undefined;
104+
}
105+
execApprovalFollowupElevatedDefaults.delete(token);
106+
return cloneExecElevatedDefaults(entry.bashElevated);
107+
}
108+
109+
export function consumeExecApprovalFollowupElevatedDefaultsFromIdempotencyKey(params: {
110+
idempotencyKey: string;
111+
sessionKey?: string;
112+
nowMs?: number;
113+
}): ExecElevatedDefaults | undefined {
114+
return consumeExecApprovalFollowupElevatedDefaults({
115+
token: parseExecApprovalFollowupToken(params.idempotencyKey),
116+
sessionKey: params.sessionKey,
117+
nowMs: params.nowMs,
118+
});
119+
}
120+
121+
export function resetExecApprovalFollowupElevatedDefaultsForTests(): void {
122+
execApprovalFollowupElevatedDefaults.clear();
123+
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,30 @@ describe("exec approval followup", () => {
283283
expect(sendMessage).not.toHaveBeenCalled();
284284
});
285285

286+
it("carries the elevated followup token through idempotency without exposing elevated defaults", async () => {
287+
await sendExecApprovalFollowup({
288+
approvalId: "req-elevated-75832",
289+
sessionKey: "agent:main:telegram:direct:123",
290+
turnSourceChannel: "telegram",
291+
resultText: "Exec finished (gateway id=req-elevated-75832, code 0)\nok",
292+
execApprovalFollowupToken: "token-75832",
293+
});
294+
295+
expect(callGatewayTool).toHaveBeenCalledWith(
296+
"agent",
297+
expect.any(Object),
298+
expect.objectContaining({
299+
sessionKey: "agent:main:telegram:direct:123",
300+
channel: "telegram",
301+
idempotencyKey: "exec-approval-followup:req-elevated-75832:elevated:token-75832",
302+
}),
303+
{ expectFinal: true },
304+
);
305+
const [, , agentArgs] = vi.mocked(callGatewayTool).mock.calls[0] ?? [];
306+
expect(agentArgs).not.toHaveProperty("bashElevated");
307+
expect(agentArgs).not.toHaveProperty("execApprovalFollowupToken");
308+
});
309+
286310
it("throws when neither a session nor a deliverable route is available", async () => {
287311
await expect(
288312
sendExecApprovalFollowup({

src/agents/bash-tools.exec-approval-followup.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { sendMessage } from "../infra/outbound/message.js";
66
import { isCronSessionKey, isSubagentSessionKey } from "../sessions/session-key-utils.js";
77
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
88
import { isGatewayMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
9+
import { buildExecApprovalFollowupIdempotencyKey } from "./bash-tools.exec-approval-followup-state.js";
910
import {
1011
formatExecDeniedUserMessage,
1112
isExecDeniedResultText,
@@ -23,6 +24,7 @@ type ExecApprovalFollowupParams = {
2324
turnSourceThreadId?: string | number;
2425
resultText: string;
2526
direct?: boolean;
27+
execApprovalFollowupToken?: string;
2628
};
2729

2830
function buildExecDeniedFollowupPrompt(resultText: string): string {
@@ -149,6 +151,7 @@ function buildAgentFollowupArgs(params: {
149151
turnSourceTo?: string;
150152
turnSourceAccountId?: string;
151153
turnSourceThreadId?: string | number;
154+
execApprovalFollowupToken?: string;
152155
}) {
153156
const { deliveryTarget, sessionOnlyOriginChannel } = params;
154157
// When the followup run has no deliverable route and no gateway-internal channel,
@@ -176,7 +179,10 @@ function buildAgentFollowupArgs(params: {
176179
: sessionOnlyOriginChannel
177180
? params.turnSourceThreadId
178181
: undefined,
179-
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
182+
idempotencyKey: buildExecApprovalFollowupIdempotencyKey({
183+
approvalId: params.approvalId,
184+
execApprovalFollowupToken: params.execApprovalFollowupToken,
185+
}),
180186
};
181187
}
182188

@@ -250,6 +256,7 @@ export async function sendExecApprovalFollowup(
250256
turnSourceTo: params.turnSourceTo,
251257
turnSourceAccountId: params.turnSourceAccountId,
252258
turnSourceThreadId: params.turnSourceThreadId,
259+
execApprovalFollowupToken: params.execApprovalFollowupToken,
253260
}),
254261
{ expectFinal: true },
255262
);

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
runExecProcess,
4242
} from "./bash-tools.exec-runtime.js";
4343
import type {
44+
ExecElevatedDefaults,
4445
ExecApprovalFollowupFactory,
4546
ExecApprovalFollowupOutcome,
4647
ExecToolDetails,
@@ -62,6 +63,7 @@ export type ProcessGatewayAllowlistParams = {
6263
trigger?: string;
6364
agentId?: string;
6465
sessionKey?: string;
66+
bashElevated?: ExecElevatedDefaults;
6567
turnSourceChannel?: string;
6668
turnSourceTo?: string;
6769
turnSourceAccountId?: string;
@@ -445,6 +447,7 @@ export async function processGatewayAllowlist(
445447
const followupTarget = buildExecApprovalFollowupTarget({
446448
approvalId,
447449
sessionKey: params.notifySessionKey ?? params.sessionKey,
450+
bashElevated: params.bashElevated,
448451
turnSourceChannel: params.turnSourceChannel,
449452
turnSourceTo: params.turnSourceTo,
450453
turnSourceAccountId: params.turnSourceAccountId,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export async function executeNodeHostCommand(
147147
const followupTarget = execHostShared.buildExecApprovalFollowupTarget({
148148
approvalId,
149149
sessionKey: params.notifySessionKey ?? params.sessionKey,
150+
bashElevated: params.bashElevated,
150151
turnSourceChannel: params.turnSourceChannel,
151152
turnSourceTo: params.turnSourceTo,
152153
turnSourceAccountId: params.turnSourceAccountId,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ExecAsk, ExecSecurity } from "../infra/exec-approvals.js";
2+
import type { ExecElevatedDefaults } from "./bash-tools.exec-types.js";
23

34
export type ExecuteNodeHostCommandParams = {
45
command: string;
@@ -8,6 +9,7 @@ export type ExecuteNodeHostCommandParams = {
89
requestedNode?: string;
910
boundNode?: string;
1011
sessionKey?: string;
12+
bashElevated?: ExecElevatedDefaults;
1113
turnSourceChannel?: string;
1214
turnSourceTo?: string;
1315
turnSourceAccountId?: string;

src/agents/bash-tools.exec-host-shared.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
resolveExecHostApprovalContext,
88
sendExecApprovalFollowupResult,
99
} from "./bash-tools.exec-host-shared.js";
10+
import {
11+
consumeExecApprovalFollowupElevatedDefaults,
12+
resetExecApprovalFollowupElevatedDefaultsForTests,
13+
} from "./bash-tools.exec-approval-followup-state.js";
1014

1115
const mocks = vi.hoisted(() => ({
1216
resolveExecApprovals: vi.fn(() => ({
@@ -59,6 +63,7 @@ describe("sendExecApprovalFollowupResult", () => {
5963
allowlist: [],
6064
file: { version: 1, agents: {} },
6165
});
66+
resetExecApprovalFollowupElevatedDefaultsForTests();
6267
});
6368

6469
it("logs repeated followup dispatch failures once per approval id and error message", async () => {
@@ -106,6 +111,44 @@ describe("sendExecApprovalFollowupResult", () => {
106111
"exec approval followup dispatch failed (id=approval-0): Channel is required",
107112
);
108113
});
114+
115+
it("registers elevated defaults behind an internal token for agent followups", async () => {
116+
sendExecApprovalFollowup.mockResolvedValue(true);
117+
const bashElevated = {
118+
enabled: true,
119+
allowed: true,
120+
defaultLevel: "on" as const,
121+
};
122+
123+
await sendExecApprovalFollowupResult(
124+
{
125+
approvalId: "approval-elevated-75832",
126+
sessionKey: "agent:main:telegram:direct:123",
127+
turnSourceChannel: "telegram",
128+
bashElevated,
129+
},
130+
"Exec finished",
131+
{ sendExecApprovalFollowup, logWarn },
132+
);
133+
134+
const call = sendExecApprovalFollowup.mock.calls[0]?.[0] as
135+
| { execApprovalFollowupToken?: string; bashElevated?: unknown }
136+
| undefined;
137+
expect(call?.execApprovalFollowupToken).toEqual(expect.any(String));
138+
expect(call).not.toHaveProperty("bashElevated");
139+
expect(
140+
consumeExecApprovalFollowupElevatedDefaults({
141+
token: call?.execApprovalFollowupToken ?? "",
142+
sessionKey: "agent:main:telegram:direct:wrong",
143+
}),
144+
).toBeUndefined();
145+
expect(
146+
consumeExecApprovalFollowupElevatedDefaults({
147+
token: call?.execApprovalFollowupToken ?? "",
148+
sessionKey: "agent:main:telegram:direct:123",
149+
}),
150+
).toEqual(bashElevated);
151+
});
109152
});
110153

111154
describe("resolveExecHostApprovalContext", () => {

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
type ExecSecurity,
1717
} from "../infra/exec-approvals.js";
1818
import { logWarn } from "../logger.js";
19+
import { registerExecApprovalFollowupElevatedDefaults } from "./bash-tools.exec-approval-followup-state.js";
1920
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
2021
import {
2122
type ExecApprovalRegistration,
2223
resolveRegisteredExecApprovalDecision,
2324
} from "./bash-tools.exec-approval-request.js";
2425
import { buildApprovalPendingMessage } from "./bash-tools.exec-runtime.js";
2526
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js";
26-
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
27+
import type { ExecElevatedDefaults, ExecToolDetails } from "./bash-tools.exec-types.js";
2728

2829
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
2930
export const MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS = 256;
@@ -89,6 +90,7 @@ export type ExecApprovalFollowupTarget = {
8990
turnSourceAccountId?: string;
9091
turnSourceThreadId?: string | number;
9192
direct?: boolean;
93+
bashElevated?: ExecElevatedDefaults;
9294
};
9395

9496
export type ExecApprovalFollowupResultDeps = {
@@ -324,6 +326,7 @@ export function buildExecApprovalFollowupTarget(
324326
turnSourceAccountId: params.turnSourceAccountId,
325327
turnSourceThreadId: params.turnSourceThreadId,
326328
direct: params.direct,
329+
bashElevated: params.bashElevated,
327330
};
328331
}
329332

@@ -408,6 +411,13 @@ export async function sendExecApprovalFollowupResult(
408411
): Promise<void> {
409412
const send = deps.sendExecApprovalFollowup ?? sendExecApprovalFollowup;
410413
const warn = deps.logWarn ?? logWarn;
414+
const execApprovalFollowupToken =
415+
target.direct === true || !target.sessionKey
416+
? undefined
417+
: registerExecApprovalFollowupElevatedDefaults({
418+
sessionKey: target.sessionKey,
419+
bashElevated: target.bashElevated,
420+
});
411421
await send({
412422
approvalId: target.approvalId,
413423
sessionKey: target.sessionKey,
@@ -417,6 +427,7 @@ export async function sendExecApprovalFollowupResult(
417427
turnSourceThreadId: target.turnSourceThreadId,
418428
resultText,
419429
direct: target.direct,
430+
execApprovalFollowupToken,
420431
}).catch((error) => {
421432
const message = formatErrorMessage(error);
422433
const key = `${target.approvalId}:${message}`;

src/agents/bash-tools.exec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,6 +1480,7 @@ export function createExecTool(
14801480
requestedNode: params.node?.trim(),
14811481
boundNode: defaults?.node?.trim(),
14821482
sessionKey: defaults?.sessionKey,
1483+
bashElevated: elevatedDefaults,
14831484
turnSourceChannel: defaults?.messageProvider,
14841485
turnSourceTo: defaults?.currentChannelId,
14851486
turnSourceAccountId: defaults?.accountId,
@@ -1520,6 +1521,7 @@ export function createExecTool(
15201521
trigger: defaults?.trigger,
15211522
agentId,
15221523
sessionKey: defaults?.sessionKey,
1524+
bashElevated: elevatedDefaults,
15231525
turnSourceChannel: defaults?.messageProvider,
15241526
turnSourceTo: defaults?.currentChannelId,
15251527
turnSourceAccountId: defaults?.accountId,

0 commit comments

Comments
 (0)