mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
Phase 1 seeds Paperclip-managed CODEX_HOME at execute time, which fixes new strandings. Agents already carrying a persisted (but unseeded) per-agent CODEX_HOME from the #8272 isolation guard (e.g. CTO) only recover the next time they run. Add a proactive startup backfill so a stranded agent's auth.json is seeded without waiting to run. - Add idempotent reconcileManagedCodexHome to the codex-local adapter, reusing the Phase 1 managed-home detection (isManagedCodexHomePath) and seeding (seedManagedCodexHome). Genuine external/user CODEX_HOME overrides are left untouched; a home with valid auth is a no-op. - Add a server startup reconciliation that enumerates codex_local agents and seeds any persisted managed home. Only literal (plain) env values are used, so a secret-bound OPENAI_API_KEY is never written verbatim into auth.json (falls back to the subscription symlink). - Wire the reconciliation into server boot alongside the other reconcile*OnStartup steps. Safe to re-run on every boot. Adds unit tests for the reconcile primitive (seed/no-op/external/api-key) and the server service (status accounting, secret-binding safety). Co-Authored-By: Paperclip <noreply@paperclip.ing>
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
|
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
|
|
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
|
|
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
|
|
|
|
function nonEmpty(value: string | undefined): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
export async function pathExists(candidate: string): Promise<boolean> {
|
|
return fs.access(candidate).then(() => true).catch(() => false);
|
|
}
|
|
|
|
export function resolveSharedCodexHomeDir(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string {
|
|
const fromEnv = nonEmpty(env.CODEX_HOME);
|
|
return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex");
|
|
}
|
|
|
|
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
|
|
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
|
|
}
|
|
|
|
export function resolveManagedCodexHomeDir(
|
|
env: NodeJS.ProcessEnv,
|
|
companyId?: string,
|
|
): string {
|
|
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
|
|
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
|
|
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
|
env,
|
|
});
|
|
return companyId
|
|
? path.resolve(instanceRoot, "companies", companyId, "codex-home")
|
|
: path.resolve(instanceRoot, "codex-home");
|
|
}
|
|
|
|
/**
|
|
* True when `homePath` lives under the Paperclip-managed company tree
|
|
* (`<instanceRoot>/companies/<companyId>/...`). This covers both the shared
|
|
* company `codex-home` and the per-agent `agents/<agentId>/codex-home` set by
|
|
* the server-side isolation guard. A path outside that tree is a genuine
|
|
* external/user-supplied override that Paperclip must not seed or overwrite.
|
|
*/
|
|
export function isManagedCodexHomePath(
|
|
env: NodeJS.ProcessEnv,
|
|
companyId: string | undefined,
|
|
homePath: string,
|
|
): boolean {
|
|
if (!companyId) return false;
|
|
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
|
|
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
|
|
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
|
env,
|
|
});
|
|
const companyRoot = path.resolve(instanceRoot, "companies", companyId);
|
|
const resolved = path.resolve(homePath);
|
|
return resolved === companyRoot || resolved.startsWith(companyRoot + path.sep);
|
|
}
|
|
|
|
/**
|
|
* True when the Codex home has a usable `auth.json`. Uses `fs.access` (follows
|
|
* symlinks), so a dangling auth symlink whose source has been removed counts as
|
|
* no usable credentials.
|
|
*/
|
|
export async function codexHomeHasUsableAuth(home: string): Promise<boolean> {
|
|
return pathExists(path.join(home, "auth.json"));
|
|
}
|
|
|
|
async function ensureParentDir(target: string): Promise<void> {
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
}
|
|
|
|
async function isExpectedSymlink(target: string, source: string): Promise<boolean> {
|
|
const existing = await fs.lstat(target).catch(() => null);
|
|
if (!existing?.isSymbolicLink()) return false;
|
|
|
|
const linkedPath = await fs.readlink(target).catch(() => null);
|
|
if (!linkedPath) return false;
|
|
|
|
return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
|
|
}
|
|
|
|
async function createExpectedSymlink(target: string, source: string): Promise<void> {
|
|
try {
|
|
await fs.symlink(source, target);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === "EEXIST" && await isExpectedSymlink(target, source)) return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function ensureSymlink(target: string, source: string): Promise<void> {
|
|
const existing = await fs.lstat(target).catch(() => null);
|
|
if (!existing) {
|
|
await ensureParentDir(target);
|
|
await createExpectedSymlink(target, source);
|
|
return;
|
|
}
|
|
|
|
if (!existing.isSymbolicLink()) {
|
|
// A previous Paperclip version copied this file into the managed home
|
|
// instead of symlinking it. Codex refresh tokens rotate and are
|
|
// single-use, so a stale copy fails with refresh_token_reused on the next
|
|
// run (#5028). Replace the regular file with a symlink so the CLI follows
|
|
// the live source. Safe to delete: target is always under the
|
|
// Paperclip-managed company home, never the user's real ~/.codex.
|
|
// Directories are left alone — `fs.unlink` would throw EISDIR on Unix
|
|
// (and behave inconsistently on Windows). A directory at this path is not
|
|
// a Paperclip-written stale copy and warrants operator inspection rather
|
|
// than silent removal.
|
|
if (existing.isDirectory()) return;
|
|
await fs.unlink(target);
|
|
await createExpectedSymlink(target, source);
|
|
return;
|
|
}
|
|
|
|
if (await isExpectedSymlink(target, source)) return;
|
|
|
|
await fs.unlink(target);
|
|
await createExpectedSymlink(target, source);
|
|
}
|
|
|
|
async function ensureCopiedFile(target: string, source: string): Promise<void> {
|
|
const existing = await fs.lstat(target).catch(() => null);
|
|
if (existing) return;
|
|
await ensureParentDir(target);
|
|
await fs.copyFile(source, target);
|
|
}
|
|
|
|
/**
|
|
* Writes an `auth.json` containing only `OPENAI_API_KEY` so the codex CLI can
|
|
* authenticate via API key. Overwrites any existing file or symlink at that
|
|
* path. Required because the codex CLI (>= 0.122) ignores the `OPENAI_API_KEY`
|
|
* environment variable and only reads credentials from `$CODEX_HOME/auth.json`.
|
|
*/
|
|
export async function writeApiKeyAuthJson(home: string, apiKey: string): Promise<void> {
|
|
await fs.mkdir(home, { recursive: true });
|
|
const target = path.join(home, "auth.json");
|
|
await fs.rm(target, { force: true });
|
|
await fs.writeFile(target, JSON.stringify({ OPENAI_API_KEY: apiKey }), { mode: 0o600 });
|
|
}
|
|
|
|
/**
|
|
* Seeds auth/config into an explicit Paperclip-managed `targetHome`. Symlinks
|
|
* `auth.json` from the shared source home (so ChatGPT-subscription credentials
|
|
* stay live and single-use refresh tokens are not copied), copies the static
|
|
* shared config files, and — when an API key is supplied — writes an API-key
|
|
* `auth.json` instead. Used both for the default company home and for the
|
|
* per-agent home set by the server isolation guard.
|
|
*/
|
|
export async function seedManagedCodexHome(
|
|
targetHome: string,
|
|
env: NodeJS.ProcessEnv,
|
|
onLog: AdapterExecutionContext["onLog"],
|
|
options: { apiKey?: string | null } = {},
|
|
): Promise<void> {
|
|
const apiKey = nonEmpty(options.apiKey ?? undefined);
|
|
|
|
const sourceHome = resolveSharedCodexHomeDir(env);
|
|
const seedFromShared = path.resolve(sourceHome) !== path.resolve(targetHome);
|
|
|
|
await fs.mkdir(targetHome, { recursive: true });
|
|
|
|
// If a previous run wrote an apikey-mode auth.json (regular file) and this
|
|
// run has no apiKey, remove it so the chatgpt-mode symlink can be restored.
|
|
// Without this cleanup, ensureSymlink bails on a non-symlink and Codex keeps
|
|
// authenticating with the stale key after it is removed from configuration.
|
|
if (!apiKey && seedFromShared) {
|
|
const authPath = path.join(targetHome, "auth.json");
|
|
const existing = await fs.lstat(authPath).catch(() => null);
|
|
if (existing && !existing.isSymbolicLink()) {
|
|
await fs.rm(authPath, { force: true });
|
|
}
|
|
}
|
|
|
|
if (seedFromShared) {
|
|
for (const name of SYMLINKED_SHARED_FILES) {
|
|
const source = path.join(sourceHome, name);
|
|
if (!(await pathExists(source))) continue;
|
|
await ensureSymlink(path.join(targetHome, name), source);
|
|
}
|
|
|
|
for (const name of COPIED_SHARED_FILES) {
|
|
const source = path.join(sourceHome, name);
|
|
if (!(await pathExists(source))) continue;
|
|
await ensureCopiedFile(path.join(targetHome, name), source);
|
|
}
|
|
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
|
|
);
|
|
}
|
|
|
|
if (apiKey) {
|
|
await writeApiKeyAuthJson(targetHome, apiKey);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function prepareManagedCodexHome(
|
|
env: NodeJS.ProcessEnv,
|
|
onLog: AdapterExecutionContext["onLog"],
|
|
companyId?: string,
|
|
options: { apiKey?: string | null } = {},
|
|
): Promise<string> {
|
|
const targetHome = resolveManagedCodexHomeDir(env, companyId);
|
|
await seedManagedCodexHome(targetHome, env, onLog, options);
|
|
return targetHome;
|
|
}
|
|
|
|
export type ReconcileManagedCodexHomeStatus =
|
|
| "no_managed_home"
|
|
| "external_override"
|
|
| "already_seeded"
|
|
| "seeded";
|
|
|
|
export interface ReconcileManagedCodexHomeInput {
|
|
companyId: string | undefined;
|
|
configuredCodexHome: string | null | undefined;
|
|
apiKey?: string | null;
|
|
env?: NodeJS.ProcessEnv;
|
|
onLog?: AdapterExecutionContext["onLog"];
|
|
}
|
|
|
|
export interface ReconcileManagedCodexHomeResult {
|
|
status: ReconcileManagedCodexHomeStatus;
|
|
home: string | null;
|
|
}
|
|
|
|
const noopOnLog: AdapterExecutionContext["onLog"] = async () => {};
|
|
|
|
/**
|
|
* Idempotently reconciles a persisted `codex_local` agent home. Phase 1 seeds
|
|
* managed homes at execute time; this is the backfill for agents that already
|
|
* carry a persisted (but unseeded) per-agent `CODEX_HOME` and have not run
|
|
* since the seeding fix landed. Shares the managed-home detection
|
|
* (`isManagedCodexHomePath`) and seeding (`seedManagedCodexHome`) logic so a
|
|
* genuine external/user override is never touched. Safe to re-run: when a valid
|
|
* `auth.json` is already present (and no API-key rewrite is requested) it is a
|
|
* no-op and reports `already_seeded`.
|
|
*/
|
|
export async function reconcileManagedCodexHome(
|
|
input: ReconcileManagedCodexHomeInput,
|
|
): Promise<ReconcileManagedCodexHomeResult> {
|
|
const env = input.env ?? process.env;
|
|
const configured = nonEmpty(input.configuredCodexHome ?? undefined);
|
|
if (!configured) return { status: "no_managed_home", home: null };
|
|
|
|
const resolved = path.resolve(configured);
|
|
if (!isManagedCodexHomePath(env, input.companyId, resolved)) {
|
|
return { status: "external_override", home: resolved };
|
|
}
|
|
|
|
const apiKey = nonEmpty(input.apiKey ?? undefined);
|
|
const hadUsableAuth = await codexHomeHasUsableAuth(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.
|
|
const status: ReconcileManagedCodexHomeStatus =
|
|
!apiKey && hadUsableAuth ? "already_seeded" : "seeded";
|
|
return { status, home: resolved };
|
|
}
|