Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,043 changes: 23 additions & 2,020 deletions services/runner/src/engines/sandbox_agent.ts

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions services/runner/src/engines/sandbox_agent/acp-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ export function createAcpDispatcher(): Agent {
* `fetch` so the `dispatcher` option is honored regardless of how the global dispatcher is set.
* The `sandbox-agent` SDK accepts a custom `fetch`; we hand it this one on every path.
*/
export function createAcpFetch(dispatcher: Agent = createAcpDispatcher()): typeof fetch {
return ((input: any, init?: any) =>
undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch;
export function createAcpFetch(
dispatcher: Agent = createAcpDispatcher(),
): typeof fetch {
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
undiciFetch(
input as unknown as Parameters<typeof undiciFetch>[0],
{ ...init, dispatcher } as unknown as Parameters<typeof undiciFetch>[1],
)) as unknown as typeof fetch;
}
68 changes: 54 additions & 14 deletions services/runner/src/engines/sandbox_agent/acp-interactions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PermissionReply } from "sandbox-agent";

import type { AgentEvent, ToolPermission } from "../../protocol.ts";
import {
decisionToReply,
Expand All @@ -18,10 +20,33 @@ import { redactContextBoundArgs } from "../../tools/relay.ts";

/** The parkable gate types a paused turn can record (the Claude ACP and Pi ACP gates). */
export type ParkedApprovalGateType =
"claude-acp-permission" | "pi-acp-permission";
| "claude-acp-permission"
| "pi-acp-permission";

/** The permission metadata the runner recovers per tool for a Pi gate (the identity-only
* envelope carries no policy). Keyed by resolved tool name. */
interface ToolCallLike extends Record<string, unknown> {
toolCallId?: unknown;
rawInput?: unknown;
input?: unknown;
name?: unknown;
title?: unknown;
kind?: unknown;
}

interface PermissionRequestLike extends Record<string, unknown> {
id?: unknown;
toolCall?: ToolCallLike;
availableReplies?: unknown;
options?: unknown;
}

function permissionRequest(value: unknown): PermissionRequestLike {
return value && typeof value === "object"
? (value as PermissionRequestLike)
: {};
}

export interface PiToolSpecMeta {
permission?: ToolPermission;
readOnly?: boolean;
Expand All @@ -31,7 +56,10 @@ export interface PiToolSpecMeta {
}

export interface AttachPermissionResponderInput {
session: any;
session: {
onPermissionRequest(listener: (request: unknown) => void): unknown;
respondPermission(id: string, reply: PermissionReply): Promise<void> | void;
};
run: { emitEvent: (event: AgentEvent) => void; events?: () => AgentEvent[] };
responder: Responder;
latch: PendingApprovalLatch;
Expand Down Expand Up @@ -101,8 +129,8 @@ export function attachPermissionResponder({
onPiGateAllowed,
piToolSpecsByName,
}: AttachPermissionResponderInput): void {
session.onPermissionRequest((req: any) => {
void handleRequest(req).catch((err) => {
session.onPermissionRequest((value: unknown) => {
void handleRequest(permissionRequest(value)).catch((err) => {
log?.(`[HITL] permission handling failed: ${errorMessage(err)}`);
onPause?.();
});
Expand All @@ -115,7 +143,10 @@ export function attachPermissionResponder({
// is the Pi gate's id/args normalization in `handlePiGate`, which must happen in place so
// every downstream read sees the envelope's real identity — with `rawInput` set to the
// gate's REDACTED args, never the model's values for context-bound paths.)
const stampResolvedName = (toolCall: any, gate: GateDescriptor): any => {
const stampResolvedName = (
toolCall: ToolCallLike | undefined,
gate: GateDescriptor,
): ToolCallLike | undefined => {
if (!toolCall || typeof toolCall !== "object" || !gate.toolName)
return toolCall;
return { ...toolCall, resolvedName: gate.toolName };
Expand All @@ -130,7 +161,7 @@ export function attachPermissionResponder({
// `onPause` (session teardown resolves the RPC as cancelled, not rejected) and the next
// turn's stored decision answers the re-raised gate.
const pauseUserApproval = (
req: any,
req: PermissionRequestLike,
id: string,
gate: GateDescriptor,
gateType: ParkedApprovalGateType,
Expand Down Expand Up @@ -167,7 +198,7 @@ export function attachPermissionResponder({
};

const pauseClientTool = (
req: any,
req: PermissionRequestLike,
id: string,
gate: GateDescriptor,
spec: ToolSpecLike,
Expand Down Expand Up @@ -207,7 +238,7 @@ export function attachPermissionResponder({
try {
await session.respondPermission(
id,
decisionToReply(decision, availableReplies) as any,
permissionReply(decisionToReply(decision, availableReplies)),
);
} catch (err) {
log?.(
Expand All @@ -227,7 +258,7 @@ export function attachPermissionResponder({
try {
await session.respondPermission(
id,
clientToolReply(verdict, availableReplies) as any,
permissionReply(clientToolReply(verdict, availableReplies)),
);
} catch (err) {
log?.(
Expand Down Expand Up @@ -255,7 +286,7 @@ export function attachPermissionResponder({
try {
await session.respondPermission(
id,
decisionToReply("deny", availableReplies) as any,
permissionReply(decisionToReply("deny", availableReplies)),
);
} catch (err) {
log?.(`[HITL] reject failed id=${id}: ${errorMessage(err)}`);
Expand All @@ -272,7 +303,7 @@ export function attachPermissionResponder({
* like a relay-gate card without showing model values the execution will overwrite.
*/
const handlePiGate = async (
req: any,
req: PermissionRequestLike,
id: string,
availableReplies: string[],
envelope: PiGateEnvelope,
Expand Down Expand Up @@ -325,7 +356,7 @@ export function attachPermissionResponder({
await replyPermission(id, verdict.kind, availableReplies);
};

async function handleRequest(req: any): Promise<void> {
async function handleRequest(req: PermissionRequestLike): Promise<void> {
const id = stringValue(req?.id) ?? "";
const availableReplies = stringArray(req?.availableReplies);

Expand Down Expand Up @@ -470,7 +501,7 @@ function recordedToolName(
}

function buildGateDescriptor(
toolCall: any,
toolCall: ToolCallLike | undefined,
run: { events?: () => AgentEvent[] },
serverPermissions: ReadonlyMap<string, ToolPermission>,
): GateDescriptor {
Expand Down Expand Up @@ -516,7 +547,9 @@ type ToolSpecLike = {
render?: unknown;
};

function resolvedSpecOf(toolCall: any): ToolSpecLike | undefined {
function resolvedSpecOf(
toolCall: ToolCallLike | undefined,
): ToolSpecLike | undefined {
const spec =
toolCall?.spec ??
toolCall?.toolSpec ??
Expand Down Expand Up @@ -552,6 +585,13 @@ function interactionEventId(id: string, toolCallId: unknown): string {
return id || stringValue(toolCallId) || "";
}

function permissionReply(value: string): PermissionReply {
if (value === "once" || value === "always" || value === "reject") {
return value;
}
throw new Error(`unsupported ACP permission reply: ${value}`);
}

function clientToolReply(
verdict: Exclude<ClientToolVerdict, { kind: "pendingApproval" }>,
availableReplies: string[],
Expand Down
37 changes: 23 additions & 14 deletions services/runner/src/engines/sandbox_agent/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,31 +67,36 @@ export function toolDeliveryUnsupportedMessage(
*/
export function mapCapabilities(
harness: string,
info: any,
info: unknown,
): ProbedCapabilities {
assert(
typeof harness === "string" && harness.length > 0,
"mapCapabilities requires a non-empty harness id",
);
const c = info?.capabilities;
const c =
info && typeof info === "object"
? (info as { capabilities?: unknown }).capabilities
: undefined;
if (c) {
assert(
typeof c === "object",
`probed capabilities for '${harness}' is not an object (got ${typeof c})`,
);
const flags = c as Record<string, unknown>;
return {
source: "probed",
capabilities: {
textMessages: c.textMessages ?? true,
images: !!c.images,
fileAttachments: !!c.fileAttachments,
mcpTools: !!c.mcpTools,
toolCalls: !!c.toolCalls,
reasoning: !!c.reasoning,
planMode: !!c.planMode,
permissions: !!c.permissions,
streamingDeltas: !!c.streamingDeltas,
sessionLifecycle: !!c.sessionLifecycle,
textMessages:
typeof flags.textMessages === "boolean" ? flags.textMessages : true,
images: !!flags.images,
fileAttachments: !!flags.fileAttachments,
mcpTools: !!flags.mcpTools,
toolCalls: !!flags.toolCalls,
reasoning: !!flags.reasoning,
planMode: !!flags.planMode,
permissions: !!flags.permissions,
streamingDeltas: !!flags.streamingDeltas,
sessionLifecycle: !!flags.sessionLifecycle,
usage: true,
},
};
Expand All @@ -117,8 +122,12 @@ export function mapCapabilities(
}

/** Probe the harness's capabilities from the daemon, falling back to static policy. */
export interface CapabilityProbe {
getAgent?(agent: string, options: { config: true }): Promise<unknown>;
}

export async function probeCapabilities(
sandbox: any,
sandbox: CapabilityProbe,
harness: string,
): Promise<ProbedCapabilities> {
assert(
Expand All @@ -127,7 +136,7 @@ export async function probeCapabilities(
);
let probed: ProbedCapabilities;
try {
const info = await sandbox.getAgent(harness, { config: true });
const info = await sandbox.getAgent!(harness, { config: true });
probed = mapCapabilities(harness, info);
} catch {
probed = mapCapabilities(harness, undefined);
Expand Down
45 changes: 31 additions & 14 deletions services/runner/src/engines/sandbox_agent/daytona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
uploadSystemPromptToSandbox,
} from "./pi-assets.ts";
import { shouldUploadOwnLogin, type RunPlan } from "./run-plan.ts";
import type {
SandboxAssetPort,
SandboxFilePort,
SandboxProcessPort,
} from "./sandbox-ports.ts";

type Log = (message: string) => void;

Expand Down Expand Up @@ -48,7 +53,7 @@ export function daytonaEnvVars(

/** Install the `pi` CLI into a Daytona sandbox (the sandbox-agent image lacks it). Best-effort. */
export async function installPiInSandbox(
sandbox: any,
sandbox: SandboxProcessPort,
log: Log = () => {},
): Promise<void> {
try {
Expand All @@ -64,9 +69,13 @@ export async function installPiInSandbox(
cwd: DAYTONA_PI_INSTALL_DIR,
timeoutMs: 180_000,
});
if (res?.exitCode !== 0) {
const result =
res && typeof res === "object"
? (res as { exitCode?: number | null; stderr?: unknown })
: undefined;
if (result?.exitCode !== 0) {
log(
`pi install in sandbox exit=${res?.exitCode}: ${String(res?.stderr).slice(-400)}`,
`pi install in sandbox exit=${result?.exitCode}: ${String(result?.stderr).slice(-400)}`,
);
}
} catch (err) {
Expand All @@ -80,7 +89,7 @@ export async function installPiInSandbox(
* back to any provider key in the sandbox env.
*/
export async function uploadPiAuthToSandbox(
sandbox: any,
sandbox: SandboxFilePort,
log: Log = () => {},
): Promise<void> {
const localDir =
Expand All @@ -107,7 +116,7 @@ export async function uploadPiAuthToSandbox(
}

export interface PrepareDaytonaPiAssetsInput {
sandbox: any;
sandbox: SandboxAssetPort;
plan: Pick<
RunPlan,
| "isPi"
Expand Down Expand Up @@ -170,12 +179,17 @@ export function createCookieFetch(
inner: typeof fetch = createAcpFetch(),
): typeof fetch {
const jar = new Map<string, Map<string, string>>(); // host -> (name -> "name=value")
return async (input: any, init?: any) => {
const url = new URL(typeof input === "string" ? input : input.url);
return async (
input: Parameters<typeof fetch>[0],
init?: RequestInit,
): Promise<Response> => {
const url = new URL(
typeof input === "string" || input instanceof URL ? input : input.url,
);
const host = url.host;
const cookies = jar.get(host);
const headers = new Headers(
init?.headers ?? (typeof input !== "string" ? input.headers : undefined),
init?.headers ?? (input instanceof Request ? input.headers : undefined),
);
if (cookies && cookies.size > 0) {
const existing = headers.get("cookie");
Expand All @@ -184,12 +198,15 @@ export function createCookieFetch(
headers.set("cookie", merged.join("; "));
}
const response = await inner(input, { ...init, headers });
const setCookies =
typeof (response.headers as any).getSetCookie === "function"
? (response.headers as any).getSetCookie()
: response.headers.get("set-cookie")
? [response.headers.get("set-cookie")]
: [];
const cookieHeaders = response.headers as Headers & {
getSetCookie?: () => string[];
};
const combinedSetCookie = response.headers.get("set-cookie");
const setCookies = cookieHeaders.getSetCookie
? cookieHeaders.getSetCookie()
: combinedSetCookie
? [combinedSetCookie]
: [];
if (setCookies.length) {
const store = jar.get(host) ?? new Map<string, string>();
for (const sc of setCookies) {
Expand Down
Loading
Loading