Files
paperclip/packages/adapters/claude-local/src/server/execute.ts
T
Devin Foley a4ac6ff133 Add sandbox callback bridge for remote environment API access (#4801)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents can run inside sandboxed environments like E2B, which are
isolated from the host network
> - Sandboxed agents need to call back to the Paperclip API to report
progress, post comments, and update issue status
> - But sandbox environments cannot reach the Paperclip server directly
because they run in isolated network namespaces
> - This PR adds a callback bridge that proxies API requests from the
sandbox to the Paperclip server, running as a local HTTP server on the
host that forwards authenticated requests
> - The bridge is started automatically when an adapter launches a
sandbox execution, and torn down when the run completes
> - The benefit is sandboxed agents can interact with the Paperclip API
without requiring network-level access to the host, enabling E2B and
similar providers to work end-to-end

## What Changed

- Added `sandbox-callback-bridge.ts` in `packages/adapter-utils/` — a
lightweight HTTP bridge server that accepts requests from sandbox
environments and proxies them to the Paperclip API with authentication
- Added request validation and security policy: the bridge only forwards
requests to the configured API URL, validates content types, enforces
size limits, and rejects non-API paths
- Wired the bridge into all remote adapter execute paths (claude, codex,
cursor, gemini, pi) — the bridge starts before the agent process and the
bridge URL is passed via environment variables
- Updated `environment-execution-target.ts` to prefer the explicit API
URL from environment lease metadata for sandbox callback routing
- Fixed Claude sandbox runtime setup to work with the bridge
configuration
- Added comprehensive test coverage for bridge request handling, policy
enforcement, and sandbox execution integration
- Fixed browser bundling — the bridge module is excluded from the
frontend bundle via the adapter-utils index export

## Verification

- `pnpm test` — all existing and new tests pass, including bridge unit
tests and sandbox execution integration tests
- `pnpm typecheck` — clean
- Manual: configure an E2B environment, run an agent task, verify the
agent can post comments and update issue status through the bridge

## Risks

- Medium. This is a new network-facing component (HTTP server on
localhost). The security policy restricts forwarding to the configured
API URL only and validates all requests, but any proxy introduces attack
surface. The bridge binds to localhost only and is scoped to the
lifetime of a single agent run.

## Model Used

Codex GPT 5.4 high via Paperclip.

## 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
- [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
2026-04-29 16:37:34 -07:00

861 lines
32 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
asNumber,
asBoolean,
asStringArray,
parseObject,
parseJson,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
readPaperclipRuntimeSkillEntries,
joinPromptSections,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePathInEnv,
renderTemplate,
renderPaperclipWakePrompt,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
import {
parseClaudeStreamJson,
describeClaudeFailure,
detectClaudeLoginRequired,
extractClaudeRetryNotBefore,
isClaudeMaxTurnsResult,
isClaudeTransientUpstreamError,
isClaudeUnknownSessionError,
} from "./parse.js";
import { prepareClaudeConfigSeed } from "./claude-config.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
interface ClaudeExecutionInput {
runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context: Record<string, unknown>;
executionTarget?: ReturnType<typeof readAdapterExecutionTarget>;
authToken?: string;
}
interface ClaudeRuntimeConfig {
command: string;
resolvedCommand: string;
cwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
env: Record<string, string>;
loggedEnv: Record<string, string>;
timeoutSec: number;
graceSec: number;
extraArgs: string[];
}
function buildLoginResult(input: {
proc: RunProcessResult;
loginUrl: string | null;
}) {
return {
exitCode: input.proc.exitCode,
signal: input.proc.signal,
timedOut: input.proc.timedOut,
stdout: input.proc.stdout,
stderr: input.proc.stderr,
loginUrl: input.loginUrl,
};
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function isBedrockAuth(env: Record<string, string>): boolean {
return (
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
env.CLAUDE_CODE_USE_BEDROCK === "true" ||
hasNonEmptyEnvValue(env, "ANTHROPIC_BEDROCK_BASE_URL")
);
}
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" | "metered_api" {
if (isBedrockAuth(env)) return "metered_api";
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
}
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
const { runId, agent, config, context, executionTarget, authToken } = input;
const command = asString(config.command, "claude");
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
const agentHome = asString(workspaceContext.agentHome, "") || null;
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
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);
if (wakeTaskId) {
env.PAPERCLIP_TASK_ID = wakeTaskId;
}
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;
}
applyPaperclipWorkspaceEnv(env, {
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceStrategy,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceBranch,
workspaceWorktreePath,
agentHome,
});
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) {
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
}
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
return {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
};
}
export async function runClaudeLogin(input: {
runId: string;
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context?: Record<string, unknown>;
authToken?: string;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) {
const onLog = input.onLog ?? (async () => {});
const runtime = await buildClaudeRuntimeConfig({
runId: input.runId,
agent: input.agent,
config: input.config,
context: input.context ?? {},
authToken: input.authToken,
});
const proc = await runAdapterExecutionTargetProcess(input.runId, null, runtime.command, ["login"], {
cwd: runtime.cwd,
env: runtime.env,
timeoutSec: runtime.timeoutSec,
graceSec: runtime.graceSec,
onLog,
});
const loginMeta = detectClaudeLoginRequired({
parsed: null,
stdout: proc.stdout,
stderr: proc.stderr,
});
return buildLoginResult({
proc,
loginUrl: loginMeta.loginUrl,
});
}
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 model = asString(config.model, "");
const effort = asString(config.effort, "");
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const configEnv = parseObject(config.env);
const hasExplicitClaudeConfigDir =
typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
const runtimeConfig = await buildClaudeRuntimeConfig({
runId,
agent,
config,
context,
executionTarget,
authToken,
});
const {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv: initialLoggedEnv,
timeoutSec,
graceSec,
extraArgs,
} = runtimeConfig;
let loggedEnv = initialLoggedEnv;
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const terminalResultCleanupGraceMs = Math.max(
0,
asNumber(config.terminalResultCleanupGraceMs, 5_000),
);
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveClaudeBillingType(effectiveEnv);
const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries));
// When instructionsFilePath is configured, build a stable content-addressed
// file that includes both the file content and the path directive, so we only
// need --append-system-prompt-file (Claude CLI forbids using both flags together).
let combinedInstructionsContents: string | null = null;
if (instructionsFilePath) {
try {
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
const pathDirective =
`\nThe above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}. ` +
`This base directory is authoritative for sibling instruction files such as ` +
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`;
combinedInstructionsContents = instructionsContent + pathDirective;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
);
}
}
const promptBundle = await prepareClaudePromptBundle({
companyId: agent.companyId,
skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)),
instructionsContents: combinedInstructionsContents,
onLog,
});
const useManagedRemoteClaudeConfig =
executionTargetIsRemote &&
adapterExecutionTargetUsesManagedHome(executionTarget) &&
!hasExplicitClaudeConfigDir;
const claudeConfigSeedDir = useManagedRemoteClaudeConfig
? await prepareClaudeConfigSeed(process.env, onLog, agent.companyId)
: null;
const preparedExecutionTargetRuntime = executionTargetIsRemote
? await (async () => {
await onLog(
"stdout",
`[paperclip] Syncing workspace and Claude runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
return await prepareAdapterExecutionTargetRuntime({
target: executionTarget,
adapterKey: "claude",
workspaceLocalDir: cwd,
assets: [
{
key: "skills",
localDir: promptBundle.addDir,
followSymlinks: true,
},
...(claudeConfigSeedDir
? [{
key: "config-seed",
localDir: claudeConfigSeedDir,
followSymlinks: true,
}]
: []),
],
});
})()
: null;
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
? () => preparedExecutionTargetRuntime.restoreWorkspace()
: null;
const effectivePromptBundleAddDir = executionTargetIsRemote
? preparedExecutionTargetRuntime?.assetDirs.skills ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "claude", "skills")
: promptBundle.addDir;
const effectiveInstructionsFilePath = promptBundle.instructionsFilePath
? executionTargetIsRemote
? path.posix.join(effectivePromptBundleAddDir, path.basename(promptBundle.instructionsFilePath))
: promptBundle.instructionsFilePath
: undefined;
const remoteClaudeRuntimeRoot = executionTargetIsRemote
? preparedExecutionTargetRuntime?.runtimeRootDir ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "claude")
: null;
const remoteClaudeConfigSeedDir = claudeConfigSeedDir && remoteClaudeRuntimeRoot
? preparedExecutionTargetRuntime?.assetDirs["config-seed"] ??
path.posix.join(remoteClaudeRuntimeRoot, "config-seed")
: null;
const remoteClaudeConfigDir = useManagedRemoteClaudeConfig && remoteClaudeRuntimeRoot
? path.posix.join(remoteClaudeRuntimeRoot, "config")
: null;
if (remoteClaudeConfigDir && remoteClaudeConfigSeedDir) {
env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
loggedEnv.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
await onLog(
"stdout",
`[paperclip] Materializing Claude auth/config into ${remoteClaudeConfigDir}.\n`,
);
await runAdapterExecutionTargetShellCommand(
runId,
executionTarget,
`mkdir -p ${shellQuote(remoteClaudeConfigDir)} && ` +
`if [ -d ${shellQuote(remoteClaudeConfigSeedDir)} ]; then ` +
`cp -R ${shellQuote(`${remoteClaudeConfigSeedDir}/.`)} ${shellQuote(remoteClaudeConfigDir)}/; ` +
`fi`,
{
cwd,
env,
timeoutSec: Math.max(timeoutSec, 15),
graceSec,
onLog,
},
);
}
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
adapterKey: "claude",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
if (remoteClaudeConfigDir) {
loggedEnv.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
}
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
const hasMatchingPromptBundle =
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
const canResumeSession =
runtimeSessionId.length > 0 &&
hasMatchingPromptBundle &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (
executionTargetIsRemote &&
runtimeSessionId &&
!canResumeSession
) {
await onLog(
"stdout",
`[paperclip] Claude 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 &&
runtimeSessionCwd.length > 0 &&
path.resolve(runtimeSessionCwd) !== path.resolve(effectiveExecutionCwd)
) {
await onLog(
"stdout",
`[paperclip] Claude 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] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
);
}
if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) {
await onLog(
"stdout",
`[paperclip] Claude session "${runtimeSessionId}" was saved for prompt bundle "${runtimePromptBundleKey}" and will not be resumed with "${promptBundle.bundleKey}".\n`,
);
}
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 taskContextNote = asString(context.paperclipTaskMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
taskContextNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
taskContextChars: taskContextNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildClaudeArgs = (
resumeSessionId: string | null,
attemptInstructionsFilePath: string | undefined,
) => {
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
if (chrome) args.push("--chrome");
// For Bedrock: only pass --model when the ID is a Bedrock-native identifier
// (e.g. "us.anthropic.*" or ARN). Anthropic-style IDs like "claude-opus-4-6" are invalid
// on Bedrock, so skip them and let the CLI use its own configured model.
if (model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model))) {
args.push("--model", model);
}
if (effort) args.push("--effort", effort);
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
// On resumed sessions the instructions are already in the session cache;
// re-injecting them via --append-system-prompt-file wastes 5-10K tokens
// per heartbeat and the Claude CLI may reject the combination outright.
if (attemptInstructionsFilePath && !resumeSessionId) {
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
}
args.push("--add-dir", effectivePromptBundleAddDir);
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const parseFallbackErrorMessage = (proc: RunProcessResult) => {
const stderrLine =
proc.stderr
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? "";
if ((proc.exitCode ?? 0) === 0) {
return "Failed to parse claude JSON output";
}
return stderrLine
? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}`
: `Claude exited with code ${proc.exitCode ?? -1}`;
};
const runAttempt = async (resumeSessionId: string | null) => {
const attemptInstructionsFilePath = resumeSessionId ? undefined : effectiveInstructionsFilePath;
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
const commandNotes: string[] = [];
if (!resumeSessionId) {
commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`);
}
if (attemptInstructionsFilePath && !resumeSessionId) {
commandNotes.push(
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
);
}
if (onMeta) {
await onMeta({
adapterType: "claude_local",
command: resolvedCommand,
cwd: effectiveExecutionCwd,
commandArgs: args,
commandNotes,
env: loggedEnv,
prompt,
promptMetrics,
context,
});
}
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
cwd,
env,
stdin: prompt,
timeoutSec,
graceSec,
onSpawn,
onLog,
terminalResultCleanup: {
graceMs: terminalResultCleanupGraceMs,
hasTerminalResult: ({ stdout }) => parseClaudeStreamJson(stdout).resultJson !== null,
},
});
const parsedStream = parseClaudeStreamJson(proc.stdout);
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
return { proc, parsedStream, parsed };
};
const toAdapterResult = (
attempt: {
proc: RunProcessResult;
parsedStream: ReturnType<typeof parseClaudeStreamJson>;
parsed: Record<string, unknown> | null;
},
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
): AdapterExecutionResult => {
const { proc, parsedStream, parsed } = attempt;
const loginMeta = detectClaudeLoginRequired({
parsed,
stdout: proc.stdout,
stderr: proc.stderr,
});
const errorMeta =
loginMeta.loginUrl != null
? {
loginUrl: loginMeta.loginUrl,
}
: undefined;
if (proc.timedOut) {
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
errorCode: "timeout",
errorMeta,
clearSession: Boolean(opts.clearSessionOnMissingSession),
};
}
if (!parsed) {
const fallbackErrorMessage = parseFallbackErrorMessage(proc);
const transientUpstream =
!loginMeta.requiresLogin &&
(proc.exitCode ?? 0) !== 0 &&
isClaudeTransientUpstreamError({
parsed: null,
stdout: proc.stdout,
stderr: proc.stderr,
errorMessage: fallbackErrorMessage,
});
const transientRetryNotBefore = transientUpstream
? extractClaudeRetryNotBefore({
parsed: null,
stdout: proc.stdout,
stderr: proc.stderr,
errorMessage: fallbackErrorMessage,
})
: null;
const errorCode = loginMeta.requiresLogin
? "claude_auth_required"
: transientUpstream
? "claude_transient_upstream"
: null;
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: false,
errorMessage: fallbackErrorMessage,
errorCode,
errorFamily: transientUpstream ? "transient_upstream" : null,
retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null,
errorMeta,
resultJson: {
stdout: proc.stdout,
stderr: proc.stderr,
...(transientUpstream ? { errorFamily: "transient_upstream" } : {}),
...(transientRetryNotBefore
? { retryNotBefore: transientRetryNotBefore.toISOString() }
: {}),
...(transientRetryNotBefore
? { transientRetryNotBefore: transientRetryNotBefore.toISOString() }
: {}),
},
clearSession: Boolean(opts.clearSessionOnMissingSession),
};
}
const usage =
parsedStream.usage ??
(() => {
const usageObj = parseObject(parsed.usage);
return {
inputTokens: asNumber(usageObj.input_tokens, 0),
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
outputTokens: asNumber(usageObj.output_tokens, 0),
};
})();
const resolvedSessionId =
parsedStream.sessionId ??
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd: effectiveExecutionCwd,
promptBundleKey: promptBundle.bundleKey,
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
}
: {}),
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
const parsedIsError = asBoolean(parsed.is_error, false);
const failed = (proc.exitCode ?? 0) !== 0 || parsedIsError;
const errorMessage = failed
? describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`
: null;
const transientUpstream =
failed &&
!loginMeta.requiresLogin &&
isClaudeTransientUpstreamError({
parsed,
stdout: proc.stdout,
stderr: proc.stderr,
errorMessage,
});
const transientRetryNotBefore = transientUpstream
? extractClaudeRetryNotBefore({
parsed,
stdout: proc.stdout,
stderr: proc.stderr,
errorMessage,
})
: null;
const resolvedErrorCode = loginMeta.requiresLogin
? "claude_auth_required"
: transientUpstream
? "claude_transient_upstream"
: null;
const mergedResultJson: Record<string, unknown> = {
...parsed,
...(transientUpstream ? { errorFamily: "transient_upstream" } : {}),
...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
};
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: false,
errorMessage,
errorCode: resolvedErrorCode,
errorFamily: transientUpstream ? "transient_upstream" : null,
retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null,
errorMeta,
usage,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "anthropic",
biller: isBedrockAuth(effectiveEnv) ? "aws_bedrock" : "anthropic",
model: parsedStream.model || asString(parsed.model, model),
billingType,
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
resultJson: mergedResultJson,
summary: parsedStream.summary || asString(parsed.result, ""),
clearSession: clearSessionForMaxTurns || Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
};
};
try {
const initial = await runAttempt(sessionId ?? null);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
initial.parsed &&
isClaudeUnknownSessionError(initial.parsed)
) {
await onLog(
"stdout",
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
}
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
await restoreRemoteWorkspace();
}
}
}