Exec: relay approved completions without heartbeat

This commit is contained in:
huntharo
2026-03-09 08:06:02 -04:00
parent 272c64c95c
commit e18e4b2009
12 changed files with 292 additions and 80 deletions

View File

@@ -0,0 +1,61 @@
import { callGatewayTool } from "./tools/gateway.js";
type ExecApprovalFollowupParams = {
approvalId: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
resultText: string;
};
export function buildExecApprovalFollowupPrompt(resultText: string): string {
return [
"An async command the user already approved has completed.",
"Do not run the command again.",
"",
"Exact completion details:",
resultText.trim(),
"",
"Reply to the user in a helpful way.",
"If it succeeded, share the relevant output.",
"If it failed, explain what went wrong.",
].join("\n");
}
export async function sendExecApprovalFollowup(
params: ExecApprovalFollowupParams,
): Promise<boolean> {
const sessionKey = params.sessionKey?.trim();
const resultText = params.resultText.trim();
if (!sessionKey || !resultText) {
return false;
}
const channel = params.turnSourceChannel?.trim();
const to = params.turnSourceTo?.trim();
const threadId =
params.turnSourceThreadId != null && params.turnSourceThreadId !== ""
? String(params.turnSourceThreadId)
: undefined;
await callGatewayTool(
"agent",
{ timeoutMs: 60_000 },
{
sessionKey,
message: buildExecApprovalFollowupPrompt(resultText),
deliver: true,
bestEffortDeliver: true,
channel: channel && to ? channel : undefined,
to: channel && to ? to : undefined,
accountId: channel && to ? params.turnSourceAccountId?.trim() || undefined : undefined,
threadId: channel && to ? threadId : undefined,
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
},
{ expectFinal: true },
);
return true;
}

View File

@@ -13,6 +13,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
import { logInfo } from "../logger.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
@@ -28,7 +29,6 @@ import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
normalizeNotifyOutput,
runExecProcess,
} from "./bash-tools.exec-runtime.js";
@@ -142,8 +142,6 @@ export async function processGatewayAllowlist(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -181,13 +179,15 @@ export async function processGatewayAllowlist(
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
),
void sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
if (decision === undefined) {
return;
@@ -231,13 +231,15 @@ export async function processGatewayAllowlist(
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
return;
}
@@ -263,32 +265,21 @@ export async function processGatewayAllowlist(
timeoutSec: effectiveTimeout,
});
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
}).catch(() => {});
return;
}
markBackgrounded(run.session);
let runningTimer: NodeJS.Timeout | null = null;
if (params.approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
}, params.approvalRunningNoticeMs);
}
const outcome = await run.promise;
if (runningTimer) {
clearTimeout(runningTimer);
}
const output = normalizeNotifyOutput(
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
@@ -296,7 +287,15 @@ export async function processGatewayAllowlist(
const summary = output
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey });
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
})();
return {

View File

@@ -12,6 +12,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
import { logInfo } from "../logger.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
@@ -25,8 +26,9 @@ import {
} from "./bash-tools.exec-host-shared.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
normalizeNotifyOutput,
} from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
@@ -191,6 +193,7 @@ export async function executeNodeHostCommand(
approvedByAsk: boolean,
approvalDecision: "allow-once" | "allow-always" | null,
runId?: string,
suppressNotifyOnExit?: boolean,
) =>
({
nodeId,
@@ -206,6 +209,7 @@ export async function executeNodeHostCommand(
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
runId: runId ?? undefined,
suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined,
},
idempotencyKey: crypto.randomUUID(),
}) satisfies Record<string, unknown>;
@@ -214,8 +218,6 @@ export async function executeNodeHostCommand(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -253,10 +255,15 @@ export async function executeNodeHostCommand(
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
),
void sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
if (decision === undefined) {
return;
@@ -282,44 +289,67 @@ export async function executeNodeHostCommand(
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
return;
}
let runningTimer: NodeJS.Timeout | null = null;
if (params.approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
}, params.approvalRunningNoticeMs);
}
try {
await callGatewayTool(
const raw = await callGatewayTool<{
payload?: {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
};
}>(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(approvedByAsk, approvalDecision, approvalId),
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
);
const payload =
raw?.payload && typeof raw.payload === "object"
? (raw.payload as {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
})
: {};
const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n");
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
const summary = output
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
} catch {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
} finally {
if (runningTimer) {
clearTimeout(runningTimer);
}
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
}).catch(() => {});
}
})();

View File

@@ -77,6 +77,7 @@ describe("exec approvals", () => {
it("reuses approval id as the node runId", async () => {
let invokeParams: unknown;
let agentParams: unknown;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
@@ -85,6 +86,10 @@ describe("exec approvals", () => {
if (method === "exec.approval.waitDecision") {
return { decision: "allow-once" };
}
if (method === "agent") {
agentParams = params;
return { status: "ok" };
}
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
@@ -102,6 +107,7 @@ describe("exec approvals", () => {
host: "node",
ask: "always",
approvalRunningNoticeMs: 0,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call1", { command: "ls -la" });
@@ -126,6 +132,12 @@ describe("exec approvals", () => {
interval: 20,
})
.toBe(approvalId);
expect(
(invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params,
).toMatchObject({
suppressNotifyOnExit: true,
});
await expect.poll(() => agentParams, { timeout: 2_000, interval: 20 }).toBeTruthy();
});
it("skips approval when node allowlist is satisfied", async () => {
@@ -313,6 +325,53 @@ describe("exec approvals", () => {
expect(calls).toContain("exec.approval.waitDecision");
});
it("starts a direct agent follow-up after approved gateway exec completes", async () => {
const agentCalls: Array<Record<string, unknown>> = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: (params as { id?: string })?.id };
}
if (method === "exec.approval.waitDecision") {
return { decision: "allow-once" };
}
if (method === "agent") {
agentCalls.push(params as Record<string, unknown>);
return { status: "ok" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
sessionKey: "agent:main:main",
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const result = await tool.execute("call-gw-followup", {
command: "echo ok",
workdir: process.cwd(),
gatewayUrl: undefined,
gatewayToken: undefined,
});
expect(result.details.status).toBe("approval-pending");
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1);
expect(agentCalls[0]).toEqual(
expect.objectContaining({
sessionKey: "agent:main:main",
deliver: true,
idempotencyKey: expect.stringContaining("exec-approval-followup:"),
}),
);
expect(typeof agentCalls[0]?.message).toBe("string");
expect(agentCalls[0]?.message).toContain(
"An async command the user already approved has completed.",
);
});
it("requires a separate approval for each elevated command after allow-once", async () => {
const requestCommands: string[] = [];
const requestIds: string[] = [];

View File

@@ -12,6 +12,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
}));
const {
buildThreadingToolContext,
buildEmbeddedRunBaseParams,
buildEmbeddedRunContexts,
resolveModelFallbackOptions,
@@ -173,4 +174,25 @@ describe("agent-runner-utils", () => {
expect(resolved.embeddedContext.messageProvider).toBe("telegram");
expect(resolved.embeddedContext.messageTo).toBe("268300329");
});
it("uses OriginatingTo for threading tool context on telegram native commands", () => {
const context = buildThreadingToolContext({
sessionCtx: {
Provider: "telegram",
To: "slash:8460800771",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1003841603622",
MessageThreadId: 928,
MessageSid: "2284",
},
config: { channels: { telegram: { allowFrom: ["*"] } } },
hasRepliedRef: undefined,
});
expect(context).toMatchObject({
currentChannelId: "telegram:-1003841603622",
currentThreadTs: "928",
currentMessageId: "2284",
});
});
});

View File

@@ -23,12 +23,20 @@ export function buildThreadingToolContext(params: {
}): ChannelThreadingToolContext {
const { sessionCtx, config, hasRepliedRef } = params;
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
const originProvider = resolveOriginMessageProvider({
originatingChannel: sessionCtx.OriginatingChannel,
provider: sessionCtx.Provider,
});
const originTo = resolveOriginMessageTo({
originatingTo: sessionCtx.OriginatingTo,
to: sessionCtx.To,
});
if (!config) {
return {
currentMessageId,
};
}
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
const rawProvider = originProvider?.trim().toLowerCase();
if (!rawProvider) {
return {
currentMessageId,
@@ -39,7 +47,7 @@ export function buildThreadingToolContext(params: {
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {
return {
currentChannelId: sessionCtx.To?.trim() || undefined,
currentChannelId: originTo?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId),
currentMessageId,
hasRepliedRef,
@@ -50,9 +58,9 @@ export function buildThreadingToolContext(params: {
cfg: config,
accountId: sessionCtx.AccountId,
context: {
Channel: sessionCtx.Provider,
Channel: originProvider,
From: sessionCtx.From,
To: sessionCtx.To,
To: originTo,
ChatType: sessionCtx.ChatType,
CurrentMessageId: currentMessageId,
ReplyToId: sessionCtx.ReplyToId,

View File

@@ -23,6 +23,7 @@ type SystemRunParamsLike = {
approved?: unknown;
approvalDecision?: unknown;
runId?: unknown;
suppressNotifyOnExit?: unknown;
};
type ApprovalLookup = {
@@ -78,6 +79,7 @@ function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unkno
"agentId",
"sessionKey",
"runId",
"suppressNotifyOnExit",
]) {
if (key in raw) {
next[key] = raw[key];

View File

@@ -492,6 +492,23 @@ describe("notifications changed events", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(2);
expect(requestHeartbeatNowMock).toHaveBeenCalledTimes(1);
});
it("suppresses exec notifyOnExit events when payload opts out", async () => {
const ctx = buildCtx();
await handleNodeEvent(ctx, "node-n7", {
event: "exec.finished",
payloadJSON: JSON.stringify({
sessionKey: "agent:main:main",
runId: "approval-1",
exitCode: 0,
output: "ok",
suppressNotifyOnExit: true,
}),
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
});
});
describe("agent request events", () => {

View File

@@ -538,6 +538,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
if (!notifyOnExit) {
return;
}
if (obj.suppressNotifyOnExit === true) {
return;
}
const runId = typeof obj.runId === "string" ? obj.runId.trim() : "";
const command = typeof obj.command === "string" ? obj.command.trim() : "";

View File

@@ -57,6 +57,7 @@ type SystemRunExecutionContext = {
sessionKey: string;
runId: string;
cmdText: string;
suppressNotifyOnExit: boolean;
};
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
@@ -77,6 +78,7 @@ type SystemRunParsePhase = {
timeoutMs: number | undefined;
needsScreenRecording: boolean;
approved: boolean;
suppressNotifyOnExit: boolean;
};
type SystemRunPolicyPhase = SystemRunParsePhase & {
@@ -167,6 +169,7 @@ async function sendSystemRunDenied(
host: "node",
command: execution.cmdText,
reason: params.reason,
suppressNotifyOnExit: execution.suppressNotifyOnExit,
}),
);
await opts.sendInvokeResult({
@@ -216,6 +219,7 @@ async function parseSystemRunPhase(
const agentId = opts.params.agentId?.trim() || undefined;
const sessionKey = opts.params.sessionKey?.trim() || "node";
const runId = opts.params.runId?.trim() || crypto.randomUUID();
const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true;
const envOverrides = sanitizeSystemRunEnvOverrides({
overrides: opts.params.env ?? undefined,
shellWrapper: shellCommand !== null,
@@ -228,7 +232,7 @@ async function parseSystemRunPhase(
agentId,
sessionKey,
runId,
execution: { sessionKey, runId, cmdText },
execution: { sessionKey, runId, cmdText, suppressNotifyOnExit },
approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision),
envOverrides,
env: opts.sanitizeEnv(envOverrides),
@@ -236,6 +240,7 @@ async function parseSystemRunPhase(
timeoutMs: opts.params.timeoutMs ?? undefined,
needsScreenRecording: opts.params.needsScreenRecording === true,
approved: opts.params.approved === true,
suppressNotifyOnExit,
};
}
@@ -434,6 +439,7 @@ async function executeSystemRunPhase(
runId: phase.runId,
cmdText: phase.cmdText,
result,
suppressNotifyOnExit: phase.suppressNotifyOnExit,
});
await opts.sendInvokeResult({
ok: true,
@@ -501,6 +507,7 @@ async function executeSystemRunPhase(
runId: phase.runId,
cmdText: phase.cmdText,
result,
suppressNotifyOnExit: phase.suppressNotifyOnExit,
});
await opts.sendInvokeResult({

View File

@@ -13,6 +13,7 @@ export type SystemRunParams = {
approved?: boolean | null;
approvalDecision?: string | null;
runId?: string | null;
suppressNotifyOnExit?: boolean | null;
};
export type RunResult = {
@@ -35,6 +36,7 @@ export type ExecEventPayload = {
success?: boolean;
output?: string;
reason?: string;
suppressNotifyOnExit?: boolean;
};
export type ExecFinishedResult = {
@@ -51,6 +53,7 @@ export type ExecFinishedEventParams = {
runId: string;
cmdText: string;
result: ExecFinishedResult;
suppressNotifyOnExit?: boolean;
};
export type SkillBinsProvider = {

View File

@@ -355,6 +355,7 @@ async function sendExecFinishedEvent(
timedOut: params.result.timedOut,
success: params.result.success,
output: combined,
suppressNotifyOnExit: params.suppressNotifyOnExit,
}),
);
}