mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
## Thinking Path > - Paperclip is the control plane for companies that use AI agents for work > - Local adapters connect Paperclip agents to provider command line tools > - The Codex adapter stores login data in a shared company home > - A shared home cannot keep credentials for more than one Codex account > - This pull request gives each account a safe home and a matching company secret > - The benefit is that one company can use multiple Codex accounts at the same time ## Linked Issues or Issue Description **Problem or motivation** A company can hold only one Codex subscription credential because device login uses one shared home. A second account cannot log in without replacing or conflicting with the first credential. **Proposed solution** This change validates the vendor account identifier, stores each credential in its own home, and creates a company secret that points to that home. Repeat login calls return success when the matching secret already exists. **Roadmap alignment** The change supports the roadmap goal for centrally managed secrets with scoped access and audited resolution. **Additional context** The security review returned approve with no blocking finding. The branch adds shared account-handle validation and tests for device login and the Codex local adapter. ## What Changed - Add strict allowlist validation for Codex account handles. - Store each Codex account credential in a separate home under the Codex cache root. - Verify that the resolved account home stays inside the cache root. - Create the `CODEX_HOME_<handle>` company secret for each account. - Keep repeat and concurrent login calls safe and idempotent. - Add shared helper and route, adapter, and validation tests. ## Verification - `pnpm --filter @paperclipai/adapter-codex-local test` passes with 343 tests. - `pnpm --filter @paperclipai/server test src/__tests__/agent-device-login-routes.test.ts` passes with 25 tests. - The adapter suite passes with 23 tests. - The shared package and Codex adapter typechecks pass. - Continuous integration must pass on every check before merge. ## Risks The account handle becomes part of a directory path and secret name. The strict allowlist and root containment check reduce path traversal risk. Existing single-account homes remain unchanged unless a new device login creates an account-specific home. ## Model Used OpenAI GPT-5 (exact runtime model ID: gpt-5), with tool use and code execution. The runtime context window is not exposed in this run. ## 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 - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
564 lines
26 KiB
TypeScript
564 lines
26 KiB
TypeScript
import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
assertAccountHomeCacheDirStillValid,
|
|
clearCodexAuthCache,
|
|
clearCodexAuthCacheEntry,
|
|
ensureCodexAuthCacheEntryDir,
|
|
isCodexAuthCacheEnabled,
|
|
resolveCodexAuthCacheDir,
|
|
resolveCodexAuthCacheEntryPath,
|
|
selectVendCredential,
|
|
toCacheKey,
|
|
withAccountHomeSecretMutationLock,
|
|
withCodexAccountHomePromotionLock,
|
|
} from "./codex-auth-cache.js";
|
|
import { resolveSharedCodexHomeDir } from "./codex-home.js";
|
|
|
|
// This suite proves the per-identity host credential cache store. The cache is a
|
|
// separate directory outside the shared Codex home and outside the symlink
|
|
// allowlist. It keys one usable subscription credential per identity
|
|
// (`account_id`). The suite drives the real path resolver, the real directory
|
|
// guards, and the real decision predicate (`codex-auth-merge-decision.cjs`)
|
|
// against a real host tmp filesystem.
|
|
describe("codex auth cache store", () => {
|
|
const cleanupDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
while (cleanupDirs.length > 0) {
|
|
const dir = cleanupDirs.pop();
|
|
if (!dir) continue;
|
|
await chmod(dir, 0o700).catch(() => undefined);
|
|
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
async function makeInstanceRoot(): Promise<string> {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-cache-"));
|
|
cleanupDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function envFor(instanceHome: string, extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
return {
|
|
PAPERCLIP_HOME: instanceHome,
|
|
PAPERCLIP_INSTANCE_ID: "default",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function subscriptionAuth(input: { accountId: string; lastRefresh?: string; marker?: string }): string {
|
|
const suffix = input.marker ?? input.accountId;
|
|
return JSON.stringify({
|
|
tokens: {
|
|
id_token: `id-token-${suffix}`,
|
|
access_token: `access-token-${suffix}`,
|
|
refresh_token: `refresh-token-${suffix}`,
|
|
account_id: input.accountId,
|
|
},
|
|
...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
|
|
});
|
|
}
|
|
|
|
const NEWER = "2026-07-09T02:00:00Z";
|
|
const OLDER = "2026-07-09T01:00:00Z";
|
|
|
|
describe("Phase 1: cache store location and path scheme", () => {
|
|
it("resolveCodexAuthCacheDir returns a path outside resolveSharedCodexHomeDir", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") });
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
const sharedHome = resolveSharedCodexHomeDir(env);
|
|
expect(cacheDir.startsWith(sharedHome + path.sep)).toBe(false);
|
|
expect(cacheDir).not.toBe(sharedHome);
|
|
});
|
|
|
|
it("resolveCodexAuthCacheDir returns a company-scoped path under the instance companies directory when companyId is set", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
expect(cacheDir).toBe(
|
|
path.resolve(home, "instances", "default", "companies", "company-a", "codex-auth-cache"),
|
|
);
|
|
});
|
|
|
|
it("resolveCodexAuthCacheEntryPath keys the entry by a sanitized account_id and ends with auth.json", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const entryPath = resolveCodexAuthCacheEntryPath(env, "acct-42", "company-a");
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
expect(entryPath).toBe(path.join(cacheDir, "acct-42", "auth.json"));
|
|
});
|
|
|
|
it("toCacheKey rejects an empty account_id, a path separator, and ..", () => {
|
|
expect(() => toCacheKey("")).toThrow();
|
|
expect(() => toCacheKey(" ")).toThrow();
|
|
expect(() => toCacheKey("..")).toThrow();
|
|
expect(() => toCacheKey(".")).toThrow();
|
|
expect(() => toCacheKey("a/b")).toThrow();
|
|
expect(() => toCacheKey("a\\b")).toThrow();
|
|
expect(() => toCacheKey("../escape")).toThrow();
|
|
expect(() => toCacheKey("a\0b")).toThrow();
|
|
expect(toCacheKey("acct-42")).toBe("acct-42");
|
|
});
|
|
|
|
it("resolveCodexAuthCacheDir rejects a traversal companyId before it builds the path", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
// A traversal companyId must never make the cache root escape the
|
|
// companies/ directory. Sanitization runs before path construction, so a
|
|
// relative segment, a path separator, an absolute path, and a NUL byte all
|
|
// fail loud.
|
|
expect(() => resolveCodexAuthCacheDir(env, "")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, " ")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, "..")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, ".")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, "../../etc")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, "/etc")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, "a/b")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, "a\\b")).toThrow();
|
|
expect(() => resolveCodexAuthCacheDir(env, "a\0b")).toThrow();
|
|
// The clear path inherits the same guard, so a traversal companyId can
|
|
// never reach rm() on an escaped root.
|
|
await expect(clearCodexAuthCache(env, "../../etc")).rejects.toThrow();
|
|
const safeDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
expect(safeDir).toBe(
|
|
path.resolve(home, "instances", "default", "companies", "company-a", "codex-auth-cache"),
|
|
);
|
|
});
|
|
|
|
it("resolveCodexAuthCacheEntryPath rejects an identifier that toAccountHandle rejects", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
// A space, a plus sign, and a leading hyphen all fail the stricter
|
|
// account-handle allowlist, even though the older denylist alone would
|
|
// accept some of them.
|
|
expect(() => resolveCodexAuthCacheEntryPath(env, "acct 42", "company-a")).toThrow();
|
|
expect(() => resolveCodexAuthCacheEntryPath(env, "acct+42", "company-a")).toThrow();
|
|
expect(() => resolveCodexAuthCacheEntryPath(env, "-rf", "company-a")).toThrow();
|
|
});
|
|
|
|
it("resolveCodexAuthCacheEntryPath verifies the resolved path stays under the cache root", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
// A traversal account_id is rejected by toCacheKey, so the resolved entry
|
|
// path can never escape the cache root.
|
|
expect(() => resolveCodexAuthCacheEntryPath(env, "../../etc", "company-a")).toThrow();
|
|
const entryPath = resolveCodexAuthCacheEntryPath(env, "acct-ok", "company-a");
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
expect(entryPath.startsWith(cacheDir + path.sep)).toBe(true);
|
|
expect(entryPath.endsWith(path.join("acct-ok", "auth.json"))).toBe(true);
|
|
});
|
|
|
|
it("ensureCodexAuthCacheEntryDir creates the cache root and the entry directory private (0700)", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-priv", "company-a");
|
|
const entryDir = path.dirname(entryPath);
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
expect((await lstat(entryDir)).mode & 0o777).toBe(0o700);
|
|
expect((await lstat(cacheDir)).mode & 0o777).toBe(0o700);
|
|
});
|
|
|
|
it("the cache root fails closed (lstat) when it is a symlink or a non-directory", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
// Make the cache root a symlink to another directory. lstat (not stat)
|
|
// must catch it and fail closed.
|
|
await mkdir(path.dirname(cacheDir), { recursive: true });
|
|
const target = path.join(home, "elsewhere");
|
|
await mkdir(target, { recursive: true });
|
|
await symlink(target, cacheDir);
|
|
await expect(ensureCodexAuthCacheEntryDir(env, "acct-x", "company-a")).rejects.toThrow();
|
|
});
|
|
|
|
it("the entry directory fails closed (lstat) when it is a symlink or a non-directory", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
|
|
// Put a regular file where the entry directory must be.
|
|
await writeFile(path.join(cacheDir, "acct-file"), "not a dir", { mode: 0o600 });
|
|
await expect(ensureCodexAuthCacheEntryDir(env, "acct-file", "company-a")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Phase 4: identity-anchored vend", () => {
|
|
async function stageHostAndCache(input: {
|
|
hostAuth?: string;
|
|
cacheAuthByAccount?: Record<string, string>;
|
|
}): Promise<{ env: NodeJS.ProcessEnv; sharedHomeAuthPath: string }> {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") });
|
|
const sharedHome = resolveSharedCodexHomeDir(env);
|
|
await mkdir(sharedHome, { recursive: true });
|
|
const sharedHomeAuthPath = path.join(sharedHome, "auth.json");
|
|
if (input.hostAuth !== undefined) {
|
|
await writeFile(sharedHomeAuthPath, input.hostAuth, { mode: 0o600 });
|
|
}
|
|
for (const [accountId, auth] of Object.entries(input.cacheAuthByAccount ?? {})) {
|
|
const entryPath = await ensureCodexAuthCacheEntryDir(env, accountId, "company-a");
|
|
await writeFile(entryPath, auth, { mode: 0o600 });
|
|
}
|
|
return { env, sharedHomeAuthPath };
|
|
}
|
|
|
|
const resolveEntry =
|
|
(env: NodeJS.ProcessEnv) => (accountId: string) =>
|
|
resolveCodexAuthCacheEntryPath(env, accountId, "company-a");
|
|
|
|
it("vend refreshes the host identity when the cached copy is strictly newer for the same identity", async () => {
|
|
const { env, sharedHomeAuthPath } = await stageHostAndCache({
|
|
hostAuth: subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }),
|
|
cacheAuthByAccount: {
|
|
"acct-x": subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "cache" }),
|
|
},
|
|
});
|
|
const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env);
|
|
expect(outcome).toBe("vended");
|
|
const finalHost = await readFile(sharedHomeAuthPath, "utf8");
|
|
expect(finalHost).toContain("acct-x");
|
|
expect(finalHost).toContain("cache");
|
|
});
|
|
|
|
it("vend keeps the shared-home credential when the cache copy is older or a tie", async () => {
|
|
for (const cacheRefresh of [OLDER, NEWER]) {
|
|
const hostRefresh = cacheRefresh === OLDER ? NEWER : NEWER; // host newer or tie
|
|
const { env, sharedHomeAuthPath } = await stageHostAndCache({
|
|
hostAuth: subscriptionAuth({ accountId: "acct-x", lastRefresh: hostRefresh, marker: "host" }),
|
|
cacheAuthByAccount: {
|
|
"acct-x": subscriptionAuth({ accountId: "acct-x", lastRefresh: cacheRefresh, marker: "cache" }),
|
|
},
|
|
});
|
|
const before = await readFile(sharedHomeAuthPath, "utf8");
|
|
const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env);
|
|
expect(outcome).toBe("kept-host");
|
|
expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before);
|
|
}
|
|
});
|
|
|
|
it("vend never selects a cache slot with a different account_id than the host holds", async () => {
|
|
const { env, sharedHomeAuthPath } = await stageHostAndCache({
|
|
hostAuth: subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }),
|
|
cacheAuthByAccount: {
|
|
"acct-y": subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "other" }),
|
|
},
|
|
});
|
|
const before = await readFile(sharedHomeAuthPath, "utf8");
|
|
const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env);
|
|
expect(outcome).toBe("kept-host");
|
|
expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before);
|
|
});
|
|
|
|
it("vend does nothing when the host shared home has no auth.json (no random pick from the cache)", async () => {
|
|
const { env, sharedHomeAuthPath } = await stageHostAndCache({
|
|
cacheAuthByAccount: {
|
|
"acct-y": subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "other" }),
|
|
},
|
|
});
|
|
const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env);
|
|
expect(outcome).toBe("no-host-identity");
|
|
await expect(lstat(sharedHomeAuthPath)).rejects.toThrow();
|
|
});
|
|
|
|
it("vend does nothing when the host shared home holds an apikey (no subscription identity)", async () => {
|
|
const { env, sharedHomeAuthPath } = await stageHostAndCache({
|
|
hostAuth: JSON.stringify({ OPENAI_API_KEY: "sk-host" }),
|
|
cacheAuthByAccount: {
|
|
"acct-y": subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "other" }),
|
|
},
|
|
});
|
|
const before = await readFile(sharedHomeAuthPath, "utf8");
|
|
const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env);
|
|
expect(outcome).toBe("no-host-identity");
|
|
expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before);
|
|
});
|
|
|
|
it("the vend never emits token bytes or a raw account_id to the log", async () => {
|
|
const { env, sharedHomeAuthPath } = await stageHostAndCache({
|
|
hostAuth: subscriptionAuth({ accountId: "SECRET-ACCT", lastRefresh: OLDER, marker: "HOST-SENTINEL" }),
|
|
cacheAuthByAccount: {
|
|
"SECRET-ACCT": subscriptionAuth({ accountId: "SECRET-ACCT", lastRefresh: NEWER, marker: "CACHE-SENTINEL" }),
|
|
},
|
|
});
|
|
const logs: string[] = [];
|
|
await selectVendCredential(
|
|
sharedHomeAuthPath,
|
|
resolveEntry(env),
|
|
(line) => {
|
|
logs.push(line);
|
|
},
|
|
env,
|
|
);
|
|
const combined = logs.join("\n");
|
|
expect(combined).not.toContain("SENTINEL");
|
|
expect(combined).not.toContain("SECRET-ACCT");
|
|
expect(combined).not.toContain("id-token");
|
|
});
|
|
});
|
|
|
|
describe("Phase 5: cache-clear operator action and off-switch", () => {
|
|
it("clearCodexAuthCacheEntry removes one identity slot and leaves other slots intact", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const keepPath = await ensureCodexAuthCacheEntryDir(env, "acct-keep", "company-a");
|
|
const dropPath = await ensureCodexAuthCacheEntryDir(env, "acct-drop", "company-a");
|
|
await writeFile(keepPath, subscriptionAuth({ accountId: "acct-keep" }), { mode: 0o600 });
|
|
await writeFile(dropPath, subscriptionAuth({ accountId: "acct-drop" }), { mode: 0o600 });
|
|
|
|
await clearCodexAuthCacheEntry(env, "acct-drop", "company-a");
|
|
await expect(lstat(path.dirname(dropPath))).rejects.toThrow();
|
|
expect(await readFile(keepPath, "utf8")).toContain("acct-keep");
|
|
});
|
|
|
|
it("clearCodexAuthCacheEntry rejects an account_id that escapes the cache root", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
await expect(clearCodexAuthCacheEntry(env, "../../etc", "company-a")).rejects.toThrow();
|
|
await expect(clearCodexAuthCacheEntry(env, "a/b", "company-a")).rejects.toThrow();
|
|
});
|
|
|
|
it("clearCodexAuthCacheEntry is a benign no-op for a missing slot", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
await expect(clearCodexAuthCacheEntry(env, "acct-absent", "company-a")).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("clearCodexAuthCache removes every slot", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
await ensureCodexAuthCacheEntryDir(env, "acct-1", "company-a");
|
|
await ensureCodexAuthCacheEntryDir(env, "acct-2", "company-a");
|
|
const cacheDir = resolveCodexAuthCacheDir(env, "company-a");
|
|
expect((await readdir(cacheDir)).sort()).toEqual(["acct-1", "acct-2"]);
|
|
await clearCodexAuthCache(env, "company-a");
|
|
await expect(lstat(cacheDir)).rejects.toThrow();
|
|
});
|
|
|
|
it("isCodexAuthCacheEnabled defaults to on and turns off on an explicit falsy flag", () => {
|
|
expect(isCodexAuthCacheEnabled({})).toBe(true);
|
|
expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "1" })).toBe(true);
|
|
expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "on" })).toBe(true);
|
|
expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "0" })).toBe(false);
|
|
expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "false" })).toBe(false);
|
|
expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "off" })).toBe(false);
|
|
expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "no" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Phase 6: whole-promotion serialization for one company", () => {
|
|
it("withCodexAccountHomePromotionLock never overlaps two concurrent callers for the same company", async () => {
|
|
// The device-login route holds this lock across its whole promotion
|
|
// sequence: the account-home directory decision, the credential write,
|
|
// and the secret bind or cleanup that follows. Two different logins for
|
|
// the SAME Codex account must run that whole sequence one at a time, so
|
|
// a login can never delete the shared account-home directory while
|
|
// another login's own sequence is still writing its credential or
|
|
// binding its own secret to that same directory. This proves the lock
|
|
// itself enforces that: two concurrent callers for one company never run
|
|
// their callbacks at the same time, whichever caller goes first.
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const events: string[] = [];
|
|
let releaseFirstCaller!: () => void;
|
|
const firstCallerGate = new Promise<void>((resolve) => {
|
|
releaseFirstCaller = resolve;
|
|
});
|
|
|
|
const firstCall = withCodexAccountHomePromotionLock(env, "company-shared", async () => {
|
|
events.push("first-enter");
|
|
await firstCallerGate;
|
|
events.push("first-exit");
|
|
return "first";
|
|
});
|
|
// Give the first caller a chance to acquire the lock and enter its
|
|
// callback before the second caller starts racing for the same lock.
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
const secondCall = withCodexAccountHomePromotionLock(env, "company-shared", async () => {
|
|
events.push("second-enter");
|
|
events.push("second-exit");
|
|
return "second";
|
|
});
|
|
// The second caller must stay blocked on the lock while the first
|
|
// caller still holds it: it must never log `second-enter` before the
|
|
// first caller's `first-exit`.
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(events).toEqual(["first-enter"]);
|
|
|
|
releaseFirstCaller();
|
|
const [first, second] = await Promise.all([firstCall, secondCall]);
|
|
expect(first).toBe("first");
|
|
expect(second).toBe("second");
|
|
expect(events).toEqual(["first-enter", "first-exit", "second-enter", "second-exit"]);
|
|
});
|
|
|
|
it("withCodexAccountHomePromotionLock keeps two different companies independent", async () => {
|
|
// The lock is per company, so two different companies' device logins
|
|
// never wait on each other.
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const events: string[] = [];
|
|
let releaseCompanyA!: () => void;
|
|
const companyAGate = new Promise<void>((resolve) => {
|
|
releaseCompanyA = resolve;
|
|
});
|
|
|
|
const companyACall = withCodexAccountHomePromotionLock(env, "company-a", async () => {
|
|
events.push("a-enter");
|
|
await companyAGate;
|
|
events.push("a-exit");
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
const companyBCall = withCodexAccountHomePromotionLock(env, "company-b", async () => {
|
|
events.push("b-enter");
|
|
events.push("b-exit");
|
|
});
|
|
await companyBCall;
|
|
// Company B's callback ran and finished while company A's callback was
|
|
// still waiting on its own gate, so the two locks never contended.
|
|
expect(events).toEqual(["a-enter", "b-enter", "b-exit"]);
|
|
|
|
releaseCompanyA();
|
|
await companyACall;
|
|
expect(events).toEqual(["a-enter", "b-enter", "b-exit", "a-exit"]);
|
|
});
|
|
});
|
|
|
|
describe("Phase 7: account-home secret-mutation serialization for one company", () => {
|
|
it("withAccountHomeSecretMutationLock never overlaps two concurrent callers for the same company", async () => {
|
|
// The secrets service holds this lock for the whole of a `local_encrypted`
|
|
// secret's create or rotate call, and an account-home cleanup's claimant
|
|
// scan holds it for the whole of its final check-and-delete step. This
|
|
// proves the lock itself enforces mutual exclusion between any two
|
|
// holders for one company, whichever caller goes first.
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const events: string[] = [];
|
|
let releaseFirstCaller!: () => void;
|
|
const firstCallerGate = new Promise<void>((resolve) => {
|
|
releaseFirstCaller = resolve;
|
|
});
|
|
|
|
const firstCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
|
|
events.push("first-enter");
|
|
await firstCallerGate;
|
|
events.push("first-exit");
|
|
return "first";
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
const secondCall = withAccountHomeSecretMutationLock(env, "company-shared", async () => {
|
|
events.push("second-enter");
|
|
events.push("second-exit");
|
|
return "second";
|
|
});
|
|
// The second caller must stay blocked on the lock while the first
|
|
// caller still holds it: it must never log `second-enter` before the
|
|
// first caller's `first-exit`.
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(events).toEqual(["first-enter"]);
|
|
|
|
releaseFirstCaller();
|
|
const [first, second] = await Promise.all([firstCall, secondCall]);
|
|
expect(first).toBe("first");
|
|
expect(second).toBe("second");
|
|
expect(events).toEqual(["first-enter", "first-exit", "second-enter", "second-exit"]);
|
|
});
|
|
|
|
it("withAccountHomeSecretMutationLock keeps two different companies independent", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const events: string[] = [];
|
|
let releaseCompanyA!: () => void;
|
|
const companyAGate = new Promise<void>((resolve) => {
|
|
releaseCompanyA = resolve;
|
|
});
|
|
|
|
const companyACall = withAccountHomeSecretMutationLock(env, "company-a", async () => {
|
|
events.push("a-enter");
|
|
await companyAGate;
|
|
events.push("a-exit");
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
const companyBCall = withAccountHomeSecretMutationLock(env, "company-b", async () => {
|
|
events.push("b-enter");
|
|
events.push("b-exit");
|
|
});
|
|
await companyBCall;
|
|
expect(events).toEqual(["a-enter", "b-enter", "b-exit"]);
|
|
|
|
releaseCompanyA();
|
|
await companyACall;
|
|
expect(events).toEqual(["a-enter", "b-enter", "b-exit", "a-exit"]);
|
|
});
|
|
|
|
it("withAccountHomeSecretMutationLock does not contend with withCodexAccountHomePromotionLock for the same company", async () => {
|
|
// The two locks use separate lock directories (Security condition: no
|
|
// shared lock key), so a device-login promotion that holds
|
|
// `withCodexAccountHomePromotionLock` for its whole sequence can still
|
|
// call into a secrets-service write that takes this lock without
|
|
// deadlocking on itself.
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const events: string[] = [];
|
|
|
|
await withCodexAccountHomePromotionLock(env, "company-shared", async () => {
|
|
events.push("promotion-enter");
|
|
await withAccountHomeSecretMutationLock(env, "company-shared", async () => {
|
|
events.push("mutation-enter");
|
|
events.push("mutation-exit");
|
|
});
|
|
events.push("promotion-exit");
|
|
});
|
|
|
|
expect(events).toEqual(["promotion-enter", "mutation-enter", "mutation-exit", "promotion-exit"]);
|
|
});
|
|
});
|
|
|
|
describe("Phase 8: account-home directory validity before a secret write commits", () => {
|
|
it("resolves for a value naming a directory that still exists under the cache root", async () => {
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const companyId = "company-still-exists";
|
|
const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-still-exists", companyId);
|
|
const accountHomeDir = path.dirname(entryPath);
|
|
|
|
await expect(
|
|
assertAccountHomeCacheDirStillValid(env, companyId, accountHomeDir),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("rejects a value naming a directory under the cache root that no longer exists", async () => {
|
|
// This is the shape an account-home cleanup leaves behind: a value that
|
|
// once named a real directory, now removed. A create or a rotate that
|
|
// is about to commit this exact value must fail instead of writing a
|
|
// secret that points at nothing.
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
const companyId = "company-removed";
|
|
const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-removed", companyId);
|
|
const accountHomeDir = path.dirname(entryPath);
|
|
await rm(accountHomeDir, { recursive: true, force: true });
|
|
|
|
await expect(assertAccountHomeCacheDirStillValid(env, companyId, accountHomeDir)).rejects.toThrow(
|
|
/no longer exists/,
|
|
);
|
|
});
|
|
|
|
it("leaves a value alone when it does not sit under this company's cache root", async () => {
|
|
// Most `local_encrypted` secret values (an API key, a token, a
|
|
// hand-typed string) never sit under the cache root, so this check must
|
|
// never reject a write that only happens to name a missing path.
|
|
const home = await makeInstanceRoot();
|
|
const env = envFor(home);
|
|
|
|
await expect(
|
|
assertAccountHomeCacheDirStillValid(env, "company-unrelated", "/some/unrelated/missing/path"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|