Telegram: exec approvals for OpenCode/Codex (#37233)

Merged via squash.

Prepared head SHA: f243379094
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
Harold Hunt
2026-03-09 23:04:35 -04:00
committed by GitHub
parent 9432a8bb3f
commit de49a8b72c
78 changed files with 4058 additions and 524 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

@@ -1,4 +1,10 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
resolveExecApprovalInitiatingSurfaceState,
} from "../infra/exec-approval-surface.js";
import {
addAllowlistEntry,
type ExecAsk,
@@ -13,6 +19,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,
@@ -25,9 +32,9 @@ import {
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
normalizeNotifyOutput,
runExecProcess,
} from "./bash-tools.exec-runtime.js";
@@ -141,8 +148,6 @@ export async function processGatewayAllowlist(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -174,19 +179,37 @@ export async function processGatewayAllowlist(
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
channel: params.turnSourceChannel,
accountId: params.turnSourceAccountId,
});
const cfg = loadConfig();
const sentApproverDms =
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
hasConfiguredExecApprovalDmRoute(cfg);
const unavailableReason =
preResolvedDecision === null
? "no-approval-route"
: initiatingSurface.kind === "disabled"
? "initiating-platform-disabled"
: initiatingSurface.kind === "unsupported"
? "initiating-platform-unsupported"
: null;
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
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;
@@ -230,13 +253,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;
}
@@ -262,32 +287,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),
);
@@ -295,7 +309,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 {
@@ -304,19 +326,45 @@ export async function processGatewayAllowlist(
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: params.command,
cwd: params.workdir,
host: "gateway",
}),
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
},
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails),
},
};
}

View File

@@ -1,5 +1,11 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
resolveExecApprovalInitiatingSurfaceState,
} from "../infra/exec-approval-surface.js";
import {
type ExecApprovalsFile,
type ExecAsk,
@@ -12,6 +18,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,
@@ -23,7 +30,12 @@ import {
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
normalizeNotifyOutput,
} from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
@@ -187,6 +199,7 @@ export async function executeNodeHostCommand(
approvedByAsk: boolean,
approvalDecision: "allow-once" | "allow-always" | null,
runId?: string,
suppressNotifyOnExit?: boolean,
) =>
({
nodeId,
@@ -202,6 +215,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>;
@@ -210,8 +224,6 @@ export async function executeNodeHostCommand(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -243,16 +255,37 @@ export async function executeNodeHostCommand(
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
channel: params.turnSourceChannel,
accountId: params.turnSourceAccountId,
});
const cfg = loadConfig();
const sentApproverDms =
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
hasConfiguredExecApprovalDmRoute(cfg);
const unavailableReason =
preResolvedDecision === null
? "no-approval-route"
: initiatingSurface.kind === "disabled"
? "initiating-platform-disabled"
: initiatingSurface.kind === "unsupported"
? "initiating-platform-unsupported"
: null;
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
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;
@@ -278,44 +311,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(() => {});
}
})();
@@ -324,20 +380,48 @@ export async function executeNodeHostCommand(
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: prepared.cmdText,
cwd: runCwd,
host: "node",
nodeId,
}),
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
},
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
} satisfies ExecToolDetails),
};
}

View File

@@ -230,6 +230,40 @@ export function createApprovalSlug(id: string) {
return id.slice(0, APPROVAL_SLUG_LENGTH);
}
export function buildApprovalPendingMessage(params: {
warningText?: string;
approvalSlug: string;
approvalId: string;
command: string;
cwd: string;
host: "gateway" | "node";
nodeId?: string;
}) {
let fence = "```";
while (params.command.includes(fence)) {
fence += "`";
}
const commandBlock = `${fence}sh\n${params.command}\n${fence}`;
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText, "");
}
lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`);
lines.push(`Host: ${params.host}`);
if (params.nodeId) {
lines.push(`Node: ${params.nodeId}`);
}
lines.push(`CWD: ${params.cwd}`);
lines.push("Command:");
lines.push(commandBlock);
lines.push("Mode: foreground (interactive approvals available).");
lines.push("Background mode requires pre-approved policy (allow-always or ask=off).");
lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`);
lines.push("If the short code is ambiguous, use the full id in /approve.");
return lines.join("\n");
}
export function resolveApprovalRunningNoticeMs(value?: number) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS;

View File

@@ -60,4 +60,19 @@ export type ExecToolDetails =
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
}
| {
status: "approval-unavailable";
reason:
| "initiating-platform-disabled"
| "initiating-platform-unsupported"
| "no-approval-route";
channelLabel?: string;
sentApproverDms?: boolean;
host: ExecHost;
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
};

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { clearConfigCache } from "../config/config.js";
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
vi.mock("./tools/gateway.js", () => ({
@@ -63,6 +64,7 @@ describe("exec approvals", () => {
afterEach(() => {
vi.resetAllMocks();
clearConfigCache();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
@@ -77,6 +79,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 +88,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,11 +109,24 @@ describe("exec approvals", () => {
host: "node",
ask: "always",
approvalRunningNoticeMs: 0,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call1", { command: "ls -la" });
expect(result.details.status).toBe("approval-pending");
const approvalId = (result.details as { approvalId: string }).approvalId;
const details = result.details as { approvalId: string; approvalSlug: string };
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
);
expect(pendingText).toContain(`full ${details.approvalId}`);
expect(pendingText).toContain("Host: node");
expect(pendingText).toContain("Node: node-1");
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
expect(pendingText).toContain("Command:\n```sh\nls -la\n```");
expect(pendingText).toContain("Mode: foreground (interactive approvals available).");
expect(pendingText).toContain("Background mode requires pre-approved policy");
const approvalId = details.approvalId;
await expect
.poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, {
@@ -114,6 +134,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 () => {
@@ -287,11 +313,181 @@ describe("exec approvals", () => {
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
expect(result.details.status).toBe("approval-pending");
const details = result.details as { approvalId: string; approvalSlug: string };
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
);
expect(pendingText).toContain(`full ${details.approvalId}`);
expect(pendingText).toContain("Host: gateway");
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
expect(pendingText).toContain("Command:\n```sh\necho ok\n```");
await approvalSeen;
expect(calls).toContain("exec.approval.request");
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[] = [];
const waitIds: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
const request = params as { id?: string; command?: string };
if (typeof request.command === "string") {
requestCommands.push(request.command);
}
if (typeof request.id === "string") {
requestIds.push(request.id);
}
return { status: "accepted", id: request.id };
}
if (method === "exec.approval.waitDecision") {
const wait = params as { id?: string };
if (typeof wait.id === "string") {
waitIds.push(wait.id);
}
return { decision: "allow-once" };
}
return { ok: true };
});
const tool = createExecTool({
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const first = await tool.execute("call-seq-1", {
command: "npm view diver --json",
elevated: true,
});
const second = await tool.execute("call-seq-2", {
command: "brew outdated",
elevated: true,
});
expect(first.details.status).toBe("approval-pending");
expect(second.details.status).toBe("approval-pending");
expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]);
expect(requestIds).toHaveLength(2);
expect(requestIds[0]).not.toBe(requestIds[1]);
expect(waitIds).toEqual(requestIds);
});
it("shows full chained gateway commands in approval-pending message", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approval.request") {
return { status: "accepted", id: (params as { id?: string })?.id };
}
if (method === "exec.approval.waitDecision") {
return { decision: "deny" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-chain-gateway", {
command: "npm view diver --json | jq .name && brew outdated",
});
expect(result.details.status).toBe("approval-pending");
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
);
expect(calls).toContain("exec.approval.request");
});
it("shows full chained node commands in approval-pending message", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-chain-node", {
command: "npm view diver --json | jq .name && brew outdated",
});
expect(result.details.status).toBe("approval-pending");
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
);
expect(calls).toContain("exec.approval.request");
});
it("waits for approval registration before returning approval-pending", async () => {
const calls: string[] = [];
let resolveRegistration: ((value: unknown) => void) | undefined;
@@ -354,6 +550,111 @@ describe("exec approvals", () => {
);
});
it("returns an unavailable approval message instead of a local /approve prompt when discord exec approvals are disabled", async () => {
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
channels: {
discord: {
enabled: true,
execApprovals: { enabled: false },
},
},
}),
);
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: "approval-id" };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
messageProvider: "discord",
accountId: "default",
currentChannelId: "1234567890",
});
const result = await tool.execute("call-unavailable", {
command: "npm view diver name version description",
});
expect(result.details.status).toBe("approval-unavailable");
const text = result.content.find((part) => part.type === "text")?.text ?? "";
expect(text).toContain("chat exec approvals are not enabled on Discord");
expect(text).toContain("Web UI or terminal UI");
expect(text).not.toContain("/approve");
expect(text).not.toContain("npm view diver name version description");
expect(text).not.toContain("Pending command:");
expect(text).not.toContain("Host:");
expect(text).not.toContain("CWD:");
});
it("tells Telegram users that allowed approvers were DMed when Telegram approvals are disabled but Discord DM approvals are enabled", async () => {
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
channels: {
telegram: {
enabled: true,
execApprovals: { enabled: false },
},
discord: {
enabled: true,
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
},
null,
2,
),
);
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: "approval-id" };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
messageProvider: "telegram",
accountId: "default",
currentChannelId: "-1003841603622",
});
const result = await tool.execute("call-tg-unavailable", {
command: "npm view diver name version description",
});
expect(result.details.status).toBe("approval-unavailable");
const text = result.content.find((part) => part.type === "text")?.text ?? "";
expect(text).toContain("Approval required. I sent the allowed approvers DMs.");
expect(text).not.toContain("/approve");
expect(text).not.toContain("npm view diver name version description");
expect(text).not.toContain("Pending command:");
expect(text).not.toContain("Host:");
expect(text).not.toContain("CWD:");
});
it("denies node obfuscated command when approval request times out", async () => {
vi.mocked(detectCommandObfuscation).mockReturnValue({
detected: true,

View File

@@ -1457,6 +1457,7 @@ export async function runEmbeddedPiAgent(
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
inlineToolResultsAllowed: false,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
});
// Timeout aborts can leave the run without any assistant payloads.
@@ -1479,6 +1480,7 @@ export async function runEmbeddedPiAgent(
systemPromptReport: attempt.systemPromptReport,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
@@ -1526,6 +1528,7 @@ export async function runEmbeddedPiAgent(
: undefined,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,

View File

@@ -1544,6 +1544,7 @@ export async function runEmbeddedAttempt(
getMessagingToolSentTargets,
getSuccessfulCronAdds,
didSendViaMessagingTool,
didSendDeterministicApprovalPrompt,
getLastToolError,
getUsageTotals,
getCompactionCount,
@@ -2058,6 +2059,7 @@ export async function runEmbeddedAttempt(
lastAssistant,
lastToolError: getLastToolError?.(),
didSendViaMessagingTool: didSendViaMessagingTool(),
didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(),
messagingToolSentTexts: getMessagingToolSentTexts(),
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
messagingToolSentTargets: getMessagingToolSentTargets(),

View File

@@ -1,5 +1,6 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import type { AgentStreamParams } from "../../../commands/agent/types.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { enqueueCommand } from "../../../process/command-queue.js";
@@ -104,7 +105,7 @@ export type RunEmbeddedPiAgentParams = {
blockReplyChunking?: BlockReplyChunking;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningEnd?: () => void | Promise<void>;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
lane?: string;
enqueue?: typeof enqueueCommand;

View File

@@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expect(payloads).toHaveLength(0);
});
it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => {
const payloads = buildPayloads({
assistantTexts: ["Approval is needed. Please run /approve abc allow-once"],
didSendDeterministicApprovalPrompt: true,
});
expect(payloads).toHaveLength(0);
});
});

View File

@@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: {
suppressToolErrorWarnings?: boolean;
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didSendDeterministicApprovalPrompt?: boolean;
}): Array<{
text?: string;
mediaUrl?: string;
@@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: {
}> = [];
const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
const errorText = params.lastAssistant
? formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
? suppressAssistantArtifacts
? undefined
: formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
: undefined;
const rawErrorMessage = lastAssistantErrored
? params.lastAssistant?.errorMessage?.trim() || undefined
@@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: {
}
}
const reasoningText =
params.lastAssistant && params.reasoningLevel === "on"
const reasoningText = suppressAssistantArtifacts
? ""
: params.lastAssistant && params.reasoningLevel === "on"
? formatReasoningMessage(extractAssistantThinking(params.lastAssistant))
: "";
if (reasoningText) {
@@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: {
}
return isRawApiErrorPayload(trimmed);
};
const answerTexts = (
params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
const answerTexts = suppressAssistantArtifacts
? []
: (params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = false;
for (const text of answerTexts) {

View File

@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
actionFingerprint?: string;
};
didSendViaMessagingTool: boolean;
didSendDeterministicApprovalPrompt?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];

View File

@@ -85,6 +85,9 @@ export function handleMessageUpdate(
}
ctx.noteLastAssistant(msg);
if (ctx.state.deterministicApprovalPromptSent) {
return;
}
const assistantEvent = evt.assistantMessageEvent;
const assistantRecord =
@@ -261,6 +264,9 @@ export function handleMessageEnd(
const assistantMessage = msg;
ctx.noteLastAssistant(assistantMessage);
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
if (ctx.state.deterministicApprovalPromptSent) {
return;
}
promoteThinkingTagsToBlocks(assistantMessage);
const rawText = extractAssistantText(assistantMessage);

View File

@@ -28,6 +28,7 @@ function createMockContext(overrides?: {
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
deterministicApprovalPromptSent: false,
},
log: { debug: vi.fn(), warn: vi.fn() },
shouldEmitToolResult: vi.fn(() => false),

View File

@@ -45,6 +45,7 @@ function createTestContext(): {
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
successfulCronAdds: 0,
deterministicApprovalPromptSent: false,
},
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
@@ -175,6 +176,161 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => {
});
});
describe("handleToolExecutionEnd exec approval prompts", () => {
it("emits a deterministic approval payload and marks assistant output suppressed", async () => {
const { ctx } = createTestContext();
const onToolResult = vi.fn();
ctx.params.onToolResult = onToolResult;
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-approval",
isError: false,
result: {
details: {
status: "approval-pending",
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
expiresAtMs: 1_800_000_000_000,
host: "gateway",
command: "npm view diver name version description",
cwd: "/tmp/work",
warningText: "Warning: heredoc execution requires explicit approval in allowlist mode.",
},
},
} as never,
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("```txt\n/approve 12345678 allow-once\n```"),
channelData: {
execApproval: {
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
}),
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
});
it("emits a deterministic unavailable payload when the initiating surface cannot approve", async () => {
const { ctx } = createTestContext();
const onToolResult = vi.fn();
ctx.params.onToolResult = onToolResult;
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-unavailable",
isError: false,
result: {
details: {
status: "approval-unavailable",
reason: "initiating-platform-disabled",
channelLabel: "Discord",
},
},
} as never,
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("chat exec approvals are not enabled on Discord"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("/approve"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("Pending command:"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("Host:"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("CWD:"),
}),
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
});
it("emits the shared approver-DM notice when another approval client received the request", async () => {
const { ctx } = createTestContext();
const onToolResult = vi.fn();
ctx.params.onToolResult = onToolResult;
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-unavailable-dm-redirect",
isError: false,
result: {
details: {
status: "approval-unavailable",
reason: "initiating-platform-disabled",
channelLabel: "Telegram",
sentApproverDms: true,
},
},
} as never,
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: "Approval required. I sent the allowed approvers DMs.",
}),
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
});
it("does not suppress assistant output when deterministic prompt delivery rejects", async () => {
const { ctx } = createTestContext();
ctx.params.onToolResult = vi.fn(async () => {
throw new Error("delivery failed");
});
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-approval-reject",
isError: false,
result: {
details: {
status: "approval-pending",
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
expiresAtMs: 1_800_000_000_000,
host: "gateway",
command: "npm view diver name version description",
cwd: "/tmp/work",
},
},
} as never,
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(false);
});
});
describe("messaging tool media URL tracking", () => {
it("tracks media arg from messaging tool as pending", async () => {
const { ctx } = createTestContext();

View File

@@ -1,5 +1,9 @@
import type { AgentEvent } from "@mariozechner/pi-agent-core";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
buildExecApprovalPendingReplyPayload,
buildExecApprovalUnavailableReplyPayload,
} from "../infra/exec-approval-reply.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
@@ -139,7 +143,81 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] {
return urls;
}
function emitToolResultOutput(params: {
function readExecApprovalPendingDetails(result: unknown): {
approvalId: string;
approvalSlug: string;
expiresAtMs?: number;
host: "gateway" | "node";
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
} | null {
if (!result || typeof result !== "object") {
return null;
}
const outer = result as Record<string, unknown>;
const details =
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
? (outer.details as Record<string, unknown>)
: outer;
if (details.status !== "approval-pending") {
return null;
}
const approvalId = typeof details.approvalId === "string" ? details.approvalId.trim() : "";
const approvalSlug = typeof details.approvalSlug === "string" ? details.approvalSlug.trim() : "";
const command = typeof details.command === "string" ? details.command : "";
const host = details.host === "node" ? "node" : details.host === "gateway" ? "gateway" : null;
if (!approvalId || !approvalSlug || !command || !host) {
return null;
}
return {
approvalId,
approvalSlug,
expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined,
host,
command,
cwd: typeof details.cwd === "string" ? details.cwd : undefined,
nodeId: typeof details.nodeId === "string" ? details.nodeId : undefined,
warningText: typeof details.warningText === "string" ? details.warningText : undefined,
};
}
function readExecApprovalUnavailableDetails(result: unknown): {
reason: "initiating-platform-disabled" | "initiating-platform-unsupported" | "no-approval-route";
warningText?: string;
channelLabel?: string;
sentApproverDms?: boolean;
} | null {
if (!result || typeof result !== "object") {
return null;
}
const outer = result as Record<string, unknown>;
const details =
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
? (outer.details as Record<string, unknown>)
: outer;
if (details.status !== "approval-unavailable") {
return null;
}
const reason =
details.reason === "initiating-platform-disabled" ||
details.reason === "initiating-platform-unsupported" ||
details.reason === "no-approval-route"
? details.reason
: null;
if (!reason) {
return null;
}
return {
reason,
warningText: typeof details.warningText === "string" ? details.warningText : undefined,
channelLabel: typeof details.channelLabel === "string" ? details.channelLabel : undefined,
sentApproverDms: details.sentApproverDms === true,
};
}
async function emitToolResultOutput(params: {
ctx: ToolHandlerContext;
toolName: string;
meta?: string;
@@ -152,6 +230,46 @@ function emitToolResultOutput(params: {
return;
}
const approvalPending = readExecApprovalPendingDetails(result);
if (!isToolError && approvalPending) {
try {
await ctx.params.onToolResult(
buildExecApprovalPendingReplyPayload({
approvalId: approvalPending.approvalId,
approvalSlug: approvalPending.approvalSlug,
command: approvalPending.command,
cwd: approvalPending.cwd,
host: approvalPending.host,
nodeId: approvalPending.nodeId,
expiresAtMs: approvalPending.expiresAtMs,
warningText: approvalPending.warningText,
}),
);
ctx.state.deterministicApprovalPromptSent = true;
} catch {
// ignore delivery failures
}
return;
}
const approvalUnavailable = readExecApprovalUnavailableDetails(result);
if (!isToolError && approvalUnavailable) {
try {
await ctx.params.onToolResult?.(
buildExecApprovalUnavailableReplyPayload({
reason: approvalUnavailable.reason,
warningText: approvalUnavailable.warningText,
channelLabel: approvalUnavailable.channelLabel,
sentApproverDms: approvalUnavailable.sentApproverDms,
}),
);
ctx.state.deterministicApprovalPromptSent = true;
} catch {
// ignore delivery failures
}
return;
}
if (ctx.shouldEmitToolOutput()) {
const outputText = extractToolResultText(sanitizedResult);
if (outputText) {
@@ -427,7 +545,7 @@ export async function handleToolExecutionEnd(
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult });
await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult });
// Run after_tool_call plugin hook (fire-and-forget)
const hookRunnerAfter = ctx.hookRunner ?? getGlobalHookRunner();

View File

@@ -76,6 +76,7 @@ export type EmbeddedPiSubscribeState = {
pendingMessagingTargets: Map<string, MessagingToolSend>;
successfulCronAdds: number;
pendingMessagingMediaUrls: Map<string, string[]>;
deterministicApprovalPromptSent: boolean;
lastAssistant?: AgentMessage;
};
@@ -155,6 +156,7 @@ export type ToolHandlerState = Pick<
| "messagingToolSentMediaUrls"
| "messagingToolSentTargets"
| "successfulCronAdds"
| "deterministicApprovalPromptSent"
>;
export type ToolHandlerContext = {

View File

@@ -78,6 +78,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
pendingMessagingTargets: new Map(),
successfulCronAdds: 0,
pendingMessagingMediaUrls: new Map(),
deterministicApprovalPromptSent: false,
};
const usageTotals = {
input: 0,
@@ -598,6 +599,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
pendingMessagingTargets.clear();
state.successfulCronAdds = 0;
state.pendingMessagingMediaUrls.clear();
state.deterministicApprovalPromptSent = false;
resetAssistantMessageState(0);
};
@@ -688,6 +690,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
// which is generated AFTER the tool sends the actual answer.
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
didSendDeterministicApprovalPrompt: () => state.deterministicApprovalPromptSent,
getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined),
getUsageTotals,
getCompactionCount: () => compactionCount,

View File

@@ -1,5 +1,6 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
@@ -16,7 +17,7 @@ export type SubscribeEmbeddedPiSessionParams = {
toolResultFormat?: ToolResultFormat;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
/** Called when a thinking/reasoning block ends (</think> tag processed). */
onReasoningEnd?: () => void | Promise<void>;

View File

@@ -10,6 +10,7 @@ export function createBaseToolHandlerState() {
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSentTargets: [] as unknown[],
deterministicApprovalPromptSent: false,
blockBuffer: "",
};
}

View File

@@ -464,6 +464,9 @@ export function buildAgentSystemPrompt(params: {
"Keep narration brief and value-dense; avoid repeating obvious steps.",
"Use plain human language for narration unless in a technical context.",
"When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.",
"When exec returns approval-pending, include the concrete /approve command from tool output (with allow-once|allow-always|deny) and do not ask for a different or rotated code.",
"Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.",
"When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run.",
"",
...safetySection,
"## OpenClaw CLI Quick Reference",