Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity

* origin/master:
  fix(onboarding): preserve draft through company refetch (#12735)
  feat(codex-local): give each Codex account its own home and path secret (#12709)
  feat(claude-local): add Claude Fable 5.1 support (#12730)
This commit is contained in:
Dotta committed 2026-09-02 19:32:45 -05:00
commit 2ffcc0104a
33 files changed
+3994 -728

No files matched your search

@@ -66,6 +66,7 @@ describe("adapter model listing", () => {
// Newer flagship models are offered, but Opus 4.8 stays the default (first) option.
expect(models[0]?.id).toBe("claude-opus-4-8");
expect(models.some((model) => model.id === "claude-sonnet-5")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
expect(models.some((model) => model.id === "claude-mythos-5")).toBe(true);
// Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable.
@@ -130,6 +131,36 @@ describe("adapter model listing", () => {
expect(models).toEqual(claudeFallbackModels);
});
it("does not duplicate claude-fable-5-1 when discovery returns the identical ID", async () => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "claude-fable-5-1", display_name: "Claude Fable 5.1" }],
}),
} as Response);
const models = await listAdapterModels("claude_local");
expect(models.filter((model) => model.id === "claude-fable-5-1")).toHaveLength(1);
// Curated fallbacks discovery did not return are still merged in.
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
});
it("exposes the Bedrock-native Fable 5.1 ID (never the direct ID) in Bedrock mode", async () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
const fetchSpy = vi.spyOn(globalThis, "fetch");
const models = await listAdapterModels("claude_local");
// The Bedrock default (first entry) is unchanged.
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8-v1");
expect(models.some((model) => model.id === "us.anthropic.claude-fable-5-1")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("loads codex models dynamically and merges fallback options", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
@@ -1,6 +1,10 @@
import express from "express";
import request from "supertest";
import { lstat, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import { AdapterAuthSessionConflictError } from "../services/device-login-service.js";
import type {
AdapterAuthSessionRow,
@@ -68,6 +72,26 @@ const mockSecretService = vi.hoisted(() => ({
secretKeys: new Set<string>(),
manifest: [],
})),
// The account-home secret lookup and creation. Absent by default, so a
// successful promotion creates it; a test overrides either to drive the
// idempotent-repeat-login and secret-creation-failure paths.
getByName: vi.fn(async (_companyId: string, _name: string) => null as Record<string, unknown> | null),
create: vi.fn(async (_companyId: string, _input: Record<string, unknown>) => ({ id: "secret-1" })),
// The company-wide secret listing the cleanup-safety scan reads. Empty by
// default, so a cleanup that finds no other claimant removes the
// directory; a test overrides it with a differently named secret to prove
// the scan finds a claimant a name-only check would miss.
list: vi.fn(
async (_companyId: string) => [] as Array<{ id: string; name: string; provider: string }>,
),
// The pre-existing-secret value check. Defaults to a value that never
// matches an account home, so a test that leaves this unset and still
// reaches an existing-secret branch fails loud instead of passing by
// accident; a test overrides it to the account home under test.
resolveSecretValueForDeviceLoginCheck: vi.fn(
async (_companyId: string, _secretId: string, _context: { configPath: string }) =>
"/unset-mock-value" as string,
),
}));
const mockEnvironmentService = vi.hoisted(() => ({
@@ -341,10 +365,20 @@ const loginPath = (companyId: string, type = "codex_local") =>
`/api/companies/${companyId}/adapters/${type}/login-sessions`;
describe("adapter device-login routes", () => {
// Real temp directories a test plants as an account home, cleaned up in
// `afterEach` whether or not the route removed them itself.
const accountHomeTestDirs: string[] = [];
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockDeviceLoginPromotion.mockResolvedValue("promoted");
mockDeviceLoginPromotion.mockResolvedValue({
outcome: "promoted",
accountId: "acct-default",
accountHomeDir: "/tmp/paperclip-codex-account-home/acct-default",
});
mockSecretService.getByName.mockResolvedValue(null);
mockSecretService.create.mockResolvedValue({ id: "secret-1" });
harness.store = createMemoryStore();
harness.runtime = createFakeRuntime();
harness.acquisitions = [];
@@ -376,9 +410,14 @@ describe("adapter device-login routes", () => {
);
});
afterEach(() => {
afterEach(async () => {
// Release the gate, so every in-flight login run ends and clears its timer.
harness.releaseGate();
while (accountHomeTestDirs.length > 0) {
const dir = accountHomeTestDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("requires a board actor and rejects an agent-token start", async () => {
@@ -726,6 +765,12 @@ describe("adapter device-login routes", () => {
});
it("lets a second owner start an active login in the same company", async () => {
// Both logins bind the default account home, and the login service
// reconfirms the bound secret's value a second time right before it
// commits the terminal `authenticated` state.
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
"/tmp/paperclip-codex-account-home/acct-default",
);
const app = await createApp();
const first = await request(app)
@@ -744,9 +789,31 @@ describe("adapter device-login routes", () => {
expect(second.body.sessionId).not.toBe(first.body.sessionId);
// Each owner's start acquires its own lease.
expect(harness.acquisitions).toHaveLength(2);
// Drain both sessions to a terminal status before the test ends. Neither
// session's promotion has run yet (both still wait on the gate), so an
// undrained session keeps running after this test returns and can consume
// a later test's own mocked promotion result. A status read is owner
// scoped, so read each session back as the owner that started it.
harness.releaseGate();
await vi.waitFor(async () => {
currentActor = boardActor(OWNER_A);
const firstStatus = await request(app).get(`${loginPath(COMPANY_1)}/${first.body.sessionId}`);
expect(firstStatus.body.status).toBe("authenticated");
});
await vi.waitFor(async () => {
currentActor = boardActor(OWNER_B);
const secondStatus = await request(app).get(`${loginPath(COMPANY_1)}/${second.body.sessionId}`);
expect(secondStatus.body.status).toBe("authenticated");
});
});
it("returns 409 for a second active start in a different environment", async () => {
// The login binds the default account home, and the login service
// reconfirms the bound secret's value a second time right before it
// commits the terminal `authenticated` state.
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
"/tmp/paperclip-codex-account-home/acct-default",
);
const app = await createApp();
const first = await request(app)
@@ -761,12 +828,24 @@ describe("adapter device-login routes", () => {
.send({ environmentId: SANDBOX_ENV_2 });
expect(second.status, JSON.stringify(second.body)).toBe(409);
expect(harness.acquisitions).toHaveLength(1);
// Drain the first session to a terminal status before the test ends. An
// undrained session keeps running after this test returns and can consume
// a later test's own mocked promotion result.
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${first.body.sessionId}`);
expect(status.body.status).toBe("authenticated");
});
});
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.
mockDeviceLoginPromotion.mockResolvedValueOnce("not_sole_owner");
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "not_sole_owner",
accountId: null,
accountHomeDir: null,
});
const app = await createApp();
const start = await request(app)
@@ -790,12 +869,176 @@ describe("adapter device-login routes", () => {
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
});
it("fails closed when the login is a different account than the company home", async () => {
// The promotion keeps the occupied company home and installs nothing durable
// for a different-identity login. The identity-anchored vend can never select
// this login, so a later run keeps the existing account. The route must fail
// the session instead of a report of `authenticated`.
mockDeviceLoginPromotion.mockResolvedValueOnce("kept_foreign_identity");
it("a successful Codex login creates the company secret naming the account home", async () => {
// A second, different account no longer fails behind an occupied company
// home: each account gets its own home, named by a company secret.
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
"/tmp/paperclip-codex-account-home/acct-second",
);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-second",
accountHomeDir: "/tmp/paperclip-codex-account-home/acct-second",
});
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("authenticated");
});
expect(mockSecretService.getByName).toHaveBeenCalledWith(COMPANY_1, "CODEX_HOME_acct-second");
expect(mockSecretService.create).toHaveBeenCalledWith(
COMPANY_1,
expect.objectContaining({
name: "CODEX_HOME_acct-second",
value: "/tmp/paperclip-codex-account-home/acct-second",
}),
expect.anything(),
);
});
it("a repeat login for the same account creates no second secret", async () => {
// The secret already names this account's home, so the route reads it,
// confirms the value still matches, and creates nothing new: a repeat
// login is idempotent.
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "kept",
accountId: "acct-default",
accountHomeDir,
});
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("authenticated");
});
expect(mockSecretService.create).not.toHaveBeenCalled();
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledWith(
COMPANY_1,
"existing-secret",
expect.objectContaining({ configPath: "secrets.CODEX_HOME_acct-default" }),
);
});
it("holds the account-home secret mutation lock while checking a pre-existing secret's value", async () => {
// A `local_encrypted` secret rotate takes `withAccountHomeSecretMutationLock`
// for the whole of its write. The pre-existing-secret check must run
// inside the same lock and return right after: otherwise a rotate could
// commit a new value in the gap between the check and the login
// reporting success, and the login would report success for a value
// that no longer names the account's own home. This proves the check
// waits for a lock held elsewhere, and only reads the secret's value
// once that lock frees.
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "kept",
accountId: "acct-default",
accountHomeDir,
});
let releaseHeldLock!: () => void;
const heldLockGate = new Promise<void>((resolve) => {
releaseHeldLock = resolve;
});
const lockHolder = withAccountHomeSecretMutationLock(undefined, COMPANY_1, () => heldLockGate);
// Give the held lock a chance to actually acquire before the login
// starts racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
// The login's check must stay blocked behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).not.toHaveBeenCalled();
releaseHeldLock();
await lockHolder;
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("authenticated");
});
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalled();
});
it("reconfirms the account-home secret a second time immediately before the terminal authenticated commit", async () => {
// The early check inside `promote` releases its lock well before the
// login service records the terminal `authenticated` state, so it alone
// cannot prove the value still matches at that later point. The service
// must run the same check again, right before that commit, under a
// fresh lock acquisition: this test proves the second check runs, not
// only the first.
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "kept",
accountId: "acct-default",
accountHomeDir,
});
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("authenticated");
});
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledTimes(2);
});
it("fails closed when the account-home secret is rotated after validation but before the login service commits its terminal state", async () => {
// A board user can rotate the matching `CODEX_HOME` secret after
// `promote`'s own check passes and before the login service commits its
// terminal state, because `promote`'s lock is fully released by then.
// The service must catch this with a second check it runs right before
// that commit, or it would report `authenticated` for a value that no
// longer names the account's own home.
const accountHomeDir = "/tmp/paperclip-codex-account-home/acct-default";
mockSecretService.getByName.mockResolvedValue({ id: "existing-secret" });
mockSecretService.resolveSecretValueForDeviceLoginCheck
// `promote`'s own early check: still matches.
.mockResolvedValueOnce(accountHomeDir)
// The rotation lands in the gap. The final check, immediately before
// the terminal commit, now reads the rotated value.
.mockResolvedValueOnce("/rotated/away/from/the/account/home");
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "kept",
accountId: "acct-default",
accountHomeDir,
});
const app = await createApp();
const start = await request(app)
@@ -811,12 +1054,471 @@ describe("adapter device-login routes", () => {
expect(status.body.failure?.reason).toBe("promotion_failed");
});
const row = await (harness.store as ReturnType<typeof createMemoryStore>).getByPublicId(
sessionId,
COMPANY_1,
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledTimes(2);
});
it("fails closed when a pre-existing secret names a different account home", async () => {
// A stale or hand-entered secret at this name must not let the login
// report success while a bound agent reads the wrong credential home.
mockSecretService.getByName.mockResolvedValue({ id: "stale-secret" });
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
"/some/other/path/left-over-from-before",
);
expect(row?.status).toBe("failed");
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "kept",
accountId: "acct-stale",
accountHomeDir: "/tmp/paperclip-codex-account-home/acct-stale",
});
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
expect(mockSecretService.create).not.toHaveBeenCalled();
});
it("fails closed and removes the account home when secret creation fails", async () => {
// This login created the account home, so a secret-creation failure must
// fail the login and remove the directory it just created, so the
// operation stays atomic.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-broken-secret",
accountHomeDir,
accountHomeCreated: true,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
await expect(lstat(accountHomeDir)).rejects.toThrow();
});
it("a secret creation failure keeps an account home that already existed", async () => {
// The account home directory pre-dates this login (a user deleted the
// secret but kept the directory, or an earlier login already wrote it).
// A secret-creation failure must still fail the login, but it must never
// remove a directory this login did not create.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-existing-home",
accountHomeDir,
accountHomeCreated: false,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("a secret name conflict succeeds and keeps the account home", async () => {
// Two logins for one account can both read no secret and then race the
// create call. The loser gets a 409 conflict, which proves the secret
// already exists. A repeat login for the same account must be idempotent,
// so the loser must confirm the winning secret's value, report a
// successful login, and must never remove the account home the winner
// just wrote.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-conflict",
accountHomeDir,
accountHomeCreated: true,
});
// `vi.resetModules()` in `beforeEach` gives the route module a fresh copy
// of `../errors.js`. Import the conflict helper from that same fresh copy,
// so the route's `err instanceof HttpError` check sees a matching class.
const { conflict: freshConflict } =
await vi.importActual<typeof import("../errors.js")>("../errors.js");
mockSecretService.create.mockRejectedValueOnce(
freshConflict("a secret with this name already exists"),
);
// The first read (before `create`) finds no secret; the second read
// (after the conflict) finds the winner's secret.
mockSecretService.getByName
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "winning-secret" });
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(accountHomeDir);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("authenticated");
});
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("fails closed when the secret that won the create race names a different account home", async () => {
// The 409 conflict proves some secret now holds this name, but that
// secret could belong to unrelated stale state, not the winner of a
// genuine same-account race. The login must not report success without
// checking the winning secret's value.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-conflict-mismatch",
accountHomeDir,
accountHomeCreated: true,
});
const { conflict: freshConflict } =
await vi.importActual<typeof import("../errors.js")>("../errors.js");
mockSecretService.create.mockRejectedValueOnce(
freshConflict("a secret with this name already exists"),
);
mockSecretService.getByName
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "winning-secret" });
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValue(
"/some/other/path/left-over-from-before",
);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
// The mismatch is a validation failure on the winner's secret, not this
// call's own directory, so this call must still never delete it.
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("does not remove an account home a concurrent login already claimed", async () => {
// This call created the account home directory (`accountHomeCreated:
// true`), but a concurrent login for the same account can still create
// the company secret before this call's own, unrelated secret-creation
// attempt fails. The directory now belongs to that other login's secret,
// so the cleanup scan must find it by value and keep the directory.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-concurrent-claim",
accountHomeDir,
accountHomeCreated: true,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
// The first read (before `create`) finds no secret.
mockSecretService.getByName.mockResolvedValueOnce(null);
// The cleanup scan lists every company secret and resolves each one's
// value. The concurrent login's secret carries this call's own generated
// name, so this proves the scan still catches a same-name claimant.
mockSecretService.list.mockResolvedValueOnce([
{ id: "concurrent-secret", name: "CODEX_HOME_acct-concurrent-claim", provider: "local_encrypted" },
]);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValueOnce(accountHomeDir);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("does not remove an account home a differently named secret still references", async () => {
// A user can bind a hand-named secret to an account home directly, with
// no relation to the generated `CODEX_HOME_<handle>` name. A cleanup that
// checks only the generated name would miss this secret and delete a
// directory it still needs; the scan must catch it by value instead.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-aliased",
accountHomeDir,
accountHomeCreated: true,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
mockSecretService.getByName.mockResolvedValueOnce(null);
mockSecretService.list.mockResolvedValueOnce([
// An unrelated secret that resolves to a different value. The scan
// must not stop at the first entry and must not treat every secret as
// a match.
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
{ id: "hand-named-secret", name: "MY_CODEX_HOME", provider: "local_encrypted" },
]);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockImplementation(
async (_companyId: string, secretId: string, _context: { configPath: string }) =>
secretId === "hand-named-secret" ? accountHomeDir : "/some/unrelated/path",
);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("does not remove an account home an AWS Secrets Manager-backed secret still references", async () => {
// A secret's value is a plain string regardless of its provider, so a
// hand-named secret bound to this account home through AWS Secrets
// Manager is just as real a claimant as a `local_encrypted` one. The
// scan must resolve it too, not skip it for its provider.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-aws-claim",
accountHomeDir,
accountHomeCreated: true,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
mockSecretService.getByName.mockResolvedValueOnce(null);
mockSecretService.list.mockResolvedValueOnce([
{ id: "aws-secret", name: "MY_CODEX_HOME", provider: "aws_secrets_manager" },
]);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValueOnce(accountHomeDir);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
expect(mockSecretService.resolveSecretValueForDeviceLoginCheck).toHaveBeenCalledWith(
COMPANY_1,
"aws-secret",
expect.objectContaining({ configPath: "secrets.MY_CODEX_HOME" }),
);
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("removes the account home when the cleanup scan finds no claimant", async () => {
// No secret, under any name, resolves to this directory, so the cleanup
// must still remove it — the broader scan must not make cleanup any less
// eager than the old name-only check when nothing claims the directory.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-unclaimed",
accountHomeDir,
accountHomeCreated: true,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
mockSecretService.getByName.mockResolvedValueOnce(null);
mockSecretService.list.mockResolvedValueOnce([
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
]);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockResolvedValueOnce("/some/unrelated/path");
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
await expect(lstat(accountHomeDir)).rejects.toThrow();
});
it("preserves the account home when the cleanup scan cannot resolve a secret's value", async () => {
// A resolve failure on one secret is not proof that secret names a
// different directory: the failure can hit the exact secret that would
// have matched. The cleanup must keep the directory rather than treat an
// unresolved secret as a non-match, even when every secret that DID
// resolve named a different directory.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-unresolvable",
accountHomeDir,
accountHomeCreated: true,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
mockSecretService.getByName.mockResolvedValueOnce(null);
mockSecretService.list.mockResolvedValueOnce([
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
{ id: "unresolvable-secret", name: "MAYBE_CODEX_HOME", provider: "local_encrypted" },
]);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockImplementation(
async (_companyId: string, secretId: string, _context: { configPath: string }) => {
if (secretId === "unresolvable-secret") throw new Error("decryption key unavailable");
return "/some/unrelated/path";
},
);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("preserves the account home when a secret appears after the scan starts", async () => {
// The scan lists secrets once, then resolves each one in turn. A new
// secret can appear after that first list call, while the scan is still
// resolving an earlier secret. The scan must re-list and check that new
// secret too, instead of trusting its first, now-stale list.
const accountHomeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-account-home-test-"));
accountHomeTestDirs.push(accountHomeDir);
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct-late-claim",
accountHomeDir,
accountHomeCreated: true,
});
mockSecretService.create.mockRejectedValueOnce(new Error("db unavailable"));
mockSecretService.getByName.mockResolvedValueOnce(null);
// The first pass sees only the unrelated secret. By the time the scan
// re-lists, a second, differently named secret now names this account
// home; the scan must catch it on that later pass.
mockSecretService.list
.mockResolvedValueOnce([{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" }])
.mockResolvedValueOnce([
{ id: "unrelated-secret", name: "SOME_OTHER_SECRET", provider: "local_encrypted" },
{ id: "late-secret", name: "LATE_CODEX_HOME", provider: "local_encrypted" },
]);
mockSecretService.resolveSecretValueForDeviceLoginCheck.mockImplementation(
async (_companyId: string, secretId: string, _context: { configPath: string }) =>
secretId === "late-secret" ? accountHomeDir : "/some/unrelated/path",
);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
await expect(lstat(accountHomeDir)).resolves.toBeDefined();
});
it("fails closed when the account identifier cannot form a valid account home name", async () => {
// The promotion helper itself already rejects an identifier that cannot
// become an account handle; a defensive re-check in the route covers a
// promotion result the route did not fully trust.
mockDeviceLoginPromotion.mockResolvedValueOnce({
outcome: "promoted",
accountId: "acct with space",
accountHomeDir: "/tmp/paperclip-codex-account-home/unused",
});
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
expect(mockSecretService.create).not.toHaveBeenCalled();
});
it("omits the URL, code, credential bytes, and lease id from logs and activity", async () => {
@@ -382,6 +382,84 @@ describe("device login service", () => {
expect(JSON.stringify(activity)).not.toContain(PROMPT_CODE);
});
it("wraps the terminal authenticated commit in a promotion's runTerminalCommit", async () => {
// A promotion that defines `runTerminalCommit` gets the chance to wrap the
// service's own terminal write, so it can hold a lock across a check it
// already ran once earlier in `promote` and the write that publishes
// `authenticated`.
const store = createMemoryStore();
const { runtime } = createFakeRuntime({
exec: execSuccess,
authBytes: Buffer.from('{"token":"secret"}'),
});
const companyId = randomUUID();
const wrapCalls: unknown[] = [];
const service = makeService({
store,
runtime,
promotion: {
promote: () => {},
async runTerminalCommit(commit, context) {
wrapCalls.push(context);
return commit();
},
},
});
const { session, completed } = await service.start({
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
const outcome = await completed;
expect(outcome.status).toBe("authenticated");
expect(wrapCalls).toHaveLength(1);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("authenticated");
});
it("records a failed terminal, and never authenticates, when runTerminalCommit rejects", async () => {
// A promotion's `runTerminalCommit` rejects when its own re-check, run
// immediately before the terminal write, finds the value it validated
// earlier no longer holds (for example, a rotation landed in the gap).
// The service must fail the login the same way a `promote` rejection
// does, never publishing `authenticated` for the stale value.
const store = createMemoryStore();
const { runtime, deleteCalls } = createFakeRuntime({
exec: execSuccess,
authBytes: Buffer.from('{"token":"secret"}'),
});
const companyId = randomUUID();
const service = makeService({
store,
runtime,
promotion: {
promote: () => {},
async runTerminalCommit() {
// A real promotion would run its re-check here, find the bound
// value stale, and throw before ever calling `commit`.
throw new Error("the bound value no longer matches");
},
},
});
const { session, completed } = await service.start({
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
const outcome = await completed;
expect(outcome.status).toBe("failed");
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("failed");
expect(row?.failureReason).toBe("promotion_failed");
// The service still deletes the sandbox on this failure path, the same
// as a `promote` rejection.
expect(deleteCalls).toHaveLength(1);
});
it("gives a Grok prompt to a grok_local session, and the session surfaces the Grok code and URL", async () => {
// This proves the Grok parser ran: the profile map resolves the parser from
// the trusted adapter type, so a `grok_local` session runs the Grok parser,
@@ -1,9 +1,11 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import { resolveCodexAuthCacheDir, withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import {
activityLog,
agents,
@@ -88,6 +90,16 @@ describeEmbeddedPostgres("secretService", () => {
return companyId;
}
// Creates a real directory under this company's Codex account-home cache
// root, so a test can prove the write-time directory-validity check reads
// an actual filesystem entry, the same way the production cleanup and the
// production secret write do.
async function makeAccountHomeDir(companyId: string, accountHandle: string): Promise<string> {
const accountHomeDir = path.join(resolveCodexAuthCacheDir(undefined, companyId), accountHandle);
await mkdir(accountHomeDir, { recursive: true });
return accountHomeDir;
}
async function seedCompanyMember(
companyId: string,
userId: string,
@@ -360,6 +372,446 @@ describeEmbeddedPostgres("secretService", () => {
).rejects.toThrow(/already exists/i);
});
it("serializes two local_encrypted secret creates in the same company so their writes never overlap", async () => {
// An account-home cleanup's claimant scan and a `local_encrypted` secret
// write share one lock (`withAccountHomeSecretMutationLock`), so a write
// can never commit inside the exact window the scan already used to
// decide no secret claims a directory it is about to delete. This proves
// the lock itself enforces that: two `local_encrypted` creates in the
// SAME company never run their provider write at the same time,
// whichever caller goes first.
const companyId = await seedCompany();
const svc = secretService(db);
const events: string[] = [];
let releaseFirstWrite!: () => void;
const firstWriteGate = new Promise<void>((resolve) => {
releaseFirstWrite = resolve;
});
const originalCreateSecret = localEncryptedProvider.createSecret.bind(localEncryptedProvider);
vi.spyOn(localEncryptedProvider, "createSecret")
.mockImplementationOnce(async (input) => {
events.push("first-provider-enter");
await firstWriteGate;
events.push("first-provider-exit");
return originalCreateSecret(input);
})
.mockImplementationOnce(async (input) => {
events.push("second-provider-enter");
return originalCreateSecret(input);
});
const firstCreate = svc.create(companyId, {
name: `account-home-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-a",
});
// Give the first call a chance to acquire the lock and enter its provider
// write before the second call starts racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const secondCreate = svc.create(companyId, {
name: `hand-named-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-a",
});
// The second call must stay blocked on the lock while the first call
// still holds it: it must never enter its own provider write before the
// first call's provider write exits.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["first-provider-enter"]);
releaseFirstWrite();
await Promise.all([firstCreate, secondCreate]);
expect(events).toEqual(["first-provider-enter", "first-provider-exit", "second-provider-enter"]);
});
it("serializes a local_encrypted secret rotate against a concurrent create of a different secret in the same company", async () => {
// Same lock, the other write path: a rotate that writes a new
// `local_encrypted` value must serialize against a concurrent create the
// same way a create serializes against another create.
const companyId = await seedCompany();
const svc = secretService(db);
const existing = await svc.create(companyId, {
name: `existing-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-b",
});
const events: string[] = [];
let releaseRotateWrite!: () => void;
const rotateWriteGate = new Promise<void>((resolve) => {
releaseRotateWrite = resolve;
});
const originalCreateVersion = localEncryptedProvider.createVersion.bind(localEncryptedProvider);
vi.spyOn(localEncryptedProvider, "createVersion").mockImplementationOnce(async (input) => {
events.push("rotate-provider-enter");
await rotateWriteGate;
events.push("rotate-provider-exit");
return originalCreateVersion(input);
});
const originalCreateSecret = localEncryptedProvider.createSecret.bind(localEncryptedProvider);
vi.spyOn(localEncryptedProvider, "createSecret").mockImplementationOnce(async (input) => {
events.push("create-provider-enter");
return originalCreateSecret(input);
});
const rotateCall = svc.rotate(existing.id, { value: "/company/codex-home/acct-b-rotated" });
await new Promise((resolve) => setTimeout(resolve, 20));
const createCall = svc.create(companyId, {
name: `hand-named-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-b",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["rotate-provider-enter"]);
releaseRotateWrite();
await Promise.all([rotateCall, createCall]);
expect(events).toEqual(["rotate-provider-enter", "rotate-provider-exit", "create-provider-enter"]);
});
it("fails a queued local_encrypted create when an account-home cleanup removes its directory first", async () => {
// The mutation lock alone stops a write and an account-home cleanup's
// check-and-delete from interleaving; it does not stop them from running
// in either order. When the cleanup wins the lock first, deletes the
// directory, and releases the lock, a create that was only queued behind
// it must not go on to commit that now-deleted directory as a secret
// value. It must fail instead.
const companyId = await seedCompany();
const svc = secretService(db);
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-create");
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
await rm(accountHomeDir, { recursive: true, force: true });
events.push("cleanup-exit");
});
// Give the cleanup a chance to acquire the lock before the create starts
// racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const createCall = svc.create(companyId, {
name: `account-home-${randomUUID()}`,
provider: "local_encrypted",
value: accountHomeDir,
});
// The create must stay queued behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
await expect(createCall).rejects.toThrow(/no longer exists/);
});
it("fails a queued local_encrypted rotate when an account-home cleanup removes its directory first", async () => {
// Same race, the other write path: a rotate that would commit a
// now-deleted account-home directory must fail the same way a create
// does.
const companyId = await seedCompany();
const svc = secretService(db);
const existing = await svc.create(companyId, {
name: `hand-named-${randomUUID()}`,
provider: "local_encrypted",
value: "/some/unrelated/placeholder/value",
});
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-rotate");
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
await rm(accountHomeDir, { recursive: true, force: true });
events.push("cleanup-exit");
});
await new Promise((resolve) => setTimeout(resolve, 20));
const rotateCall = svc.rotate(existing.id, { value: accountHomeDir });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
await expect(rotateCall).rejects.toThrow(/no longer exists/);
});
it("serializes an aws_secrets_manager secret create against a concurrent account-home mutation lock holder", async () => {
// A plain string value can equal a Codex account-home path regardless of
// which provider stores it. Prove a non-local (`aws_secrets_manager`)
// create now takes the SAME `withAccountHomeSecretMutationLock` a
// `local_encrypted` create takes, not only when the create's own
// provider is `local_encrypted`.
const companyId = await seedCompany();
const svc = secretService(db);
const awsVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret",
providerVersionRef: "aws-version-1",
});
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
events.push("cleanup-exit");
});
// Give the cleanup a chance to acquire the lock before the AWS create
// starts racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const createCall = svc.create(companyId, {
name: `aws-secret-${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
});
// The AWS create must stay queued behind the held lock, the same way a
// `local_encrypted` create does.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
const created = await createCall;
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
expect(created.status).toBe("active");
});
it("serializes an aws_secrets_manager secret rotate against a concurrent account-home mutation lock holder", async () => {
// Same reasoning as the create test above, the other write path: a
// non-local rotate must also take the lock, not only a
// `local_encrypted` rotate.
const companyId = await seedCompany();
const svc = secretService(db);
const awsVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
providerVersionRef: "aws-version-1",
});
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
versionId: "aws-version-2",
source: "managed",
},
valueSha256: "value-sha-2",
fingerprintSha256: "fingerprint-sha-2",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
providerVersionRef: "aws-version-2",
});
const existing = await svc.create(companyId, {
name: `aws-secret-rotate-${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
});
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
events.push("cleanup-exit");
});
await new Promise((resolve) => setTimeout(resolve, 20));
const rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
const rotated = await rotateCall;
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
expect(rotated.latestVersion).toBe(2);
});
it("serializes a rename, archive, or disable update against a concurrent account-home mutation lock holder", async () => {
// A rename or a status change to `archived` or `disabled` can stop the
// generated account-home secret from resolving to the value a
// device-login promotion already validated. Prove `update` now holds
// the SAME lock a cleanup (or a promotion's terminal-commit re-check)
// holds for its whole critical section, so an update can never land
// inside that section.
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `CODEX_HOME_update-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-update",
});
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
await cleanupGate;
});
await new Promise((resolve) => setTimeout(resolve, 20));
let updateResolved = false;
const updateCall = svc.update(secret.id, { status: "archived" }).then((result) => {
updateResolved = true;
return result;
});
// The update must stay queued behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(updateResolved).toBe(false);
releaseCleanup();
await cleanupCall;
const updated = await updateCall;
expect(updateResolved).toBe(true);
expect(updated?.status).toBe("archived");
});
it("serializes a secret delete against a concurrent account-home mutation lock holder", async () => {
// Same reasoning as the update test above: a delete must also take the
// lock, so it can never land inside a cleanup's, or a promotion's
// terminal-commit re-check's, critical section.
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `CODEX_HOME_delete-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-delete",
});
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
await cleanupGate;
});
await new Promise((resolve) => setTimeout(resolve, 20));
let removeResolved = false;
const removeCall = svc.remove(secret.id).then((result) => {
removeResolved = true;
return result;
});
// The delete must stay queued behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(removeResolved).toBe(false);
releaseCleanup();
await cleanupCall;
await removeCall;
expect(removeResolved).toBe(true);
const remaining = await svc.getById(secret.id);
expect(remaining).toBeNull();
});
it("commits a local_encrypted secret naming an account-home directory that still exists", async () => {
// A regression guard for the check above: a create whose value names a
// directory that is still present must keep succeeding, unblocked.
const companyId = await seedCompany();
const svc = secretService(db);
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-still-present");
const created = await svc.create(companyId, {
name: `account-home-${randomUUID()}`,
provider: "local_encrypted",
value: accountHomeDir,
});
expect(created.status).toBe("active");
});
it("fails a queued adapter-schema-secret create when an account-home cleanup removes its directory first", async () => {
// `normalizeAdapterConfigForPersistence` creates an adapter config secret
// through a separate managed-create path (`createManagedLocalSecret`),
// not `svc.create`. It must run the same queued-behind-the-lock
// directory check as `svc.create` and `svc.rotate`, so an adapter
// schema secret can never commit an account-home directory a cleanup
// already removed while this call waited for the lock.
const companyId = await seedCompany();
const svc = secretService(db);
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-adapter-secret");
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
await rm(accountHomeDir, { recursive: true, force: true });
events.push("cleanup-exit");
});
// Give the cleanup a chance to acquire the lock before the adapter
// config normalization starts racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const normalizeCall = svc.normalizeAdapterConfigForPersistence(
companyId,
{ apiKey: accountHomeDir },
{ adapterType: "hermes_gateway" },
);
// The queued create must stay blocked behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
await expect(normalizeCall).rejects.toThrow(/no longer exists/);
});
it("keeps an adapter-schema-secret create working for a value outside the account-home cache root", async () => {
// A regression guard for the check above: an adapter schema secret whose
// value is not an account-home directory (an ordinary API key, for
// example) must keep succeeding, unblocked.
const companyId = await seedCompany();
const svc = secretService(db);
const normalized = await svc.normalizeAdapterConfigForPersistence(
companyId,
{ apiKey: `plain-api-key-${randomUUID()}` },
{ adapterType: "hermes_gateway" },
);
const apiKeyBinding = (normalized as Record<string, unknown>).apiKey as { type: string; secretId: string };
expect(apiKeyBinding.type).toBe("secret_ref");
const secret = await svc.getById(apiKeyBinding.secretId);
expect(secret?.status).toBe("active");
});
it("validates the access namespace as agent-only with env-style aliases", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
+345 -50
View File
@@ -1,5 +1,6 @@
import { Router, type NextFunction, type Request, type Response } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db";
@@ -32,6 +33,7 @@ import {
startAdapterAuthSessionRequestSchema,
startClaudeSetupTokenSessionRequestSchema,
submitBrowserCodeRequestSchema,
toAccountHandle,
type AgentAdapterType,
} from "@paperclipai/shared";
import {
@@ -174,6 +176,8 @@ import {
import {
checkStagedCredentialReadiness,
promoteDeviceLoginCredential,
withAccountHomeSecretMutationLock,
withCodexAccountHomePromotionLock,
} from "@paperclipai/adapter-codex-local/server";
import {
checkStagedGrokCredentialReadiness,
@@ -273,6 +277,119 @@ function readRunIssueId(context: Record<string, unknown> | null) {
return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null;
}
// Confirms a pre-existing `CODEX_HOME_<handle>` secret still names this
// account's own home before a device login treats the secret's presence as a
// successful, idempotent login. The secret name alone is not proof of a
// match: a stale value from before the cache root moved, or a value a user
// entered by hand, would otherwise let the login report success while a
// bound agent reads the wrong (or a missing) credential home. Fails loud on
// a mismatch, so the login fails instead of silently pointing agents at the
// wrong home.
//
// Each caller must run this function inside `withAccountHomeSecretMutationLock`,
// the same lock a `local_encrypted` secret rotate holds for its whole write.
// A caller that only checks once, early in the promotion, and then reports
// success later is not enough on its own: the lock this call held is fully
// released by the time it returns, so a rotate queued behind it can commit a
// new value before the login service records its terminal `authenticated`
// state, which happens well after this call returns (see `runTerminalCommit`
// below, which runs this same check again, under a fresh lock acquisition,
// immediately before that terminal state commits).
async function assertAccountHomeSecretMatches(
secretsSvc: { resolveSecretValueForDeviceLoginCheck: (companyId: string, secretId: string, context: { configPath: string }) => Promise<string> },
companyId: string,
secret: { id: string },
secretName: string,
expectedAccountHomeDir: string,
): Promise<void> {
const storedValue = await secretsSvc.resolveSecretValueForDeviceLoginCheck(companyId, secret.id, {
configPath: `secrets.${secretName}`,
});
if (storedValue !== expectedAccountHomeDir) {
throw new Error(
`device-login credential promotion rejected: the existing ${secretName} secret does not name this account's own home`,
);
}
}
// Confirms no company secret, under any name or provider, still names this
// account home before a failed promotion deletes the directory. The
// generated `CODEX_HOME_<handle>` name is not the only secret that can
// reference this directory: a user can bind a hand-named secret to the same
// account home, so a check that reads only the generated name misses that
// secret and deletes a directory it still needs. A bound agent then reads a
// `CODEX_HOME` value that points at nothing. A secret's value is a plain
// string regardless of its provider, so an AWS Secrets Manager-backed secret
// (or any other provider) can equal this directory's path just as a
// `local_encrypted` secret can; the scan resolves every secret's value, not
// only `local_encrypted` ones.
//
// A secret whose value fails to resolve is NOT proof that secret names a
// different directory: the resolve call can fail for a secret that would
// have matched. Treating that failure as a non-match would let the cleanup
// delete a directory a secret still needs. So the scan fails closed: any
// resolution failure makes the whole scan report a claim, even when every
// secret that DID resolve named a different directory.
//
// The scan lists every secret once, then resolves each secret's value in
// turn, and each resolve call is its own round trip. A new secret can enter
// the company between the initial list and the last resolve call, so a
// single pass can finish, find no claimant among the secrets it read, and
// still miss a secret that named this directory moments later. So the scan
// re-lists after every pass and resolves only the secrets it has not yet
// checked, and it only reports "no claimant" once a pass finds nothing new
// to check. A scan that keeps finding new secrets on every pass fails
// closed after a bounded number of passes, so a fast stream of concurrent
// secret creation cannot force an unsafe delete.
//
// The scan alone still cannot rule out a secret write that commits after the
// scan's own last pass finishes but before the caller's delete runs: the
// scan and that write are two separate operations with no shared state, so
// neither can see the other. The caller closes that window by running the
// scan and the delete inside `withAccountHomeSecretMutationLock`, the same
// lock every `local_encrypted` secret create or rotate holds for its whole
// write. That lock is the atomic protection; the multi-pass scan above stays
// as a defense-in-depth check for a write path that has not taken the lock.
const ACCOUNT_HOME_CLAIM_SCAN_MAX_PASSES = 5;
async function anySecretNamesAccountHome(
secretsSvc: {
list: (companyId: string) => Promise<Array<{ id: string; name: string; provider: string }>>;
resolveSecretValueForDeviceLoginCheck: (
companyId: string,
secretId: string,
context: { configPath: string },
) => Promise<string>;
},
companyId: string,
accountHomeDir: string,
): Promise<boolean> {
const checkedSecretIds = new Set<string>();
for (let pass = 0; pass < ACCOUNT_HOME_CLAIM_SCAN_MAX_PASSES; pass += 1) {
const secrets = await secretsSvc.list(companyId);
const uncheckedSecrets = secrets.filter((secret) => !checkedSecretIds.has(secret.id));
if (uncheckedSecrets.length === 0) return false;
let resolutionFailed = false;
for (const secret of uncheckedSecrets) {
checkedSecretIds.add(secret.id);
const storedValue = await secretsSvc
.resolveSecretValueForDeviceLoginCheck(companyId, secret.id, {
configPath: `secrets.${secret.name}`,
})
.catch(() => {
resolutionFailed = true;
return null;
});
if (storedValue === accountHomeDir) return true;
}
if (resolutionFailed) return true;
}
// Every pass found a secret it had not yet checked. Fail closed: an
// endless stream of new secrets is not proof that none of them claims
// this directory.
return true;
}
export function agentRoutes(
db: Db,
options: {
@@ -542,6 +659,21 @@ export function agentRoutes(
// process owns one instance, so the in-memory prompt and the cancellation
// controllers persist across requests.
const adapterLoginStore = createDbAdapterAuthSessionStore(db);
// The account-home secret a Codex login validated and bound, keyed by
// session id, queued for `runTerminalCommit` to reconfirm right before the
// login service commits its terminal `authenticated` write. `promote`'s own
// check runs under a lock that is fully released by the time `promote`
// returns, so a rotation can still land after that check and before the
// terminal write; `runTerminalCommit` closes that gap by re-running the
// same check under a fresh lock acquisition that it holds across the
// terminal write itself. `runTerminalCommit` deletes the entry it reads, so
// nothing outlives one login attempt.
const pendingAccountHomeSecretCommits = new Map<
string,
{ secretId: string; secretName: string; accountHomeDir: string }
>();
const adapterLoginService = createDeviceLoginService({
store: adapterLoginStore,
runtime: createProductionLoginSessionRuntime({
@@ -577,59 +709,222 @@ export function agentRoutes(
// promotion function.
promotionByAdapterType: {
codex_local: {
// Hold one lock across the whole promotion sequence below: the
// credential write, the existing-secret check, the secret create, and
// the cleanup a create failure can trigger. Two different logins for
// the SAME Codex account run this whole sequence one at a time, so a
// login can never decide to delete the shared account-home directory
// while another login's own sequence is still mid-way through writing
// its credential or binding its own secret to that same directory. A
// lock around only the directory-creation step is not enough: that
// lock is already released by the time a login reaches the secret
// bind, so a second login can write its credential and be about to
// bind its own secret while the first login's later, unrelated
// secret-write failure removes the directory both logins now share.
async promote(authBytes, context) {
// Hold the promotion critical-section lock across the ownership check and
// the credential write. The reaper takes the same lock before it reclaims
// a stale `promoting` row. So a reclaim never interleaves with a live
// write: the reaper either wins the lock first and the ownership check
// then reads a reclaimed row and writes nothing, or the write finishes
// first under the lock and the reaper reclaims only after it completes. A
// read-only fence is not enough, because the filesystem write can start
// after the fence; the lock spans the whole section.
const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock(
context.companyId,
context.startedByUserId,
context.adapterType,
() =>
promoteDeviceLoginCredential({
authBytes,
companyId: context.companyId,
userInitiated: true,
checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes),
isSoleActiveOwner: async () => {
// The partial unique index allows one active row per company and
// adapter. So a `promoting` row for this session is the sole
// active owner of the company credential slot. The read runs
// inside the lock, so it observes a reaper reclaim that committed
// before this section acquired the lock.
const row = await adapterLoginStore.get(context.sessionId);
return row?.status === "promoting" && row.companyId === context.companyId;
},
log: (line) => {
// The promotion lines carry no token bytes and no raw account id,
// so it is safe to log them with the session identifier.
logger.info({ sessionId: context.sessionId }, line);
},
}),
);
// A resolved promotion is not necessarily an accepted promotion. In
// particular, a reaper/expiry race can revoke this session's sole
// ownership between the service transition and Decision H. Fail closed:
// only a credential write or a deliberate safe keep can authenticate.
if (outcome === "kept_foreign_identity") {
// The login produced a different account than the one the company
// credential home already holds. The promotion never clobbers an
// occupied home, so this login installed nothing durable, and the
// identity-anchored vend can never select it: a later run keeps the
// existing account. Fail the session, so the operator never sees a
// false `authenticated` for an account the system will not use.
throw new Error(
"device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept",
return withCodexAccountHomePromotionLock(undefined, context.companyId, async () => {
// Hold the promotion critical-section lock across the ownership check
// and the credential write. The reaper takes the same lock before it
// reclaims a stale `promoting` row. So a reclaim never interleaves with
// a live write: the reaper either wins the lock first and the
// ownership check then reads a reclaimed row and writes nothing, or
// the write finishes first under the lock and the reaper reclaims only
// after it completes. A read-only fence is not enough, because the
// filesystem write can start after the fence; the lock spans the whole
// section.
const result = await adapterLoginStore.withCompanyAdapterPromotionLock(
context.companyId,
context.startedByUserId,
context.adapterType,
() =>
promoteDeviceLoginCredential({
authBytes,
companyId: context.companyId,
userInitiated: true,
checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes),
isSoleActiveOwner: async () => {
// The partial unique index allows one active row per company and
// adapter. So a `promoting` row for this session is the sole
// active owner of the company credential slot. The read runs
// inside the lock, so it observes a reaper reclaim that committed
// before this section acquired the lock.
const row = await adapterLoginStore.get(context.sessionId);
return row?.status === "promoting" && row.companyId === context.companyId;
},
log: (line) => {
// The promotion lines carry no token bytes and no raw account id,
// so it is safe to log them with the session identifier.
logger.info({ sessionId: context.sessionId }, line);
},
}),
);
// A resolved promotion is not necessarily an accepted promotion. In
// particular, a reaper/expiry race can revoke this session's sole
// ownership between the service transition and Decision H. Fail closed:
// only a credential write or a deliberate safe keep can authenticate.
if (result.outcome !== "promoted" && result.outcome !== "kept") {
throw new Error(`device-login credential promotion rejected: ${result.outcome}`);
}
// The account's own home is durable at this point (the promotion above
// wrote it fail-loud). Name it with a company secret, so any agent can
// bind to it. Reading the secret by name first keeps a repeat login for
// the same account idempotent: `create` throws a conflict when the name
// already exists.
const handle = result.accountId ? toAccountHandle(result.accountId) : null;
if (!handle || !result.accountHomeDir) {
throw new Error(
"device-login credential promotion rejected: the promotion carried no account home",
);
}
const secretName = `CODEX_HOME_${handle}`;
const accountHomeDir = result.accountHomeDir;
const existingSecret = await secretsSvc.getByName(context.companyId, secretName);
if (existingSecret) {
// A same-name secret already exists. Confirm it still names this
// account's own home before treating a repeat login as a success:
// the name alone is not proof of a match.
//
// Run the check inside the same lock a `local_encrypted` secret
// rotate holds for its whole write, the same lock a rotate
// takes. This is an early fail-fast only: the lock is fully
// released once this call returns, well before the login
// service commits its terminal state, so queue the same check
// for `runTerminalCommit` to run again right before that
// commit, under a fresh lock acquisition it holds across the
// commit itself.
await withAccountHomeSecretMutationLock(undefined, context.companyId, () =>
assertAccountHomeSecretMatches(secretsSvc, context.companyId, existingSecret, secretName, accountHomeDir),
);
pendingAccountHomeSecretCommits.set(context.sessionId, {
secretId: existingSecret.id,
secretName,
accountHomeDir,
});
return;
}
try {
const createdSecret = await secretsSvc.create(
context.companyId,
{
name: secretName,
provider: "local_encrypted",
value: accountHomeDir,
description: `CODEX_HOME generated by logging into account ${handle}`,
},
{ userId: context.startedByUserId, agentId: null },
);
// The value just committed is correct at this instant, but a
// rotate queued behind the create's own lock can still commit a
// different value before the login service records its
// terminal state. Queue the same reconfirm `runTerminalCommit`
// runs for the two branches above.
pendingAccountHomeSecretCommits.set(context.sessionId, {
secretId: createdSecret.id,
secretName,
accountHomeDir,
});
} catch (err) {
if (err instanceof HttpError && err.status === 409) {
// A conflict means a concurrent login for the same account won the
// create race. Confirm the winning secret still names this
// account's own home before treating the race as a successful,
// idempotent login.
const winningSecret = await secretsSvc.getByName(context.companyId, secretName);
if (!winningSecret) {
throw new Error(
`device-login credential promotion rejected: the ${secretName} secret conflict could not be resolved`,
);
}
// Same lock and the same reasoning as the pre-existing-secret
// check above: an early fail-fast only, so also queue the
// same check for `runTerminalCommit` to run again, under a
// fresh lock acquisition it holds across the terminal commit.
await withAccountHomeSecretMutationLock(undefined, context.companyId, () =>
assertAccountHomeSecretMatches(secretsSvc, context.companyId, winningSecret, secretName, accountHomeDir),
);
pendingAccountHomeSecretCommits.set(context.sessionId, {
secretId: winningSecret.id,
secretName,
accountHomeDir,
});
return;
}
// The account home write failed for a reason other than a naming
// conflict. Remove the directory only when this exact login created
// it AND no secret, under any name, still names it. The lock
// around this whole method already rules out another
// SAME-ACCOUNT LOGIN from being mid-sequence here, but it does
// not rule out a secret a different path created at this exact
// directory in between the read above and this failure — for
// example, a concurrent login for the same account that won a
// race on this exact name, or a user who names a secret by hand
// under a different name entirely. The re-check is this call's
// only signal for that case, so it stays even under the lock:
// `accountHomeCreated` alone is not proof no such secret now
// claims the directory, and a same-name check alone is not proof
// either, because the claiming secret can carry any name.
//
// The check and the delete run inside `withAccountHomeSecretMutationLock`,
// the same lock the secrets service holds for the whole of a
// `local_encrypted` secret's create or rotate call. That closes the
// window `anySecretNamesAccountHome`'s own multi-pass scan cannot: a
// secret write that commits after this check's last pass but before
// the delete runs. Under the shared lock, a write either finishes
// (and becomes visible to the check) before this section acquires the
// lock, or it waits for this section to finish before it can commit.
if (result.accountHomeCreated) {
await withAccountHomeSecretMutationLock(undefined, context.companyId, async () => {
const claimed = await anySecretNamesAccountHome(secretsSvc, context.companyId, accountHomeDir);
if (!claimed) {
await rm(accountHomeDir, { recursive: true, force: true }).catch(() => undefined);
}
});
}
throw new Error(
"device-login credential promotion rejected: failed to record the account home secret",
);
}
});
},
// The login service calls this immediately before it commits its
// terminal `authenticated` write, wrapping that write in the
// callback it hands in as `commit`. `promote` above already
// validated the bound account-home secret once, early, but its own
// lock is fully released by the time `promote` returns — well before
// this runs. Re-run the same check here, and hold the SAME lock
// across both the check and `commit`, so a rotate cannot land in the
// gap between the validated value and the terminal write that
// reports it as authenticated: a rotate either finishes (and this
// check reads its new value, and rejects) before this section
// acquires the lock, or it waits for this section — including the
// terminal commit — to finish first.
async runTerminalCommit(commit, context) {
const pending = pendingAccountHomeSecretCommits.get(context.sessionId);
pendingAccountHomeSecretCommits.delete(context.sessionId);
if (!pending) {
// `promote` never reached a secret bind for this session (for
// example, a rejected promotion already failed the login before
// the service ever reaches this call). Nothing to reconfirm.
return commit();
}
if (outcome !== "promoted" && outcome !== "kept") {
throw new Error(`device-login credential promotion rejected: ${outcome}`);
}
return withAccountHomeSecretMutationLock(undefined, context.companyId, async () => {
// Resolve by the secret's id, not its name: a rotate changes the
// value under the same id, so re-resolving this id picks up a
// rotation the same way the very first check would have, with no
// need to re-look the secret up by name. A deleted secret makes
// this resolve call itself fail (unlike a value mismatch, which
// `assertAccountHomeSecretMatches` turns into its own error), and
// that failure propagates the same way: the login never
// authenticates.
await assertAccountHomeSecretMatches(
secretsSvc,
context.companyId,
{ id: pending.secretId },
pending.secretName,
pending.accountHomeDir,
);
return commit();
});
},
},
grok_local: {
+44 -8
View File
@@ -163,6 +163,16 @@ export interface CredentialPromotionContext {
*/
export interface CredentialPromotion {
promote(authBytes: Buffer, context: CredentialPromotionContext): void | Promise<void>;
/**
* Wraps the service's terminal "authenticated" commit, immediately before
* the service runs it. A promotion that already bound and validated a
* value in `promote` can hold the SAME lock across this call, so a write
* that would invalidate that value cannot land in the gap between the
* earlier validation and this terminal commit. When the wrapper throws,
* 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>;
}
/** The redacted lifecycle phases. Each phase carries no secret data. */
@@ -1082,15 +1092,41 @@ 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`.
return await terminate({
const commitAuthenticated = () =>
terminate({
sessionId,
lease,
terminal: "authenticated",
reason: null,
expectedStatuses: ["promoting"],
conditionalTransition,
activity,
});
const promotionContext: CredentialPromotionContext = {
sessionId,
lease,
terminal: "authenticated",
reason: null,
expectedStatuses: ["promoting"],
conditionalTransition,
activity,
});
companyId: input.companyId,
startedByUserId: input.startedByUserId,
adapterType: input.adapterType,
};
try {
return profile.promotion.runTerminalCommit
? await profile.promotion.runTerminalCommit(commitAuthenticated, promotionContext)
: await commitAuthenticated();
} catch {
// The wrapped final check rejected the value `promote` bound earlier
// (for example, a rotation landed after that validation). The login
// did not finish, so fail closed the same way a `promote` rejection
// does: never publish `authenticated`, still delete the sandbox.
return await terminate({
sessionId,
lease,
terminal: "failed",
reason: "promotion_failed",
expectedStatuses: ["promoting"],
conditionalTransition,
activity,
});
}
}
const terminal: AdapterAuthSessionStatus =
File diff suppressed because it is too large. Load diff