mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Fix managed Codex credentials across the Runner ACPX sidecar
Stage the selected Codex login in the Runner session home and carry only its derived file path through the trusted Rust sidecar launch. Return the private credential after provider shutdown so the tenant can use its existing identity and freshness merge. Retain credentials when export or merge fails, and exclude staged files from recovery backups. Validated with credential/runtime, tenant executor, and Rust sidecar regression tests plus a two-turn authenticated Linux VM probe. Broader local typecheck and build are blocked by missing host Cargo; the Linux Runner compiled in Docker. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
17 files changed
+220
-33
No files matched your search
@@ -56,6 +56,20 @@ stdin/stdout bridge admits the pinned Claude and Codex ACPX profiles. It
|
||||
validates the exact model, session identity, tool catalog, structured input,
|
||||
and terminal settlement at the process boundary. Pi remains unavailable.
|
||||
|
||||
ACPX Codex subscription logins use the selected run credential, staged in the
|
||||
Runner's private `codex-home/auth.json`. The trusted launch environment carries
|
||||
its derived path to the sidecar; arbitrary caller paths and ambient homes are
|
||||
not inherited. The sidecar stages that file in its isolated Codex home and uses
|
||||
file credential storage. It checkpoints the credential to
|
||||
`codex-home/auth.json.returned` before publishing each turn's terminal result,
|
||||
so the tenant can flush refreshes into that invocation's credential home while
|
||||
the provider stays warm. It exports once more after the provider stops, before
|
||||
removing its private copy. Failed export
|
||||
retains the credential and lease for retry. The tenant merges the returned file
|
||||
using its existing subscription identity and freshness checks. Both files are
|
||||
excluded from backups and removed after a successful tenant merge. API-key
|
||||
launches continue to use their explicit key environment.
|
||||
|
||||
Native Claude skill assignments travel in the runtime-context snapshot through
|
||||
runnerd to the ACPX sidecar. After acquiring the provider lifetime lease, the
|
||||
host materializes the assigned bundles under the isolated Claude home's
|
||||
|
||||
@@ -97,7 +97,11 @@ impl AcpxSidecarTransport {
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
let credential_keys: &[&str] = match agent {
|
||||
"claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
||||
"codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"],
|
||||
"codex" => &[
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
"PAPERCLIP_ACPX_CODEX_AUTH_FILE",
|
||||
],
|
||||
_ => {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX sidecar credentials require a qualified claude or codex agent",
|
||||
|
||||
@@ -148,6 +148,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"url": std::env::var("PAPERCLIP_NATIVE_MCP_URL").ok(),
|
||||
"hasToken": std::env::var("PAPERCLIP_NATIVE_MCP_TOKEN").is_ok(),
|
||||
"hasUnrelatedSecret": std::env::var("UNRELATED_EVAL_SECRET").is_ok(),
|
||||
"codexAuthFile": std::env::var("PAPERCLIP_ACPX_CODEX_AUTH_FILE").ok(),
|
||||
}
|
||||
}),
|
||||
)?;
|
||||
|
||||
@@ -233,6 +233,10 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets(
|
||||
"fixture-token-never-returned-in-test-output",
|
||||
)
|
||||
.env("UNRELATED_EVAL_SECRET", "must-not-cross-boundary")
|
||||
.env(
|
||||
"PAPERCLIP_ACPX_CODEX_AUTH_FILE",
|
||||
"/private/session/auth.json",
|
||||
)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "isolated gateway environment test failed");
|
||||
@@ -260,6 +264,14 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets(
|
||||
"assigned gateway credential was dropped"
|
||||
);
|
||||
assert_eq!(response["hasUnrelatedSecret"], false);
|
||||
assert_eq!(
|
||||
response["codexAuthFile"],
|
||||
if agent == "codex" {
|
||||
json!("/private/session/auth.json")
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
}
|
||||
);
|
||||
sidecar.shutdown().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,10 @@ async function dispatch(
|
||||
systemInstructions: params.systemInstructions,
|
||||
runtimeContext: params.runtimeContext,
|
||||
environment: process.env,
|
||||
managedCodexCredentialSourcePath: params.agent === "codex"
|
||||
? process.env.PAPERCLIP_ACPX_CODEX_AUTH_FILE : undefined,
|
||||
managedCodexCredentialReturnPath: params.agent === "codex" && process.env.PAPERCLIP_ACPX_CODEX_AUTH_FILE
|
||||
? `${process.env.PAPERCLIP_ACPX_CODEX_AUTH_FILE}.returned` : undefined,
|
||||
expectedIdentity: params.expectedIdentity,
|
||||
semanticTools: {
|
||||
tools: params.tools,
|
||||
@@ -544,6 +548,7 @@ async function pumpTurn(
|
||||
);
|
||||
}
|
||||
const result = await runtimeTurn.result;
|
||||
await activeHost.checkpointCredential();
|
||||
try {
|
||||
const usage = persistedAcpxTurnUsage(
|
||||
usageBefore,
|
||||
|
||||
@@ -3283,6 +3283,7 @@ const runnerExplicitProviderEnvironmentKeys = [
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
"PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET",
|
||||
"PAPERCLIP_ACPX_CODEX_AUTH_FILE",
|
||||
"AWS_REGION",
|
||||
"AWS_DEFAULT_REGION",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
||||
|
||||
@@ -696,6 +696,30 @@ describe("managed Codex credentials", () => {
|
||||
await expect(readFile(source, "utf8")).resolves.toContain("managed-canary");
|
||||
});
|
||||
|
||||
it("returns refreshed credentials privately and retains them on export failure", async () => {
|
||||
const fixture = await credentialFixture();
|
||||
const source = join(fixture.root, "auth.json");
|
||||
const returnPath = `${source}.returned`;
|
||||
await writeFile(source, '{"tokens":{"access_token":"original"}}', { mode: 0o600 });
|
||||
const lease = await stageManagedCodexCredential({ agentHomeDirectory: fixture.home, sourcePath: source, returnPath });
|
||||
await writeFile(lease.path, '{"tokens":{"access_token":"refreshed"}}', { mode: 0o600 });
|
||||
await lease.checkpoint!();
|
||||
await expect(readFile(returnPath, "utf8")).resolves.toContain("refreshed");
|
||||
await expect(readFile(lease.path, "utf8")).resolves.toContain("refreshed");
|
||||
await rm(returnPath);
|
||||
await mkdir(returnPath);
|
||||
await expect(lease.close()).rejects.toThrow();
|
||||
await expect(readFile(lease.path, "utf8")).resolves.toContain("refreshed");
|
||||
await expect(stageManagedCodexCredential({ agentHomeDirectory: fixture.home, sourcePath: source })).rejects.toThrow("active lease");
|
||||
await rm(returnPath, { recursive: true });
|
||||
await lease.close();
|
||||
await lease.close();
|
||||
expect((await stat(returnPath)).mode & 0o777).toBe(0o600);
|
||||
await expect(readFile(returnPath, "utf8")).resolves.toContain("refreshed");
|
||||
await expect(readFile(source, "utf8")).resolves.toContain("original");
|
||||
await expect(readFile(lease.path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("replaces a stale regular auth destination in JSON modes", async () => {
|
||||
const fixture = await credentialFixture();
|
||||
const destination = join(fixture.home, "auth.json");
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import { verifiedRuntimeExecutableHandoff } from "./verified-runtime-executable.js";
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface AcpxProviderLifetimeLease {
|
||||
readonly lifetimeFenceFds: readonly [number, number];
|
||||
/** Validate the guardian while the provider-lifetime quorum is still held. */
|
||||
activateLifetimeOwner(pid: number): Promise<void>;
|
||||
/** Publish a private credential snapshot before acknowledging a warm turn. */
|
||||
checkpoint?(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -180,6 +182,7 @@ export async function stageManagedCodexCredential(input: {
|
||||
agentHomeDirectory: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
sourcePath?: string;
|
||||
returnPath?: string;
|
||||
}): Promise<ManagedCodexCredentialLease> {
|
||||
const home = await resolvePrivateAgentHome(input.agentHomeDirectory);
|
||||
// Join an older failed close before claiming the next generation. This
|
||||
@@ -229,6 +232,7 @@ async function stageClaimedManagedCodexCredential(
|
||||
input: {
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
sourcePath?: string;
|
||||
returnPath?: string;
|
||||
},
|
||||
home: string,
|
||||
ownerGeneration: CredentialLeaseGeneration,
|
||||
@@ -279,6 +283,16 @@ async function stageClaimedManagedCodexCredential(
|
||||
);
|
||||
}
|
||||
|
||||
if (input.returnPath !== undefined) {
|
||||
if (!hasManagedFile || !isAbsolute(input.returnPath) ||
|
||||
resolve(input.returnPath) === destination ||
|
||||
resolve(input.returnPath) === resolve(input.sourcePath!) ||
|
||||
dirname(resolve(input.returnPath)) !== dirname(resolve(input.sourcePath!))) {
|
||||
throw new Error("Managed Codex credential return must be a separate file beside its source");
|
||||
}
|
||||
await resolvePrivateAgentHome(dirname(input.returnPath));
|
||||
}
|
||||
|
||||
if (hasApiKey) {
|
||||
// Codex will read the API key from the launch environment. Persist cleanup
|
||||
// intent before admitting the provider and retain it for the lease
|
||||
@@ -331,6 +345,7 @@ async function stageClaimedManagedCodexCredential(
|
||||
hasInlineJson ? "inline_json" : "managed_file",
|
||||
ownerGeneration,
|
||||
lock,
|
||||
input.returnPath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -653,6 +668,7 @@ function credentialLease(
|
||||
mode: ManagedCodexCredentialMode,
|
||||
ownerGeneration: CredentialLeaseGeneration,
|
||||
lock: CredentialHomeLock,
|
||||
returnPath?: string,
|
||||
): ManagedCodexCredentialLease {
|
||||
// Do not admit a provider if kernel ownership was lost while its credential
|
||||
// was being staged.
|
||||
@@ -660,6 +676,22 @@ function credentialLease(
|
||||
let closed = false;
|
||||
let closeAttempt: Promise<void> | null = null;
|
||||
let lifetimeOwnerAttempt: Promise<void> | null = null;
|
||||
let exportAttempt: Promise<void> | null = null;
|
||||
const exportCredential = (): Promise<void> => {
|
||||
if (returnPath === undefined) return Promise.resolve();
|
||||
exportAttempt ??= (async () => {
|
||||
lock.assertHeld();
|
||||
const returnHome = await resolvePrivateAgentHome(dirname(returnPath));
|
||||
const bytes = await readManagedCredential(path);
|
||||
try {
|
||||
validateCredentialDocument(bytes);
|
||||
await writeCredential(returnPath, `${returnPath}.staging`, returnHome, bytes);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
})().finally(() => { exportAttempt = null; });
|
||||
return exportAttempt;
|
||||
};
|
||||
return Object.freeze({
|
||||
path,
|
||||
mode,
|
||||
@@ -678,6 +710,10 @@ function credentialLease(
|
||||
if (lifetimeOwnerAttempt === attempt) lifetimeOwnerAttempt = null;
|
||||
}
|
||||
},
|
||||
async checkpoint(): Promise<void> {
|
||||
if (closed || closeAttempt !== null) throw new Error("Managed Codex credential lease is closing");
|
||||
await exportCredential();
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
if (closeAttempt !== null) return await closeAttempt;
|
||||
@@ -696,6 +732,11 @@ function credentialLease(
|
||||
closed = true;
|
||||
return;
|
||||
}
|
||||
lock.assertHeld();
|
||||
// Join a turn checkpoint, then capture the final stopped-provider state.
|
||||
await exportAttempt?.catch(() => undefined);
|
||||
// Failed export retains the only refreshed copy and its lease for retry.
|
||||
await exportCredential();
|
||||
try {
|
||||
lock.assertHeld();
|
||||
await removeCredential(path, home);
|
||||
|
||||
@@ -207,6 +207,8 @@ export interface OpenAcpxRuntimeHostOptions {
|
||||
runtimeContext?: NativeRuntimeContextSnapshot | null;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
managedCodexCredentialSourcePath?: string;
|
||||
/** Private per-session return slot; the caller owns the authoritative merge. */
|
||||
managedCodexCredentialReturnPath?: string;
|
||||
expectedIdentity?: AcpxExpectedSessionIdentity;
|
||||
/** Revalidate a pinned recovery workspace through provider admission. */
|
||||
assertWorkspaceHeld?: () => void;
|
||||
@@ -319,7 +321,7 @@ export class AcpxRuntimeHost {
|
||||
options.assertWorkspaceHeld?.();
|
||||
if (
|
||||
options.agent !== "codex" &&
|
||||
options.managedCodexCredentialSourcePath !== undefined
|
||||
(options.managedCodexCredentialSourcePath !== undefined || options.managedCodexCredentialReturnPath !== undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
"Managed Codex credentials require the Codex ACPX profile",
|
||||
@@ -398,6 +400,7 @@ export class AcpxRuntimeHost {
|
||||
agentHomeDirectory: sandbox.agentHomeDirectory,
|
||||
environment: options.environment,
|
||||
sourcePath: options.managedCodexCredentialSourcePath,
|
||||
returnPath: options.managedCodexCredentialReturnPath,
|
||||
}),
|
||||
resource: "credential",
|
||||
releaseLate: (lateCredential) => lateCredential.close(),
|
||||
@@ -675,6 +678,10 @@ export class AcpxRuntimeHost {
|
||||
return turn;
|
||||
}
|
||||
|
||||
async checkpointCredential(): Promise<void> {
|
||||
await this.#credential?.checkpoint?.();
|
||||
}
|
||||
|
||||
async interruptActiveTurn(reason: string): Promise<void> {
|
||||
const turn = this.#activeTurn;
|
||||
if (!turn) throw new Error("ACPX runtime host has no active turn");
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("ACPX runtime sandbox", () => {
|
||||
join(sandbox.agentHomeDirectory, "config.toml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(config).toBe("[features]\nshell_snapshot = false\n");
|
||||
expect(config).toBe('cli_auth_credentials_store = "file"\n[features]\nshell_snapshot = false\n');
|
||||
expect(config).not.toContain("provider-secret");
|
||||
}
|
||||
expect(await readFile(sandbox.workspaceRecordPath, "utf8")).toBe(
|
||||
|
||||
@@ -398,6 +398,7 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
await writePrivateFile(
|
||||
join(agentHomeDirectory, "config.toml"),
|
||||
[
|
||||
'cli_auth_credentials_store = "file"',
|
||||
// Codex shell snapshots serialize the provider process environment.
|
||||
// The ACPX sidecar receives a short-lived managed credential only so
|
||||
// it can authenticate the provider; that value must never become
|
||||
|
||||
@@ -1646,6 +1646,25 @@ it("uses file-backed AWS workload identity without forwarding access keys or Pap
|
||||
expect(environment.PAPERCLIP_NATIVE_MCP_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives the ACPX credential path from the Runner home", () => {
|
||||
const input = {
|
||||
provider: "acpx" as const,
|
||||
options: { acpxAgent: "codex" as const, environment: { PAPERCLIP_ACPX_CODEX_AUTH_FILE: "/attacker/auth.json" } },
|
||||
identity: { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "run", normalizedSessionId: "session", turnId: "turn", itemId: "item" },
|
||||
codexHome: "/private/session/codex-home", runtimeContextPath: "/private/context", hasRuntimeContext: false,
|
||||
acpxSidecarPath: "/verified/provider-pack/dist/cli/acpx-runtime-sidecar.cjs",
|
||||
};
|
||||
expect(createCapabilityRunnerdProviderEnvironment(input).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBe("/private/session/codex-home/auth.json");
|
||||
expect(createCapabilityRunnerdProviderEnvironment({ ...input, options: { ...input.options, environment: { OPENAI_API_KEY: "explicit-key" } } }).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBeUndefined();
|
||||
expect(createCapabilityRunnerdProviderEnvironment({ ...input, options: { ...input.options, acpxAgent: "claude" } }).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBeUndefined();
|
||||
vi.stubEnv("OPENAI_API_KEY", "inherited-key");
|
||||
try {
|
||||
expect(createCapabilityRunnerdProviderEnvironment({ ...input, options: { acpxAgent: "codex" } }).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBeUndefined();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
agent: "pi" as const,
|
||||
|
||||
@@ -3098,6 +3098,10 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
||||
input.options.acpxSidecarPath ??
|
||||
resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.cjs");
|
||||
const providerPackageAuthority = acpxProviderPackageAuthority(sidecarPath);
|
||||
const spawnEnvironment = createSanitizedAcpxSpawnInput(
|
||||
input.options.environment,
|
||||
input.options.acpxAgent ?? "codex",
|
||||
).env;
|
||||
// This is the trusted runner/sidecar boundary. The provider sandbox still
|
||||
// uses createSanitizedAcpxSpawnInput and does not inherit gateway tokens.
|
||||
const assignedGateway = input.options.acpxAgent === "pi"
|
||||
@@ -3106,11 +3110,13 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
||||
...(assignedGateway ? {
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: assignedGateway.token,
|
||||
} : {}),
|
||||
...createSanitizedAcpxSpawnInput(
|
||||
input.options.environment,
|
||||
input.options.acpxAgent ?? "codex",
|
||||
).env,
|
||||
...spawnEnvironment,
|
||||
...commonIdentity,
|
||||
...((input.options.acpxAgent ?? "codex") === "codex" &&
|
||||
!spawnEnvironment.CODEX_API_KEY &&
|
||||
!spawnEnvironment.OPENAI_API_KEY
|
||||
? { PAPERCLIP_ACPX_CODEX_AUTH_FILE: resolve(input.codexHome, "auth.json") }
|
||||
: {}),
|
||||
// The verified sidecar bundle cannot use import.meta.url while Node
|
||||
// executes it through /proc/self/fd. Anchor its closed provider package
|
||||
// lookups at the package that owns the already-authenticated bundle.
|
||||
@@ -4340,7 +4346,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
if (provider === "aws_agentcore") {
|
||||
mkdirSync(codexHome, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
if (provider === "codex") {
|
||||
if (
|
||||
provider === "codex" ||
|
||||
(provider === "acpx" && (this.options.acpxAgent ?? "codex") === "codex")
|
||||
) {
|
||||
await prepareIsolatedCodexHome({
|
||||
context: sourceRuntimeContext,
|
||||
codexHome: localCodexHome,
|
||||
@@ -4350,7 +4359,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
apiKey:
|
||||
this.options.environment?.CODEX_API_KEY ??
|
||||
this.options.environment?.OPENAI_API_KEY,
|
||||
nativeMcp: nativeMcpLaunchBinding(this.options.environment),
|
||||
nativeMcp: provider === "codex"
|
||||
? nativeMcpLaunchBinding(this.options.environment) : null,
|
||||
});
|
||||
}
|
||||
const opencodeProxyPath =
|
||||
@@ -5234,7 +5244,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
}
|
||||
if (provider === "aws_agentcore")
|
||||
mkdirSync(codexHome, { recursive: true, mode: 0o700 });
|
||||
if (provider === "codex") {
|
||||
if (
|
||||
provider === "codex" ||
|
||||
(provider === "acpx" && (this.options.acpxAgent ?? "codex") === "codex")
|
||||
) {
|
||||
await releaseMaterializedNativeRuntimeSkills(
|
||||
resolve(localCodexHome, "skills"),
|
||||
);
|
||||
@@ -5247,7 +5260,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
apiKey:
|
||||
this.options.environment?.CODEX_API_KEY ??
|
||||
this.options.environment?.OPENAI_API_KEY,
|
||||
nativeMcp: nativeMcpLaunchBinding(this.options.environment),
|
||||
nativeMcp: provider === "codex"
|
||||
? nativeMcpLaunchBinding(this.options.environment) : null,
|
||||
});
|
||||
}
|
||||
const adoptedRunner = this.options.adoptExistingRunner;
|
||||
|
||||
@@ -101,11 +101,15 @@ describe("managed native credential turns", () => {
|
||||
await closing;
|
||||
});
|
||||
|
||||
it("removes stopped-provider credentials even when refresh copy-back fails", async () => {
|
||||
it("retains stopped-provider credentials until a failed copy-back succeeds", async () => {
|
||||
const session = { close: async () => {} } as unknown as NativeSession;
|
||||
const remove = vi.fn(async () => {});
|
||||
bindManagedNativeCredentialTurn(session, { copyBack: async () => { throw new Error("copy-back failed"); }, remove });
|
||||
const copyBack = vi.fn(async () => {}).mockRejectedValueOnce(new Error("copy-back failed"));
|
||||
bindManagedNativeCredentialTurn(session, { copyBack, remove });
|
||||
await expect(session.close({ reason: "completed" })).rejects.toThrow("copy-back failed");
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
await session.close({ reason: "retry" });
|
||||
expect(copyBack).toHaveBeenCalledTimes(2);
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -49,15 +49,12 @@ export function bindManagedNativeCredentialTurn(
|
||||
if (owner) owner.closing = true;
|
||||
await close(input);
|
||||
if (!owner || owner.detached || credentialTurns.get(session) !== owner) return;
|
||||
try {
|
||||
await completeCredentialTurn(owner);
|
||||
} finally {
|
||||
// Idle expiry removes the provider credential, but never writes back to
|
||||
// the already-deleted temporary home of a completed invocation.
|
||||
if (!owner.detached && !owner.removed && credentialTurns.get(session) === owner) {
|
||||
owner.removed = true;
|
||||
await owner.remove();
|
||||
}
|
||||
await completeCredentialTurn(owner);
|
||||
// Failed copy-back retains credentials for retry. Idle expiry never writes
|
||||
// back to the already-deleted temporary home of a completed invocation.
|
||||
if (!owner.detached && !owner.removed && credentialTurns.get(session) === owner) {
|
||||
await owner.remove();
|
||||
owner.removed = true;
|
||||
}
|
||||
};
|
||||
return session;
|
||||
@@ -65,7 +62,10 @@ export function bindManagedNativeCredentialTurn(
|
||||
|
||||
async function completeCredentialTurn(turn: CredentialTurn) {
|
||||
if (turn.detached || turn.completed) return;
|
||||
turn.completion ??= turn.copyBack().finally(() => { turn.completed = true; });
|
||||
turn.completion ??= turn.copyBack().then(
|
||||
() => { turn.completed = true; },
|
||||
(error: unknown) => { turn.completion = undefined; throw error; },
|
||||
);
|
||||
await turn.completion;
|
||||
}
|
||||
|
||||
|
||||
@@ -1191,7 +1191,7 @@ describe("native harness persistence profiles", () => {
|
||||
"acpx codex",
|
||||
{ kind: "acpx", agent: "codex" },
|
||||
"acpx_runtime",
|
||||
["runner", "acpx"],
|
||||
["runner", "acpx", "codex-home"],
|
||||
],
|
||||
])(
|
||||
"declares the complete %s recovery state",
|
||||
@@ -1210,7 +1210,7 @@ describe("native harness persistence profiles", () => {
|
||||
expect(
|
||||
codex.directories.find((directory) => directory.name === "codex-home"),
|
||||
).toMatchObject({
|
||||
excludeEntries: ["tmp", ".tmp", "auth.json", "config.toml"],
|
||||
excludeEntries: ["tmp", ".tmp", "auth.json", "auth.json.returned", "auth.json.returned.staging", "config.toml"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1224,6 +1224,8 @@ describe("native harness persistence profiles", () => {
|
||||
`acpx/${sessionDirectory}/codex-home/tmp`,
|
||||
`acpx/${sessionDirectory}/codex-home/.tmp`,
|
||||
`acpx/${sessionDirectory}/codex-home/auth.json`,
|
||||
`acpx/${sessionDirectory}/codex-home/auth.json.returned`,
|
||||
`acpx/${sessionDirectory}/codex-home/auth.json.returned.staging`,
|
||||
`acpx/${sessionDirectory}/codex-home/config.toml`,
|
||||
],
|
||||
});
|
||||
@@ -1840,7 +1842,7 @@ describe("remote provider checkpoint snapshots", () => {
|
||||
sourcePath: "/remote/session/filesystem/codex-home",
|
||||
targetPath: "/tmp/paperclip-checkpoint-test-codex-home",
|
||||
mode: 0o700,
|
||||
excludeEntries: ["tmp", ".tmp", "auth.json", "config.toml"],
|
||||
excludeEntries: ["tmp", ".tmp", "auth.json", "auth.json.returned", "auth.json.returned.staging", "config.toml"],
|
||||
});
|
||||
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
@@ -2022,7 +2024,7 @@ describe("remote provider checkpoint restores", () => {
|
||||
sourcePath,
|
||||
targetPath: "/remote/codex-home",
|
||||
mode: 0o700,
|
||||
excludeEntries: ["tmp", ".tmp", "auth.json", "config.toml"],
|
||||
excludeEntries: ["tmp", ".tmp", "auth.json", "auth.json.returned", "auth.json.returned.staging", "config.toml"],
|
||||
});
|
||||
|
||||
expect(syncIn).toHaveBeenCalledOnce();
|
||||
@@ -7688,6 +7690,34 @@ describe("runnerd provider runtime wiring", () => {
|
||||
await expect(access(authPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("merges ACPX returned auth and retains it when the tenant merge fails", async () => {
|
||||
const acpxExecution = { ...execution, provider: { kind: "acpx", agent: "codex", model: "gpt-5.6-sol" }, session: { ...execution.session, driverKind: "acpx_runtime" } } as unknown as NativeExecutionInputV1;
|
||||
const close = vi.fn(async () => undefined);
|
||||
state.createBackend.mockReturnValueOnce({ kind: "test", openSession: async () => ({ close }) } as never);
|
||||
const managedHome = join(isolatedStateDirectory, "managed-home");
|
||||
const backend = await createRunnerdBackend({ db: leaseDb(acpxExecution), execution: acpxExecution, runnerInstanceId: "runner-acpx-credential-close", managedAiCredentialHome: managedHome, runnerEnvironment: { OPENAI_API_KEY: "", CODEX_API_KEY: "" } });
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const root = state.createTransport.mock.calls.at(-1)![0].stateDirectory!;
|
||||
const source = join(root, "codex-home", "auth.json");
|
||||
const returned = `${source}.returned`;
|
||||
await mkdir(join(root, "codex-home"), { recursive: true });
|
||||
await writeFile(source, "original-fixture");
|
||||
await writeFile(returned, "refreshed-fixture");
|
||||
const session = await backend.openSession({} as never);
|
||||
state.copyBackCodexAuth.mockRejectedValueOnce(new Error("merge unavailable"));
|
||||
await expect(session.close({ reason: "completed" })).rejects.toThrow("merge unavailable");
|
||||
await expect(readFile(returned, "utf8")).resolves.toBe("refreshed-fixture");
|
||||
state.copyBackCodexAuth.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const input = args[0] as { readSandboxAuth: () => Promise<Buffer>; hostAuthPath: string };
|
||||
expect(input.hostAuthPath).toBe(join(managedHome, "auth.json"));
|
||||
expect((await input.readSandboxAuth()).toString()).toBe("refreshed-fixture");
|
||||
return "copied";
|
||||
});
|
||||
await session.close({ reason: "retry" });
|
||||
await expect(access(source)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(access(returned)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("stages from the authenticated run snapshot and cleans up after the provider turn", async () => {
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
state.stageNativeRunnerWakeAttachments.mockResolvedValueOnce({
|
||||
|
||||
@@ -283,6 +283,8 @@ const CODEX_HOME_NON_PERSISTENT_ENTRIES = [
|
||||
"tmp",
|
||||
".tmp",
|
||||
"auth.json",
|
||||
"auth.json.returned",
|
||||
"auth.json.returned.staging",
|
||||
"config.toml",
|
||||
] as const;
|
||||
const RUNNERD_CONTROL_PLANE_STATE_SCHEMA =
|
||||
@@ -5273,6 +5275,9 @@ export function resolveNativeHarnessPersistenceProfile(
|
||||
directories: [
|
||||
{ name: "runner", location: "runner", excludeEntries: [] },
|
||||
...(providerDirectory ? [providerDirectory] : []),
|
||||
...(execution.provider.kind === "acpx" && execution.provider.agent === "codex"
|
||||
? [{ name: "codex-home" as const, location: "filesystem" as const, excludeEntries: CODEX_HOME_NON_PERSISTENT_ENTRIES }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -12627,10 +12632,13 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
});
|
||||
const boundManagedSessions = new WeakSet<NativeSession>();
|
||||
const wrapManagedSession = (session: NativeSession): NativeSession => {
|
||||
if (!input.managedAiCredentialHome || input.execution.provider.kind !== "codex" || boundManagedSessions.has(session)) return session;
|
||||
const acpxCodex = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "codex";
|
||||
if (!input.managedAiCredentialHome || !(input.execution.provider.kind === "codex" || acpxCodex) || boundManagedSessions.has(session)) return session;
|
||||
boundManagedSessions.add(session);
|
||||
const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", "auth.json") : null;
|
||||
const localAuth = join(root, "codex-home", "auth.json");
|
||||
const authName = acpxCodex && !effectiveRunnerEnvironment.CODEX_API_KEY && !effectiveRunnerEnvironment.OPENAI_API_KEY
|
||||
? "auth.json.returned" : "auth.json";
|
||||
const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", authName) : null;
|
||||
const localAuth = join(root, "codex-home", authName);
|
||||
return bindManagedNativeCredentialTurn(session, {
|
||||
copyBack: async () => {
|
||||
await copyBackCodexAuth({
|
||||
@@ -12645,8 +12653,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
});
|
||||
},
|
||||
remove: async () => {
|
||||
rmSync(localAuth, { force: true });
|
||||
if (remoteAuth && remoteCommandRunner) await remoteCommandRunner.execute({ command: "rm", args: ["-f", "--", remoteAuth], bypassSession: true, timeoutMs: 10000 });
|
||||
for (const name of ["auth.json", "auth.json.returned"] as const) {
|
||||
rmSync(join(root, "codex-home", name), { force: true });
|
||||
if (remoteRunnerFilesystemRoot && remoteCommandRunner) await remoteCommandRunner.execute({ command: "rm", args: ["-f", "--", posix.join(remoteRunnerFilesystemRoot, "codex-home", name)], bypassSession: true, timeoutMs: 10000 });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user