feat: bind an agent to a Codex login whose account differs from the company default (#13067)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `codex_local` adapter signs agents in to OpenAI, and a company
keeps one default Codex identity in its shared company home
> - A login with a DIFFERENT account than the company default is
deliberately kept out of the shared home — one agent's sign-in must not
switch every unbound agent's credentials — but that left the
cross-account login inert: nothing connected the agent the operator was
configuring to the credential the login stored
> - The stored credential and its company secret already exist; only the
last mile — an agent actually using them — was missing
> - This pull request reports a non-secret binding claim on the
authenticated login and lets the agent page bind that one agent's
`CODEX_HOME` to the account's secret, exactly and only when the
identities differ
> - The benefit is that multi-account Codex becomes one click on the
agent that needs it, with company-wide identity untouched

## Linked Issues or Issue Description

**What happened?**

On an agent's detail page, "Sign in with Codex" using a different OpenAI
account than the company default succeeds but changes nothing for that
agent. The credential lands in the per-identity store and a company
secret names it, but the agent keeps using the company default. The Test
keeps reporting that authentication is needed, and no repeat login
helps.

**Expected behavior**

When the operator deliberately signs an agent's page in with a different
account, that agent starts using that account. Agents that were not part
of the action keep the company default. A same-account login keeps
working through the shared company home with no per-agent pinning.

**Steps to reproduce**

1. Configure a company whose Codex home holds account A.
2. Open a `codex_local` agent's detail page with a sandbox environment
and complete "Sign in with Codex" using account B.
3. Press Test. Before this change the agent still resolves account A and
the authentication-needed check returns.

## What Changed

- `packages/adapters/codex-local` — the prerequisite shield:
`isCodexAuthCachePath` recognizes per-identity credential-store entries,
and `seedManagedCodexHome` refuses to symlink, heal, or
API-key-overwrite an entry's `auth.json`. The seeding pass runs before
every probe and execute; without the shield, an agent bound to an entry
would have its stored login silently swapped for the host credential.
Static shared config files still copy in. Rotation already survives
binding: the sandbox copy-back writes rotated credentials into the
identity-keyed store slot.
- `server` — the promotion records whether the company default home
ended on a different account than the login (any read failure degrades
to `false`, so the client can never be told to bind wrongly). After the
terminal commit, the routes layer remembers a non-secret claim — the
opaque account-home secret id plus that verdict — in a bounded in-memory
map, and merges it into the owner read of an `authenticated`
`codex_local` session. A restart drops the claim; the panel then shows
plain success.
- `packages/shared` — `CodexAccountBindingClaim` on the owner session
response. It carries no account identifier and no credential byte.
- `ui` — the login panel reports the claim upward once. The edit-mode
form binds the agent's `CODEX_HOME` to the secret and saves in one step,
only when `companyIdentityDiffers` is true. Same-account logins bind
nothing on purpose: the company-home refresh already carried them, and
an unbound agent keeps following the company default across rotations.
Create mode is unchanged.

## Verification

- Adapter suite: 381 passed, 1 skipped (includes the new store-entry
shield tests and the path-predicate cases).
- Server suites (8 files): 130 passed, 15 skipped — including two new
route tests that drive a login to `authenticated` and assert the claim
with both identity verdicts.
- UI render suite: 85 passed — including a panel test that the claim is
reported upward exactly once.
- `tsc --noEmit` clean in `packages/shared`, the adapter package, and
`ui`; `server` clean for the touched file.

## Risks

- The bind changes one agent's configuration through the normal
agent-update patch, initiated by the operator's own login on that
agent's page. The failure direction of every fallback is "offer
nothing": a missing claim, a restart, or an unreadable company home all
degrade to no bind.
- The seed shield narrows what the seeding pass may touch; homes outside
the credential store behave exactly as before, covered by the existing
seed tests.
- Builds on the sign-in credential-resolution fix (#13064), now merged;
this branch is rebased onto master and the diff contains only the
binding feature. Supersedes #13066, which GitHub auto-closed when its
stacked base branch was deleted on merge.

## Model Used

Claude (Anthropic) — Claude Fable 5 (`claude-fable-5`), extended
thinking, agentic tool use in Claude Code (terminal).

**Related PRs (searched; no duplicates found):** #12740, #12082, and
#9621 touch adjacent Codex credential sync paths; #8495 is the standing
hardening effort for probe auth seeding.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (no
standalone docs cover this flow; the behavioral contracts are documented
in-line at each changed site)
- [x] I have considered and documented any risks above
This commit is contained in:
Devin Foley authored and GitHub committed 2026-09-10 07:15:01 -07:00
1 parent e25a6b797f
commit bce976d60d
17 files changed
+47991 -22

No files matched your search

@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import { lstat, mkdtemp, rm } from "node:fs/promises";
import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -253,6 +253,7 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, Adap
promotionExpiresAt: null,
finishedAt: null,
failureReason: null,
resultClaim: null,
});
},
async recordLeaseAcquired(input) {
@@ -266,6 +267,7 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, Adap
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (input.resultClaim !== undefined) row.resultClaim = input.resultClaim;
if (!isActive(input.status))
activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType));
},
@@ -276,6 +278,7 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, Adap
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (input.resultClaim !== undefined) row.resultClaim = input.resultClaim;
if (!isActive(input.status))
activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType));
return true;
@@ -910,6 +913,95 @@ describe("adapter device-login routes", () => {
});
});
it("an authenticated login's owner read carries the account-binding claim with the identity verdict", async () => {
// The claim is non-secret — the opaque company secret id plus whether the
// company default home stayed on a DIFFERENT account. The client offers
// binding the agent's CODEX_HOME only when the identities differ, which
// is the one case where the login cannot take effect through the shared
// company home.
const instanceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-login-binding-"));
try {
vi.stubEnv("PAPERCLIP_HOME", instanceRoot);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
const companyHome = path.join(instanceRoot, "instances", "default", "companies", COMPANY_1, "codex-home");
await mkdir(companyHome, { recursive: true });
await writeFile(
path.join(companyHome, "auth.json"),
JSON.stringify({
tokens: { id_token: "t", access_token: "t", refresh_token: "t", account_id: "acct-other" },
}),
);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
"/tmp/paperclip-codex-account-home/acct-default",
);
const app = await createApp();
const started = await request(app).post(loginPath(COMPANY_1)).send({ environmentId: SANDBOX_ENV_1 });
expect(started.status, JSON.stringify(started.body)).toBe(201);
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${started.body.sessionId}`);
expect(status.body.status).toBe("authenticated");
expect(status.body.codexAccountBinding).toEqual({
secretId: "secret-1",
companyIdentityDiffers: true,
});
});
// The claim rides the terminal write, not process memory: a fresh app
// over the same durable store (a restart) still serves it, so the
// client's bind offer survives the exact window a restart used to
// silently lose.
const restarted = await createApp();
const afterRestart = await request(restarted).get(
`${loginPath(COMPANY_1)}/${started.body.sessionId}`,
);
expect(afterRestart.body.status).toBe("authenticated");
expect(afterRestart.body.codexAccountBinding).toEqual({
secretId: "secret-1",
companyIdentityDiffers: true,
});
} finally {
vi.unstubAllEnvs();
await rm(instanceRoot, { recursive: true, force: true });
}
});
it("a login for the account the company home already holds reports no identity mismatch", async () => {
// Same-account logins take effect through the company-home refresh; the
// claim still rides along with `companyIdentityDiffers: false`, and the
// client deliberately binds nothing.
const instanceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-login-binding-same-"));
try {
vi.stubEnv("PAPERCLIP_HOME", instanceRoot);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
const companyHome = path.join(instanceRoot, "instances", "default", "companies", COMPANY_1, "codex-home");
await mkdir(companyHome, { recursive: true });
await writeFile(
path.join(companyHome, "auth.json"),
JSON.stringify({
tokens: { id_token: "t", access_token: "t", refresh_token: "t", account_id: "acct-default" },
}),
);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
"/tmp/paperclip-codex-account-home/acct-default",
);
const app = await createApp();
const started = await request(app).post(loginPath(COMPANY_1)).send({ environmentId: SANDBOX_ENV_1 });
expect(started.status, JSON.stringify(started.body)).toBe(201);
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${started.body.sessionId}`);
expect(status.body.status).toBe("authenticated");
expect(status.body.codexAccountBinding).toEqual({
secretId: "secret-1",
companyIdentityDiffers: false,
});
});
} finally {
vi.unstubAllEnvs();
await rm(instanceRoot, { recursive: true, force: true });
}
});
it("fails closed when promotion loses the sole-owner claim", async () => {
// This is the expiry/reaper-race result from Decision H. It is a normal
// resolved adapter outcome, but it must never be accepted as authentication.
+68 -5
View File
@@ -2,7 +2,7 @@ import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaper
import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js";
import { Router, type NextFunction, type Request, type Response } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { activityLog, agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db";
@@ -89,7 +89,7 @@ import type {
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-local/server";
import type { AdapterAuthSignal, AdapterAuthSignalResponse } from "@paperclipai/shared";
import type { AdapterAuthSignal, AdapterAuthSignalResponse, CodexAccountBindingClaim } from "@paperclipai/shared";
import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
import { skillVersionSelectionMap } from "../services/runtime-skill-selections.js";
import { secretService } from "../services/secrets.js";
@@ -185,6 +185,8 @@ import {
import {
checkStagedCredentialReadiness,
promoteDeviceLoginCredential,
readSubscriptionAccountId,
resolveManagedCodexHomeDir,
withAccountHomeSecretMutationLock,
withCodexAccountHomePromotionLock,
} from "@paperclipai/adapter-codex-local/server";
@@ -728,9 +730,10 @@ export function agentRoutes(
// nothing outlives one login attempt.
const pendingAccountHomeSecretCommits = new Map<
string,
{ secretId: string; secretName: string; accountHomeDir: string }
{ secretId: string; secretName: string; accountHomeDir: string; companyIdentityDiffers: boolean }
>();
const adapterLoginService = createDeviceLoginService({
store: adapterLoginStore,
runtime: createProductionLoginSessionRuntime({
@@ -835,6 +838,26 @@ export function agentRoutes(
}
const secretName = `CODEX_HOME_${handle}`;
const accountHomeDir = result.accountHomeDir;
// Whether the company default home ended on a DIFFERENT account
// than this login. The promotion's own company-home write already
// ran (a seed or same-identity refresh landed this login there; a
// different account's claim was kept), so this read observes the
// post-promotion state. The flag rides to the owner status read,
// where the client offers binding the agent to this account — the
// only way the login can take effect while another account holds
// the company slot. Any read failure degrades to `false`: the
// client then simply offers nothing, never a wrong bind.
const companyIdentityDiffers = await (async () => {
try {
const companyAuthBytes = await readFile(
path.join(resolveManagedCodexHomeDir(process.env, context.companyId), "auth.json"),
);
const companyIdentity = readSubscriptionAccountId(companyAuthBytes);
return companyIdentity !== null && companyIdentity !== result.accountId;
} catch {
return false;
}
})();
const existingSecret = await secretsSvc.getByName(context.companyId, secretName);
if (existingSecret) {
// A same-name secret already exists. Confirm it still names this
@@ -856,6 +879,7 @@ export function agentRoutes(
secretId: existingSecret.id,
secretName,
accountHomeDir,
companyIdentityDiffers,
});
return;
}
@@ -879,6 +903,7 @@ export function agentRoutes(
secretId: createdSecret.id,
secretName,
accountHomeDir,
companyIdentityDiffers,
});
} catch (err) {
if (err instanceof HttpError && err.status === 409) {
@@ -903,6 +928,7 @@ export function agentRoutes(
secretId: winningSecret.id,
secretName,
accountHomeDir,
companyIdentityDiffers,
});
return;
}
@@ -980,7 +1006,13 @@ export function agentRoutes(
pending.secretName,
pending.accountHomeDir,
);
return commit();
// The claim rides the terminal write itself, so it is durable, it
// survives a restart, and it can never exist for a session that
// did not authenticate.
return commit({
secretId: pending.secretId,
companyIdentityDiffers: pending.companyIdentityDiffers,
});
});
},
},
@@ -1845,7 +1877,38 @@ export function agentRoutes(
if (!row || row.adapterType !== adapterType || row.startedByUserId !== requestingUserId) {
return null;
}
return adapterLoginService.readOwnerSession(publicSessionId, companyId, requestingUserId);
const session = await adapterLoginService.readOwnerSession(publicSessionId, companyId, requestingUserId);
if (!session) return session;
// Merge the non-secret account-binding claim onto an authenticated Codex
// owner read. The claim was written atomically with the terminal status
// (see runTerminalCommit), so it survives restarts and can never appear
// on a session that did not authenticate.
//
// Read the claim from a row fetched AFTER the status was observed, never
// from the earlier authorization read: the terminal write can land
// between the two, and a poll that sees `authenticated` paired with the
// older claim-less row would drop the claim forever (polling stops at
// the terminal status). The claim-and-status write is one atomic update,
// so any row read after `authenticated` was observed carries the claim.
// Shape-validate the durable value rather than trusting a cast: a
// malformed claim degrades to "offer nothing", never to a wrong bind.
if (row.adapterType === "codex_local" && session.status === "authenticated") {
const settled = await adapterLoginStore.getByPublicId(publicSessionId, companyId);
const raw = settled?.resultClaim;
if (
raw &&
typeof raw.secretId === "string" &&
raw.secretId.length > 0 &&
typeof raw.companyIdentityDiffers === "boolean"
) {
const claim: CodexAccountBindingClaim = {
secretId: raw.secretId,
companyIdentityDiffers: raw.companyIdentityDiffers,
};
return { ...session, codexAccountBinding: claim };
}
}
return session;
}
async function assertCanReadConfigurations(req: Request, companyId: string) {
+23 -2
View File
@@ -172,7 +172,10 @@ export interface CredentialPromotion {
* the service records a failed login instead of `authenticated` and never
* runs `commit`. A promotion that omits this runs `commit` directly.
*/
runTerminalCommit?<T>(commit: () => Promise<T>, context: CredentialPromotionContext): Promise<T>;
runTerminalCommit?<T>(
commit: (resultClaim?: Record<string, unknown>) => Promise<T>,
context: CredentialPromotionContext,
): Promise<T>;
}
/** The redacted lifecycle phases. Each phase carries no secret data. */
@@ -277,6 +280,9 @@ export interface AdapterAuthSessionRow {
promotionExpiresAt: Date | null;
finishedAt: Date | null;
failureReason: string | null;
/** The non-secret result claim of a terminal success, written atomically
* with the terminal status. Null for failures and claim-less flows. */
resultClaim: Record<string, unknown> | null;
}
export interface InsertAdapterAuthSessionInput {
@@ -303,6 +309,13 @@ export interface SetAdapterAuthSessionStatusInput {
* unchanged. A `Date` sets a live claim; `null` clears the claim.
*/
promotionExpiresAt?: Date | null;
/**
* The non-secret result claim to record with a terminal-success write.
* `undefined` leaves the column unchanged. Writing it in the SAME
* conditional write as the terminal status means a claim can never exist
* for a session that did not authenticate, and a restart never loses it.
*/
resultClaim?: Record<string, unknown> | null;
}
/**
@@ -487,6 +500,7 @@ function toRow(row: typeof adapterAuthSessions.$inferSelect): AdapterAuthSession
promotionExpiresAt: row.promotionExpiresAt ?? null,
finishedAt: row.finishedAt ?? null,
failureReason: row.failureReason ?? null,
resultClaim: (row.resultClaim as Record<string, unknown> | null) ?? null,
};
}
@@ -501,6 +515,7 @@ function buildStatusPatch(input: SetAdapterAuthSessionStatusInput) {
...(input.promotionExpiresAt !== undefined
? { promotionExpiresAt: input.promotionExpiresAt }
: {}),
...(input.resultClaim !== undefined ? { resultClaim: input.resultClaim } : {}),
};
}
@@ -1017,6 +1032,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
failureReason?: string | null;
finishedAt?: Date | null;
promotionExpiresAt?: Date | null;
resultClaim?: Record<string, unknown> | null;
},
): Promise<boolean> {
const run = statusTail.then(
@@ -1121,7 +1137,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
// Publish `authenticated` only when the final conditional write still finds
// the held claim. A lost write means the claim expired and the reaper
// reclaimed the row, so the service never publishes `authenticated`.
const commitAuthenticated = () =>
const commitAuthenticated = (resultClaim?: Record<string, unknown>) =>
terminate({
sessionId,
lease,
@@ -1130,6 +1146,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
expectedStatuses: ["promoting"],
conditionalTransition,
activity,
resultClaim: resultClaim ?? null,
});
const promotionContext: CredentialPromotionContext = {
sessionId,
@@ -1218,9 +1235,12 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
failureReason?: string | null;
finishedAt?: Date | null;
promotionExpiresAt?: Date | null;
resultClaim?: Record<string, unknown> | null;
},
) => Promise<boolean>;
activity: (phase: LoginSessionActivityPhase) => void;
/** The non-secret claim to record atomically with a terminal success. */
resultClaim?: Record<string, unknown> | null;
}): Promise<DeviceLoginOutcome> {
const { sessionId, lease, terminal, reason, expectedStatuses, conditionalTransition, activity } =
ctx;
@@ -1249,6 +1269,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
finishedAt,
failureReason: write.failureReason,
promotionExpiresAt: null,
...(ctx.resultClaim !== undefined ? { resultClaim: ctx.resultClaim } : {}),
});
if (!committed) {
// The reaper already terminated the row. Leave its terminal in place. The