diff --git a/src/agents/bash-tools.exec-approval-followup-state.ts b/src/agents/bash-tools.exec-approval-followup-state.ts index 64ed0ac486a..7c1c00913bd 100644 --- a/src/agents/bash-tools.exec-approval-followup-state.ts +++ b/src/agents/bash-tools.exec-approval-followup-state.ts @@ -3,16 +3,30 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { ExecElevatedDefaults } from "./bash-tools.exec-types.js"; const EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX = "exec-approval-followup:"; -const EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER = ":elevated:"; -const EXEC_APPROVAL_FOLLOWUP_ELEVATED_TTL_MS = 5 * 60 * 1000; +const EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER = ":nonce:"; +const EXEC_APPROVAL_FOLLOWUP_RUNTIME_HANDOFF_TTL_MS = 5 * 60 * 1000; -type ExecApprovalFollowupElevatedEntry = { +export type ExecApprovalFollowupRuntimeHandoff = { + kind: "exec-approval-followup"; + approvalId: string; sessionKey: string; + idempotencyKey: string; bashElevated: ExecElevatedDefaults; +}; + +export type ExecApprovalFollowupRuntimeHandoffRegistration = { + handoffId: string; + idempotencyKey: string; +}; + +type ExecApprovalFollowupRuntimeHandoffEntry = ExecApprovalFollowupRuntimeHandoff & { expiresAtMs: number; }; -const execApprovalFollowupElevatedDefaults = new Map(); +const execApprovalFollowupRuntimeHandoffs = new Map< + string, + ExecApprovalFollowupRuntimeHandoffEntry +>(); function cloneExecElevatedDefaults(value: ExecElevatedDefaults): ExecElevatedDefaults { return { @@ -28,96 +42,109 @@ function cloneExecElevatedDefaults(value: ExecElevatedDefaults): ExecElevatedDef }; } -function pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs: number): void { - for (const [token, entry] of execApprovalFollowupElevatedDefaults) { +function cloneExecApprovalFollowupRuntimeHandoff( + value: ExecApprovalFollowupRuntimeHandoff, +): ExecApprovalFollowupRuntimeHandoff { + return { + kind: value.kind, + approvalId: value.approvalId, + sessionKey: value.sessionKey, + idempotencyKey: value.idempotencyKey, + bashElevated: cloneExecElevatedDefaults(value.bashElevated), + }; +} + +function pruneExpiredExecApprovalFollowupRuntimeHandoffs(nowMs: number): void { + for (const [handoffId, entry] of execApprovalFollowupRuntimeHandoffs) { if (entry.expiresAtMs <= nowMs) { - execApprovalFollowupElevatedDefaults.delete(token); + execApprovalFollowupRuntimeHandoffs.delete(handoffId); } } } export function buildExecApprovalFollowupIdempotencyKey(params: { approvalId: string; - execApprovalFollowupToken?: string; + nonce?: string; }): string { const base = `${EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX}${params.approvalId}`; - return params.execApprovalFollowupToken - ? `${base}${EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER}${params.execApprovalFollowupToken}` - : base; + const nonce = normalizeOptionalString(params.nonce); + return nonce ? `${base}${EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER}${nonce}` : base; } -function parseExecApprovalFollowupToken(idempotencyKey: string): string | undefined { - if (!idempotencyKey.startsWith(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX)) { +export function parseExecApprovalFollowupApprovalId(idempotencyKey: string): string | undefined { + const normalized = normalizeOptionalString(idempotencyKey); + if (!normalized?.startsWith(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX)) { return undefined; } - const tokenMarker = idempotencyKey.lastIndexOf(EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER); - if (tokenMarker < EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX.length) { - return undefined; - } - return normalizeOptionalString( - idempotencyKey.slice(tokenMarker + EXEC_APPROVAL_FOLLOWUP_ELEVATED_TOKEN_MARKER.length), - ); + const body = normalized.slice(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_PREFIX.length); + const nonceMarker = body.lastIndexOf(EXEC_APPROVAL_FOLLOWUP_IDEMPOTENCY_NONCE_MARKER); + return normalizeOptionalString(nonceMarker >= 0 ? body.slice(0, nonceMarker) : body); } -export function registerExecApprovalFollowupElevatedDefaults(params: { +export function registerExecApprovalFollowupRuntimeHandoff(params: { + approvalId: string; sessionKey: string; bashElevated?: ExecElevatedDefaults; nowMs?: number; -}): string | undefined { +}): ExecApprovalFollowupRuntimeHandoffRegistration | undefined { + const approvalId = normalizeOptionalString(params.approvalId); const sessionKey = normalizeOptionalString(params.sessionKey); - if (!params.bashElevated || !sessionKey) { + if (!approvalId || !sessionKey || !params.bashElevated) { return undefined; } const nowMs = params.nowMs ?? Date.now(); - pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs); - const token = randomUUID(); - execApprovalFollowupElevatedDefaults.set(token, { - sessionKey, - bashElevated: cloneExecElevatedDefaults(params.bashElevated), - expiresAtMs: nowMs + EXEC_APPROVAL_FOLLOWUP_ELEVATED_TTL_MS, + pruneExpiredExecApprovalFollowupRuntimeHandoffs(nowMs); + const handoffId = randomUUID(); + const idempotencyKey = buildExecApprovalFollowupIdempotencyKey({ + approvalId, + nonce: randomUUID(), }); - return token; + execApprovalFollowupRuntimeHandoffs.set(handoffId, { + kind: "exec-approval-followup", + approvalId, + sessionKey, + idempotencyKey, + bashElevated: cloneExecElevatedDefaults(params.bashElevated), + expiresAtMs: nowMs + EXEC_APPROVAL_FOLLOWUP_RUNTIME_HANDOFF_TTL_MS, + }); + return { handoffId, idempotencyKey }; } -export function consumeExecApprovalFollowupElevatedDefaults(params: { - token?: string; +export function consumeExecApprovalFollowupRuntimeHandoff(params: { + handoffId?: string; + approvalId?: string; + idempotencyKey?: string; sessionKey?: string; nowMs?: number; -}): ExecElevatedDefaults | undefined { - const token = normalizeOptionalString(params.token); - if (!token) { +}): ExecApprovalFollowupRuntimeHandoff | undefined { + const handoffId = normalizeOptionalString(params.handoffId); + const approvalId = normalizeOptionalString(params.approvalId); + const idempotencyKey = normalizeOptionalString(params.idempotencyKey); + if (!handoffId || !approvalId || !idempotencyKey) { return undefined; } const nowMs = params.nowMs ?? Date.now(); - pruneExpiredExecApprovalFollowupElevatedDefaults(nowMs); - const entry = execApprovalFollowupElevatedDefaults.get(token); + pruneExpiredExecApprovalFollowupRuntimeHandoffs(nowMs); + const entry = execApprovalFollowupRuntimeHandoffs.get(handoffId); if (!entry) { return undefined; } if (entry.expiresAtMs <= nowMs) { - execApprovalFollowupElevatedDefaults.delete(token); + execApprovalFollowupRuntimeHandoffs.delete(handoffId); return undefined; } const sessionKey = normalizeOptionalString(params.sessionKey); - if (entry.sessionKey !== sessionKey) { + if ( + entry.approvalId !== approvalId || + entry.idempotencyKey !== idempotencyKey || + entry.sessionKey !== sessionKey + ) { return undefined; } - execApprovalFollowupElevatedDefaults.delete(token); - return cloneExecElevatedDefaults(entry.bashElevated); + execApprovalFollowupRuntimeHandoffs.delete(handoffId); + return cloneExecApprovalFollowupRuntimeHandoff(entry); } -export function consumeExecApprovalFollowupElevatedDefaultsFromIdempotencyKey(params: { - idempotencyKey: string; - sessionKey?: string; - nowMs?: number; -}): ExecElevatedDefaults | undefined { - return consumeExecApprovalFollowupElevatedDefaults({ - token: parseExecApprovalFollowupToken(params.idempotencyKey), - sessionKey: params.sessionKey, - nowMs: params.nowMs, - }); -} - -export function resetExecApprovalFollowupElevatedDefaultsForTests(): void { - execApprovalFollowupElevatedDefaults.clear(); +export function resetExecApprovalFollowupRuntimeHandoffsForTests(): void { + execApprovalFollowupRuntimeHandoffs.clear(); } diff --git a/src/agents/bash-tools.exec-approval-followup.test.ts b/src/agents/bash-tools.exec-approval-followup.test.ts index 8617c5059bf..4d1d69051c1 100644 --- a/src/agents/bash-tools.exec-approval-followup.test.ts +++ b/src/agents/bash-tools.exec-approval-followup.test.ts @@ -283,13 +283,14 @@ describe("exec approval followup", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - it("carries the elevated followup token through idempotency without exposing elevated defaults", async () => { + it("carries the runtime handoff separately from idempotency without exposing elevated defaults", async () => { await sendExecApprovalFollowup({ approvalId: "req-elevated-75832", sessionKey: "agent:main:telegram:direct:123", turnSourceChannel: "telegram", resultText: "Exec finished (gateway id=req-elevated-75832, code 0)\nok", - execApprovalFollowupToken: "token-75832", + internalRuntimeHandoffId: "handoff-75832", + idempotencyKey: "exec-approval-followup:req-elevated-75832:nonce:nonce-75832", }); expect(callGatewayTool).toHaveBeenCalledWith( @@ -298,7 +299,8 @@ describe("exec approval followup", () => { expect.objectContaining({ sessionKey: "agent:main:telegram:direct:123", channel: "telegram", - idempotencyKey: "exec-approval-followup:req-elevated-75832:elevated:token-75832", + idempotencyKey: "exec-approval-followup:req-elevated-75832:nonce:nonce-75832", + internalRuntimeHandoffId: "handoff-75832", }), { expectFinal: true }, ); diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts index 49c59298b76..9e4190bf980 100644 --- a/src/agents/bash-tools.exec-approval-followup.ts +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -24,7 +24,8 @@ type ExecApprovalFollowupParams = { turnSourceThreadId?: string | number; resultText: string; direct?: boolean; - execApprovalFollowupToken?: string; + internalRuntimeHandoffId?: string; + idempotencyKey?: string; }; function buildExecDeniedFollowupPrompt(resultText: string): string { @@ -151,7 +152,8 @@ function buildAgentFollowupArgs(params: { turnSourceTo?: string; turnSourceAccountId?: string; turnSourceThreadId?: string | number; - execApprovalFollowupToken?: string; + internalRuntimeHandoffId?: string; + idempotencyKey?: string; }) { const { deliveryTarget, sessionOnlyOriginChannel } = params; // When the followup run has no deliverable route and no gateway-internal channel, @@ -179,10 +181,14 @@ function buildAgentFollowupArgs(params: { : sessionOnlyOriginChannel ? params.turnSourceThreadId : undefined, - idempotencyKey: buildExecApprovalFollowupIdempotencyKey({ - approvalId: params.approvalId, - execApprovalFollowupToken: params.execApprovalFollowupToken, - }), + idempotencyKey: + params.idempotencyKey ?? + buildExecApprovalFollowupIdempotencyKey({ + approvalId: params.approvalId, + }), + ...(params.internalRuntimeHandoffId + ? { internalRuntimeHandoffId: params.internalRuntimeHandoffId } + : {}), }; } @@ -256,7 +262,8 @@ export async function sendExecApprovalFollowup( turnSourceTo: params.turnSourceTo, turnSourceAccountId: params.turnSourceAccountId, turnSourceThreadId: params.turnSourceThreadId, - execApprovalFollowupToken: params.execApprovalFollowupToken, + internalRuntimeHandoffId: params.internalRuntimeHandoffId, + idempotencyKey: params.idempotencyKey, }), { expectFinal: true }, ); diff --git a/src/agents/bash-tools.exec-host-shared.test.ts b/src/agents/bash-tools.exec-host-shared.test.ts index 7bda2747ada..ffdb363182d 100644 --- a/src/agents/bash-tools.exec-host-shared.test.ts +++ b/src/agents/bash-tools.exec-host-shared.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { - consumeExecApprovalFollowupElevatedDefaults, - resetExecApprovalFollowupElevatedDefaultsForTests, + consumeExecApprovalFollowupRuntimeHandoff, + resetExecApprovalFollowupRuntimeHandoffsForTests, } from "./bash-tools.exec-approval-followup-state.js"; import { buildExecApprovalPendingToolResult, @@ -63,7 +63,7 @@ describe("sendExecApprovalFollowupResult", () => { allowlist: [], file: { version: 1, agents: {} }, }); - resetExecApprovalFollowupElevatedDefaultsForTests(); + resetExecApprovalFollowupRuntimeHandoffsForTests(); }); it("logs repeated followup dispatch failures once per approval id and error message", async () => { @@ -132,22 +132,65 @@ describe("sendExecApprovalFollowupResult", () => { ); const call = sendExecApprovalFollowup.mock.calls[0]?.[0] as - | { execApprovalFollowupToken?: string; bashElevated?: unknown } + | { + internalRuntimeHandoffId?: string; + idempotencyKey?: string; + execApprovalFollowupToken?: string; + bashElevated?: unknown; + } | undefined; - expect(call?.execApprovalFollowupToken).toEqual(expect.any(String)); + expect(call?.internalRuntimeHandoffId).toEqual(expect.any(String)); + expect(call?.idempotencyKey).toMatch(/^exec-approval-followup:approval-elevated-75832:nonce:/); + expect(call?.idempotencyKey).not.toContain(call?.internalRuntimeHandoffId ?? ""); expect(call).not.toHaveProperty("bashElevated"); + expect(call).not.toHaveProperty("execApprovalFollowupToken"); expect( - consumeExecApprovalFollowupElevatedDefaults({ - token: call?.execApprovalFollowupToken ?? "", + consumeExecApprovalFollowupRuntimeHandoff({ + handoffId: call?.internalRuntimeHandoffId ?? "", + approvalId: "approval-elevated-75832", + idempotencyKey: call?.idempotencyKey ?? "", sessionKey: "agent:main:telegram:direct:wrong", }), ).toBeUndefined(); expect( - consumeExecApprovalFollowupElevatedDefaults({ - token: call?.execApprovalFollowupToken ?? "", + consumeExecApprovalFollowupRuntimeHandoff({ + handoffId: call?.internalRuntimeHandoffId ?? "", + approvalId: "approval-elevated-75832", + idempotencyKey: call?.idempotencyKey ?? "", sessionKey: "agent:main:telegram:direct:123", }), - ).toEqual(bashElevated); + ).toEqual({ + kind: "exec-approval-followup", + approvalId: "approval-elevated-75832", + sessionKey: "agent:main:telegram:direct:123", + idempotencyKey: call?.idempotencyKey, + bashElevated, + }); + }); + + it("keeps non-elevated agent followups on the deterministic idempotency path", async () => { + sendExecApprovalFollowup.mockResolvedValue(true); + + await sendExecApprovalFollowupResult( + { + approvalId: "approval-normal-75832", + sessionKey: "agent:main:telegram:direct:123", + turnSourceChannel: "telegram", + }, + "Exec finished", + { sendExecApprovalFollowup, logWarn }, + ); + + const call = sendExecApprovalFollowup.mock.calls[0]?.[0] as + | { + internalRuntimeHandoffId?: string; + idempotencyKey?: string; + bashElevated?: unknown; + } + | undefined; + expect(call).not.toHaveProperty("internalRuntimeHandoffId"); + expect(call).not.toHaveProperty("idempotencyKey"); + expect(call).not.toHaveProperty("bashElevated"); }); }); diff --git a/src/agents/bash-tools.exec-host-shared.ts b/src/agents/bash-tools.exec-host-shared.ts index c4ce28910e3..b66e9bf54cd 100644 --- a/src/agents/bash-tools.exec-host-shared.ts +++ b/src/agents/bash-tools.exec-host-shared.ts @@ -16,7 +16,7 @@ import { type ExecSecurity, } from "../infra/exec-approvals.js"; import { logWarn } from "../logger.js"; -import { registerExecApprovalFollowupElevatedDefaults } from "./bash-tools.exec-approval-followup-state.js"; +import { registerExecApprovalFollowupRuntimeHandoff } from "./bash-tools.exec-approval-followup-state.js"; import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { type ExecApprovalRegistration, @@ -411,10 +411,11 @@ export async function sendExecApprovalFollowupResult( ): Promise { const send = deps.sendExecApprovalFollowup ?? sendExecApprovalFollowup; const warn = deps.logWarn ?? logWarn; - const execApprovalFollowupToken = + const runtimeHandoff = target.direct === true || !target.sessionKey ? undefined - : registerExecApprovalFollowupElevatedDefaults({ + : registerExecApprovalFollowupRuntimeHandoff({ + approvalId: target.approvalId, sessionKey: target.sessionKey, bashElevated: target.bashElevated, }); @@ -427,7 +428,12 @@ export async function sendExecApprovalFollowupResult( turnSourceThreadId: target.turnSourceThreadId, resultText, direct: target.direct, - execApprovalFollowupToken, + ...(runtimeHandoff + ? { + internalRuntimeHandoffId: runtimeHandoff.handoffId, + idempotencyKey: runtimeHandoff.idempotencyKey, + } + : {}), }).catch((error) => { const message = formatErrorMessage(error); const key = `${target.approvalId}:${message}`; diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 6d451301873..b10cbda06e7 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -169,6 +169,7 @@ export const AgentParamsSchema = Type.Object( Type.Union([Type.Literal("default"), Type.Literal("heartbeat"), Type.Literal("cron")]), ), acpTurnSource: Type.Optional(Type.Literal("manual_spawn")), + internalRuntimeHandoffId: Type.Optional(NonEmptyString), internalEvents: Type.Optional(Type.Array(AgentInternalEventSchema)), inputProvenance: Type.Optional(InputProvenanceSchema), voiceWakeTrigger: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 1e53df53289..03b5815c823 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - registerExecApprovalFollowupElevatedDefaults, - resetExecApprovalFollowupElevatedDefaultsForTests, + registerExecApprovalFollowupRuntimeHandoff, + resetExecApprovalFollowupRuntimeHandoffsForTests, } from "../../agents/bash-tools.exec-approval-followup-state.js"; import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js"; import { @@ -360,6 +360,22 @@ function readLastAgentCommandCall(): AgentCommandCall | undefined { return mocks.agentCommand.mock.calls.at(-1)?.[0] as AgentCommandCall | undefined; } +function backendGatewayClient(): AgentHandlerArgs["client"] { + return { + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "gateway-client", + version: "test", + platform: "test", + mode: "backend", + }, + scopes: ["operator.write"], + }, + } as AgentHandlerArgs["client"]; +} + async function waitForAgentCommandCall< T extends AgentCommandCall = AgentCommandCall, >(): Promise { @@ -458,7 +474,7 @@ describe("gateway agent handler", () => { mocks.resolveSendPolicy.mockReset().mockReturnValue("allow"); dateOnlyFakeClockActive = false; vi.useRealTimers(); - resetExecApprovalFollowupElevatedDefaultsForTests(); + resetExecApprovalFollowupRuntimeHandoffsForTests(); }); it("preserves ACP metadata from the current stored session entry", async () => { @@ -1592,16 +1608,20 @@ describe("gateway agent handler", () => { expect(callArgs.runContext?.messageChannel).toBe("webchat"); }); - it("forwards elevated defaults only for valid exec approval followup tokens", async () => { + it("forwards elevated defaults only for valid exec approval runtime handoffs", async () => { const bashElevated = { enabled: true, allowed: true, defaultLevel: "on" as const, }; - const token = registerExecApprovalFollowupElevatedDefaults({ + const registration = registerExecApprovalFollowupRuntimeHandoff({ + approvalId: "req-elevated-75832", sessionKey: "agent:main:telegram:direct:123", bashElevated, }); + if (!registration) { + throw new Error("expected runtime handoff id"); + } mockMainSessionEntry({ sessionId: "existing-session-id", lastChannel: "telegram", @@ -1617,16 +1637,59 @@ describe("gateway agent handler", () => { message: "exec followup", sessionKey: "agent:main:telegram:direct:123", channel: "telegram", - idempotencyKey: `exec-approval-followup:req-elevated-75832:elevated:${token}`, + idempotencyKey: registration.idempotencyKey, + internalRuntimeHandoffId: registration.handoffId, }, - { reqId: "exec-followup-elevated" }, + { reqId: "exec-followup-elevated", client: backendGatewayClient() }, ); const callArgs = await waitForAgentCommandCall<{ bashElevated?: unknown }>(); expect(callArgs.bashElevated).toEqual(bashElevated); }); - it("does not honor caller-supplied exec approval followup token strings without registry state", async () => { + it("does not consume exec approval runtime handoffs from non-backend callers", async () => { + const bashElevated = { + enabled: true, + allowed: true, + defaultLevel: "on" as const, + }; + const registration = registerExecApprovalFollowupRuntimeHandoff({ + approvalId: "req-elevated-75832", + sessionKey: "agent:main:telegram:direct:123", + bashElevated, + }); + if (!registration) { + throw new Error("expected runtime handoff id"); + } + mockMainSessionEntry({ + sessionId: "existing-session-id", + lastChannel: "telegram", + lastTo: "123", + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + const agentCommandCallsBefore = mocks.agentCommand.mock.calls.length; + + const respond = await invokeAgent( + { + message: "exec followup", + sessionKey: "agent:main:telegram:direct:123", + channel: "telegram", + idempotencyKey: registration.idempotencyKey, + internalRuntimeHandoffId: registration.handoffId, + }, + { reqId: "exec-followup-non-backend", flushDispatch: false }, + ); + + expect(mocks.agentCommand).toHaveBeenCalledTimes(agentCommandCallsBefore); + expectRespondError(respond, { + message: "exec approval followup idempotency keys are reserved for backend callers.", + }); + }); + + it("does not honor caller-supplied exec approval runtime handoff ids without registry state", async () => { mockMainSessionEntry({ sessionId: "existing-session-id", lastChannel: "telegram", @@ -1642,9 +1705,49 @@ describe("gateway agent handler", () => { message: "forged exec followup", sessionKey: "agent:main:telegram:direct:123", channel: "telegram", - idempotencyKey: "exec-approval-followup:req-elevated-75832:elevated:forged-token", + idempotencyKey: "exec-approval-followup:req-elevated-75832:nonce:forged-nonce", + internalRuntimeHandoffId: "forged-handoff", }, - { reqId: "exec-followup-forged" }, + { reqId: "exec-followup-forged", client: backendGatewayClient() }, + ); + + const callArgs = await waitForAgentCommandCall<{ bashElevated?: unknown }>(); + expect(callArgs).not.toHaveProperty("bashElevated"); + }); + + it("does not restore elevated defaults from idempotency key suffixes", async () => { + const bashElevated = { + enabled: true, + allowed: true, + defaultLevel: "on" as const, + }; + const registration = registerExecApprovalFollowupRuntimeHandoff({ + approvalId: "req-elevated-75832", + sessionKey: "agent:main:telegram:direct:123", + bashElevated, + }); + if (!registration) { + throw new Error("expected runtime handoff id"); + } + mockMainSessionEntry({ + sessionId: "existing-session-id", + lastChannel: "telegram", + lastTo: "123", + }); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "forged exec followup", + sessionKey: "agent:main:telegram:direct:123", + channel: "telegram", + idempotencyKey: `exec-approval-followup:req-elevated-75832:elevated:${registration.handoffId}`, + internalRuntimeHandoffId: registration.handoffId, + }, + { reqId: "exec-followup-idempotency-suffix", client: backendGatewayClient() }, ); const callArgs = await waitForAgentCommandCall<{ bashElevated?: unknown }>(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index dceafb61591..4c8a3c7d9e2 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -4,7 +4,10 @@ import { resolveDefaultAgentId, resolveAgentWorkspaceDir, } from "../../agents/agent-scope.js"; -import { consumeExecApprovalFollowupElevatedDefaultsFromIdempotencyKey } from "../../agents/bash-tools.exec-approval-followup-state.js"; +import { + consumeExecApprovalFollowupRuntimeHandoff, + parseExecApprovalFollowupApprovalId, +} from "../../agents/bash-tools.exec-approval-followup-state.js"; import { isTimeoutError } from "../../agents/failover-error.js"; import { resolveAgentAvatar, @@ -100,7 +103,11 @@ import { } from "../chat-attachments.js"; import { resolveAssistantAvatarUrl } from "../control-ui-shared.js"; import { ADMIN_SCOPE } from "../method-scopes.js"; -import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_MODES, + hasGatewayClientCap, +} from "../protocol/client-info.js"; import { ErrorCodes, errorShape, @@ -176,6 +183,12 @@ function resolveCanResetSessionFromClient(client: GatewayRequestHandlerOptions[" return resolveSenderIsOwnerFromClient(client); } +function resolveCanUseInternalRuntimeHandoff( + client: GatewayRequestHandlerOptions["client"], +): boolean { + return client?.connect?.client?.mode === GATEWAY_CLIENT_MODES.BACKEND; +} + async function runSessionResetFromAgent(params: { key: string; reason: "new" | "reset"; @@ -583,6 +596,7 @@ export const agentHandlers: GatewayRequestHandlers = { bootstrapContextMode?: "full" | "lightweight"; bootstrapContextRunKind?: "default" | "heartbeat" | "cron"; acpTurnSource?: "manual_spawn"; + internalRuntimeHandoffId?: string; internalEvents?: AgentInternalEvent[]; idempotencyKey: string; timeout?: number; @@ -596,6 +610,7 @@ export const agentHandlers: GatewayRequestHandlers = { const senderIsOwner = resolveSenderIsOwnerFromClient(client); const allowModelOverride = resolveAllowModelOverrideFromClient(client); const canResetSession = resolveCanResetSessionFromClient(client); + const canUseInternalRuntimeHandoff = resolveCanUseInternalRuntimeHandoff(client); const requestedModelOverride = Boolean(request.provider || request.model); const isRawModelRun = request.modelRun === true || request.promptMode === "none"; if (requestedModelOverride && !allowModelOverride) { @@ -613,6 +628,18 @@ export const agentHandlers: GatewayRequestHandlers = { const modelOverride = allowModelOverride ? request.model : undefined; const cfg = context.getRuntimeConfig(); const idem = request.idempotencyKey; + const execApprovalFollowupApprovalId = parseExecApprovalFollowupApprovalId(idem); + if (execApprovalFollowupApprovalId && !canUseInternalRuntimeHandoff) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "exec approval followup idempotency keys are reserved for backend callers.", + ), + ); + return; + } const normalizedSpawned = normalizeSpawnedRunMetadata({ groupId: request.groupId, groupChannel: request.groupChannel, @@ -1392,22 +1419,31 @@ export const agentHandlers: GatewayRequestHandlers = { (!resolvedSessionKey || resolveAgentIdFromSessionKey(resolvedSessionKey) === agentId) ? agentId : undefined; - let execApprovalFollowupElevatedDefaults = - consumeExecApprovalFollowupElevatedDefaultsFromIdempotencyKey({ - idempotencyKey: idem, - sessionKey: resolvedSessionKey, - }); + let execApprovalFollowupRuntimeHandoff = + canUseInternalRuntimeHandoff && execApprovalFollowupApprovalId + ? consumeExecApprovalFollowupRuntimeHandoff({ + handoffId: request.internalRuntimeHandoffId, + approvalId: execApprovalFollowupApprovalId, + idempotencyKey: idem, + sessionKey: resolvedSessionKey, + }) + : undefined; if ( - !execApprovalFollowupElevatedDefaults && + !execApprovalFollowupRuntimeHandoff && + canUseInternalRuntimeHandoff && + execApprovalFollowupApprovalId && requestedSessionKeyRaw && requestedSessionKeyRaw !== resolvedSessionKey ) { - execApprovalFollowupElevatedDefaults = - consumeExecApprovalFollowupElevatedDefaultsFromIdempotencyKey({ - idempotencyKey: idem, - sessionKey: requestedSessionKeyRaw, - }); + execApprovalFollowupRuntimeHandoff = consumeExecApprovalFollowupRuntimeHandoff({ + handoffId: request.internalRuntimeHandoffId, + approvalId: execApprovalFollowupApprovalId, + idempotencyKey: idem, + sessionKey: requestedSessionKeyRaw, + }); } + const execApprovalFollowupElevatedDefaults = + execApprovalFollowupRuntimeHandoff?.bashElevated; dispatchAgentRunFromGateway({ ingressOpts: {