diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index ac6ed57aa72..149a4785dd5 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,5 +1,4 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; import { addAllowlistEntry, type ExecAsk, @@ -14,20 +13,22 @@ 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, registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; import { + buildDefaultExecApprovalRequestArgs, + buildExecApprovalFollowupTarget, + buildExecApprovalPendingToolResult, + createExecApprovalDecisionState, createAndRegisterDefaultExecApprovalRequest, - resolveBaseExecApprovalDecision, resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, + sendExecApprovalFollowupResult, } from "./bash-tools.exec-host-shared.js"; import { - buildApprovalPendingMessage, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, normalizeNotifyOutput, @@ -140,6 +141,28 @@ export async function processGatewayAllowlist( } if (requiresAsk) { + const requestArgs = buildDefaultExecApprovalRequestArgs({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + turnSourceChannel: params.turnSourceChannel, + turnSourceAccountId: params.turnSourceAccountId, + }); + const registerGatewayApproval = async (approvalId: string) => + await registerExecApprovalRequestForHostOrThrow({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + ...buildExecApprovalRequesterContext({ + agentId: params.agentId, + sessionKey: params.sessionKey, + }), + resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath, + ...buildExecApprovalTurnSourceContext(params), + }); const { approvalId, approvalSlug, @@ -150,57 +173,46 @@ export async function processGatewayAllowlist( sentApproverDms, unavailableReason, } = await createAndRegisterDefaultExecApprovalRequest({ - warnings: params.warnings, - approvalRunningNoticeMs: params.approvalRunningNoticeMs, - createApprovalSlug, - turnSourceChannel: params.turnSourceChannel, - turnSourceAccountId: params.turnSourceAccountId, - register: async (approvalId) => - await registerExecApprovalRequestForHostOrThrow({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - ...buildExecApprovalRequesterContext({ - agentId: params.agentId, - sessionKey: params.sessionKey, - }), - resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath, - ...buildExecApprovalTurnSourceContext(params), - }), + ...requestArgs, + register: registerGatewayApproval, }); const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; + const followupTarget = buildExecApprovalFollowupTarget({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + }); void (async () => { const decision = await resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - 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}`, - }), + void sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, + ), }); if (decision === undefined) { return; } - const baseDecision = resolveBaseExecApprovalDecision({ + const { + baseDecision, + approvedByAsk: initialApprovedByAsk, + deniedReason: initialDeniedReason, + } = createExecApprovalDecisionState({ decision, askFallback, obfuscationDetected: obfuscation.detected, }); - let approvedByAsk = baseDecision.approvedByAsk; - let deniedReason = baseDecision.deniedReason; + let approvedByAsk = initialApprovedByAsk; + let deniedReason = initialDeniedReason; if (baseDecision.timedOut && askFallback === "allowlist") { if (!analysisOk || !allowlistSatisfied) { @@ -232,15 +244,10 @@ export async function processGatewayAllowlist( } if (deniedReason) { - 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(() => {}); + await sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, + ); return; } @@ -266,15 +273,10 @@ export async function processGatewayAllowlist( timeoutSec: effectiveTimeout, }); } catch { - 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(() => {}); + await sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, + ); return; } @@ -288,63 +290,22 @@ 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})`; - await sendExecApprovalFollowup({ - approvalId, - sessionKey: params.notifySessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - resultText: summary, - }).catch(() => {}); + await sendExecApprovalFollowupResult(followupTarget, summary); })(); return { - pendingResult: { - content: [ - { - type: "text", - text: - unavailableReason !== null - ? (buildExecApprovalUnavailableReplyPayload({ - warningText, - reason: unavailableReason, - channelLabel: initiatingSurface.channelLabel, - sentApproverDms, - }).text ?? "") - : buildApprovalPendingMessage({ - warningText, - approvalSlug, - approvalId, - command: params.command, - cwd: params.workdir, - host: "gateway", - }), - }, - ], - 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), - }, + pendingResult: buildExecApprovalPendingToolResult({ + host: "gateway", + command: params.command, + cwd: params.workdir, + warningText, + approvalId, + approvalSlug, + expiresAtMs, + initiatingSurface, + sentApproverDms, + unavailableReason, + }), }; } diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 6f5fc25f966..16af23590b4 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -1,6 +1,5 @@ import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; import { type ExecApprovalsFile, type ExecAsk, @@ -13,20 +12,13 @@ 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, registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; +import * as execHostShared from "./bash-tools.exec-host-shared.js"; import { - createAndRegisterDefaultExecApprovalRequest, - resolveBaseExecApprovalDecision, - resolveApprovalDecisionOrUndefined, - resolveExecHostApprovalContext, -} from "./bash-tools.exec-host-shared.js"; -import { - buildApprovalPendingMessage, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, normalizeNotifyOutput, @@ -61,7 +53,7 @@ export type ExecuteNodeHostCommandParams = { export async function executeNodeHostCommand( params: ExecuteNodeHostCommandParams, ): Promise> { - const { hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({ + const { hostSecurity, hostAsk, askFallback } = execHostShared.resolveExecHostApprovalContext({ agentId: params.agentId, security: params.security, ask: params.ask, @@ -216,6 +208,29 @@ export async function executeNodeHostCommand( }) satisfies Record; if (requiresAsk) { + const requestArgs = execHostShared.buildDefaultExecApprovalRequestArgs({ + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug, + turnSourceChannel: params.turnSourceChannel, + turnSourceAccountId: params.turnSourceAccountId, + }); + const registerNodeApproval = async (approvalId: string) => + await registerExecApprovalRequestForHostOrThrow({ + approvalId, + systemRunPlan: prepared.plan, + env: nodeEnv, + workdir: runCwd, + host: "node", + nodeId, + security: hostSecurity, + ask: hostAsk, + ...buildExecApprovalRequesterContext({ + agentId: runAgentId, + sessionKey: runSessionKey, + }), + ...buildExecApprovalTurnSourceContext(params), + }); const { approvalId, approvalSlug, @@ -225,57 +240,45 @@ export async function executeNodeHostCommand( initiatingSurface, sentApproverDms, unavailableReason, - } = await createAndRegisterDefaultExecApprovalRequest({ - warnings: params.warnings, - approvalRunningNoticeMs: params.approvalRunningNoticeMs, - createApprovalSlug, + } = await execHostShared.createAndRegisterDefaultExecApprovalRequest({ + ...requestArgs, + register: registerNodeApproval, + }); + const followupTarget = execHostShared.buildExecApprovalFollowupTarget({ + approvalId, + sessionKey: params.notifySessionKey, turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, turnSourceAccountId: params.turnSourceAccountId, - register: async (approvalId) => - await registerExecApprovalRequestForHostOrThrow({ - approvalId, - systemRunPlan: prepared.plan, - env: nodeEnv, - workdir: runCwd, - host: "node", - nodeId, - security: hostSecurity, - ask: hostAsk, - ...buildExecApprovalRequesterContext({ - agentId: runAgentId, - sessionKey: runSessionKey, - }), - ...buildExecApprovalTurnSourceContext(params), - }), + turnSourceThreadId: params.turnSourceThreadId, }); void (async () => { - const decision = await resolveApprovalDecisionOrUndefined({ + const decision = await execHostShared.resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - 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}`, - }), + void execHostShared.sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, + ), }); if (decision === undefined) { return; } - const baseDecision = resolveBaseExecApprovalDecision({ + const { + baseDecision, + approvedByAsk: initialApprovedByAsk, + deniedReason: initialDeniedReason, + } = execHostShared.createExecApprovalDecisionState({ decision, askFallback, obfuscationDetected: obfuscation.detected, }); - let approvedByAsk = baseDecision.approvedByAsk; + let approvedByAsk = initialApprovedByAsk; let approvalDecision: "allow-once" | "allow-always" | null = null; - let deniedReason = baseDecision.deniedReason; + let deniedReason = initialDeniedReason; if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) { approvalDecision = "allow-once"; @@ -288,15 +291,10 @@ export async function executeNodeHostCommand( } if (deniedReason) { - 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(() => {}); + await execHostShared.sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, + ); return; } @@ -330,76 +328,28 @@ export async function executeNodeHostCommand( 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(() => {}); + await execHostShared.sendExecApprovalFollowupResult(followupTarget, summary); } catch { - 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(() => {}); + await execHostShared.sendExecApprovalFollowupResult( + followupTarget, + `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, + ); } })(); - return { - content: [ - { - type: "text", - text: - unavailableReason !== null - ? (buildExecApprovalUnavailableReplyPayload({ - warningText, - reason: unavailableReason, - channelLabel: initiatingSurface.channelLabel, - sentApproverDms, - }).text ?? "") - : buildApprovalPendingMessage({ - warningText, - approvalSlug, - approvalId, - command: prepared.plan.commandText, - cwd: runCwd, - host: "node", - 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), - }; + return execHostShared.buildExecApprovalPendingToolResult({ + host: "node", + command: params.command, + cwd: params.workdir, + warningText, + approvalId, + approvalSlug, + expiresAtMs, + initiatingSurface, + sentApproverDms, + unavailableReason, + nodeId, + }); } const startedAt = Date.now(); diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index e62bc8d484a..a9adaff17ee 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -1,5 +1,7 @@ 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, type ExecApprovalInitiatingSurfaceState, @@ -12,11 +14,14 @@ import { type ExecAsk, type ExecSecurity, } from "../infra/exec-approvals.js"; +import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { type ExecApprovalRegistration, resolveRegisteredExecApprovalDecision, } from "./bash-tools.exec-approval-request.js"; +import { buildApprovalPendingMessage } from "./bash-tools.exec-runtime.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js"; +import type { ExecToolDetails } from "./bash-tools.exec-types.js"; type ResolvedExecApprovals = ReturnType; @@ -53,6 +58,23 @@ export type RegisteredExecApprovalRequestContext = { unavailableReason: ExecApprovalUnavailableReason | null; }; +export type ExecApprovalFollowupTarget = { + approvalId: string; + sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; +}; + +export type DefaultExecApprovalRequestArgs = { + warnings: string[]; + approvalRunningNoticeMs: number; + createApprovalSlug: (approvalId: string) => string; + turnSourceChannel?: string; + turnSourceAccountId?: string; +}; + export function createExecApprovalPendingState(params: { warnings: string[]; timeoutMs: number; @@ -257,3 +279,123 @@ export async function createAndRegisterDefaultExecApprovalRequest(params: { unavailableReason, }; } + +export function buildDefaultExecApprovalRequestArgs( + params: DefaultExecApprovalRequestArgs, +): DefaultExecApprovalRequestArgs { + return { + warnings: params.warnings, + approvalRunningNoticeMs: params.approvalRunningNoticeMs, + createApprovalSlug: params.createApprovalSlug, + turnSourceChannel: params.turnSourceChannel, + turnSourceAccountId: params.turnSourceAccountId, + }; +} + +export function buildExecApprovalFollowupTarget( + params: ExecApprovalFollowupTarget, +): ExecApprovalFollowupTarget { + return { + approvalId: params.approvalId, + sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + }; +} + +export function createExecApprovalDecisionState(params: { + decision: string | null | undefined; + askFallback: ResolvedExecApprovals["agent"]["askFallback"]; + obfuscationDetected: boolean; +}) { + const baseDecision = resolveBaseExecApprovalDecision({ + decision: params.decision ?? null, + askFallback: params.askFallback, + obfuscationDetected: params.obfuscationDetected, + }); + return { + baseDecision, + approvedByAsk: baseDecision.approvedByAsk, + deniedReason: baseDecision.deniedReason, + }; +} + +export async function sendExecApprovalFollowupResult( + target: ExecApprovalFollowupTarget, + resultText: string, +): Promise { + await sendExecApprovalFollowup({ + approvalId: target.approvalId, + sessionKey: target.sessionKey, + turnSourceChannel: target.turnSourceChannel, + turnSourceTo: target.turnSourceTo, + turnSourceAccountId: target.turnSourceAccountId, + turnSourceThreadId: target.turnSourceThreadId, + resultText, + }).catch(() => {}); +} + +export function buildExecApprovalPendingToolResult(params: { + host: "gateway" | "node"; + command: string; + cwd: string; + warningText: string; + approvalId: string; + approvalSlug: string; + expiresAtMs: number; + initiatingSurface: ExecApprovalInitiatingSurfaceState; + sentApproverDms: boolean; + unavailableReason: ExecApprovalUnavailableReason | null; + nodeId?: string; +}): AgentToolResult { + return { + content: [ + { + type: "text", + text: + params.unavailableReason !== null + ? (buildExecApprovalUnavailableReplyPayload({ + warningText: params.warningText, + reason: params.unavailableReason, + channelLabel: params.initiatingSurface.channelLabel, + sentApproverDms: params.sentApproverDms, + }).text ?? "") + : buildApprovalPendingMessage({ + warningText: params.warningText, + approvalSlug: params.approvalSlug, + approvalId: params.approvalId, + command: params.command, + cwd: params.cwd, + host: params.host, + nodeId: params.nodeId, + }), + }, + ], + details: + params.unavailableReason !== null + ? ({ + status: "approval-unavailable", + reason: params.unavailableReason, + channelLabel: params.initiatingSurface.channelLabel, + sentApproverDms: params.sentApproverDms, + host: params.host, + command: params.command, + cwd: params.cwd, + nodeId: params.nodeId, + warningText: params.warningText, + } satisfies ExecToolDetails) + : ({ + status: "approval-pending", + approvalId: params.approvalId, + approvalSlug: params.approvalSlug, + expiresAtMs: params.expiresAtMs, + host: params.host, + command: params.command, + cwd: params.cwd, + nodeId: params.nodeId, + warningText: params.warningText, + } satisfies ExecToolDetails), + }; +}