import path from "node:path"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { buildRemoteExecutionSessionIdentity, prepareRemoteManagedRuntime, remoteExecutionSessionMatches, type RemoteManagedRuntimeAsset, } from "./remote-managed-runtime.js"; import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; import { ensureCommandResolvable, resolveCommandForLogs, runChildProcess, type RunProcessResult, type TerminalResultCleanupOptions, } from "./server-utils.js"; export interface AdapterLocalExecutionTarget { kind: "local"; environmentId?: string | null; leaseId?: string | null; } export interface AdapterSshExecutionTarget { kind: "remote"; transport: "ssh"; environmentId?: string | null; leaseId?: string | null; remoteCwd: string; paperclipApiUrl?: string | null; spec: SshRemoteExecutionSpec; } export type AdapterExecutionTarget = | AdapterLocalExecutionTarget | AdapterSshExecutionTarget; export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec; export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset; export interface PreparedAdapterExecutionTargetRuntime { target: AdapterExecutionTarget; runtimeRootDir: string | null; assetDirs: Record; restoreWorkspace(): Promise; } export interface AdapterExecutionTargetProcessOptions { cwd: string; env: Record; stdin?: string; timeoutSec: number; graceSec: number; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; terminalResultCleanup?: TerminalResultCleanupOptions; } export interface AdapterExecutionTargetShellOptions { cwd: string; env: Record; timeoutSec?: number; graceSec?: number; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; } function parseObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function readString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function readStringMeta(parsed: Record, key: string): string | null { return readString(parsed[key]); } function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget { const parsed = parseObject(value); if (parsed.kind === "local") return true; if (parsed.kind !== "remote") return false; if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null; return false; } export function adapterExecutionTargetToRemoteSpec( target: AdapterExecutionTarget | null | undefined, ): AdapterRemoteExecutionSpec | null { return target?.kind === "remote" && target.transport === "ssh" ? target.spec : null; } export function adapterExecutionTargetIsRemote( target: AdapterExecutionTarget | null | undefined, ): boolean { return target?.kind === "remote"; } export function adapterExecutionTargetUsesManagedHome( target: AdapterExecutionTarget | null | undefined, ): boolean { // SSH execution targets sync the runtime assets they need into the remote cwd today, // so neither local nor remote targets provision a separate managed adapter home. void target; return false; } export function adapterExecutionTargetRemoteCwd( target: AdapterExecutionTarget | null | undefined, localCwd: string, ): string { return target?.kind === "remote" ? target.remoteCwd : localCwd; } export function adapterExecutionTargetPaperclipApiUrl( target: AdapterExecutionTarget | null | undefined, ): string | null { if (target?.kind !== "remote") return null; return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null; } export function describeAdapterExecutionTarget( target: AdapterExecutionTarget | null | undefined, ): string { if (!target || target.kind === "local") return "local environment"; return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`; } export async function ensureAdapterExecutionTargetCommandResolvable( command: string, target: AdapterExecutionTarget | null | undefined, cwd: string, env: NodeJS.ProcessEnv, ) { await ensureCommandResolvable(command, cwd, env, { remoteExecution: adapterExecutionTargetToRemoteSpec(target), }); } export async function resolveAdapterExecutionTargetCommandForLogs( command: string, target: AdapterExecutionTarget | null | undefined, cwd: string, env: NodeJS.ProcessEnv, ): Promise { return await resolveCommandForLogs(command, cwd, env, { remoteExecution: adapterExecutionTargetToRemoteSpec(target), }); } export async function runAdapterExecutionTargetProcess( runId: string, target: AdapterExecutionTarget | null | undefined, command: string, args: string[], options: AdapterExecutionTargetProcessOptions, ): Promise { return await runChildProcess(runId, command, args, { cwd: options.cwd, env: options.env, stdin: options.stdin, timeoutSec: options.timeoutSec, graceSec: options.graceSec, onLog: options.onLog, onSpawn: options.onSpawn, terminalResultCleanup: options.terminalResultCleanup, remoteExecution: adapterExecutionTargetToRemoteSpec(target), }); } export async function runAdapterExecutionTargetShellCommand( runId: string, target: AdapterExecutionTarget | null | undefined, command: string, options: AdapterExecutionTargetShellOptions, ): Promise { const onLog = options.onLog ?? (async () => {}); if (target?.kind === "remote") { const startedAt = new Date().toISOString(); try { const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, { timeoutMs: (options.timeoutSec ?? 15) * 1000, }); if (result.stdout) await onLog("stdout", result.stdout); if (result.stderr) await onLog("stderr", result.stderr); return { exitCode: 0, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr, pid: null, startedAt, }; } catch (error) { const timedOutError = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; signal?: string | null; }; const stdout = timedOutError.stdout ?? ""; const stderr = timedOutError.stderr ?? ""; if (typeof timedOutError.code === "number") { if (stdout) await onLog("stdout", stdout); if (stderr) await onLog("stderr", stderr); return { exitCode: timedOutError.code, signal: timedOutError.signal ?? null, timedOut: false, stdout, stderr, pid: null, startedAt, }; } if (timedOutError.code !== "ETIMEDOUT") { throw error; } if (stdout) await onLog("stdout", stdout); if (stderr) await onLog("stderr", stderr); return { exitCode: null, signal: timedOutError.signal ?? null, timedOut: true, stdout, stderr, pid: null, startedAt, }; } } return await runAdapterExecutionTargetProcess( runId, target, "sh", ["-lc", command], { cwd: options.cwd, env: options.env, timeoutSec: options.timeoutSec ?? 15, graceSec: options.graceSec ?? 5, onLog, }, ); } export async function readAdapterExecutionTargetHomeDir( runId: string, target: AdapterExecutionTarget | null | undefined, options: AdapterExecutionTargetShellOptions, ): Promise { const result = await runAdapterExecutionTargetShellCommand( runId, target, 'printf %s "$HOME"', options, ); const homeDir = result.stdout.trim(); return homeDir.length > 0 ? homeDir : null; } export async function ensureAdapterExecutionTargetFile( runId: string, target: AdapterExecutionTarget | null | undefined, filePath: string, options: AdapterExecutionTargetShellOptions, ): Promise { await runAdapterExecutionTargetShellCommand( runId, target, `mkdir -p ${shellQuote(path.posix.dirname(filePath))} && : > ${shellQuote(filePath)}`, options, ); } export function adapterExecutionTargetSessionIdentity( target: AdapterExecutionTarget | null | undefined, ): Record | null { if (!target || target.kind === "local") return null; return buildRemoteExecutionSessionIdentity(target.spec); } export function adapterExecutionTargetSessionMatches( saved: unknown, target: AdapterExecutionTarget | null | undefined, ): boolean { if (!target || target.kind === "local") { return Object.keys(parseObject(saved)).length === 0; } return remoteExecutionSessionMatches(saved, target.spec); } export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTarget | null { const parsed = parseObject(value); const kind = readStringMeta(parsed, "kind"); if (kind === "local") { return { kind: "local", environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), }; } if (kind === "remote" && readStringMeta(parsed, "transport") === "ssh") { const spec = parseSshRemoteExecutionSpec(parseObject(parsed.spec)); if (!spec) return null; return { kind: "remote", transport: "ssh", environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), remoteCwd: spec.remoteCwd, paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null, spec, }; } return null; } export function adapterExecutionTargetFromRemoteExecution( remoteExecution: unknown, metadata: Pick = {}, ): AdapterExecutionTarget | null { const parsed = parseObject(remoteExecution); const ssh = parseSshRemoteExecutionSpec(parsed); if (ssh) { return { kind: "remote", transport: "ssh", environmentId: metadata.environmentId ?? null, leaseId: metadata.leaseId ?? null, remoteCwd: ssh.remoteCwd, paperclipApiUrl: ssh.paperclipApiUrl ?? null, spec: ssh, }; } return null; } export function readAdapterExecutionTarget(input: { executionTarget?: unknown; legacyRemoteExecution?: unknown; }): AdapterExecutionTarget | null { if (isAdapterExecutionTargetInstance(input.executionTarget)) { return input.executionTarget; } return ( parseAdapterExecutionTarget(input.executionTarget) ?? adapterExecutionTargetFromRemoteExecution(input.legacyRemoteExecution) ); } export async function prepareAdapterExecutionTargetRuntime(input: { target: AdapterExecutionTarget | null | undefined; adapterKey: string; workspaceLocalDir: string; workspaceExclude?: string[]; preserveAbsentOnRestore?: string[]; assets?: AdapterManagedRuntimeAsset[]; installCommand?: string | null; }): Promise { const target = input.target ?? { kind: "local" as const }; if (target.kind === "local") { return { target, runtimeRootDir: null, assetDirs: {}, restoreWorkspace: async () => {}, }; } const prepared = await prepareRemoteManagedRuntime({ spec: target.spec, adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, assets: input.assets, }); return { target, runtimeRootDir: prepared.runtimeRootDir, assetDirs: prepared.assetDirs, restoreWorkspace: prepared.restoreWorkspace, }; } export function runtimeAssetDir( prepared: Pick, key: string, fallbackRemoteCwd: string, ): string { return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key); }