Merge branch 'master' into add-gpt-5-4-xhigh-effort

This commit is contained in:
Dotta 2026-03-31 06:19:26 -05:00 committed by GitHub
commit 19aaa54ae4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
882 changed files with 316479 additions and 9403 deletions

View file

@ -1,5 +1,24 @@
# @paperclipai/adapter-codex-local
## 0.3.1
### Patch Changes
- Stable release preparation for 0.3.1
- Updated dependencies
- @paperclipai/adapter-utils@0.3.1
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View file

@ -1,6 +1,16 @@
{
"name": "@paperclipai/adapter-codex-local",
"version": "0.2.7",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/adapters/codex-local"
},
"type": "module",
"exports": {
".": "./src/index.ts",
@ -38,7 +48,8 @@
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
import {
fetchCodexQuota,
fetchCodexRpcQuota,
getQuotaWindows,
readCodexAuthInfo,
readCodexToken,
} from "../server/quota.js";
interface ProbeArgs {
json: boolean;
rpcOnly: boolean;
whamOnly: boolean;
}
function parseArgs(argv: string[]): ProbeArgs {
return {
json: argv.includes("--json"),
rpcOnly: argv.includes("--rpc-only"),
whamOnly: argv.includes("--wham-only"),
};
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.rpcOnly && args.whamOnly) {
throw new Error("Choose either --rpc-only or --wham-only, not both.");
}
const auth = await readCodexAuthInfo();
const token = await readCodexToken();
const result: Record<string, unknown> = {
timestamp: new Date().toISOString(),
auth,
tokenAvailable: token != null,
};
if (!args.whamOnly) {
try {
result.rpc = {
ok: true,
...(await fetchCodexRpcQuota()),
};
} catch (error) {
result.rpc = {
ok: false,
error: stringifyError(error),
windows: [],
};
}
}
if (!args.rpcOnly) {
if (!token) {
result.wham = {
ok: false,
error: "No local Codex auth token found in ~/.codex/auth.json.",
windows: [],
};
} else {
try {
result.wham = {
ok: true,
windows: await fetchCodexQuota(token.token, token.accountId),
};
} catch (error) {
result.wham = {
ok: false,
error: stringifyError(error),
windows: [],
};
}
}
}
if (!args.rpcOnly && !args.whamOnly) {
try {
result.aggregated = await getQuotaWindows();
} catch (error) {
result.aggregated = {
ok: false,
error: stringifyError(error),
};
}
}
const rpcOk = (result.rpc as { ok?: boolean } | undefined)?.ok === true;
const whamOk = (result.wham as { ok?: boolean } | undefined)?.ok === true;
const aggregatedOk = (result.aggregated as { ok?: boolean } | undefined)?.ok === true;
const ok = rpcOk || whamOk || aggregatedOk;
if (args.json || process.stdout.isTTY === false) {
console.log(JSON.stringify({ ok, ...result }, null, 2));
} else {
console.log(`timestamp: ${result.timestamp}`);
console.log(`auth: ${JSON.stringify(auth)}`);
console.log(`tokenAvailable: ${token != null}`);
if (result.rpc) console.log(`rpc: ${JSON.stringify(result.rpc, null, 2)}`);
if (result.wham) console.log(`wham: ${JSON.stringify(result.wham, null, 2)}`);
if (result.aggregated) console.log(`aggregated: ${JSON.stringify(result.aggregated, null, 2)}`);
}
if (!ok) process.exitCode = 1;
}
await main();

View file

@ -31,6 +31,8 @@ Core fields:
- command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
@ -38,6 +40,10 @@ Operational fields:
Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument).
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances/<id>/companies/<companyId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View file

@ -0,0 +1,103 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-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;
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
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 paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
return companyId
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home")
: path.resolve(paperclipHome, "instances", instanceId, "codex-home");
}
async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
return;
}
if (!existing.isSymbolicLink()) {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
await fs.unlink(target);
await fs.symlink(source, target);
}
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);
}
export async function prepareManagedCodexHome(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
companyId?: string,
): Promise<string> {
const targetHome = resolveManagedCodexHomeDir(env, companyId);
const sourceHome = resolveSharedCodexHomeDir(env);
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
await fs.mkdir(targetHome, { recursive: true });
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`,
);
return targetHome;
}

View file

@ -1,8 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
@ -10,20 +9,23 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
renderTemplate,
joinPromptSections,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
import { resolveCodexDesiredSkillNames } from "./skills.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
@ -61,51 +63,157 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".codex");
function resolveCodexBiller(env: Record<string, string>, billingType: "api" | "subscription"): string {
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, "openai");
if (openAiCompatibleBiller === "openrouter") return "openrouter";
return billingType === "subscription" ? "chatgpt" : openAiCompatibleBiller ?? "openai";
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
pathExists(path.join(candidate, "package.json")),
pathExists(path.join(candidate, "server")),
pathExists(path.join(candidate, "packages", "adapter-utils")),
]);
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
}
async function isLikelyPaperclipRuntimeSkillPath(
candidate: string,
skillName: string,
options: { requireSkillMarkdown?: boolean } = {},
): Promise<boolean> {
if (path.basename(candidate) !== skillName) return false;
const skillsRoot = path.dirname(candidate);
if (path.basename(skillsRoot) !== "skills") return false;
if (options.requireSkillMarkdown !== false && !(await pathExists(path.join(candidate, "SKILL.md")))) {
return false;
}
return null;
let cursor = path.dirname(skillsRoot);
for (let depth = 0; depth < 6; depth += 1) {
if (await isLikelyPaperclipRepoRoot(cursor)) return true;
const parent = path.dirname(cursor);
if (parent === cursor) break;
cursor = parent;
}
return false;
}
async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
async function pruneBrokenUnavailablePaperclipSkillSymlinks(
skillsHome: string,
allowedSkillNames: Iterable<string>,
onLog: AdapterExecutionContext["onLog"],
) {
const allowed = new Set(Array.from(allowedSkillNames));
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
const skillsHome = path.join(codexHomeDir(), "skills");
await fs.mkdir(skillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
if (allowed.has(entry.name) || !entry.isSymbolicLink()) continue;
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) continue;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (await pathExists(resolvedLinkedPath)) continue;
if (
!(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.name, {
requireSkillMarkdown: false,
}))
) {
continue;
}
await fs.unlink(target).catch(() => {});
await onLog(
"stdout",
`[paperclip] Removed stale Codex skill "${entry.name}" from ${skillsHome}\n`,
);
}
}
function resolveCodexSkillsDir(codexHome: string): string {
return path.join(codexHome, "skills");
}
type EnsureCodexSkillsInjectedOptions = {
skillsHome?: string;
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
desiredSkillNames?: string[];
linkSkill?: (source: string, target: string) => Promise<void>;
};
export async function ensureCodexSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCodexSkillsInjectedOptions = {},
) {
const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir);
const desiredSkillNames =
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.key);
const desiredSet = new Set(desiredSkillNames);
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? resolveCodexSkillsDir(resolveSharedCodexHomeDir());
await fs.mkdir(skillsHome, { recursive: true });
const linkSkill = options.linkSkill;
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.runtimeName);
try {
await fs.symlink(source, target);
const existing = await fs.lstat(target).catch(() => null);
if (existing?.isSymbolicLink()) {
const linkedPath = await fs.readlink(target).catch(() => null);
const resolvedLinkedPath = linkedPath
? path.resolve(path.dirname(target), linkedPath)
: null;
if (
resolvedLinkedPath &&
resolvedLinkedPath !== entry.source &&
(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.runtimeName))
) {
await fs.unlink(target);
if (linkSkill) {
await linkSkill(entry.source, target);
} else {
await fs.symlink(entry.source, target);
}
await onLog(
"stdout",
`[paperclip] Repaired Codex skill "${entry.runtimeName}" into ${skillsHome}\n`,
);
continue;
}
}
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
"stdout",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.runtimeName}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Failed to inject Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
await pruneBrokenUnavailablePaperclipSkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.runtimeName),
onLog,
);
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const promptTemplate = asString(
config.promptTemplate,
@ -126,24 +234,61 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceBranch = asString(workspaceContext.branchName, "");
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
const agentHome = asString(workspaceContext.agentHome, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureCodexSkillsInjected(onLog);
const envConfig = parseObject(config.env);
const configuredCodexHome =
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
? path.resolve(envConfig.CODEX_HOME.trim())
: null;
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const preparedManagedCodexHome =
configuredCodexHome ? null : await prepareManagedCodexHome(process.env, onLog, agent.companyId);
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true });
// Inject skills into the same CODEX_HOME that Codex will actually run with
// (managed home in the default case, or an explicit override from adapter config).
const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome);
await ensureCodexSkillsInjected(
onLog,
{
skillsHome: codexSkillsDir,
skillsEntries: codexSkillEntries,
desiredSkillNames,
},
);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.CODEX_HOME = effectiveCodexHome;
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
@ -192,6 +337,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceStrategy) {
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
@ -201,18 +349,47 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (agentHome) {
env.AGENT_HOME = agentHome;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveCodexBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveCodexBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -231,13 +408,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
"stdout",
`[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let instructionsPrefix = "";
let instructionsChars = 0;
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
@ -245,31 +423,35 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
);
instructionsChars = instructionsPrefix.length;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
"stdout",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
);
}
}
const repoAgentsNote =
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
const commandNotes = (() => {
if (!instructionsFilePath) return [] as string[];
if (!instructionsFilePath) {
return [repoAgentsNote];
}
if (instructionsPrefix.length > 0) {
return [
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
repoAgentsNote,
];
}
return [
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
repoAgentsNote,
];
})();
const renderedPrompt = renderTemplate(promptTemplate, {
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
@ -277,8 +459,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
run: { id: runId, source: "on_demand" },
context,
});
const prompt = `${instructionsPrefix}${renderedPrompt}`;
};
const renderedPrompt = renderTemplate(promptTemplate, templateData);
const renderedBootstrapPrompt =
!sessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["exec", "--json"];
@ -297,15 +497,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "codex_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
return value;
}),
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,
});
}
@ -316,6 +517,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stdin: prompt,
timeoutSec,
graceSec,
onSpawn,
onLog: async (stream, chunk) => {
if (stream !== "stderr") {
await onLog(stream, chunk);
@ -381,6 +583,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "openai",
biller: resolveCodexBiller(effectiveEnv, billingType),
model,
billingType,
costUsd: null,
@ -401,7 +604,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stderr",
"stdout",
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);

View file

@ -1,6 +1,18 @@
export { execute } from "./execute.js";
export { execute, ensureCodexSkillsInjected } from "./execute.js";
export { listCodexSkills, syncCodexSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
export {
getQuotaWindows,
readCodexAuthInfo,
readCodexToken,
fetchCodexQuota,
fetchCodexRpcQuota,
mapCodexRpcQuota,
secondsToWindowLabel,
fetchWithTimeout,
codexHomeDir,
} from "./quota.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {

View file

@ -0,0 +1,85 @@
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ChildProcess } from "node:child_process";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const cp = await importOriginal<typeof import("node:child_process")>();
return {
...cp,
spawn: (...args: Parameters<typeof cp.spawn>) => mockSpawn(...args) as ReturnType<typeof cp.spawn>,
};
});
import { getQuotaWindows } from "./quota.js";
function createChildThatErrorsOnMicrotask(err: Error): ChildProcess {
const child = new EventEmitter() as ChildProcess;
const stream = Object.assign(new EventEmitter(), {
setEncoding: () => {},
});
Object.assign(child, {
stdout: stream,
stderr: Object.assign(new EventEmitter(), { setEncoding: () => {} }),
stdin: { write: vi.fn(), end: vi.fn() },
kill: vi.fn(),
});
queueMicrotask(() => {
child.emit("error", err);
});
return child;
}
describe("CodexRpcClient spawn failures", () => {
let previousCodexHome: string | undefined;
let isolatedCodexHome: string | undefined;
beforeEach(() => {
mockSpawn.mockReset();
// After the RPC path fails, getQuotaWindows() calls readCodexToken() which
// reads $CODEX_HOME/auth.json (default ~/.codex). Point CODEX_HOME at an
// empty temp directory so we never hit real host auth or the WHAM network.
previousCodexHome = process.env.CODEX_HOME;
isolatedCodexHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-codex-spawn-test-"));
process.env.CODEX_HOME = isolatedCodexHome;
});
afterEach(() => {
if (isolatedCodexHome) {
try {
fs.rmSync(isolatedCodexHome, { recursive: true, force: true });
} catch {
/* ignore */
}
isolatedCodexHome = undefined;
}
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodexHome;
}
});
it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => {
const enoent = Object.assign(new Error("spawn codex ENOENT"), {
code: "ENOENT",
errno: -2,
syscall: "spawn codex",
path: "codex",
});
mockSpawn.mockImplementation(() => createChildThatErrorsOnMicrotask(enoent));
const result = await getQuotaWindows();
expect(result.ok).toBe(false);
expect(result.windows).toEqual([]);
expect(result.error).toContain("Codex app-server");
expect(result.error).toContain("spawn codex ENOENT");
});
});

View file

@ -0,0 +1,563 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
const CODEX_USAGE_SOURCE_RPC = "codex-rpc";
const CODEX_USAGE_SOURCE_WHAM = "codex-wham";
export function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".codex");
}
interface CodexLegacyAuthFile {
accessToken?: string | null;
accountId?: string | null;
}
interface CodexTokenBlock {
id_token?: string | null;
access_token?: string | null;
refresh_token?: string | null;
account_id?: string | null;
}
interface CodexModernAuthFile {
OPENAI_API_KEY?: string | null;
tokens?: CodexTokenBlock | null;
last_refresh?: string | null;
}
export interface CodexAuthInfo {
accessToken: string;
accountId: string | null;
refreshToken: string | null;
idToken: string | null;
email: string | null;
planType: string | null;
lastRefresh: string | null;
}
function base64UrlDecode(input: string): string | null {
try {
let normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const remainder = normalized.length % 4;
if (remainder > 0) normalized += "=".repeat(4 - remainder);
return Buffer.from(normalized, "base64").toString("utf8");
} catch {
return null;
}
}
function decodeJwtPayload(token: string | null | undefined): Record<string, unknown> | null {
if (typeof token !== "string" || token.trim().length === 0) return null;
const parts = token.split(".");
if (parts.length < 2) return null;
const decoded = base64UrlDecode(parts[1] ?? "");
if (!decoded) return null;
try {
const parsed = JSON.parse(decoded) as unknown;
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function readNestedString(record: Record<string, unknown>, pathSegments: string[]): string | null {
let current: unknown = record;
for (const segment of pathSegments) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" && current.trim().length > 0 ? current.trim() : null;
}
function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string | null): {
email: string | null;
planType: string | null;
} {
const payloads = [decodeJwtPayload(idToken), decodeJwtPayload(accessToken)].filter(
(value): value is Record<string, unknown> => value != null,
);
for (const payload of payloads) {
const directEmail = typeof payload.email === "string" ? payload.email : null;
const authBlock =
typeof payload["https://api.openai.com/auth"] === "object" &&
payload["https://api.openai.com/auth"] !== null &&
!Array.isArray(payload["https://api.openai.com/auth"])
? payload["https://api.openai.com/auth"] as Record<string, unknown>
: null;
const profileBlock =
typeof payload["https://api.openai.com/profile"] === "object" &&
payload["https://api.openai.com/profile"] !== null &&
!Array.isArray(payload["https://api.openai.com/profile"])
? payload["https://api.openai.com/profile"] as Record<string, unknown>
: null;
const email =
directEmail
?? (typeof profileBlock?.email === "string" ? profileBlock.email : null)
?? (typeof authBlock?.chatgpt_user_email === "string" ? authBlock.chatgpt_user_email : null);
const planType =
typeof authBlock?.chatgpt_plan_type === "string" ? authBlock.chatgpt_plan_type : null;
if (email || planType) return { email: email ?? null, planType };
}
return { email: null, planType: null };
}
export async function readCodexAuthInfo(codexHome?: string): Promise<CodexAuthInfo | null> {
const authPath = path.join(codexHome ?? codexHomeDir(), "auth.json");
let raw: string;
try {
raw = await fs.readFile(authPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
const modern = obj as CodexModernAuthFile;
const legacy = obj as CodexLegacyAuthFile;
const accessToken =
legacy.accessToken
?? modern.tokens?.access_token
?? readNestedString(obj, ["tokens", "access_token"]);
if (typeof accessToken !== "string" || accessToken.length === 0) return null;
const accountId =
legacy.accountId
?? modern.tokens?.account_id
?? readNestedString(obj, ["tokens", "account_id"]);
const refreshToken =
modern.tokens?.refresh_token
?? readNestedString(obj, ["tokens", "refresh_token"]);
const idToken =
modern.tokens?.id_token
?? readNestedString(obj, ["tokens", "id_token"]);
const { email, planType } = parsePlanAndEmailFromToken(idToken, accessToken);
return {
accessToken,
accountId:
typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : null,
refreshToken:
typeof refreshToken === "string" && refreshToken.trim().length > 0 ? refreshToken.trim() : null,
idToken:
typeof idToken === "string" && idToken.trim().length > 0 ? idToken.trim() : null,
email,
planType,
lastRefresh:
typeof modern.last_refresh === "string" && modern.last_refresh.trim().length > 0
? modern.last_refresh.trim()
: null,
};
}
export async function readCodexToken(): Promise<{ token: string; accountId: string | null } | null> {
const auth = await readCodexAuthInfo();
if (!auth) return null;
return { token: auth.accessToken, accountId: auth.accountId };
}
interface WhamWindow {
used_percent?: number | null;
limit_window_seconds?: number | null;
reset_at?: string | number | null;
}
interface WhamCredits {
balance?: number | null;
unlimited?: boolean | null;
}
interface WhamUsageResponse {
plan_type?: string | null;
rate_limit?: {
primary_window?: WhamWindow | null;
secondary_window?: WhamWindow | null;
} | null;
credits?: WhamCredits | null;
}
/**
* Map a window duration in seconds to a human-readable label.
* Falls back to the provided fallback string when seconds is null/undefined.
*/
export function secondsToWindowLabel(
seconds: number | null | undefined,
fallback: string,
): string {
if (seconds == null) return fallback;
const hours = seconds / 3600;
if (hours < 6) return "5h";
if (hours <= 24) return "24h";
if (hours <= 168) return "7d";
return `${Math.round(hours / 24)}d`;
}
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
export async function fetchWithTimeout(
url: string,
init: RequestInit,
ms = 8000,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function normalizeCodexUsedPercent(rawPct: number | null | undefined): number | null {
if (rawPct == null) return null;
return Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct));
}
export async function fetchCodexQuota(
token: string,
accountId: string | null,
): Promise<QuotaWindow[]> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
const resp = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", { headers });
if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
const body = (await resp.json()) as WhamUsageResponse;
const windows: QuotaWindow[] = [];
const rateLimit = body.rate_limit;
if (rateLimit?.primary_window != null) {
const w = rateLimit.primary_window;
windows.push({
label: "5h limit",
usedPercent: normalizeCodexUsedPercent(w.used_percent),
resetsAt:
typeof w.reset_at === "number"
? unixSecondsToIso(w.reset_at)
: (w.reset_at ?? null),
valueLabel: null,
detail: null,
});
}
if (rateLimit?.secondary_window != null) {
const w = rateLimit.secondary_window;
windows.push({
label: "Weekly limit",
usedPercent: normalizeCodexUsedPercent(w.used_percent),
resetsAt:
typeof w.reset_at === "number"
? unixSecondsToIso(w.reset_at)
: (w.reset_at ?? null),
valueLabel: null,
detail: null,
});
}
if (body.credits != null && body.credits.unlimited !== true) {
const balance = body.credits.balance;
const valueLabel = balance != null ? `$${(balance / 100).toFixed(2)} remaining` : "N/A";
windows.push({
label: "Credits",
usedPercent: null,
resetsAt: null,
valueLabel,
detail: null,
});
}
return windows;
}
interface CodexRpcWindow {
usedPercent?: number | null;
windowDurationMins?: number | null;
resetsAt?: number | null;
}
interface CodexRpcCredits {
hasCredits?: boolean | null;
unlimited?: boolean | null;
balance?: string | number | null;
}
interface CodexRpcLimit {
limitId?: string | null;
limitName?: string | null;
primary?: CodexRpcWindow | null;
secondary?: CodexRpcWindow | null;
credits?: CodexRpcCredits | null;
planType?: string | null;
}
interface CodexRpcRateLimitsResult {
rateLimits?: CodexRpcLimit | null;
rateLimitsByLimitId?: Record<string, CodexRpcLimit> | null;
}
interface CodexRpcAccountResult {
account?: {
type?: string | null;
email?: string | null;
planType?: string | null;
} | null;
requiresOpenaiAuth?: boolean | null;
}
export interface CodexRpcQuotaSnapshot {
windows: QuotaWindow[];
email: string | null;
planType: string | null;
}
function unixSecondsToIso(value: number | null | undefined): string | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
return new Date(value * 1000).toISOString();
}
function buildCodexRpcWindow(label: string, window: CodexRpcWindow | null | undefined): QuotaWindow | null {
if (!window) return null;
return {
label,
usedPercent: normalizeCodexUsedPercent(window.usedPercent),
resetsAt: unixSecondsToIso(window.resetsAt),
valueLabel: null,
detail: null,
};
}
function parseCreditBalance(value: string | number | null | undefined): string | null {
if (typeof value === "number" && Number.isFinite(value)) {
return `$${value.toFixed(2)} remaining`;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return `$${parsed.toFixed(2)} remaining`;
}
return value.trim();
}
return null;
}
export function mapCodexRpcQuota(result: CodexRpcRateLimitsResult, account?: CodexRpcAccountResult | null): CodexRpcQuotaSnapshot {
const windows: QuotaWindow[] = [];
const limitOrder = ["codex"];
const limitsById = result.rateLimitsByLimitId ?? {};
for (const key of Object.keys(limitsById)) {
if (!limitOrder.includes(key)) limitOrder.push(key);
}
const rootLimit = result.rateLimits ?? null;
const allLimits = new Map<string, CodexRpcLimit>();
if (rootLimit?.limitId) allLimits.set(rootLimit.limitId, rootLimit);
for (const [key, value] of Object.entries(limitsById)) {
allLimits.set(key, value);
}
if (!allLimits.has("codex") && rootLimit) allLimits.set("codex", rootLimit);
for (const limitId of limitOrder) {
const limit = allLimits.get(limitId);
if (!limit) continue;
const prefix =
limitId === "codex"
? ""
: `${limit.limitName ?? limitId} · `;
const primary = buildCodexRpcWindow(`${prefix}5h limit`, limit.primary);
if (primary) windows.push(primary);
const secondary = buildCodexRpcWindow(`${prefix}Weekly limit`, limit.secondary);
if (secondary) windows.push(secondary);
if (limitId === "codex" && limit.credits && limit.credits.unlimited !== true) {
windows.push({
label: "Credits",
usedPercent: null,
resetsAt: null,
valueLabel: parseCreditBalance(limit.credits.balance) ?? "N/A",
detail: null,
});
}
}
return {
windows,
email:
typeof account?.account?.email === "string" && account.account.email.trim().length > 0
? account.account.email.trim()
: null,
planType:
typeof account?.account?.planType === "string" && account.account.planType.trim().length > 0
? account.account.planType.trim()
: (typeof rootLimit?.planType === "string" && rootLimit.planType.trim().length > 0 ? rootLimit.planType.trim() : null),
};
}
type PendingRequest = {
resolve: (value: Record<string, unknown>) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
};
class CodexRpcClient {
private proc = spawn(
"codex",
["-s", "read-only", "-a", "untrusted", "app-server"],
{ stdio: ["pipe", "pipe", "pipe"], env: process.env },
);
private nextId = 1;
private buffer = "";
private pending = new Map<number, PendingRequest>();
private stderr = "";
constructor() {
this.proc.stdout.setEncoding("utf8");
this.proc.stderr.setEncoding("utf8");
this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
this.proc.stderr.on("data", (chunk: string) => {
this.stderr += chunk;
});
this.proc.on("exit", () => {
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(new Error(this.stderr.trim() || "codex app-server closed unexpectedly"));
}
this.pending.clear();
});
this.proc.on("error", (err: Error) => {
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(err);
}
this.pending.clear();
});
}
private onStdout(chunk: string) {
this.buffer += chunk;
while (true) {
const newlineIndex = this.buffer.indexOf("\n");
if (newlineIndex < 0) break;
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (!line) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
const id = typeof parsed.id === "number" ? parsed.id : null;
if (id == null) continue;
const pending = this.pending.get(id);
if (!pending) continue;
this.pending.delete(id);
clearTimeout(pending.timer);
pending.resolve(parsed);
}
}
private request(method: string, params: Record<string, unknown> = {}, timeoutMs = 6_000): Promise<Record<string, unknown>> {
const id = this.nextId++;
const payload = JSON.stringify({ id, method, params }) + "\n";
return new Promise<Record<string, unknown>>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`codex app-server timed out on ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.proc.stdin.write(payload);
});
}
private notify(method: string, params: Record<string, unknown> = {}) {
this.proc.stdin.write(JSON.stringify({ method, params }) + "\n");
}
async initialize() {
await this.request("initialize", {
clientInfo: {
name: "paperclip",
version: "0.0.0",
},
});
this.notify("initialized", {});
}
async fetchRateLimits(): Promise<CodexRpcRateLimitsResult> {
const message = await this.request("account/rateLimits/read");
return (message.result as CodexRpcRateLimitsResult | undefined) ?? {};
}
async fetchAccount(): Promise<CodexRpcAccountResult | null> {
try {
const message = await this.request("account/read");
return (message.result as CodexRpcAccountResult | undefined) ?? null;
} catch {
return null;
}
}
async shutdown() {
this.proc.kill("SIGTERM");
}
}
export async function fetchCodexRpcQuota(): Promise<CodexRpcQuotaSnapshot> {
const client = new CodexRpcClient();
try {
await client.initialize();
const [limits, account] = await Promise.all([
client.fetchRateLimits(),
client.fetchAccount(),
]);
return mapCodexRpcQuota(limits, account);
} finally {
await client.shutdown();
}
}
function formatProviderError(source: string, error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return `${source}: ${message}`;
}
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
const errors: string[] = [];
try {
const rpc = await fetchCodexRpcQuota();
if (rpc.windows.length > 0) {
return { provider: "openai", source: CODEX_USAGE_SOURCE_RPC, ok: true, windows: rpc.windows };
}
} catch (error) {
errors.push(formatProviderError("Codex app-server", error));
}
const auth = await readCodexToken();
if (auth) {
try {
const windows = await fetchCodexQuota(auth.token, auth.accountId);
return { provider: "openai", source: CODEX_USAGE_SOURCE_WHAM, ok: true, windows };
} catch (error) {
errors.push(formatProviderError("ChatGPT WHAM usage", error));
}
} else {
errors.push("no local codex auth token");
}
return {
provider: "openai",
ok: false,
error: errors.join("; "),
windows: [],
};
}

View file

@ -0,0 +1,87 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
async function buildCodexSkillSnapshot(
config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be linked into the effective CODEX_HOME/skills/ directory on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "codex_local",
supported: true,
mode: "ephemeral",
desiredSkills,
entries,
warnings,
};
}
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
return buildCodexSkillSnapshot(ctx.config);
}
export async function syncCodexSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildCodexSkillSnapshot(ctx.config);
}
export function resolveCodexDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View file

@ -15,6 +15,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import path from "node:path";
import { parseCodexJsonl } from "./parse.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -108,12 +109,23 @@ export async function testEnvironment(
detail: `Detected in ${source}.`,
});
} else {
checks.push({
code: "codex_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or Codex auth configuration.",
});
const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
if (codexAuth) {
checks.push({
code: "codex_native_auth_present",
level: "info",
message: "Codex is authenticated via its own auth configuration.",
detail: codexAuth.email ? `Logged in as ${codexAuth.email}.` : `Credentials found in ${path.join(codexHome ?? codexHomeDir(), "auth.json")}.`,
});
} else {
checks.push({
code: "codex_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or run `codex auth` to log in.",
});
}
}
const canRunProbe =

View file

@ -54,11 +54,24 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
ac.timeoutSec = 0;
@ -76,6 +89,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
typeof v.dangerouslyBypassSandbox === "boolean"
? v.dangerouslyBypassSandbox
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;

View file

@ -1,4 +1,4 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { type TranscriptEntry } from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
@ -57,6 +57,7 @@ function parseCommandExecutionItem(
const command = asString(item.command);
const status = asString(item.status);
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
const safeCommand = command;
const output = asString(item.aggregated_output).replace(/\s+$/, "");
if (phase === "started") {
@ -64,15 +65,16 @@ function parseCommandExecutionItem(
kind: "tool_call",
ts,
name: "command_execution",
toolUseId: id || command || "command_execution",
input: {
id,
command,
command: safeCommand,
},
}];
}
const lines: string[] = [];
if (command) lines.push(`command: ${command}`);
if (safeCommand) lines.push(`command: ${safeCommand}`);
if (status) lines.push(`status: ${status}`);
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
if (output) {
@ -148,6 +150,7 @@ function parseCodexItem(
kind: "tool_call",
ts,
name: asString(item.name, "unknown"),
toolUseId: asString(item.id),
input: item.input ?? {},
}];
}
@ -171,7 +174,11 @@ function parseCodexItem(
const id = asString(item.id);
const status = asString(item.status);
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }];
return [{
kind: "system",
ts,
text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`,
}];
}
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {

View file

@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});