mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Merge branch 'master' into add-gpt-5-4-xhigh-effort
This commit is contained in:
commit
19aaa54ae4
882 changed files with 316479 additions and 9403 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
112
packages/adapters/codex-local/src/cli/quota-probe.ts
Normal file
112
packages/adapters/codex-local/src/cli/quota-probe.ts
Normal 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();
|
||||
|
|
@ -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.
|
||||
`;
|
||||
|
|
|
|||
103
packages/adapters/codex-local/src/server/codex-home.ts
Normal file
103
packages/adapters/codex-local/src/server/codex-home.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
563
packages/adapters/codex-local/src/server/quota.ts
Normal file
563
packages/adapters/codex-local/src/server/quota.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
87
packages/adapters/codex-local/src/server/skills.ts
Normal file
87
packages/adapters/codex-local/src/server/skills.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
|
|
|||
7
packages/adapters/codex-local/vitest.config.ts
Normal file
7
packages/adapters/codex-local/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue