diff --git a/packages/paperclip-runner/src/configured-environment.ts b/packages/paperclip-runner/src/configured-environment.ts index 475e62c3cc9..5cd30c0e765 100644 --- a/packages/paperclip-runner/src/configured-environment.ts +++ b/packages/paperclip-runner/src/configured-environment.ts @@ -25,12 +25,16 @@ export const GENERATED_RUNTIME_ENVIRONMENT_KEYS: ReadonlySet = new Set([ "PAPERCLIP_GITHUB_BROKER_TOKEN", "PAPERCLIP_GITHUB_BROKER_URL", "PAPERCLIP_GITHUB_BRIDGE_TOKEN", "PAPERCLIP_GITHUB_LAUNCHER_DIR", ]); -const RESERVED_KEYS = new Set([ - "PATH", "HOME", "USERPROFILE", "CODEX_HOME", "AGENT_HOME", "SHELL", "NODE_OPTIONS", "NODE_PATH", - "PYTHONPATH", "PYTHONHOME", "RUBYOPT", "RUBYLIB", "PERL5OPT", "PERL5LIB", "BASH_ENV", "ENV", "ZDOTDIR", "GH_CONFIG_DIR", +export const PROVIDER_CREDENTIAL_ENVIRONMENT_KEYS: readonly string[] = [ "OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "OPENROUTER_API_KEY", "XAI_API_KEY", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "COPILOT_GITHUB_TOKEN", "KIMI_API_KEY", "GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN", +]; + +const RESERVED_KEYS = new Set([ + "PATH", "HOME", "USERPROFILE", "CODEX_HOME", "AGENT_HOME", "SHELL", "NODE_OPTIONS", "NODE_PATH", + "PYTHONPATH", "PYTHONHOME", "RUBYOPT", "RUBYLIB", "PERL5OPT", "PERL5LIB", "BASH_ENV", "ENV", "ZDOTDIR", "GH_CONFIG_DIR", + ...PROVIDER_CREDENTIAL_ENVIRONMENT_KEYS, ]); function eligibleKey(key: string): boolean { diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index c2fcfea2347..58eaaa7c44a 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -12938,6 +12938,98 @@ describe("native warm session supervision", () => { }, ); + it.each([ + { name: "unchanged configuration", reuse: true }, + { name: "unchanged provider key", firstEnvironment: { OPENAI_API_KEY: "fixture-key" }, secondEnvironment: { OPENAI_API_KEY: "fixture-key" }, reuse: true }, + { name: "rotated provider key", firstEnvironment: { OPENAI_API_KEY: "fixture-key" }, secondEnvironment: { OPENAI_API_KEY: "fixture-rotated-key" }, reuse: false }, + { name: "rotated Claude token", firstEnvironment: { CLAUDE_CODE_OAUTH_TOKEN: "fixture-token" }, secondEnvironment: { CLAUDE_CODE_OAUTH_TOKEN: "fixture-rotated-token" }, reuse: false }, + { name: "tool refresh", refreshTools: true, reuse: false }, + { name: "network access change", firstEnvironment: { PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" }, secondEnvironment: { PAPERCLIP_RUNNER_NETWORK_ACCESS: "enabled" }, reuse: false }, + { name: "run-scoped environment broker", firstEnvironment: { PAPERCLIP_GITHUB_BROKER_TOKEN: "first-run-capability" }, secondEnvironment: { PAPERCLIP_GITHUB_BROKER_TOKEN: "second-run-capability" }, reuse: false }, + { name: "Core-hosted GitHub access", managedGitHub: true, reuse: true, adoptionReuse: false }, + ] as Array<{ name: string; firstEnvironment?: Record; secondEnvironment?: Record; + refreshTools?: boolean; managedGitHub?: boolean; reuse: boolean; adoptionReuse?: boolean }>)( + "decides warm and adopted process reuse from one key: $name", + async ({ name, firstEnvironment = {}, secondEnvironment = {}, refreshTools, managedGitHub = false, reuse, adoptionReuse = reuse }) => { + const decisions: Record = {}; + for (const path of ["warm", "adoption"] as const) { + githubAccess.create.mockReset().mockImplementation(async () => ({ env: {}, ready: true, duplexRequested: false, + activate: githubAccess.activate.mockReset().mockImplementation(() => vi.fn()), stop: githubAccess.stop.mockReset().mockResolvedValue(undefined) })); + const stateBase = await mkdtemp(join(tmpdir(), "paperclip-runnerd-reuse-key-")); + const previousStateDirectory = process.env.PAPERCLIP_RUNNER_STATE_DIR; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase; + process.env.PAPERCLIP_HOME = stateBase; + const scope = `${path}-${name.replaceAll(" ", "-")}`; + const first = { + ...execution, + binding: { ...execution.binding, runId: `run-reuse-key-first-${scope}`, executionWorkspaceId: "workspace-reuse-key" }, + workspace: { cwd: "/tmp/runnerd-reuse-key", repoUrl: null, repoRef: null, branchName: null }, + session: { normalizedSessionId: `session-reuse-key-${scope}`, driverKind: "codex_app_server" as const, + protocolVersion: 1 as const, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } }, + } as NativeExecutionInputV1; + const second = { ...first, binding: { ...first.binding, runId: `run-reuse-key-second-${scope}` } } as NativeExecutionInputV1; + const target = { kind: "remote" as const, transport: "sandbox" as const, environmentId: "environment-reuse-key", + remoteCwd: "/home/daytona/paperclip-workspace", runner: { execute: vi.fn() } } as never; + const firstSession = { close: vi.fn(async () => undefined) }; + const result = { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, turnId: "turn", + normalizedSessionId: first.session.normalizedSessionId, providerSessionId: "provider-reuse-key", driverKind: "test", + driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null }; + state.execute.mockReset() + .mockImplementationOnce(async (options) => { + await options.onCheckpoint?.({ + identity: { runId: first.binding.runId, sessionId: first.session.normalizedSessionId, + companyId: first.binding.companyId, issueId: first.binding.issueId, agentId: first.binding.agentId }, + providerSessionId: "provider-reuse-key", activeTurnId: "provider-turn-reuse-key", + semanticResult: { summary: "first" }, terminal: { runTerminalState: "succeeded" }, + }); + options.onSession?.(firstSession); + return result; + }) + .mockImplementationOnce(async (options) => { + decisions[path] = options.existingSession === firstSession; + return result; + }); + const inspected = vi.spyOn(remoteWarmRecoveryRuntime, "inspectRemoteWarmSessionRecovery").mockImplementation(async request => { + decisions[path] = request.canReuseProcess(first); + throw new Error("fixture adoption decision recorded"); + }); + try { + await executePaperclipNativeSession({ db: leaseDb(first), execution: first, runnerInstanceId: "runner-reuse-key", + useRunnerd: true, managedGitHub, runnerExecutionTarget: target, + runnerEnvironment: { PAPERCLIP_GITHUB_AUTH_MODE: "managed", ...firstEnvironment } }); + const durableRoot = (await readdir(stateBase, { withFileTypes: true })) + .filter(entry => entry.isDirectory() && /^[a-f0-9]{64}$/.test(entry.name)).map(entry => join(stateBase, entry.name))[0]!; + const durableIdentity = { runId: first.binding.runId, normalizedSessionId: first.session.normalizedSessionId, + runnerInstanceId: "runner-reuse-key", environmentLeaseId: first.binding.executionWorkspaceId }; + await mkdir(join(durableRoot, "control-plane"), { recursive: true }); + await writeFile(join(durableRoot, "control-plane", "control-plane-state.json"), JSON.stringify(durableControlPlaneState(durableIdentity))); + await mkdir(join(durableRoot, "runner"), { recursive: true }); + await writeFile(join(durableRoot, "runner", "runner-state.json"), JSON.stringify(durableRunnerState(durableIdentity, "ready"))); + if (path === "adoption") expect(await closeIdleWarmNativeSessionsForRestart()).toEqual({ closed: 1, busy: 0, failed: 0 }); + const continuation = executePaperclipNativeSession({ + db: { ...leaseDb(second), select: () => ({ from: () => ({ where: () => ({ limit: () => + Promise.resolve([{ status: "succeeded", runnerProfileJson: { nativeExecutionInput: first } }]) }) }) }) } as unknown as Db, + execution: second, runnerInstanceId: "runner-reuse-key", useRunnerd: true, managedGitHub, refreshTools, + runnerExecutionTarget: target, runnerEnvironment: { PAPERCLIP_GITHUB_AUTH_MODE: "managed", ...secondEnvironment } }); + if (path === "adoption") await expect(continuation).rejects.toThrow("fixture adoption decision recorded"); + else await continuation; + expect(decisions[path]).toBe(path === "adoption" ? adoptionReuse : reuse); + if (path === "warm") expect(firstSession.close).toHaveBeenCalledTimes(reuse ? 0 : 1); + } finally { + inspected.mockRestore(); + await closeIdleWarmNativeSessionsForRestart(); + if (previousStateDirectory === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; + else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousStateDirectory; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + await rm(stateBase, { recursive: true, force: true }); + } + } + expect(decisions).toEqual({ warm: reuse, adoption: adoptionReuse }); + }, + ); + it("does not replace a different company's warm session with the same normalized id", async () => { const firstClose = vi.fn(async () => undefined); const secondClose = vi.fn(async () => undefined); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 4b5d5ecd027..f4aacfdb298 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -68,7 +68,7 @@ import { isKnownNativeSessionRootEntry, } from "./native-session-root-entries.js"; import { createRunPreparationTiming } from "../execution-preparation-timing.js"; -import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js"; +import { configuredEnvironment, PROVIDER_CREDENTIAL_ENVIRONMENT_KEYS } from "../../vendor/paperclip-runner/index.js"; import { parseProviderPerformanceSpans } from "../../vendor/paperclip-runner/index.js"; import { apiKeyBillingType } from "@paperclipai/adapter-utils/api-billing-type"; import { agents } from "@paperclipai/db"; @@ -122,7 +122,7 @@ import { boundedExecutionCleanup, EXECUTION_CONTROL_DEADLINE_MS, } from "../execution-control-deadline.js"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash, createHmac, randomUUID } from "node:crypto"; import { execFile, execFileSync } from "node:child_process"; import { chmodSync, @@ -241,7 +241,7 @@ import { assertNativeRetainedSandboxBinding, deferNativeControllerRecovery, Nati import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js"; import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { appendNativeCredentialUpdate } from "./native-credential-updates.js"; -import { nativeSha256 } from "./canonical.js"; +import { canonicalNativeJson, nativeSha256 } from "./canonical.js"; import { lockSourceRecoveryCoordinators } from "./native-source-recovery-ownership.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; import { createAssignedMcpTools, getAssignedMcpGateway } from "./assigned-mcp-tools.js"; @@ -669,22 +669,15 @@ type WarmNativeSession = { runId: string; issueId: string; instructionWorkingCopy?: NativeInstructionWorkingCopy; - // Keep the admitted physical root independent of the per-run collection - // capability, which instruction preparation adopts before final admission. - instructionRoot?: string; instructionPreparationRunId?: string; agentId: string; instructionCopy?: { runId: string; root?: string; targetIdentity: string; collectStopped: () => Promise }; preparingRunId?: string; - managedAiCredentialIdentity?: string; - credentialRunId?: string; githubAccess?: NativeGitHubAccess; - githubAuthenticationMode?: string; - networkAccess: boolean; session: NativeSession; ownerToken: symbol; configDigest: string; - configuredEnvironmentDigest: string; + processConfigDigest?: string; ownerScope: string; normalizedSessionId: string; companyId: string; @@ -7605,17 +7598,29 @@ function needsCoreGitHubAccess(input: Pick[0]): string | undefined { - if (needsCoreGitHubAccess(input) || input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN || input.instructionWorkingCopy) return undefined; +function nativeProcessReuseKey(input: Parameters[0]): string | undefined { + const environment = input.runnerEnvironment; + if (!input.managedGitHub && environment?.PAPERCLIP_GITHUB_BROKER_TOKEN) return undefined; + const schema = "paperclip.native-process-reuse.v2"; + const providerCredentials = PROVIDER_CREDENTIAL_ENVIRONMENT_KEYS.flatMap(key => environment?.[key] ? [[key, environment[key]]] : []); return nativeSha256({ - environment: configuredEnvironment(input.runnerEnvironment), + schema, + environment: configuredEnvironment(environment), + providerCredentials: createHmac("sha256", `${schema}:${nativeSessionScopeKey(input.execution)}`) + .update(canonicalNativeJson(providerCredentials)).digest("hex"), managedAiCredentialIdentity: input.managedAiCredentialIdentity ?? null, observabilityCompatibilityKey: input.observability?.compatibilityKey ?? null, - githubAuthenticationMode: input.runnerEnvironment?.PAPERCLIP_GITHUB_AUTH_MODE ?? null, - networkAccess: input.runnerEnvironment?.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled", + githubAuthenticationMode: environment?.PAPERCLIP_GITHUB_AUTH_MODE ?? null, + networkAccess: environment?.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled", + coreGitHubAccess: needsCoreGitHubAccess(input), + instructionRoot: input.instructionWorkingCopy?.root ?? null, }); } +function canReuseNativeProcess(input: Parameters[0], processConfigDigest: unknown): boolean { + return input.refreshTools !== true && typeof processConfigDigest === "string" && processConfigDigest === nativeProcessReuseKey(input); +} + function loadWarmNativeCheckpoint( execution: NativeExecutionInput, configDigest: string, @@ -9631,13 +9636,9 @@ async function executePaperclipNativeSessionWithinScope( } return loadWarmNativeCheckpoint(previous, nativeSessionConfigDigest(previous, "remote"), "remote"); }, - canReuseProcess: previous => { - const expected = warmNativeProcessConfigDigest(input); - if (!expected) return false; - const envelope = record(JSON.parse(readBoundedNativeFile(nativeSessionCheckpointPath(previous), - NATIVE_WARM_CHECKPOINT_MAX_BYTES, "native_session_supervisor_checkpoint_too_large").toString("utf8"))); - return envelope.processConfigDigest === expected; - }, + canReuseProcess: previous => !needsCoreGitHubAccess(input) && canReuseNativeProcess(input, + record(JSON.parse(readBoundedNativeFile(nativeSessionCheckpointPath(previous), + NATIVE_WARM_CHECKPOINT_MAX_BYTES, "native_session_supervisor_checkpoint_too_large").toString("utf8"))).processConfigDigest), }); remoteWarmRecovery.releasedSource = releasedSource; } @@ -10192,7 +10193,7 @@ async function executePaperclipNativeSessionWithinScope( }; const controllerCompatibility = { configDigest: nativeSessionConfigDigest(input.execution, input.runnerExecutionTarget?.kind ?? "local"), - processConfigDigest: warmNativeProcessConfigDigest(input) ?? null, + processConfigDigest: nativeProcessReuseKey(input) ?? null, }; const controlPlane = new PaperclipControlPlanePort( input.db, @@ -10539,9 +10540,6 @@ async function executePaperclipNativeSessionWithinScope( lifecyclePolicy.mode === "warm" ? controllerCompatibility.configDigest : null; - const configuredEnvironmentDigest = createHash("sha256") - .update(JSON.stringify(configuredEnvironment(input.runnerEnvironment))) - .digest("hex"); const warmSessionOwnerToken = Symbol( `native-warm-session:${input.execution.binding.runId}`, ); @@ -10592,34 +10590,15 @@ async function executePaperclipNativeSessionWithinScope( } } if (entry) { - // Old run-scoped environments still require process replacement. A - // session-owned broker can change run authority without replacing it. - const hasBrokerCapability = Boolean( - !input.managedGitHub && input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN, - ); - const credentialRunChanged = - Boolean(entry.credentialRunId) !== hasBrokerCapability || - (hasBrokerCapability && - entry.credentialRunId !== input.execution.binding.runId); if ( (entry.pluginTaskId !== undefined && (input.runnerExecutionTarget?.kind !== "remote" || input.runnerExecutionTarget.transport !== "sandbox" || entry.pluginTaskId !== input.runnerExecutionTarget.sandboxLeaseAcquisition?.providerLeaseId)) || - input.refreshTools === true || entry.closeOnReleaseReason !== undefined || entry.configDigest !== warmConfigDigest || - entry.observabilityCompatibilityKey !== input.observability?.compatibilityKey || - entry.configuredEnvironmentDigest !== configuredEnvironmentDigest || - entry.instructionRoot !== input.instructionWorkingCopy?.root || - entry.managedAiCredentialIdentity !== input.managedAiCredentialIdentity || - credentialRunChanged || + !canReuseNativeProcess(input, entry.processConfigDigest) || Boolean(entry.githubAccess) !== needsCoreGitHubAccess(input) || entry.githubAccess?.ready === false || - (entry.githubAccess !== undefined && entry.githubAccess.duplexRequested !== adapterExecutionTargetEnablesSandboxDuplexBridge(input.runnerExecutionTarget)) || - entry.githubAuthenticationMode !== - input.runnerEnvironment?.PAPERCLIP_GITHUB_AUTH_MODE || - entry.networkAccess !== - (input.runnerEnvironment?.PAPERCLIP_RUNNER_NETWORK_ACCESS === - "enabled") + (entry.githubAccess !== undefined && entry.githubAccess.duplexRequested !== adapterExecutionTargetEnablesSandboxDuplexBridge(input.runnerExecutionTarget)) ) { await finishStartupContextForFallback(); pluginTaskDescription=undefined; @@ -11153,24 +11132,13 @@ async function executePaperclipNativeSessionWithinScope( runId: input.execution.binding.runId, issueId: input.execution.binding.issueId, instructionWorkingCopy: input.instructionWorkingCopy?.checkpointWarm ? undefined : input.instructionWorkingCopy, - instructionRoot: input.instructionWorkingCopy?.root, agentId: input.execution.binding.agentId, - managedAiCredentialIdentity: input.managedAiCredentialIdentity, - githubAuthenticationMode: - input.runnerEnvironment?.PAPERCLIP_GITHUB_AUTH_MODE, - networkAccess: - input.runnerEnvironment - ?.PAPERCLIP_RUNNER_NETWORK_ACCESS === "enabled", githubAccess, - credentialRunId: !input.managedGitHub && input.runnerEnvironment - ?.PAPERCLIP_GITHUB_BROKER_TOKEN - ? input.execution.binding.runId - : undefined, session, ownerToken: warmSessionOwnerToken, configDigest: warmConfigDigest, + processConfigDigest: controllerCompatibility.processConfigDigest ?? undefined, observabilityCompatibilityKey: input.observability?.compatibilityKey, - configuredEnvironmentDigest, ownerScope: nativeSessionOwnerScope( input.execution, input.runnerExecutionTarget?.environmentId ?? null, ), diff --git a/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts b/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts index d94697eba8f..4da3feb7710 100644 --- a/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts +++ b/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts @@ -41,9 +41,12 @@ const parkId = (source: DurableRecoveryIdentity) => `park_${sha(JSON.stringify(O describeEmbeddedPostgres("plugin Runner release/resume through the Core execution entry", () => { const environment = nativeRestartFixture(); const nativeIt = process.platform === "win32" ? it.skip : realProcessIt; - nativeIt.for([...(["codex", "claude"] as const).flatMap(harness => (["cached", "adopted"] as const).map(mode => [harness, mode] as const)), ["codex", "adopted cancellation"] as const, ["codex", "adopted legacy cancellation"] as const])("completes %s runs through a retained %s process", { timeout: 60_000 }, async ([harness, mode], context) => { + nativeIt.for([...(["codex", "claude"] as const).flatMap(harness => (["cached", "adopted"] as const).map(mode => [harness, mode] as const)), ["codex", "adopted cancellation"] as const, ["codex", "adopted legacy cancellation"] as const, + ["codex", "cached managed GitHub"] as const, ["codex", "adopted managed GitHub"] as const])("completes %s runs through a retained %s process", { timeout: 60_000 }, async ([harness, mode], context) => { if (harness === "claude" && !existsSync(fakeAcpxSidecar)) context.skip(); const adopted = mode.startsWith("adopted"), cancellation = mode.includes("cancellation"), qualifiedCancellation = mode === "adopted cancellation"; + const managedGitHub = mode.endsWith("managed GitHub"), brokerUrl = "https://core.example.test/api", brokerToken = (runId: string) => `fixture-broker-${runId}`; + const preparedBrokers: Array<{ runId: string; token?: string }> = []; const first = await environment.seedRun(`RELEASE-ENTRY-${harness}-${mode}`); const { db, runId, issueId, native } = first; const { companyId, agentId, runtimeRoot } = environment.current; @@ -130,9 +133,12 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio const task = async (raw: unknown, authorityRunId: string): Promise => { const operation = environmentTaskOperationSchema.parse(raw); if (operation.kind === "describe") return { kind: "description", taskId, launchPreparationVersion: 1, - projectAuthCheckVersion: 1, warmTaskVersion: 1, releaseResumeVersion: 1, runner: artifact, ...(qualifiedCancellation ? { executionContextVersion: 1 } : {}) }; + projectAuthCheckVersion: 1, warmTaskVersion: 1, releaseResumeVersion: 1, runner: artifact, ...(qualifiedCancellation ? { executionContextVersion: 1 } : {}), + ...(managedGitHub ? { githubCredentialBrokerVersion: 1 } : {}) }; if (operation.kind === "prepare") { expect(operation.requirements.identity.runId).toBe(authorityRunId); + expect(operation.githubBroker).toEqual(managedGitHub ? { url: brokerUrl, token: brokerToken(authorityRunId) } : undefined); + preparedBrokers.push({ runId: authorityRunId, token: operation.githubBroker?.token }); expect(operation.accountIdentity).toBe("fixture-account"); if (operation.priorIdentity) expect(sameSource(operation.priorIdentity, await state())).toBe(true); expect(operation.requirements.settings.managedProfile).toBeUndefined(); @@ -204,7 +210,9 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: current }, runnerInstanceId: native.runnerInstanceId, completionContractId: contract!.id, completionContractSha256: contract!.canonicalSha256, nativeSessionId: current.session.normalizedSessionId }) .where(eq(heartbeatRuns.id, current.binding.runId)); return executePaperclipNativeSession({ db, execution: current, runnerInstanceId: native.runnerInstanceId, useRunnerd: true, runnerIngressAuthorized: true, - runnerEnvironment: harness === "claude" ? { ANTHROPIC_API_KEY: "synthetic-fixture" } : {}, + runnerEnvironment: { ...(harness === "claude" ? { ANTHROPIC_API_KEY: "synthetic-fixture" } : {}), + ...(managedGitHub ? { PAPERCLIP_GITHUB_BROKER_URL: brokerUrl, PAPERCLIP_GITHUB_BROKER_TOKEN: brokerToken(current.binding.runId) } : {}) }, + managedGitHub, managedAiCredentialHome: home, managedAiCredentialIdentity: "fixture-revision", managedAiAccountIdentity: "fixture-account", turnTimeoutMs: 15_000, runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: workspace, environmentId, leaseId, providerKey: "plugin:fixture:tasks", runnerTask: operation => task(operation, current.binding.runId), sandboxLeaseAcquisition: { outcome: leaseId === leaseA ? "created" : "resumed", providerLeaseId: taskId } } }); @@ -283,6 +291,7 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio } expect(claims).toEqual(releases.slice(0, -1)); expect(releases[1]!.runId).toBe(runB); expect(releases[1]!.environmentLeaseId).toBe(leaseA); expect(handle!.child.pid).toBe(pid); expect(submissions).toBe(1); + expect([...new Set(preparedBrokers.map(prepared => prepared.runId))]).toEqual(releases.map(release => release.runId)); const methods = (await readFile(calls, "utf8")).trim().split(/\r?\n/); if (harness === "codex") { expect(methods.filter(method => method === "process-start")).toHaveLength(1); diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index a48b7da1ed7..38108ec7a08 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -218,6 +218,7 @@ export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS; export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS; export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection; export const configuredEnvironment = runner.configuredEnvironment; +export const PROVIDER_CREDENTIAL_ENVIRONMENT_KEYS = runner.PROVIDER_CREDENTIAL_ENVIRONMENT_KEYS; export const probeNativeRunnerEnvironment = runner.probeNativeRunnerEnvironment;