diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 8a3b1ddb..71bcc344 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as ssh from "./ssh.js"; import { adapterExecutionTargetUsesManagedHome, + ensureAdapterExecutionTargetRuntimeCommandInstalled, resolveAdapterExecutionTargetCwd, runAdapterExecutionTargetShellCommand, } from "./execution-target.js"; @@ -161,6 +162,80 @@ describe("runAdapterExecutionTargetShellCommand", () => { }); }); +describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("runs install commands for sandbox targets", async () => { + const runner = { + execute: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + })), + }; + + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId: "run-install", + target: { + kind: "remote", + transport: "sandbox", + providerKey: "e2b", + remoteCwd: "/remote/workspace", + runner, + }, + installCommand: "npm install -g @google/gemini-cli", + cwd: "/local/workspace", + env: { PATH: "/usr/bin" }, + timeoutSec: 30, + }); + + expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "sh", + args: ["-lc", "npm install -g @google/gemini-cli"], + cwd: "/remote/workspace", + env: { PATH: "/usr/bin" }, + timeoutMs: 30_000, + })); + }); + + it("skips install commands for SSH targets", async () => { + const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({ + stdout: "", + stderr: "", + }); + + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId: "run-skip", + target: { + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + installCommand: "npm install -g @google/gemini-cli", + cwd: "/tmp/local", + env: {}, + }); + + expect(runSshCommandSpy).not.toHaveBeenCalled(); + }); +}); + describe("resolveAdapterExecutionTargetCwd", () => { const sshTarget = { kind: "remote" as const, diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 6372e08d..398f4899 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -393,6 +393,60 @@ export async function readAdapterExecutionTargetHomeDir( return homeDir.length > 0 ? homeDir : null; } +export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: { + runId: string; + target: AdapterExecutionTarget | null | undefined; + installCommand?: string | null; + detectCommand?: string | null; + cwd: string; + env: Record; + timeoutSec?: number; + graceSec?: number; + onLog?: AdapterExecutionTargetShellOptions["onLog"]; +}): Promise { + const installCommand = input.installCommand?.trim(); + if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") { + return; + } + + const detectCommand = input.detectCommand?.trim(); + if (detectCommand) { + const probe = await runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`, + { + cwd: input.cwd, + env: input.env, + timeoutSec: input.timeoutSec, + graceSec: input.graceSec, + }, + ); + if (!probe.timedOut && probe.exitCode === 0) { + return; + } + } + + const result = await runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + installCommand, + { + cwd: input.cwd, + env: input.env, + timeoutSec: input.timeoutSec, + graceSec: input.graceSec, + onLog: input.onLog, + }, + ); + if (result.timedOut) { + throw new Error(`Timed out while installing the adapter runtime command via: ${installCommand}`); + } + if ((result.exitCode ?? 0) !== 0) { + throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`); + } +} + export async function ensureAdapterExecutionTargetFile( runId: string, target: AdapterExecutionTarget | null | undefined, diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 0c144b7a..4ebcb076 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -27,6 +27,7 @@ export type { ConfigFieldOption, ConfigFieldSchema, AdapterConfigSchema, + AdapterRuntimeCommandSpec, ServerAdapterModule, QuotaWindow, ProviderQuotaResult, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 54e456a2..3d87f7a2 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -125,6 +125,7 @@ export interface AdapterExecutionContext { runtime: AdapterRuntime; config: Record; context: Record; + runtimeCommandSpec?: AdapterRuntimeCommandSpec | null; executionTarget?: AdapterExecutionTarget | null; /** * Legacy remote transport view. Prefer `executionTarget`, which is the @@ -328,6 +329,23 @@ export interface AdapterConfigSchema { fields: ConfigFieldSchema[]; } +export interface AdapterRuntimeCommandSpec { + /** + * The command Paperclip should execute for this adapter in the current config. + */ + command: string; + /** + * Optional command name/path to probe for availability before launch. + * Defaults to `command` when omitted by the consumer. + */ + detectCommand?: string | null; + /** + * Optional shell snippet that can install or expose the adapter command in a + * fresh remote runtime. It should be idempotent. + */ + installCommand?: string | null; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; @@ -406,6 +424,11 @@ export interface ServerAdapterModule { * rather than reading config.paperclipRuntimeSkills. */ requiresMaterializedRuntimeSkills?: boolean; + /** + * Optional: describe how this adapter's runtime command should be launched + * and provisioned in fresh remote environments such as sandboxes. + */ + getRuntimeCommandSpec?: (config: Record) => AdapterRuntimeCommandSpec | null; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 7c77b66b..cd3ed45c 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetCommandForLogs, @@ -61,8 +62,10 @@ interface ClaudeExecutionInput { agent: AdapterExecutionContext["agent"]; config: Record; context: Record; + runtimeCommandSpec?: AdapterExecutionContext["runtimeCommandSpec"]; executionTarget?: ReturnType; authToken?: string; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; } interface ClaudeRuntimeConfig { @@ -112,7 +115,8 @@ function resolveClaudeBillingType(env: Record): "api" | "subscri } async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { - const { runId, agent, config, context, executionTarget, authToken } = input; + const { runId, agent, config, context, runtimeCommandSpec, executionTarget, authToken } = input; + const onLog = input.onLog ?? (async () => {}); const command = asString(config.command, "claude"); const workspaceContext = parseObject(context.paperclipWorkspace); @@ -239,7 +243,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise typeof entry[1] === "string", + ), + ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: runtimeCommandSpec?.installCommand, + detectCommand: runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); const loggedEnv = buildInvocationEnvForLogs(env, { @@ -248,8 +269,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; @@ -335,8 +354,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec: asNumber(config.timeoutSec, 0), + graceSec: asNumber(config.graceSec, 20), + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); const loggedEnv = buildInvocationEnvForLogs(env, { diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 2f521128..62147e6c 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, @@ -307,6 +308,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); let loggedEnv = buildInvocationEnvForLogs(env, { @@ -282,8 +300,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 015cd0c7..a574fba8 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, @@ -302,6 +303,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, { @@ -309,9 +323,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); let loggedEnv = buildInvocationEnvForLogs(env, { @@ -364,8 +378,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 4817c448..c4dbb58b 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -420,6 +420,85 @@ describe("environmentRunOrchestrator — realizeForRun", () => { })); }); + it("runs project-level provision commands for ssh environments", async () => { + mockBuildWorkspaceRealizationRequest.mockReturnValue({ + version: 1, + adapterType: "gemini_local", + companyId: "company-1", + environmentId: "env-1", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: "run-1", + requestedMode: null, + source: { + kind: "project_primary", + localPath: "/workspace/project", + projectId: null, + projectWorkspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, + }, + runtimeOverlay: { + provisionCommand: "npm install -g @google/gemini-cli", + }, + }); + mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + kind: "remote", + transport: "ssh", + remoteCwd: "/remote/workspace", + environmentId: "env-1", + leaseId: "lease-1", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/remote/workspace", + remoteWorkspacePath: "/remote/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }); + + const runtime = makeMockRuntime({ + realizeWorkspace: vi.fn().mockResolvedValue({ + cwd: "/remote/workspace", + metadata: { + workspaceRealization: { + version: 1, + transport: "ssh", + remote: { path: "/remote/workspace" }, + }, + }, + }), + }); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + + await orchestrator.realizeForRun(makeRealizeInput({ + environment: makeEnvironment("ssh"), + lease: makeLease({ + provider: "ssh", + metadata: { + driver: "ssh", + remoteCwd: "/remote/workspace", + remoteWorkspacePath: "/remote/workspace", + host: "ssh.example.test", + port: 22, + username: "ssh-user", + }, + }), + })); + + expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "bash", + args: ["-lc", "npm install -g @google/gemini-cli"], + })); + expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce(); + }); + it("surfaces remote provision command failures before resolving the adapter target", async () => { mockBuildWorkspaceRealizationRequest.mockReturnValue({ version: 1, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 359aecd4..ae356365 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,4 @@ -import type { AdapterModelProfileDefinition, ServerAdapterModule } from "./types.js"; +import type { AdapterModelProfileDefinition, AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js"; import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as acpxExecute, @@ -113,6 +113,44 @@ import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; +function readConfiguredCommand(config: Record, fallback: string): string { + const value = typeof config.command === "string" ? config.command.trim() : ""; + return value.length > 0 ? value : fallback; +} + +function hasPathSeparator(command: string): boolean { + return command.includes("/") || command.includes("\\"); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function buildNpmRuntimeCommandSpec( + config: Record, + fallbackCommand: string, + packageName: string, +): AdapterRuntimeCommandSpec { + const command = readConfiguredCommand(config, fallbackCommand); + const canSelfInstall = !hasPathSeparator(command) && command === fallbackCommand; + return { + command, + detectCommand: command, + installCommand: canSelfInstall + ? `if ! command -v ${shellQuote(command)} >/dev/null 2>&1; then npm install -g ${shellQuote(packageName)}; fi` + : null, + }; +} + +function buildCursorRuntimeCommandSpec(config: Record): AdapterRuntimeCommandSpec { + const command = readConfiguredCommand(config, "agent"); + return { + command, + detectCommand: command, + installCommand: null, + }; +} + function normalizeHermesConfig(ctx: T): T { const config = ctx && typeof ctx === "object" && "config" in ctx && ctx.config && typeof ctx.config === "object" @@ -159,6 +197,8 @@ const claudeLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: false, + getRuntimeCommandSpec: (config) => + buildNpmRuntimeCommandSpec(config, "claude", "@anthropic-ai/claude-code"), agentConfigurationDoc: claudeAgentConfigurationDoc, getQuotaWindows: claudeGetQuotaWindows, }; @@ -195,6 +235,7 @@ const codexLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: false, + getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"), agentConfigurationDoc: codexAgentConfigurationDoc, getQuotaWindows: codexGetQuotaWindows, }; @@ -214,6 +255,7 @@ const cursorLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: buildCursorRuntimeCommandSpec, agentConfigurationDoc: cursorAgentConfigurationDoc, }; @@ -231,6 +273,8 @@ const geminiLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => + buildNpmRuntimeCommandSpec(config, "gemini", "@google/gemini-cli"), agentConfigurationDoc: geminiAgentConfigurationDoc, }; @@ -260,6 +304,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai"), agentConfigurationDoc: openCodeAgentConfigurationDoc, }; @@ -278,6 +323,8 @@ const piLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => + buildNpmRuntimeCommandSpec(config, "pi", "@mariozechner/pi-coding-agent"), agentConfigurationDoc: piAgentConfigurationDoc, }; diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 0b728b9c..a113aeff 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -30,5 +30,6 @@ export type { ConfigFieldOption, ConfigFieldSchema, AdapterConfigSchema, + AdapterRuntimeCommandSpec, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 71032747..49a3a8e2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -6984,6 +6984,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) runtime: runtimeForAdapter, config: runtimeConfig, context, + runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null, executionTarget, executionTransport: remoteExecution ? { remoteExecution: remoteExecution as unknown as Record }