mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(codex-local): make auth reconciliation idempotent
This commit is contained in:
1 parent
da570f0fe9
commit
2f933a4dd8
7 files changed
+69
-5
No files matched your search
@@ -468,6 +468,17 @@ describe("reconcileManagedCodexHome", () => {
|
||||
expect(result.status).toBe("seeded");
|
||||
const written = JSON.parse(await fs.readFile(fx.agentAuth, "utf8"));
|
||||
expect(written).toEqual({ OPENAI_API_KEY: "sk-reconcile-1" });
|
||||
|
||||
const second = await reconcileManagedCodexHome({
|
||||
companyId: "company-1",
|
||||
configuredCodexHome: fx.agentHome,
|
||||
apiKey: "sk-reconcile-1",
|
||||
env: fx.env,
|
||||
});
|
||||
expect(second.status).toBe("already_seeded");
|
||||
expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({
|
||||
OPENAI_API_KEY: "sk-reconcile-1",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ function hasUsableAuthPayload(authPayload: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function readApiKeyFromAuthPayload(authPayload: unknown): string | null {
|
||||
if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) {
|
||||
return null;
|
||||
}
|
||||
const raw = (authPayload as Record<string, unknown>).OPENAI_API_KEY;
|
||||
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
|
||||
}
|
||||
|
||||
export function resolveSharedCodexHomeDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
@@ -96,6 +104,19 @@ export async function codexHomeHasUsableAuth(home: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function codexHomeHasMatchingApiKeyAuth(home: string, apiKey: string): Promise<boolean> {
|
||||
const authPath = path.join(home, "auth.json");
|
||||
const existing = await fs.lstat(authPath).catch(() => null);
|
||||
if (!existing || existing.isSymbolicLink()) return false;
|
||||
try {
|
||||
const raw = await fs.readFile(authPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return readApiKeyFromAuthPayload(parsed) === apiKey.trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureParentDir(target: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
}
|
||||
@@ -307,11 +328,15 @@ export async function reconcileManagedCodexHome(
|
||||
return { status: "already_seeded", home: resolved };
|
||||
}
|
||||
|
||||
if (apiKey && await codexHomeHasMatchingApiKeyAuth(resolved, apiKey)) {
|
||||
return { status: "already_seeded", home: resolved };
|
||||
}
|
||||
|
||||
await seedManagedCodexHome(resolved, env, input.onLog ?? noopOnLog, { apiKey });
|
||||
|
||||
// Seeding always (re)writes when an API key is supplied; otherwise it only
|
||||
// changes disk state when auth was missing. Report accordingly so callers can
|
||||
// distinguish a real backfill from a verified no-op.
|
||||
// Without an API key, seeding only changes disk state when auth was missing.
|
||||
// With an API key, the matching-file short-circuit above filters out the
|
||||
// already-seeded case before this write path.
|
||||
const status: ReconcileManagedCodexHomeStatus =
|
||||
!apiKey && hadUsableAuth ? "already_seeded" : "seeded";
|
||||
return { status, home: resolved };
|
||||
|
||||
@@ -381,7 +381,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
) {
|
||||
throw new Error(
|
||||
`no Codex credentials provisioned for managed home "${effectiveCodexHome}" ` +
|
||||
`(no usable ${path.basename(effectiveCodexHome)}/auth.json and OPENAI_API_KEY is empty). ` +
|
||||
`(no usable auth.json and OPENAI_API_KEY is empty). ` +
|
||||
`Sign in to Codex on the host with a ChatGPT subscription, or configure a per-agent ` +
|
||||
`OPENAI_API_KEY.`,
|
||||
);
|
||||
|
||||
@@ -179,5 +179,11 @@ describe("reconcileCodexLocalManagedHomesOnStartup", () => {
|
||||
expect(summary).toMatchObject({ scanned: 1, seeded: 1, failed: 0 });
|
||||
const written = JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8"));
|
||||
expect(written).toEqual({ OPENAI_API_KEY: "sk-plain-1" });
|
||||
|
||||
const second = await reconcileCodexLocalManagedHomesOnStartup(makeDb(rows));
|
||||
expect(second).toMatchObject({ scanned: 1, seeded: 0, alreadySeeded: 1, failed: 0 });
|
||||
expect(JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8"))).toEqual({
|
||||
OPENAI_API_KEY: "sk-plain-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,12 @@ type LogEntry = {
|
||||
chunk: string;
|
||||
};
|
||||
|
||||
async function seedSharedCodexAuth(homeRoot: string): Promise<void> {
|
||||
const sharedCodexHome = path.join(homeRoot, ".codex");
|
||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||
}
|
||||
|
||||
function createLocalSandboxRunner() {
|
||||
let counter = 0;
|
||||
return {
|
||||
@@ -201,6 +207,7 @@ describe("codex execute", () => {
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
let commandNotes: string[] = [];
|
||||
try {
|
||||
@@ -261,6 +268,7 @@ describe("codex execute", () => {
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
let loggedCommand: string | null = null;
|
||||
let loggedEnv: Record<string, string> = {};
|
||||
@@ -328,6 +336,7 @@ describe("codex execute", () => {
|
||||
|
||||
process.env.HOME = root;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
@@ -395,6 +404,7 @@ describe("codex execute", () => {
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
@@ -505,6 +515,7 @@ describe("codex execute", () => {
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
@@ -555,6 +566,7 @@ describe("codex execute", () => {
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 22, 22, 29, 0));
|
||||
|
||||
@@ -615,6 +627,7 @@ describe("codex execute", () => {
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
let commandNotes: string[] = [];
|
||||
try {
|
||||
|
||||
@@ -168,6 +168,15 @@ vi.mock("../services/index.js", () => ({
|
||||
})),
|
||||
})),
|
||||
reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
reconcileCodexLocalManagedHomesOnStartup: vi.fn(async () => ({
|
||||
scanned: 0,
|
||||
seeded: 0,
|
||||
alreadySeeded: 0,
|
||||
externalOverride: 0,
|
||||
noManagedHome: 0,
|
||||
failed: 0,
|
||||
seededAgentIds: [],
|
||||
})),
|
||||
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
routineService: vi.fn(() => ({
|
||||
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
|
||||
|
||||
+1
-1
@@ -733,7 +733,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
// Backfill auth.json into any already-isolated codex_local managed home that
|
||||
// was created by the #8272 isolation guard before the Phase 1 seeding fix.
|
||||
// Idempotent; the Phase 1 execute-time seeding covers new strandings.
|
||||
void reconcileCodexLocalManagedHomesOnStartup(db as any)
|
||||
void reconcileCodexLocalManagedHomesOnStartup(db)
|
||||
.then((result) => {
|
||||
if (result.seeded > 0 || result.failed > 0) {
|
||||
logger.warn(
|
||||
|
||||
Reference in new issue
Block a user