Telegram: exec approvals for OpenCode/Codex (#37233)
Merged via squash. Prepared head SHA: f2433790941841ade0efe6292ff4909b2edd6f18 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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user