mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
Add command-backed attached computer execution target and allocated runner port
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5dcaa3b1a0
commit
72bf0c61c6
3 files changed
+76
-27
No files matched your search
@@ -233,10 +233,33 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor
|
||||
duplexObservabilityRecorder?: DuplexObservabilityRecorder | null;
|
||||
}
|
||||
|
||||
/** A durable attached computer owns a process, never the physical machine. */
|
||||
export interface AdapterComputerExecutionTarget extends Omit<AdapterSandboxExecutionTarget, "transport" | "sandboxLeaseAcquisition"> {
|
||||
transport: "computer";
|
||||
listenerPort: number;
|
||||
resourceAuthority: { kind: "computer-owner"; computerId: string; ownerId: string; generation: number };
|
||||
fileAuthority: { kind: "remote-persistent"; placementId: string; root: string; agentHome: string };
|
||||
launch(input: { command: string; args?: string[]; cwd?: string; env?: Record<string, string> }): Promise<unknown>;
|
||||
inspectProcess(): Promise<{ running: boolean; claim: unknown }>;
|
||||
retainWarm(idleTimeoutMs: number): Promise<void>;
|
||||
retire(): Promise<void>;
|
||||
computerTool: { command: string; args: string[] };
|
||||
}
|
||||
|
||||
export type AdapterCommandBackedExecutionTarget = AdapterSandboxExecutionTarget | AdapterComputerExecutionTarget;
|
||||
|
||||
/** Command transport capability; sandbox allocation ownership is unchanged. */
|
||||
export function adapterExecutionTargetIsCommandBacked(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): target is AdapterCommandBackedExecutionTarget {
|
||||
return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer");
|
||||
}
|
||||
|
||||
export type AdapterExecutionTarget =
|
||||
| AdapterLocalExecutionTarget
|
||||
| AdapterSshExecutionTarget
|
||||
| AdapterSandboxExecutionTarget;
|
||||
| AdapterSandboxExecutionTarget
|
||||
| AdapterComputerExecutionTarget;
|
||||
|
||||
export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec;
|
||||
|
||||
@@ -443,7 +466,7 @@ function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecu
|
||||
if (parsed.kind === "local") return true;
|
||||
if (parsed.kind !== "remote") return false;
|
||||
if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null;
|
||||
if (parsed.transport !== "sandbox") return false;
|
||||
if (parsed.transport !== "sandbox" && parsed.transport !== "computer") return false;
|
||||
return readStringMeta(parsed, "remoteCwd") !== null;
|
||||
}
|
||||
|
||||
@@ -462,7 +485,7 @@ export function adapterExecutionTargetIsRemote(
|
||||
export function adapterExecutionTargetUsesManagedHome(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): boolean {
|
||||
return target?.kind === "remote" && target.transport === "sandbox";
|
||||
return adapterExecutionTargetIsCommandBacked(target);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,8 +498,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
target?.kind === "remote" &&
|
||||
target.transport === "sandbox" &&
|
||||
adapterExecutionTargetIsCommandBacked(target) &&
|
||||
target.enableSandboxDuplexBridge === true
|
||||
);
|
||||
}
|
||||
@@ -489,7 +511,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge(
|
||||
export function adapterExecutionTargetDuplexObservabilityRecorder(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): DuplexObservabilityRecorder | null {
|
||||
return target?.kind === "remote" && target.transport === "sandbox"
|
||||
return adapterExecutionTargetIsCommandBacked(target)
|
||||
? target.duplexObservabilityRecorder ?? null
|
||||
: null;
|
||||
}
|
||||
@@ -553,7 +575,7 @@ export function describeAdapterExecutionTarget(
|
||||
if (target.transport === "ssh") {
|
||||
return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`;
|
||||
}
|
||||
return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
|
||||
return `${target.transport} environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
|
||||
}
|
||||
|
||||
export type AdapterExecutionTargetTimeoutSource =
|
||||
@@ -593,7 +615,7 @@ export function resolveAdapterExecutionTargetTimeout(
|
||||
// that usually apply their own shorter command defaults, so request an
|
||||
// explicit longer timeout for full adapter runs when the adapter leaves
|
||||
// timeoutSec unset.
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
if (adapterExecutionTargetIsCommandBacked(target)) {
|
||||
return { timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default" };
|
||||
}
|
||||
return { timeoutSec: 0, source: "unlimited" };
|
||||
@@ -659,18 +681,18 @@ export function formatAdapterExecutionTimeoutStartLogLine(
|
||||
);
|
||||
}
|
||||
|
||||
function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner {
|
||||
function requireSandboxRunner(target: AdapterCommandBackedExecutionTarget): CommandManagedRuntimeRunner {
|
||||
if (target.runner) return target.runner;
|
||||
throw new Error(
|
||||
"Sandbox execution target is missing its provider runtime runner. Sandbox commands must execute through the environment runtime.",
|
||||
);
|
||||
}
|
||||
|
||||
function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" {
|
||||
function preferredSandboxShell(target: AdapterCommandBackedExecutionTarget): "bash" | "sh" {
|
||||
return preferredShellForSandbox(target.shellCommand);
|
||||
}
|
||||
|
||||
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget;
|
||||
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterCommandBackedExecutionTarget;
|
||||
|
||||
// The Secure Shell command runner's own output buffer. This value used to
|
||||
// derive from the bridge body limit (`DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES
|
||||
@@ -697,7 +719,7 @@ function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecuti
|
||||
function adapterExecutionTargetTimeoutMs(
|
||||
target: AdapterCommandCapableExecutionTarget,
|
||||
): number | null | undefined {
|
||||
return target.transport === "sandbox" ? target.timeoutMs : undefined;
|
||||
return adapterExecutionTargetIsCommandBacked(target) ? target.timeoutMs : undefined;
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
@@ -707,7 +729,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: { installCommand?: string | null; timeoutSec?: number | null } = {},
|
||||
) {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
if (adapterExecutionTargetIsCommandBacked(target)) {
|
||||
await ensureSandboxCommandResolvable(
|
||||
command,
|
||||
target,
|
||||
@@ -726,7 +748,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
|
||||
async function probeSandboxCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterSandboxExecutionTarget,
|
||||
target: AdapterCommandBackedExecutionTarget,
|
||||
env: Record<string, string>,
|
||||
): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> {
|
||||
const runner = requireSandboxRunner(target);
|
||||
@@ -747,7 +769,7 @@ async function probeSandboxCommandResolvable(
|
||||
|
||||
async function ensureSandboxCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterSandboxExecutionTarget,
|
||||
target: AdapterCommandBackedExecutionTarget,
|
||||
env: Record<string, string>,
|
||||
installCommand: string | null,
|
||||
timeoutSec?: number | null,
|
||||
@@ -815,7 +837,7 @@ export async function resolveAdapterExecutionTargetCommandForLogs(
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<string> {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
if (adapterExecutionTargetIsCommandBacked(target)) {
|
||||
return `sandbox://${target.providerKey ?? "provider"}/${target.leaseId ?? "lease"}/${target.remoteCwd} :: ${command}`;
|
||||
}
|
||||
return await resolveCommandForLogs(command, cwd, env, {
|
||||
@@ -859,7 +881,7 @@ export async function runAdapterExecutionTargetProcess(
|
||||
args: string[],
|
||||
options: AdapterExecutionTargetProcessOptions,
|
||||
): Promise<RunProcessResult> {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
if (adapterExecutionTargetIsCommandBacked(target)) {
|
||||
const runner = requireSandboxRunner(target);
|
||||
const env = sanitizeRemoteExecutionEnv(options.env);
|
||||
await options.onRuntimeProgress?.({
|
||||
@@ -1055,7 +1077,7 @@ export async function maybeRunSandboxInstallCommand(input: {
|
||||
timeoutSec?: number;
|
||||
}): Promise<AdapterSandboxInstallCommandCheck | null> {
|
||||
const { target, adapterKey, installCommand } = input;
|
||||
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
|
||||
if (!adapterExecutionTargetIsCommandBacked(target)) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = installCommand.trim();
|
||||
@@ -1162,7 +1184,7 @@ export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input:
|
||||
onLog?: AdapterExecutionTargetShellOptions["onLog"];
|
||||
}): Promise<void> {
|
||||
const installCommand = input.installCommand?.trim();
|
||||
if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
|
||||
if (!installCommand || !adapterExecutionTargetIsCommandBacked(input.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1562,7 +1584,7 @@ export function githubOperationLauncherDirectory(input: GitHubLauncherLocation):
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(input.runId)) throw new Error("Invalid GitHub launcher run ID");
|
||||
return input.target?.kind === "remote"
|
||||
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime",
|
||||
...(input.target.transport === "sandbox" ? ["paperclip-runner"] : []), "github", input.runId)
|
||||
...(adapterExecutionTargetIsCommandBacked(input.target) ? ["paperclip-runner"] : []), "github", input.runId)
|
||||
: path.join(os.tmpdir(), "paperclip-github-runtime", input.runId);
|
||||
}
|
||||
|
||||
@@ -1986,7 +2008,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
// it does not start the 100 ms poll. Default OFF: the bridge keeps the poll.
|
||||
streamOutputViaSession?: boolean;
|
||||
}): Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null> {
|
||||
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
|
||||
if (!adapterExecutionTargetIsCommandBacked(input.target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4838,7 +4860,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
// log line as the file path. The http2 path starts no file-bridge
|
||||
// worker, so create the log directory before the tail starts.
|
||||
let duplexRunLogTail: SandboxRunLogTailFactory | null = null;
|
||||
if (target.transport === "sandbox" && target.streamRunLogs !== false) {
|
||||
if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) {
|
||||
const duplexLogsDir = sandboxCallbackBridgeDirectories(queueDir).logsDir;
|
||||
await ensureSandboxRunLogDirectory({
|
||||
runner,
|
||||
@@ -4930,7 +4952,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
}
|
||||
|
||||
let runLogTail: SandboxRunLogTailFactory | null = null;
|
||||
if (target.transport === "sandbox" && target.streamRunLogs !== false) {
|
||||
if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) {
|
||||
runLogTail = createSandboxRunLogTailFactory({
|
||||
runner,
|
||||
remoteCwd: target.remoteCwd,
|
||||
|
||||
@@ -210,3 +210,28 @@ describe("paperclip runner transport routing", () => {
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses each computer owner's allocated port without changing the sandbox default", async () => {
|
||||
const { adapterExecutionTargetIsCommandBacked } = await import("./execution-target.js");
|
||||
const endpoint = ingress();
|
||||
const getRunnerIngressEndpoint = vi.fn(async () => endpoint);
|
||||
const base = {
|
||||
kind: "remote" as const, transport: "computer" as const, providerKey: "boat", remoteCwd: "/home/user/project",
|
||||
leaseId: "attempt-lease", listenerPort: 45101, effectiveCapabilities: capabilities, getRunnerIngressEndpoint,
|
||||
resourceAuthority: { kind: "computer-owner" as const, computerId: "machine", ownerId: "owner-a", generation: 2 },
|
||||
fileAuthority: { kind: "remote-persistent" as const, placementId: "placement", root: "/home/user/project", agentHome: "/home/user/agent-a" },
|
||||
launch: async () => ({}), inspectProcess: async () => ({ running: true, claim: {} }),
|
||||
retainWarm: async () => {}, retire: async () => {}, computerTool: { command: "cua-driver", args: ["mcp"] },
|
||||
};
|
||||
expect(adapterExecutionTargetIsCommandBacked(base)).toBe(true);
|
||||
for (const port of [45101, 45102]) {
|
||||
const resolved = await resolvePaperclipRunnerTransport({ target: { ...base, listenerPort: port }, runId: "run-1",
|
||||
localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true });
|
||||
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: port });
|
||||
expect(getRunnerIngressEndpoint).toHaveBeenLastCalledWith({ leaseId: "attempt-lease", port, path: "/api/runner/v1/connect/run-1" });
|
||||
}
|
||||
const { listenerPort, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base;
|
||||
const resolved = await resolvePaperclipRunnerTransport({ target: { ...common, transport: "sandbox" }, runId: "sandbox-run",
|
||||
localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true });
|
||||
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: 43127 });
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AdapterExecutionTarget } from "./execution-target.js";
|
||||
import { adapterExecutionTargetIsCommandBacked, type AdapterExecutionTarget } from "./execution-target.js";
|
||||
|
||||
export const PAPERCLIP_RUNNER_INGRESS_PORT = 43_127;
|
||||
export const PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX = "/api/runner/v1/connect";
|
||||
@@ -133,7 +133,7 @@ export async function resolvePaperclipRunnerTransport(input: {
|
||||
}
|
||||
|
||||
if (
|
||||
input.target.transport === "sandbox" &&
|
||||
adapterExecutionTargetIsCommandBacked(input.target) &&
|
||||
input.target.effectiveCapabilities?.runnerWebSocketIngress === true
|
||||
) {
|
||||
const ingressAuthorized =
|
||||
@@ -152,16 +152,18 @@ export async function resolvePaperclipRunnerTransport(input: {
|
||||
"The sandbox runner ingress provider is unavailable for this lease.",
|
||||
);
|
||||
}
|
||||
const port = input.target.transport === "computer" ? input.target.listenerPort : PAPERCLIP_RUNNER_INGRESS_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new PaperclipRunnerTransportError("runner_ingress_unavailable", "Invalid allocated runner port.");
|
||||
const path = connectPath(input.runId);
|
||||
const ingress = await getRunnerIngressEndpoint({
|
||||
leaseId: input.target.leaseId,
|
||||
port: PAPERCLIP_RUNNER_INGRESS_PORT,
|
||||
port,
|
||||
path,
|
||||
});
|
||||
return {
|
||||
mode: "provider_ingress",
|
||||
listenAddress: "0.0.0.0",
|
||||
listenPort: PAPERCLIP_RUNNER_INGRESS_PORT,
|
||||
listenPort: port,
|
||||
listenPath: path,
|
||||
ingress,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user