refactor: isolate exec approval followup handoff

This commit is contained in:
Peter Steinberger
2026-05-10 08:25:07 +01:00
parent 438861ee0f
commit 8f4e9c841c
8 changed files with 327 additions and 102 deletions

View File

@@ -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<string, ExecApprovalFollowupElevatedEntry>();
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();
}

View File

@@ -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 },
);

View File

@@ -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 },
);

View File

@@ -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");
});
});

View File

@@ -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<void> {
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}`;

View File

@@ -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()),

View File

@@ -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<T> {
@@ -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 }>();

View File

@@ -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: {