forked from farhoodlabs/paperclip
b24c6909e8
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs inside a sandbox environment so its CLI is isolated from the host > - Sandbox-backed adapter runs go through a small set of shared helpers — `ensureAdapterExecutionTargetCommandResolvable`, the sandbox callback bridge runner, and per-adapter `SANDBOX_INSTALL_COMMAND` strings > - When standing up new sandbox provider plugins, the existing helpers timed out, missed install fallbacks, or leaned on assumptions that only held for E2B > - Local adapters (`claude-local`, `codex-local`, `gemini-local`, `opencode-local`) needed slightly hardened probes so they could install themselves and validate inside *any* remote sandbox transport, not just E2B > - This pull request bundles those runtime fixes so future sandbox provider plugins inherit a working baseline > - The benefit is that adding a new sandbox provider plugin no longer requires touching adapter-utils or each local-adapter probe — the supporting infra is already correct ## What Changed - `packages/adapter-utils/src/execution-target.ts`: introduce `DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 1800` and `resolveAdapterExecutionTargetTimeoutSec(...)`. Local and SSH adapters keep the historical "0 means no adapter timeout" behavior; sandbox-backed runs without an explicit `timeoutSec` get an explicit 30-minute default so remote installs and warm-up don't time out at the per-RPC default. Plumbed `timeoutSec` through `ensureAdapterExecutionTargetCommandResolvable` so install probes inside a sandbox honor adapter-level overrides instead of the bridge's 5-minute default. - `packages/adapters/opencode-local/src/index.ts`: switch `SANDBOX_INSTALL_COMMAND` from `npm install -g opencode-ai` to `curl -fsSL https://opencode.ai/install | bash`. The npm package reifies four large prebuilt-binary subpackages in parallel even though only one matches the host arch; on bandwidth-constrained sandboxes that blew through the 240s install budget. The official installer fetches one arch-specific binary and adds `$HOME/.opencode/bin` to PATH via `~/.bashrc`, which the sandbox-callback-bridge login-shell script already sources. - `packages/adapters/{claude,codex,gemini,opencode}-local/`: harden remote-target probes — pass `--skip-git-repo-check` for Codex when probing outside a repo, normalize permission flags for Claude, and add `*.remote.test.ts` coverage that exercises the remote-sandbox path explicitly for each adapter. - `packages/adapter-utils/src/sandbox-install-command.{ts,test.ts}` (new): add `buildSandboxNpmInstallCommand` helper. `server/src/adapters/registry.ts` + new `server/src/__tests__/adapter-registry.test.ts`: wire adapter install commands so they fall back to a writable `$HOME/.local` prefix when global install isn't available. - `server/src/__tests__/plugin-worker-manager.test.ts` + new `server/src/__tests__/fixtures/plugin-worker-delayed.cjs`: pin per-call timeout overrides so plugin worker exec calls honor the caller's timeout instead of the worker's default. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage packages/adapter-utils/src/execution-target-sandbox.test.ts packages/adapter-utils/src/sandbox-install-command.test.ts` - `pnpm exec vitest run --no-coverage server/src/__tests__/plugin-worker-manager.test.ts server/src/__tests__/adapter-registry.test.ts server/src/__tests__/claude-local-adapter-environment.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/gemini-local-adapter-environment.test.ts` - `pnpm exec vitest run --no-coverage packages/adapters/codex-local/src/server/test.remote.test.ts packages/adapters/opencode-local/src/server/test.remote.test.ts packages/adapters/codex-local/src/server/codex-args.test.ts packages/adapters/codex-local/src/server/execute.remote.test.ts packages/adapters/gemini-local/src/server/execute.remote.test.ts` All passing locally. ## Risks - Touches shared `adapter-utils` and several `*-local` adapters. The 30-minute default applies only when both (a) the target is `remote+sandbox` and (b) no `timeoutSec` is configured — local + SSH paths are unchanged. New test coverage was added alongside each behavior change to pin the contracts. - Switching OpenCode's install command to the official installer is a behavior change for any operator running OpenCode inside a remote sandbox. Local installs are unaffected (the `SANDBOX_INSTALL_COMMAND` only runs when an adapter is being installed inside a sandbox). - Low risk overall — no migrations, no API surface change. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep), no code execution beyond local repo commands ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A, no UI change - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
694 lines
27 KiB
TypeScript
694 lines
27 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
import {
|
|
adapterExecutionTargetIsRemote,
|
|
adapterExecutionTargetRemoteCwd,
|
|
overrideAdapterExecutionTargetRemoteCwd,
|
|
adapterExecutionTargetSessionIdentity,
|
|
adapterExecutionTargetSessionMatches,
|
|
adapterExecutionTargetUsesManagedHome,
|
|
adapterExecutionTargetUsesPaperclipBridge,
|
|
describeAdapterExecutionTarget,
|
|
ensureAdapterExecutionTargetCommandResolvable,
|
|
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
|
prepareAdapterExecutionTargetRuntime,
|
|
readAdapterExecutionTarget,
|
|
readAdapterExecutionTargetHomeDir,
|
|
resolveAdapterExecutionTargetTimeoutSec,
|
|
resolveAdapterExecutionTargetCommandForLogs,
|
|
runAdapterExecutionTargetProcess,
|
|
runAdapterExecutionTargetShellCommand,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
import {
|
|
asString,
|
|
asNumber,
|
|
asStringArray,
|
|
parseObject,
|
|
buildPaperclipEnv,
|
|
joinPromptSections,
|
|
buildInvocationEnvForLogs,
|
|
ensureAbsoluteDirectory,
|
|
ensurePaperclipSkillSymlink,
|
|
ensurePathInEnv,
|
|
refreshPaperclipWorkspaceEnvForExecution,
|
|
renderTemplate,
|
|
renderPaperclipWakePrompt,
|
|
stringifyPaperclipWakePayload,
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
runChildProcess,
|
|
readPaperclipRuntimeSkillEntries,
|
|
readPaperclipIssueWorkModeFromContext,
|
|
resolvePaperclipDesiredSkillNames,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
|
import {
|
|
ensureOpenCodeModelConfiguredAndAvailable,
|
|
parseOpenCodeModelsOutput,
|
|
requireOpenCodeModelId,
|
|
} from "./models.js";
|
|
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
|
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
|
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
function firstNonEmptyLine(text: string): string {
|
|
return (
|
|
text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean) ?? ""
|
|
);
|
|
}
|
|
|
|
function parseModelProvider(model: string | null): string | null {
|
|
if (!model) return null;
|
|
const trimmed = model.trim();
|
|
if (!trimmed.includes("/")) return null;
|
|
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
|
}
|
|
|
|
function resolveOpenCodeBiller(env: Record<string, string>, provider: string | null): string {
|
|
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
|
|
}
|
|
|
|
const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20;
|
|
const REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC = 120;
|
|
|
|
async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: {
|
|
runId: string;
|
|
executionTarget: NonNullable<AdapterExecutionContext["executionTarget"]>;
|
|
command: string;
|
|
model: string;
|
|
cwd: string;
|
|
env: Record<string, string>;
|
|
timeoutSec: number;
|
|
graceSec: number;
|
|
}) {
|
|
const model = requireOpenCodeModelId(input.model);
|
|
const defaultProbeTimeoutSec =
|
|
input.executionTarget.kind === "remote" && input.executionTarget.transport === "sandbox"
|
|
? REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC
|
|
: REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC;
|
|
const probeTimeoutSec = input.timeoutSec > 0
|
|
? Math.min(input.timeoutSec, defaultProbeTimeoutSec)
|
|
: defaultProbeTimeoutSec;
|
|
const probe = await runAdapterExecutionTargetProcess(
|
|
input.runId,
|
|
input.executionTarget,
|
|
input.command,
|
|
["models"],
|
|
{
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
timeoutSec: probeTimeoutSec,
|
|
graceSec: input.graceSec,
|
|
onLog: async () => {},
|
|
},
|
|
);
|
|
|
|
if (probe.timedOut) {
|
|
throw new Error(`\`opencode models\` timed out on the remote execution target after ${probeTimeoutSec}s.`);
|
|
}
|
|
|
|
if ((probe.exitCode ?? 1) !== 0) {
|
|
const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout);
|
|
throw new Error(
|
|
detail
|
|
? `\`opencode models\` failed on the remote execution target: ${detail}`
|
|
: "`opencode models` failed on the remote execution target.",
|
|
);
|
|
}
|
|
|
|
const models = parseOpenCodeModelsOutput(probe.stdout);
|
|
if (models.length === 0) {
|
|
throw new Error(
|
|
"OpenCode returned no models on the remote execution target. Run `opencode models` there and verify provider auth.",
|
|
);
|
|
}
|
|
|
|
if (!models.some((entry) => entry.id === model)) {
|
|
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
|
|
throw new Error(
|
|
`Configured OpenCode model is unavailable on the remote execution target: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function claudeSkillsHome(): string {
|
|
return path.join(os.homedir(), ".claude", "skills");
|
|
}
|
|
|
|
async function ensureOpenCodeSkillsInjected(
|
|
onLog: AdapterExecutionContext["onLog"],
|
|
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
|
desiredSkillNames?: string[],
|
|
) {
|
|
const skillsHome = claudeSkillsHome();
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
|
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
|
skillsHome,
|
|
selectedEntries.map((entry) => entry.runtimeName),
|
|
);
|
|
for (const skillName of removedSkills) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Removed maintainer-only OpenCode skill "${skillName}" from ${skillsHome}\n`,
|
|
);
|
|
}
|
|
for (const entry of selectedEntries) {
|
|
const target = path.join(skillsHome, entry.runtimeName);
|
|
|
|
try {
|
|
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
|
if (result === "skipped") continue;
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.key}" into ${skillsHome}\n`,
|
|
);
|
|
} catch (err) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to inject OpenCode skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function buildOpenCodeSkillsDir(config: Record<string, unknown>): Promise<string> {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-skills-"));
|
|
const target = path.join(tmp, "skills");
|
|
await fs.mkdir(target, { recursive: true });
|
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries));
|
|
for (const entry of availableEntries) {
|
|
if (!desiredNames.has(entry.key)) continue;
|
|
await fs.symlink(entry.source, path.join(target, entry.runtimeName));
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
|
const executionTarget = readAdapterExecutionTarget({
|
|
executionTarget: ctx.executionTarget,
|
|
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
|
|
});
|
|
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
|
|
|
const promptTemplate = asString(
|
|
config.promptTemplate,
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
);
|
|
const command = asString(config.command, "opencode");
|
|
const model = asString(config.model, "").trim();
|
|
const variant = asString(config.variant, "").trim();
|
|
|
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
|
const workspaceSource = asString(workspaceContext.source, "");
|
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
|
const agentHome = asString(workspaceContext.agentHome, "");
|
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
|
? context.paperclipWorkspaces.filter(
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
)
|
|
: [];
|
|
const configuredCwd = asString(config.cwd, "");
|
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
|
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
|
if (!executionTargetIsRemote) {
|
|
await ensureOpenCodeSkillsInjected(
|
|
onLog,
|
|
openCodeSkillEntries,
|
|
desiredOpenCodeSkillNames,
|
|
);
|
|
}
|
|
|
|
const envConfig = parseObject(config.env);
|
|
const hasExplicitApiKey =
|
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
|
env.PAPERCLIP_RUN_ID = runId;
|
|
const wakeTaskId =
|
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
|
null;
|
|
const wakeReason =
|
|
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
|
? context.wakeReason.trim()
|
|
: null;
|
|
const wakeCommentId =
|
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
|
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
|
null;
|
|
const approvalId =
|
|
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
|
? context.approvalId.trim()
|
|
: null;
|
|
const approvalStatus =
|
|
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
|
? context.approvalStatus.trim()
|
|
: null;
|
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
: [];
|
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
|
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
|
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
|
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
|
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
|
refreshPaperclipWorkspaceEnvForExecution({
|
|
env,
|
|
envConfig,
|
|
workspaceCwd: effectiveWorkspaceCwd,
|
|
workspaceSource,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
workspaceHints,
|
|
agentHome,
|
|
executionTargetIsRemote,
|
|
executionCwd: effectiveExecutionCwd,
|
|
});
|
|
// Prevent OpenCode from writing an opencode.json config file into the
|
|
// project working directory (which would pollute the git repo). Model
|
|
// selection is already handled via the --model CLI flag. Set after the
|
|
// envConfig loop so user overrides cannot disable this guard.
|
|
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
|
|
if (!hasExplicitApiKey && authToken) {
|
|
env.PAPERCLIP_API_KEY = authToken;
|
|
}
|
|
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
|
const localRuntimeConfigHome =
|
|
preparedRuntimeConfig.notes.length > 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : "";
|
|
try {
|
|
const runtimeEnv = Object.fromEntries(
|
|
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
),
|
|
);
|
|
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
|
|
executionTarget,
|
|
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, {
|
|
installCommand: SANDBOX_INSTALL_COMMAND,
|
|
timeoutSec,
|
|
});
|
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
|
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
|
runtimeEnv,
|
|
includeRuntimeKeys: ["HOME"],
|
|
resolvedCommand,
|
|
});
|
|
if (!executionTargetIsRemote) {
|
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
|
model,
|
|
command,
|
|
cwd,
|
|
env: runtimeEnv,
|
|
});
|
|
}
|
|
|
|
const extraArgs = (() => {
|
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
return asStringArray(config.args);
|
|
})();
|
|
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
|
|
let localSkillsDir: string | null = null;
|
|
let remoteRuntimeRootDir: string | null = null;
|
|
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
|
|
|
|
if (executionTarget?.kind === "remote") {
|
|
localSkillsDir = await buildOpenCodeSkillsDir(config);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Syncing workspace and OpenCode runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
|
);
|
|
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
|
|
runId,
|
|
target: executionTarget,
|
|
adapterKey: "opencode",
|
|
timeoutSec,
|
|
workspaceLocalDir: cwd,
|
|
installCommand: SANDBOX_INSTALL_COMMAND,
|
|
detectCommand: command,
|
|
assets: [
|
|
{
|
|
key: "skills",
|
|
localDir: localSkillsDir,
|
|
followSymlinks: true,
|
|
},
|
|
...(localRuntimeConfigHome
|
|
? [{
|
|
key: "xdgConfig",
|
|
localDir: localRuntimeConfigHome,
|
|
}]
|
|
: []),
|
|
],
|
|
});
|
|
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
|
|
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
|
|
refreshPaperclipWorkspaceEnvForExecution({
|
|
env: preparedRuntimeConfig.env,
|
|
envConfig,
|
|
workspaceCwd: effectiveWorkspaceCwd,
|
|
workspaceSource,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
workspaceHints,
|
|
agentHome,
|
|
executionTargetIsRemote,
|
|
executionCwd: effectiveExecutionCwd,
|
|
});
|
|
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
|
|
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
|
|
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
|
|
preparedRuntimeConfig.env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
|
|
}
|
|
if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) {
|
|
preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig;
|
|
}
|
|
const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir
|
|
? preparedExecutionTargetRuntime.runtimeRootDir
|
|
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
|
|
cwd,
|
|
env: preparedRuntimeConfig.env,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) {
|
|
const remoteSkillsDir = path.posix.join(remoteHomeDir, ".claude", "skills");
|
|
await runAdapterExecutionTargetShellCommand(
|
|
runId,
|
|
executionTarget,
|
|
`mkdir -p ${JSON.stringify(path.posix.dirname(remoteSkillsDir))} && rm -rf ${JSON.stringify(remoteSkillsDir)} && cp -a ${JSON.stringify(preparedExecutionTargetRuntime.assetDirs.skills)} ${JSON.stringify(remoteSkillsDir)}`,
|
|
{ cwd, env: preparedRuntimeConfig.env, timeoutSec, graceSec, onLog },
|
|
);
|
|
}
|
|
await ensureRemoteOpenCodeModelConfiguredAndAvailable({
|
|
runId,
|
|
executionTarget,
|
|
command,
|
|
model,
|
|
cwd,
|
|
env: preparedRuntimeConfig.env,
|
|
timeoutSec,
|
|
graceSec,
|
|
});
|
|
}
|
|
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
|
|
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
|
|
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId,
|
|
target: runtimeExecutionTarget,
|
|
runtimeRootDir: remoteRuntimeRootDir,
|
|
adapterKey: "opencode",
|
|
timeoutSec,
|
|
hostApiToken: preparedRuntimeConfig.env.PAPERCLIP_API_KEY,
|
|
onLog,
|
|
});
|
|
if (paperclipBridge) {
|
|
Object.assign(preparedRuntimeConfig.env, paperclipBridge.env);
|
|
loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
|
runtimeEnv: Object.fromEntries(
|
|
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
),
|
|
),
|
|
includeRuntimeKeys: ["HOME"],
|
|
resolvedCommand,
|
|
});
|
|
}
|
|
}
|
|
|
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
|
const canResumeSession =
|
|
runtimeSessionId.length > 0 &&
|
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
|
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] OpenCode session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
|
);
|
|
} else if (runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
|
);
|
|
}
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const resolvedInstructionsFilePath = instructionsFilePath
|
|
? path.resolve(cwd, instructionsFilePath)
|
|
: "";
|
|
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
|
let instructionsPrefix = "";
|
|
if (resolvedInstructionsFilePath) {
|
|
try {
|
|
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
|
instructionsPrefix =
|
|
`${instructionsContents}\n\n` +
|
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const commandNotes = (() => {
|
|
const notes = [...preparedRuntimeConfig.notes];
|
|
if (!resolvedInstructionsFilePath) return notes;
|
|
if (instructionsPrefix.length > 0) {
|
|
notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
|
|
notes.push(
|
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
|
);
|
|
return notes;
|
|
}
|
|
notes.push(
|
|
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
);
|
|
return notes;
|
|
})();
|
|
|
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
|
const templateData = {
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
runId,
|
|
company: { id: agent.companyId },
|
|
agent,
|
|
run: { id: runId, source: "on_demand" },
|
|
context,
|
|
};
|
|
const renderedBootstrapPrompt =
|
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
: "";
|
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
|
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
|
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
const prompt = joinPromptSections([
|
|
instructionsPrefix,
|
|
renderedBootstrapPrompt,
|
|
wakePrompt,
|
|
sessionHandoffNote,
|
|
renderedPrompt,
|
|
]);
|
|
const promptMetrics = {
|
|
promptChars: prompt.length,
|
|
instructionsChars: instructionsPrefix.length,
|
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
wakePromptChars: wakePrompt.length,
|
|
sessionHandoffChars: sessionHandoffNote.length,
|
|
heartbeatPromptChars: renderedPrompt.length,
|
|
};
|
|
|
|
const buildArgs = (resumeSessionId: string | null) => {
|
|
const args = ["run", "--format", "json"];
|
|
if (resumeSessionId) args.push("--session", resumeSessionId);
|
|
if (model) args.push("--model", model);
|
|
if (variant) args.push("--variant", variant);
|
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
return args;
|
|
};
|
|
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
|
const args = buildArgs(resumeSessionId);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "opencode_local",
|
|
command: resolvedCommand,
|
|
cwd: effectiveExecutionCwd,
|
|
commandNotes,
|
|
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
|
env: loggedEnv,
|
|
prompt,
|
|
promptMetrics,
|
|
context,
|
|
});
|
|
}
|
|
|
|
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
|
|
cwd,
|
|
env: preparedRuntimeConfig.env,
|
|
stdin: prompt,
|
|
timeoutSec,
|
|
graceSec,
|
|
onSpawn,
|
|
onLog,
|
|
});
|
|
return {
|
|
proc,
|
|
rawStderr: proc.stderr,
|
|
parsed: parseOpenCodeJsonl(proc.stdout),
|
|
};
|
|
};
|
|
|
|
const toResult = (
|
|
attempt: {
|
|
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
|
rawStderr: string;
|
|
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
|
},
|
|
clearSessionOnMissingSession = false,
|
|
): AdapterExecutionResult => {
|
|
if (attempt.proc.timedOut) {
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: true,
|
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
clearSession: clearSessionOnMissingSession,
|
|
};
|
|
}
|
|
|
|
const resolvedSessionId =
|
|
attempt.parsed.sessionId ??
|
|
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
|
const resolvedSessionParams = resolvedSessionId
|
|
? ({
|
|
sessionId: resolvedSessionId,
|
|
cwd: effectiveExecutionCwd,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
...(executionTargetIsRemote
|
|
? {
|
|
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
|
}
|
|
: {}),
|
|
} as Record<string, unknown>)
|
|
: null;
|
|
|
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
const rawExitCode = attempt.proc.exitCode;
|
|
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
|
const fallbackErrorMessage =
|
|
parsedError ||
|
|
stderrLine ||
|
|
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
|
const modelId = model || null;
|
|
|
|
return {
|
|
exitCode: synthesizedExitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: false,
|
|
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
|
usage: {
|
|
inputTokens: attempt.parsed.usage.inputTokens,
|
|
outputTokens: attempt.parsed.usage.outputTokens,
|
|
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
|
},
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: parseModelProvider(modelId),
|
|
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
|
|
model: modelId,
|
|
billingType: "unknown",
|
|
costUsd: attempt.parsed.costUsd,
|
|
resultJson: {
|
|
stdout: attempt.proc.stdout,
|
|
stderr: attempt.proc.stderr,
|
|
},
|
|
summary: attempt.parsed.summary,
|
|
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
|
};
|
|
};
|
|
|
|
try {
|
|
const initial = await runAttempt(sessionId);
|
|
const initialFailed =
|
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
|
if (
|
|
sessionId &&
|
|
initialFailed &&
|
|
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
|
) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
const retry = await runAttempt(null);
|
|
return toResult(retry, true);
|
|
}
|
|
|
|
return toResult(initial);
|
|
} finally {
|
|
await Promise.all([
|
|
paperclipBridge?.stop(),
|
|
restoreRemoteWorkspace?.(),
|
|
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
|
|
]);
|
|
}
|
|
} finally {
|
|
await preparedRuntimeConfig.cleanup();
|
|
}
|
|
}
|