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 2026-09-10 07:15:01 -07:00 committed by GitHub
parent e25a6b797f
commit bce976d60d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 47991 additions and 22 deletions

View File

@ -9,6 +9,7 @@ import {
clearCodexAuthCacheEntry,
ensureCodexAuthCacheEntryDir,
isCodexAuthCacheEnabled,
isCodexAuthCachePath,
resolveCodexAuthCacheDir,
resolveCodexAuthCacheEntryPath,
selectVendCredential,
@ -85,6 +86,25 @@ describe("codex auth cache store", () => {
);
});
it("isCodexAuthCachePath recognizes store entries for any company and rejects everything else", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
expect(isCodexAuthCachePath(env, cacheDir)).toBe(true);
expect(isCodexAuthCachePath(env, path.join(cacheDir, "acct-1"))).toBe(true);
expect(isCodexAuthCachePath(env, resolveCodexAuthCacheDir(env, "company-b"))).toBe(true);
// The company Codex home and a per-agent home are managed homes, not
// store entries — the seeding pass owns them.
expect(isCodexAuthCachePath(env, path.join(home, "instances", "default", "companies", "company-a", "codex-home"))).toBe(false);
expect(
isCodexAuthCachePath(env, path.join(home, "instances", "default", "companies", "company-a", "agents", "agent-1", "codex-home")),
).toBe(false);
// A sibling directory whose name merely STARTS with the store name
// stays out, as does anything outside the instance tree.
expect(isCodexAuthCachePath(env, path.join(home, "instances", "default", "companies", "company-a", "codex-auth-cache-extra"))).toBe(false);
expect(isCodexAuthCachePath(env, "/tmp/codex-auth-cache/acct-1")).toBe(false);
});
it("resolveCodexAuthCacheEntryPath keys the entry by a sanitized account_id and ends with auth.json", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);

View File

@ -112,6 +112,33 @@ export function resolveCodexAuthCacheDir(
return path.resolve(instanceRoot, "companies", safeCompanyId, CACHE_DIR_NAME);
}
/**
* True when `homePath` sits inside the per-identity credential store of ANY
* company: `<instanceRoot>/companies/<id>/codex-auth-cache/…` (or is a store
* root itself). These directories are identity-anchored slots owned by the
* device-login promotion, the vend, and the copy-back, each under its own
* lock. The managed-home seeding pass consults this to refuse to symlink,
* heal, or overwrite an entry's `auth.json`: an agent may bind `CODEX_HOME`
* to an entry (through the login's account-home secret), and seeding it like
* an ordinary managed home would silently swap the bound account's durable
* credential for the host login.
*/
export function isCodexAuthCachePath(
env: NodeJS.ProcessEnv,
homePath: string,
): boolean {
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
env,
});
const companiesRoot = path.resolve(instanceRoot, "companies");
const resolved = path.resolve(homePath);
if (!resolved.startsWith(companiesRoot + path.sep)) return false;
const segments = resolved.slice(companiesRoot.length + 1).split(path.sep);
return segments.length >= 2 && segments[1] === CACHE_DIR_NAME;
}
/**
* Resolves the entry path for one identity: `<cacheRoot>/<safeAccountId>/auth.json`.
* The `account_id` is validated first by {@link toAccountHandle} (a strict

View File

@ -587,6 +587,77 @@ describe("seedManagedCodexHome", () => {
}
});
it("never symlinks or heals a credential-store entry's auth.json, even for a fresher same-identity source", async () => {
// An agent can bind CODEX_HOME to a per-identity store entry through the
// login's account-home secret, and this seeding pass runs before every
// probe and execute. The entry's auth.json is the durable login the
// promotion/vend/copy-back own — a strictly-fresher same-identity shared
// source must NOT trigger the #5028 heal here, or the bound account is
// silently swapped for the host login.
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-store-"));
try {
const sharedCodexHome = path.join(root, "shared-codex-home");
const entryHome = path.join(
root, "paperclip-home", "instances", "default", "companies", "company-1", "codex-auth-cache", "acct-bound",
);
const env = {
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: path.join(root, "paperclip-home"),
PAPERCLIP_INSTANCE_ID: "default",
};
const stored = subscriptionAuth("acct-same", "stored", "2026-07-09T01:00:00Z");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(
path.join(sharedCodexHome, "auth.json"),
subscriptionAuth("acct-same", "host", "2026-07-09T02:00:00Z"),
"utf8",
);
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8");
await fs.mkdir(entryHome, { recursive: true });
await fs.writeFile(path.join(entryHome, "auth.json"), stored, "utf8");
await seedManagedCodexHome(entryHome, env, async () => {});
const kept = path.join(entryHome, "auth.json");
expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(kept, "utf8")).toBe(stored);
// The static shared config still copies in, so a bound run gets the
// same config a per-agent home gets.
expect(await fs.readFile(path.join(entryHome, "config.toml"), "utf8")).toBe('model = "gpt-5"\n');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("refuses an API-key rewrite of a credential-store entry's auth.json", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-store-apikey-"));
try {
const sharedCodexHome = path.join(root, "shared-codex-home");
const entryHome = path.join(
root, "paperclip-home", "instances", "default", "companies", "company-1", "codex-auth-cache", "acct-bound",
);
const env = {
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: path.join(root, "paperclip-home"),
PAPERCLIP_INSTANCE_ID: "default",
};
const stored = subscriptionAuth("acct-bound-id", "stored", "2026-07-09T01:00:00Z");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.mkdir(entryHome, { recursive: true });
await fs.writeFile(path.join(entryHome, "auth.json"), stored, "utf8");
const logs: string[] = [];
await seedManagedCodexHome(entryHome, env, async (_stream, line) => {
logs.push(line);
}, { apiKey: "sk-configured" });
expect(await fs.readFile(path.join(entryHome, "auth.json"), "utf8")).toBe(stored);
expect(logs.join("\n")).toContain("Refusing to write an API-key auth.json into credential-store entry");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("still removes an apikey-mode auth.json so the chatgpt-mode symlink is restored", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-apikey-residue-"));
try {

View File

@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
import { readSubscriptionAccountId } from "./codex-auth-cache.js";
import { isCodexAuthCachePath, readSubscriptionAccountId } from "./codex-auth-cache.js";
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
@ -614,6 +614,17 @@ export async function seedManagedCodexHome(
const sourceHome = resolveSharedCodexHomeDir(env);
const seedFromShared = path.resolve(sourceHome) !== path.resolve(targetHome);
// A per-identity credential-store entry is not a seedable home: its
// auth.json is the durable, identity-anchored result of a device login,
// maintained by the promotion, the vend, and the copy-back under their own
// locks. An agent can bind `CODEX_HOME` to an entry through the login's
// account-home secret, and this pass runs before every probe and execute —
// symlinking the entry to the shared source would silently swap the bound
// account for the host login, and an API-key rewrite would destroy the
// stored credential outright. The static shared config files still copy
// in below, so a bound run gets the same config a per-agent home gets.
const credentialStoreEntry = isCodexAuthCachePath(env, targetHome);
await fs.mkdir(targetHome, { recursive: true });
// A regular-file auth.json in the target home is one of two very different
@ -645,7 +656,7 @@ export async function seedManagedCodexHome(
// deleting it would silently sign the company out right after a successful
// device login.
let keepPromotedAuth = false;
if (!apiKey && seedFromShared) {
if (!apiKey && seedFromShared && !credentialStoreEntry) {
const authPath = path.join(targetHome, "auth.json");
const existing = await fs.lstat(authPath).catch(() => null);
if (existing && !existing.isSymbolicLink()) {
@ -713,8 +724,9 @@ export async function seedManagedCodexHome(
if (seedFromShared) {
for (const name of SYMLINKED_SHARED_FILES) {
// The kept promoted credential is authoritative for this home; the shared
// symlink would silently swap the account back to the host login.
if (name === "auth.json" && keepPromotedAuth) continue;
// symlink would silently swap the account back to the host login. A
// credential-store entry's auth.json is authoritative unconditionally.
if (name === "auth.json" && (keepPromotedAuth || credentialStoreEntry)) continue;
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureSymlink(path.join(targetHome, name), source);
@ -733,11 +745,22 @@ export async function seedManagedCodexHome(
}
if (apiKey) {
await writeApiKeyAuthJson(targetHome, apiKey);
await onLog(
"stdout",
`[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`,
);
if (credentialStoreEntry) {
// Refuse, loudly: overwriting a store entry's subscription credential
// with an API-key file would destroy the durable login the entry
// exists to hold. The operator combined an account binding with a
// configured OPENAI_API_KEY; the binding wins for this home.
await onLog(
"stdout",
`[paperclip] Refusing to write an API-key auth.json into credential-store entry "${targetHome}"; the bound account's stored login stays authoritative.\n`,
);
} else {
await writeApiKeyAuthJson(targetHome, apiKey);
await onLog(
"stdout",
`[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`,
);
}
}
}

View File

@ -13,6 +13,7 @@ export { getConfigSchema } from "./config-schema.js";
export {
reconcileManagedCodexHome,
isManagedCodexHomePath,
resolveManagedCodexHomeDir,
evaluateCodexCredentialReadiness,
type ReconcileManagedCodexHomeInput,
type ReconcileManagedCodexHomeResult,
@ -47,6 +48,8 @@ export {
withAccountHomeSecretMutationLock,
assertAccountHomeCacheDirStillValid,
resolveCodexAuthCacheDir,
isCodexAuthCachePath,
readSubscriptionAccountId,
} from "./codex-auth-cache.js";
export { parseCodexJsonl, isCodexHarnessCrash, isCodexProviderQuotaError, isCodexTransientUpstreamError, isCodexUnknownSessionError } from "./parse.js";
export {

View File

@ -0,0 +1 @@
ALTER TABLE "adapter_auth_sessions" ADD COLUMN "result_claim" jsonb;

File diff suppressed because it is too large Load Diff

View File

@ -1884,6 +1884,13 @@
"when": 1788973120697,
"tag": "0270_harsh_queen_noir",
"breakpoints": true
},
{
"idx": 271,
"version": "7",
"when": 1788999440971,
"tag": "0271_woozy_silver_surfer",
"breakpoints": true
}
]
}

View File

@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core";
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core";
import type { AdapterAuthSessionInternalStatus, AgentAdapterType } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
@ -81,6 +81,15 @@ export const adapterAuthSessions = pgTable(
finishedAt: timestamp("finished_at", { withTimezone: true }),
// The fixed, non-secret failure code. The public response reads it.
failureReason: text("failure_reason"),
// The non-secret result claim of a terminal success, written in the SAME
// conditional write that records the terminal status, so a claim can never
// exist for a session that did not authenticate and a restart never loses
// it. For a Codex device login this holds the account-binding claim: the
// opaque company secret id that names the login's account home, and
// whether the company default home stayed on a different account. Never a
// credential byte, never an account identifier. Null for every failure
// and for flows that produce no claim.
resultClaim: jsonb("result_claim").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},

View File

@ -938,6 +938,7 @@ export type {
AdapterAuthSessionResponse,
AdapterAuthSessionPrompt,
AdapterAuthSessionOwnerResponse,
CodexAccountBindingClaim,
StartAdapterAuthSessionRequest,
AdapterAuthPanelMode,
ClaudeSetupTokenSessionPrompt,

View File

@ -181,10 +181,24 @@ export interface AdapterAuthSessionPrompt {
code: string;
}
// The account-binding claim of a finished Codex login. `secretId` is the
// opaque company secret that names the signed-in account's own Codex home.
// `companyIdentityDiffers` is true when the company default home stayed on a
// DIFFERENT account — the promotion never displaces another account's claim —
// which is exactly when binding an agent to this secret is the only way the
// login can take effect for it. The claim carries no account identifier and
// no credential byte, and the server returns it only through an owner read of
// an `authenticated` session.
export interface CodexAccountBindingClaim {
secretId: string;
companyIdentityDiffers: boolean;
}
// The owner read of a login session. It adds the one-time prompt to the public
// response. Only the owner principal that started the session reads this shape.
export interface AdapterAuthSessionOwnerResponse extends AdapterAuthSessionResponse {
prompt: AdapterAuthSessionPrompt | null;
codexAccountBinding?: CodexAccountBindingClaim | null;
}
// The request that starts a login session for one adapter in one environment.

View File

@ -299,6 +299,7 @@ export type {
AdapterAuthSessionResponse,
AdapterAuthSessionPrompt,
AdapterAuthSessionOwnerResponse,
CodexAccountBindingClaim,
StartAdapterAuthSessionRequest,
AdapterAuthPanelMode,
ClaudeSetupTokenSessionPrompt,

View File

@ -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.

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) {

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

View File

@ -9,7 +9,7 @@ import type { Agent, Environment, UserSecretDefinition } from "@paperclipai/shar
import { getEnvironmentCapabilities } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import { AgentConfigForm, AdapterLoginPanel, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { AgentConfigForm, AdapterLoginPanel, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults";
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
import { ApiError } from "../api/client";
@ -1746,6 +1746,414 @@ describe("AgentConfigForm environment selector", () => {
expect(findButton(container, "Cancel")).toBeFalsy();
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
});
it("reports the account-binding claim upward exactly once when the session authenticates", async () => {
// The authenticated owner read can carry the non-secret Codex
// account-binding claim. The panel hands it to the caller once; the
// caller (the edit-mode form) decides whether a bind is warranted.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "bind-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/bind", code: "BIND-1" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "bind-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-bind-1", companyIdentityDiffers: true },
});
const onAccountBinding = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => onAccountBinding.mock.calls.length > 0);
expect(onAccountBinding).toHaveBeenCalledTimes(1);
expect(onAccountBinding).toHaveBeenCalledWith({
secretId: "secret-bind-1",
companyIdentityDiffers: true,
});
// The panel narrates the bind as its own state, separate from the login's
// success line — the bind is a second save that can still fail.
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
});
it("a failed bind save renders an explicit Retry instead of silently latching the claim", async () => {
// The status poll stops at the terminal state, so a rejected save behind
// a fire-and-forget latch would leave nothing to re-fire the bind. The
// panel keeps the claim and offers Retry.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "bind-session-2",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/bind", code: "BIND-2" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "bind-session-2",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-bind-2", companyIdentityDiffers: true },
});
const onAccountBinding = vi
.fn()
.mockRejectedValueOnce(new Error("save failed"))
.mockResolvedValueOnce(undefined);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => container.textContent?.includes("Could not bind this agent") ?? false);
const retry = findButton(container, "Retry");
expect(retry).toBeTruthy();
await act(async () => {
retry!.click();
});
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
expect(onAccountBinding).toHaveBeenCalledTimes(2);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-bind-2",
companyIdentityDiffers: true,
});
});
it("a second login in the same mounted panel runs its own bind", async () => {
// The terminal state re-enables Sign in without unmounting the panel. The
// bind latch is scoped to the session, so a second cross-account login
// binds again with ITS claim instead of being silently skipped.
mockAgentsApi.startAdapterAuthLogin
.mockResolvedValueOnce({
sessionId: "rebind-s1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/rebind", code: "REBIND-1" },
})
.mockResolvedValueOnce({
sessionId: "rebind-s2",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/rebind", code: "REBIND-2" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockImplementation(
async (_companyId: string, _adapterType: string, sid: string) => ({
sessionId: sid,
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: {
secretId: sid === "rebind-s2" ? "secret-second" : "secret-first",
companyIdentityDiffers: true,
},
}),
);
const onAccountBinding = vi.fn().mockResolvedValue(undefined);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => onAccountBinding.mock.calls.length === 1);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-first",
companyIdentityDiffers: true,
});
const signIn = findButton(container, "Sign in");
expect(signIn).toBeTruthy();
await act(async () => {
signIn!.click();
});
await flushUntil(() => onAccountBinding.mock.calls.length === 2);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-second",
companyIdentityDiffers: true,
});
});
it("a new Sign in stays disabled while the bind save is in flight", async () => {
// Two overlapping bind saves can land out of order — the older save
// finishing last would silently revert the agent to the previous account.
// The panel serializes at its only entry point: Sign in is disabled until
// the current bind settles.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "serialize-s1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/serialize", code: "SER-1" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "serialize-s1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-serialize", companyIdentityDiffers: true },
});
let releaseSave: (() => void) | null = null;
const onAccountBinding = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseSave = resolve;
}),
);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushUntil(() => container.textContent?.includes("Binding this agent") ?? false);
const signInWhileSaving = findButton(container, "Sign in");
expect(signInWhileSaving).toBeTruthy();
expect(signInWhileSaving!.disabled).toBe(true);
await act(async () => {
releaseSave?.();
});
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
const signInAfterSave = findButton(container, "Sign in");
expect(signInAfterSave!.disabled).toBe(false);
});
it("keeps edits made while the bind save is pending after the agent refresh", async () => {
// The bind save runs in the background while the form stays editable. The
// agent refresh that follows the save must subtract only what the save
// persisted — an edit made during "Binding this agent…" survives as
// pending dirty state instead of being wiped with the rest of the overlay.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-keep-edits", companyIdentityDiffers: true },
});
const releaseSaves: Array<() => void> = [];
const onSave = vi.fn(
(_patch: Record<string, unknown>) =>
new Promise<void>((resolve) => {
releaseSaves.push(resolve);
}),
);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
// The harness swaps the agent object the way the page does after a save
// refresh: same mounted form, new agent identity.
let refreshAgent: (agent: Agent) => void = () => {};
function RefreshHarness() {
const [agent, setAgent] = useState(() => makeAgent({ defaultEnvironmentId: "sandbox-1" }));
refreshAgent = setAgent;
return (
<AgentConfigForm
mode="edit"
agent={agent}
onSave={onSave}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<RefreshHarness />
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await runTest(container);
await startLogin(container);
await flushUntil(() => onSave.mock.calls.length > 0);
// Rename the agent while the bind save is still in flight.
const nameInput = container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]');
expect(nameInput).toBeTruthy();
setInputValue(nameInput!, "Renamed during bind");
await flushReact();
// An UNRELATED refresh lands while the save is still pending — a poll or
// another actor's save, so this agent does NOT carry the binding yet. It
// must not consume the persisted snapshot (the bind save's own refresh
// still needs it), and it must not subtract the snapshot from the overlay
// either: with the binding entry gone from both the overlay and the
// refreshed agent, an ordinary Save racing the binding refresh would
// replace the config without CODEX_HOME and undo the just-persisted bind.
await act(async () => {
refreshAgent(makeAgent({ defaultEnvironmentId: "sandbox-1" }));
});
await flushReact();
expect(
container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!.value,
).toBe("Renamed during bind");
// An ordinary Save in that window still carries the binding.
const persistedPatch = onSave.mock.calls[0]![0] as Record<string, unknown>;
const saveButton = findButton(container, "Save");
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.click();
});
await flushReact();
expect(onSave.mock.calls.length).toBeGreaterThan(1);
const racingPatch = onSave.mock.calls.at(-1)![0] as Record<string, unknown>;
const racingEnv = (racingPatch.adapterConfig as Record<string, unknown>).env as Record<
string,
unknown
>;
expect(racingEnv.CODEX_HOME).toEqual({
type: "secret_ref",
secretId: "secret-keep-edits",
version: "latest",
});
// Both saves land, and the page refreshes the agent with the persisted
// binding — the same adapter config the bind save sent.
await act(async () => {
for (const release of releaseSaves) release();
});
await flushReact();
await act(async () => {
refreshAgent(
makeAgent({
defaultEnvironmentId: "sandbox-1",
adapterConfig: persistedPatch.adapterConfig as Record<string, unknown>,
}),
);
});
await flushReact();
// The rename survives the refresh instead of reverting to the refreshed
// agent's name.
expect(
container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!.value,
).toBe("Renamed during bind");
});
it("resumes an active login session on mount, adopting its session id and prompt", async () => {
// A page reload loses every piece of local state, so the panel must read
// the caller's active session and adopt it instead of starting a new one.
@ -3392,3 +3800,62 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
expect(adapterFields?.getAttribute("data-hide-instructions-file")).toBe("true");
});
});
describe("subtractPersistedOverlay", () => {
const overlayWith = (adapterConfig: Record<string, unknown>) => ({
identity: {},
adapterConfig,
heartbeat: {},
debug: {},
runtime: {},
});
it("drops an entry whose value is structurally equal even with a new reference", () => {
// An edit-then-restore rebuilds the env object, so a reference compare
// would keep it falsely dirty after the refresh subtracts the snapshot —
// a false "Unsaved changes" state and a redundant full-config save.
const persisted = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
const current = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({});
});
it("keeps an entry the user changed after the snapshot", () => {
const persisted = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
const current = overlayWith({
env: {
CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" },
EXTRA: { type: "plain", value: "added-during-save" },
},
});
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({
env: {
CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" },
EXTRA: { type: "plain", value: "added-during-save" },
},
});
});
it("keeps an entry the snapshot never carried", () => {
const persisted = overlayWith({});
const current = overlayWith({ model: "gpt-5.5" });
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({
model: "gpt-5.5",
});
});
it("compares arrays structurally", () => {
const persisted = overlayWith({ args: ["--flag", "value"] });
const equal = overlayWith({ args: ["--flag", "value"] });
const changed = overlayWith({ args: ["--flag", "other"] });
expect(subtractPersistedOverlay(equal, persisted).adapterConfig).toEqual({});
expect(subtractPersistedOverlay(changed, persisted).adapterConfig).toEqual({
args: ["--flag", "other"],
});
});
});

View File

@ -9,6 +9,7 @@ import type {
Agent,
AdapterAuthSessionPrompt,
AdapterAuthSessionStatus,
CodexAccountBindingClaim,
AdapterEnvironmentTestResult,
CompanySecret,
EnvBinding,
@ -190,6 +191,64 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean {
);
}
/**
* Structural equality for overlay entry values. Overlay values are
* JSON-shaped (scalars, env maps, argument arrays), so a reference compare
* alone would keep an edit-then-restore of a structured value falsely dirty
* after a refresh subtracts the persisted snapshot.
*/
export function overlayValuesEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((item, index) => overlayValuesEqual(item, b[index]));
}
if (
typeof a === "object" && a !== null && !Array.isArray(a) &&
typeof b === "object" && b !== null && !Array.isArray(b)
) {
const aEntries = Object.entries(a as Record<string, unknown>);
const bRecord = b as Record<string, unknown>;
return (
aEntries.length === Object.keys(bRecord).length &&
aEntries.every(([key, value]) => key in bRecord && overlayValuesEqual(value, bRecord[key]))
);
}
return false;
}
/**
* Remove from `current` every entry `persisted` carried with a structurally
* equal value, keeping entries the user added or changed after `persisted`
* was snapshotted. The refresh that follows a background save consumes this
* so edits made while that save was in flight survive as pending dirty state
* instead of being wiped with the rest of the overlay.
*/
export function subtractPersistedOverlay(
current: AgentConfigOverlay,
persisted: AgentConfigOverlay,
): AgentConfigOverlay {
const subtractGroup = (
currentGroup: Record<string, unknown>,
persistedGroup: Record<string, unknown>,
): Record<string, unknown> =>
Object.fromEntries(
Object.entries(currentGroup).filter(
([field, value]) =>
!(field in persistedGroup) || !overlayValuesEqual(value, persistedGroup[field]),
),
);
return {
identity: subtractGroup(current.identity, persisted.identity),
...(current.adapterType !== undefined && current.adapterType !== persisted.adapterType
? { adapterType: current.adapterType }
: {}),
adapterConfig: subtractGroup(current.adapterConfig, persisted.adapterConfig),
heartbeat: subtractGroup(current.heartbeat, persisted.heartbeat),
debug: subtractGroup(current.debug, persisted.debug),
runtime: subtractGroup(current.runtime, persisted.runtime),
};
}
/* ---- Shared input class ---- */
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@ -409,12 +468,34 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false);
const [environmentEditorKey, setEnvironmentEditorKey] = useState(0);
const agentRef = useRef<Agent | null>(null);
// The overlay snapshot a background account-binding save persisted. The form
// stays editable while that save is in flight, so the agent refresh that
// follows it must not wipe edits made during the save. The refresh subtracts
// only what the save persisted; a user-initiated Save leaves the snapshot
// null and keeps the full wipe. An UNRELATED refresh can land while the save
// is still in flight — that refresh does not carry the persisted binding
// yet, so it must neither consume the snapshot nor subtract it: subtracting
// would drop the binding entry from the overlay while `props.agent` also
// lacks it, and an ordinary Save racing the binding refresh would then
// replace the config without the binding and undo the just-persisted bind.
// The overlay stays untouched until the save settles; the refresh after
// settlement consumes the snapshot and subtracts it.
const backgroundSaveOverlayRef = useRef<AgentConfigOverlay | null>(null);
const backgroundSaveInFlightRef = useRef(false);
// Clear overlay when agent data refreshes (after save)
useEffect(() => {
if (!isCreate) {
if (agentRef.current !== null && props.agent !== agentRef.current) {
setOverlay({ ...emptyOverlay });
if (
agentRef.current !== null &&
props.agent !== agentRef.current &&
!backgroundSaveInFlightRef.current
) {
const persisted = backgroundSaveOverlayRef.current;
backgroundSaveOverlayRef.current = null;
setOverlay((prev) =>
persisted ? subtractPersistedOverlay(prev, persisted) : { ...emptyOverlay },
);
}
agentRef.current = props.agent;
}
@ -600,6 +681,51 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
invalidateUserSecretDefinitions();
};
// Edit mode: a Codex login that signed in to a DIFFERENT account than the
// company default cannot take effect through the shared company home — the
// promotion never displaces another account's claim there. Bind this
// agent's CODEX_HOME to the login's account-home secret and persist at
// once, the same one-step shape as the Claude stored-login bind above.
// Same-account logins skip the bind on purpose: the company-home refresh
// already carried them, and an unbound agent keeps following the company
// default across later credential rotations. No claim flag is needed —
// the secret already exists company-scoped, so this is an ordinary
// secret-reference binding through the normal agent-update patch.
const handleCodexAccountBindingEdit = async (claim: CodexAccountBindingClaim) => {
if (isCreate || !claim.companyIdentityDiffers) return;
const flushedEnv = flushEnvironmentDraft();
const baseEnv =
flushedEnv ??
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
const nextEnv: Record<string, EnvBinding> = {
...baseEnv,
CODEX_HOME: { type: "secret_ref", secretId: claim.secretId, version: "latest" },
};
const nextOverlay: AgentConfigOverlay = {
...overlay,
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
};
setOverlay(nextOverlay);
// This save runs in the background while the form stays editable. Record
// exactly what it persists so the agent refresh it triggers keeps edits
// made during the save (see the refresh effect) instead of wiping them
// with the persisted entries. The in-flight flag protects the snapshot
// from an unrelated refresh landing mid-save. A failed save never
// refreshes the agent with the binding, so clear the snapshot there — a
// later unrelated refresh then wipes normally.
backgroundSaveOverlayRef.current = nextOverlay;
backgroundSaveInFlightRef.current = true;
try {
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
} catch (err) {
backgroundSaveOverlayRef.current = null;
throw err;
} finally {
backgroundSaveInFlightRef.current = false;
}
invalidateUserSecretDefinitions();
};
// Create mode: bind the fixed reference to an existing stored login with no new
// login round trip. Add the fixed binding and set the apply-existing flag. The
// create request sends the flag; the server binds the token only for a user
@ -1537,6 +1663,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onApplyStored={
isCreate ? handleApplyStoredClaudeLogin : handleApplyStoredClaudeLoginEdit
}
onAccountBinding={isCreate ? undefined : handleCodexAccountBindingEdit}
/>
)}
@ -2117,6 +2244,15 @@ export type AdapterLoginDescriptor = {
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
onStored?: (storedSessionId: string) => void;
onApplyStored?: () => void;
// Applies the non-secret Codex account-binding claim from an authenticated
// owner read: the company secret that names the signed-in account's own
// home. The panel calls this only when the company default home stayed on a
// DIFFERENT account — the one case where the login cannot take effect
// through the shared company home — and it AWAITS the handler, rendering
// saving/bound/failed states with an explicit Retry on failure, so a
// rejected save is never silently swallowed. The claim never carries a
// token byte or an account identifier.
onAccountBinding?: (claim: CodexAccountBindingClaim) => void | Promise<void>;
// Start the login on mount instead of waiting for a press. The connect step's
// footer button is the press — by the time the panel is rendered there, the
// customer has already asked for this.
@ -2178,6 +2314,7 @@ function DisplayedCodeLoginPanel({
environmentId,
autoStart,
onConnected,
onAccountBinding,
chrome = "panel",
onPromptReady,
}: AdapterLoginPanelProps) {
@ -2187,6 +2324,12 @@ function DisplayedCodeLoginPanel({
// it so a later poll that returns a null prompt does not hide the code and the
// URL.
const [latchedPrompt, setLatchedPrompt] = useState<AdapterAuthSessionPrompt | null>(null);
// The cross-account bind's own lifecycle (see the binding block below).
// Declared with the panel's state because `startDisabled` reads it: a
// saving bind blocks a new Sign in.
const [accountBindState, setAccountBindState] = useState<"idle" | "saving" | "bound" | "failed">(
"idle",
);
// True for the session currently held in `sessionId` when it came from the
// owner-scoped resume read rather than a fresh `startLogin`. It marks the
@ -2202,6 +2345,9 @@ function DisplayedCodeLoginPanel({
resumedRef.current = false;
setStartError(null);
setLatchedPrompt(null);
// A fresh login is a fresh bind decision: clear the previous session's
// bind narration so its outcome cannot masquerade as this session's.
setAccountBindState("idle");
setSessionId(session.sessionId);
},
onError: (error) => {
@ -2284,7 +2430,13 @@ function DisplayedCodeLoginPanel({
const prompt = latchedPrompt;
const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false;
const isActive = Boolean(sessionId) && !isTerminal;
const startDisabled = startLogin.isPending || isActive;
// A saving bind also blocks a new Sign in: the bind is an agent-update save,
// and a second login started while it is in flight could finish its own
// save first — the older save would then land last and silently revert the
// agent to the previous account while the panel reports the newer bind.
// Serializing at the only entry point is the whole fix; the panel has no
// other way to start a login mid-save.
const startDisabled = startLogin.isPending || isActive || accountBindState === "saving";
// Adopt the caller's active session once, on mount. This is what makes a
// page reload keep the session: with no local state at all, the panel would
@ -2372,6 +2524,41 @@ function DisplayedCodeLoginPanel({
onConnectedRef.current?.();
}, [status]);
// Drive the account-binding hand-off as a visible state machine, not a
// fire-and-forget latch. The bind saves the agent, and the status poll
// stops at the terminal state — so a rejected save behind a silently
// latched claim would leave nothing to re-fire it and no way to retry.
// A cross-account claim moves saving → bound | failed, and failed renders
// an explicit Retry that re-runs the same handler with the same claim.
// Latched per SESSION, not per mount: the terminal state re-enables Sign in
// inside the same mounted panel, and a second cross-account login must run
// its own bind — a mount-scoped boolean would silently skip it and leave
// the agent on the previous account.
const accountBindSessionRef = useRef<string | null>(null);
const onAccountBindingRef = useRef(onAccountBinding);
onAccountBindingRef.current = onAccountBinding;
const accountBinding = statusQuery.data?.codexAccountBinding ?? null;
const runAccountBinding = useCallback(async (claim: CodexAccountBindingClaim) => {
const handler = onAccountBindingRef.current;
if (!handler) return;
setAccountBindState("saving");
try {
await handler(claim);
setAccountBindState("bound");
} catch {
setAccountBindState("failed");
}
}, []);
useEffect(() => {
if (status !== "authenticated" || !sessionId) return;
if (accountBindSessionRef.current === sessionId) return;
if (!accountBinding || !accountBinding.companyIdentityDiffers || !onAccountBindingRef.current) {
return;
}
accountBindSessionRef.current = sessionId;
void runAccountBinding(accountBinding);
}, [status, sessionId, accountBinding, runAccountBinding]);
// Report the prompt's URL upward, the way the submitted-browser-code panel
// does. The caller's loading beat ends when this arrives, so without it the
// onboarding step waits on a card that has already opened: the code is on
@ -2534,6 +2721,41 @@ function DisplayedCodeLoginPanel({
{isTerminal && status && (
<AdapterLoginTerminalState status={status} message={session?.failure?.message ?? null} />
)}
{/* The cross-account bind's own state, below the login's success line.
The bind is a second, separate save showing it as part of the
login would report success for a write that can still fail. */}
{status === "authenticated" && accountBindState === "saving" && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="size-3 animate-spin shrink-0" />
<span>Binding this agent to the signed-in account...</span>
</div>
)}
{status === "authenticated" && accountBindState === "bound" && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
<Check className="size-3 shrink-0" />
<span>Agent bound to the signed-in account.</span>
</div>
)}
{status === "authenticated" && accountBindState === "failed" && (
<div className="flex items-center gap-2 text-(length:--text-micro)">
<TriangleAlert className="size-3 shrink-0 text-destructive" />
<span className="text-destructive">
Could not bind this agent to the signed-in account.
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
if (accountBinding) void runAccountBinding(accountBinding);
}}
>
Retry
</Button>
</div>
)}
</div>
</div>
);