Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ce2466f
feat: support Codex ChatGPT OAuth auth
open-swe Aug 2, 2026
9f1bae6
test: use trusted Codex OAuth fixtures
open-swe Aug 2, 2026
a022662
fix: persist rotated Codex OAuth tokens
open-swe Aug 2, 2026
c33c558
style: format OAuth regression test
open-swe Aug 2, 2026
353904e
fix: preserve OAuth state during runtime replacement
open-swe Aug 2, 2026
8c3fdd7
fix: release stale OAuth runtime locks
open-swe Aug 2, 2026
7a170cc
test: cover same-process stale OAuth locks
open-swe Aug 2, 2026
5c54ed5
fix: bound Codex setup requests
open-swe Aug 2, 2026
eaef487
fix: bound OAuth runtime recovery
open-swe Aug 2, 2026
65f08bd
fix: cancel timed out Codex requests
open-swe Aug 2, 2026
e646e12
style: simplify request signal selection
open-swe Aug 2, 2026
191bf7c
fix: harden Codex turn cancellation
open-swe Aug 2, 2026
97ee905
fix: harden Codex turn cancellation
open-swe Aug 2, 2026
7f9bda3
fix: harden OAuth isolation and cleanup
open-swe Aug 2, 2026
7c23b77
fix: fail closed on OAuth cleanup errors
open-swe Aug 2, 2026
0b76b18
test: stabilize Codex OAuth cancellation coverage
open-swe Aug 2, 2026
2d42657
fix: bind Codex OAuth token updates to account
open-swe Aug 2, 2026
cee71d4
fix: reject unverified Codex OAuth token rotation
open-swe Aug 2, 2026
978915a
fix: verify rotated Codex OAuth JWTs
open-swe Aug 2, 2026
5599005
fix: clean up Codex OAuth state on close failure
open-swe Aug 2, 2026
05f631a
fix: allow Codex OAuth lock recovery after release errors
open-swe Aug 2, 2026
0ed7431
fix: cancel timed out durable Codex records
open-swe Aug 2, 2026
f1e8cd6
fix: cancel PostgreSQL LLM records on timeout
open-swe Aug 2, 2026
420afb8
fix: safely cancel queued Postgres records
open-swe Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .codex/skills/dev-instance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,12 @@ The dev instance should exercise the real system:

- real LLM: needs a model credential for the harness you run. Core supports several
(`HARNESS=pi|opencode|codex|claude`); the launcher picks one from the credentials it
finds and honours an explicit `HARNESS`. Set the key your chosen harness expects, or
pass `DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring check
finds and honours an explicit `HARNESS`. Set the key your chosen harness expects. For
Codex, a ChatGPT OAuth session is also supported: `HARNESS=codex` discovers a valid
`$HOME/.codex/auth.json`, or you can set `CODEX_AUTH_FILE` to another auth file. The
launcher runs Codex in an isolated home and persists refreshed OAuth tokens. Pass
`DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring check. This OAuth path is
for local dev instances; deployed production processes still require an API key.
- real durability: uses `DATABASE_URL` when supplied; otherwise starts/reuses a local
Docker Postgres container and runs core with `SESSION_STORE=postgres` and
`RUN_STORE=postgres`
Expand Down
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@

HARNESS=pi
CODEX_AUTH_FILE=
HARNESS_SECURITY_POSTURE=auto

#ANTHROPIC_API_KEY=sk-ant-...
Expand Down
19 changes: 15 additions & 4 deletions scripts/dev/lib/envctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { dirname, join } from "node:path";
import { liveEnvPath } from "./pool.ts";
import { bestEffort, readEnvFile, sha256Hex } from "./util.ts";
import { run } from "./proc.ts";
import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "../../../src/harness/codex-auth.ts";

export interface AssembledEnv {
env: Record<string, string>;
anthropicKeySource: string;
openaiKeySource: string;
codexAuthSource: string;
harness: "pi" | "mock" | "opencode" | "codex" | "claude";
liveEnvFile: string;
warnings: string[];
Expand Down Expand Up @@ -100,6 +102,7 @@ export async function assembleEnv(opts: {
for (const [k, v] of Object.entries(readEnvFile(liveEnvFile))) {
if (!env[k]) env[k] = v;
}
const wtEnv = readEnvFile(join(opts.worktree, ".env"));

let anthropicKeySource = "";
if (opts.callerEnv.ANTHROPIC_API_KEY) anthropicKeySource = "your shell export";
Expand All @@ -112,7 +115,6 @@ export async function assembleEnv(opts: {
anthropicKeySource = liveEnvFile;
}
}
const wtEnv = readEnvFile(join(opts.worktree, ".env"));
if (!env.ANTHROPIC_API_KEY && wtEnv.ANTHROPIC_API_KEY) {
env.ANTHROPIC_API_KEY = wtEnv.ANTHROPIC_API_KEY;
anthropicKeySource = "the worktree .env";
Expand All @@ -125,13 +127,22 @@ export async function assembleEnv(opts: {
openaiKeySource = "the worktree .env";
}

if (!env.CODEX_AUTH_FILE && wtEnv.CODEX_AUTH_FILE) env.CODEX_AUTH_FILE = wtEnv.CODEX_AUTH_FILE;
let codexAuthSource = "";
const codexAuthCandidate = codexAuthFileForEnv({ ...env, ...opts.callerEnv }, true);
const codexOAuthConfigured = Boolean(codexAuthCandidate && readCodexOAuthAuthFile(codexAuthCandidate));
if (codexOAuthConfigured && codexAuthCandidate) {
env.CODEX_AUTH_FILE = codexAuthCandidate;
codexAuthSource = codexAuthCandidate;
}

let harness: "pi" | "mock" | "opencode" | "codex" | "claude";
if (opts.callerEnv.HARNESS === "codex" || opts.callerEnv.HARNESS === "claude") {
harness = opts.callerEnv.HARNESS;
env.HARNESS = harness;
if (harness === "codex" && !env.OPENAI_API_KEY) {
if (harness === "codex" && !env.OPENAI_API_KEY && !codexOAuthConfigured) {
throw new Error(
"HARNESS=codex needs OPENAI_API_KEY (its CLI cannot do browser OAuth in a container) -- export it, or add it to the live env file or the worktree .env",
"HARNESS=codex needs OPENAI_API_KEY or a readable ChatGPT OAuth auth.json via CODEX_AUTH_FILE (or ~/.codex/auth.json)",
);
}
} else if (env.ANTHROPIC_API_KEY) {
Expand All @@ -155,7 +166,7 @@ export async function assembleEnv(opts: {
if (!env[k] && wtEnv[k]) env[k] = wtEnv[k];
}

return { env, anthropicKeySource, openaiKeySource, harness, liveEnvFile, warnings };
return { env, anthropicKeySource, openaiKeySource, codexAuthSource, harness, liveEnvFile, warnings };
}

export function envFileGet(path: string, key: string): string {
Expand Down
4 changes: 3 additions & 1 deletion scripts/dev/supervisor/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ async function assembleAndPrepare(spec: BootSpec): Promise<SpecInputs> {
let harnessDetail = `live ${assembled.harness} turns (anthropic key from ${assembled.anthropicKeySource})`;
if (assembled.harness === "mock") harnessDetail = "mock turns";
else if (assembled.harness === "codex") {
harnessDetail = `live codex turns (openai key from ${assembled.openaiKeySource || "the environment"})`;
harnessDetail = assembled.codexAuthSource
? "live codex turns (ChatGPT OAuth auth.json)"
: `live codex turns (openai key from ${assembled.openaiKeySource || "the environment"})`;
} else if (assembled.harness === "claude") harnessDetail = "live claude turns (native CLI authentication)";
phase("env", "ok", harnessDetail);

Expand Down
10 changes: 7 additions & 3 deletions scripts/dev/supervisor/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface SpecInputs {
export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
const watchArgs = i.watch ? ["--watch"] : [];
const base = { ...i.baseEnv, ...i.sandboxEnv };
const siblingBase = Object.fromEntries(
Object.entries(base).filter(([key]) => key !== "HOME" && key !== "CODEX_HOME"),
);
siblingBase.CODEX_AUTH_FILE = "";
const orgId = i.baseEnv.DEV_INSTANCE_ORG_ID || "acme";
const signing: Record<string, string> = i.coreSigningSecret ? { CORE_SIGNING_SECRET: i.coreSigningSecret } : {};
return [
Expand Down Expand Up @@ -54,7 +58,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
cwd: join(i.worktree, "plugins/web-ui"),
argv: ["node", "--env-file-if-exists=.env", "server/index.ts"],
env: {
...base,
...siblingBase,
...signing,
PORT: String(i.ports.web),
CORE_API_URL: `http://localhost:${i.ports.core}`,
Expand All @@ -74,7 +78,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
cwd: join(i.worktree, "plugins/admin"),
argv: ["node", `--env-file-if-exists=${join(i.worktree, ".env")}`, ...watchArgs, "src/index.ts"],
env: {
...base,
...siblingBase,
...signing,
PORT: String(i.ports.admin),
CORE_API_URL: `http://localhost:${i.ports.core}`,
Expand All @@ -91,7 +95,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
cwd: join(i.worktree, "plugins/portal"),
argv: ["node", ...watchArgs, "src/index.ts"],
env: {
...base,
...siblingBase,
...signing,
PORT: String(i.ports.portal),
PORTAL_PUBLIC_URL: `http://localhost:${i.ports.portal}`,
Expand Down
4 changes: 3 additions & 1 deletion src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,8 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise<void> {
]);
const harnessId = deps.harnessId ?? "pi";
const managedKeys = deps.modelCredentials ? await deps.modelCredentials.availability() : null;
const configuredKeys = deps.providerKeys ?? managedKeys;
const providerStatus = harnessId === "pi" && managedKeys ? managedKeys : configuredKeys;
const catalog = managedKeys?.openrouter
? await selectableModelCatalog(deps.modelCredentialFetch)
: builtInModelCatalog();
Expand Down Expand Up @@ -889,7 +891,7 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise<void> {
webuiModels: configuredPicker.length ? configuredPicker : allowed,
baseModel: resolvedBase,
harnessId,
...(managedKeys ? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) } : {}),
...(providerStatus && { modelProviderConfigured: Object.values(providerStatus).some(Boolean) }),
externalSlackParticipants,
...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}),
});
Expand Down
29 changes: 26 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { validateCoreSecretEnv } from "./deployment/secret-schema.ts";
import { DEFAULT_CAPTURE_QUIET_MS } from "./memory/strategies/per-turn.ts";
import { parseSecurityPosture, type SecurityPosture } from "./security/security-posture.ts";
import { slackPluginConfigFromEnv, type SlackPluginConfig } from "./slack/config.ts";
import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "./harness/codex-auth.ts";
import {
MODEL_PROVIDERS,
defaultModelForProvider,
Expand Down Expand Up @@ -39,6 +40,7 @@ export interface Config {
opencodeModel?: string;
codexModel?: string;
codexBinPath?: string;
codexAuthFile?: string;
codexProcessEnv: NodeJS.ProcessEnv;
claudeModel?: string;
claudeBinPath?: string;
Expand Down Expand Up @@ -155,6 +157,7 @@ export function providerKeysPresent(config: Config): ModelProviderAvailability {
anthropic: Boolean(config.anthropicApiKey),
openai: Boolean(config.openaiApiKey),
openrouter: Boolean(config.openrouterApiKey),
...(config.harness === "codex" && config.codexAuthFile ? { codexOAuth: true } : {}),
};
}

Expand Down Expand Up @@ -551,10 +554,25 @@ function modelProviderEnvStrict(env: NodeJS.ProcessEnv): ModelProvider | undefin
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const missingSecrets = validateCoreSecretEnv(env);
const harness = harnessEnvStrict(env.HARNESS);
const codexAuthCandidate = harness === "codex" ? codexAuthFileForEnv(env, true) : undefined;
const codexOAuthConfigured = Boolean(codexAuthCandidate && readCodexOAuthAuthFile(codexAuthCandidate));
const secretEnv =
codexOAuthConfigured && codexAuthCandidate
? { ...env, CODEX_AUTH_FILE: codexAuthCandidate }
: { ...env, CODEX_AUTH_FILE: undefined };
const missingSecrets = validateCoreSecretEnv(secretEnv);
if (missingSecrets.length) {
throw new Error(`missing or insecure required core secrets: ${missingSecrets.join(", ")}`);
}
if (harness === "codex" && !env.OPENAI_API_KEY?.trim() && !codexOAuthConfigured) {
throw new Error(
"HARNESS=codex needs OPENAI_API_KEY or a readable ChatGPT OAuth auth.json via CODEX_AUTH_FILE (or ~/.codex/auth.json)",
);
}
if (env.NODE_ENV === "production" && codexOAuthConfigured) {
throw new Error("CODEX_AUTH_FILE is supported for local Codex harnesses only");
}
const modelProvider = modelProviderEnvStrict(env);
for (const key of ["SESSION_STORE", "RUN_STORE", "ARTIFACT_STORE"] as const) {
if (env[key] === "sqlite") {
Expand Down Expand Up @@ -636,6 +654,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const deployProvider: "aws" | "docker" = env.DEPLOY_PROVIDER === "aws" ? "aws" : "docker";
let runStore: "memory" | "postgres" = env.SESSION_STORE === "postgres" ? "postgres" : "memory";
if (env.RUN_STORE === "memory" || env.RUN_STORE === "postgres") runStore = env.RUN_STORE;
const codexEnv = { ...env };
if (codexOAuthConfigured && codexAuthCandidate) codexEnv.CODEX_AUTH_FILE = codexAuthCandidate;
else delete codexEnv.CODEX_AUTH_FILE;
const codexProcessEnv = Object.fromEntries(
[
"PATH",
Expand All @@ -654,7 +675,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
"CODEX_ACCESS_TOKEN",
"HOME",
"CODEX_HOME",
].flatMap((name) => (env[name] === undefined ? [] : [[name, env[name]]])),
"CODEX_AUTH_FILE",
].flatMap((name) => (codexEnv[name] === undefined ? [] : [[name, codexEnv[name]]])),
) as NodeJS.ProcessEnv;
const claudeProcessEnv = Object.fromEntries(
[
Expand Down Expand Up @@ -689,7 +711,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
orgId: env.ORG_ID ?? DEFAULT_ORG_ID,
sessionStore: env.SESSION_STORE === "postgres" ? "postgres" : "memory",
...(env.DATABASE_URL ? { databaseUrl: env.DATABASE_URL } : {}),
harness: harnessEnvStrict(env.HARNESS),
harness,
securityPosture: securityPostureEnvStrict(env.HARNESS_SECURITY_POSTURE),
securityScreenBackend,
...(securityScreenBackend === "proxy"
Expand Down Expand Up @@ -717,6 +739,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
...(env.OPENCODE_MODEL || env.PI_MODEL ? { opencodeModel: env.OPENCODE_MODEL || env.PI_MODEL } : {}),
...(env.CODEX_MODEL ? { codexModel: env.CODEX_MODEL } : {}),
...(env.CODEX_BIN ? { codexBinPath: env.CODEX_BIN } : {}),
...(codexOAuthConfigured && codexAuthCandidate ? { codexAuthFile: codexAuthCandidate } : {}),
codexProcessEnv,
...(env.CLAUDE_MODEL ? { claudeModel: env.CLAUDE_MODEL } : {}),
...(env.CLAUDE_BIN ? { claudeBinPath: env.CLAUDE_BIN } : {}),
Expand Down
16 changes: 8 additions & 8 deletions src/core/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,8 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
actor.id,
scopeLabel,
sessionId
? async (rec) => {
await deps.sessions.recordLlmRequest(sessionId, { ...rec, scopeLabel });
? async (rec, signal) => {
await deps.sessions.recordLlmRequest(sessionId, { ...rec, scopeLabel }, signal);
}
: undefined,
{ hook: "user_input", surface: "steer", origin: "ambient" },
Expand Down Expand Up @@ -503,13 +503,13 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
const securityPolicy = resolution.securityPolicy;
const screenSession: { id?: string } = {};
const pendingScreenRequests: HarnessLlmRequestRecord[] = [];
const recordScreenRequest = async (rec: HarnessLlmRequestRecord): Promise<void> => {
const recordScreenRequest = async (rec: HarnessLlmRequestRecord, signal?: AbortSignal): Promise<void> => {
if (!screenSession.id) {
pendingScreenRequests.push(rec);
return;
}
try {
await deps.sessions.recordLlmRequest(screenSession.id, { ...rec, scopeLabel: scopeId });
await deps.sessions.recordLlmRequest(screenSession.id, { ...rec, scopeLabel: scopeId }, signal);
} catch (err) {
console.error("[orchestrator] failed to persist security screen request snapshot:", err);
}
Expand Down Expand Up @@ -688,7 +688,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
try {
await withManagedRosterVersion(async () => {
await reconcileSessionParticipants(session.id);
await Promise.all(pendingScreenRequests.splice(0).map(recordScreenRequest));
await Promise.all(pendingScreenRequests.splice(0).map((rec) => recordScreenRequest(rec)));
for (const overheard of screenedOverheard) {
const imported = await deps.sessions.append(lease, {
type: "user",
Expand Down Expand Up @@ -1291,7 +1291,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
try {
await withManagedRosterVersion(async () => {
await reconcileSessionParticipants(session.id);
await Promise.all(pendingScreenRequests.splice(0).map(recordScreenRequest));
await Promise.all(pendingScreenRequests.splice(0).map((rec) => recordScreenRequest(rec)));
return true;
});
if (input.approval) {
Expand Down Expand Up @@ -2272,9 +2272,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
deps.modelGateway.recordCall({ at: Date.now(), scopeLabel: scopeId, ...rec });
void deps.budget?.record(actor.id, estimateCostUsd(rec.inputTokens));
},
recordLlmRequest: async (rec) => {
recordLlmRequest: async (rec, signal) => {
try {
await deps.sessions.recordLlmRequest(session.id, { ...rec, scopeLabel: scopeId });
await deps.sessions.recordLlmRequest(session.id, { ...rec, scopeLabel: scopeId }, signal);
} catch (err) {
console.error("[orchestrator] failed to persist LLM request snapshot:", err);
}
Expand Down
2 changes: 1 addition & 1 deletion src/core/orchestrator/security-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type SecurityClassifier = (
payload: string,
actorId: string,
scopeLabel: ScopeId,
recordLlmRequest?: (rec: HarnessLlmRequestRecord) => void | Promise<void>,
recordLlmRequest?: (rec: HarnessLlmRequestRecord, signal?: AbortSignal) => void | Promise<void>,
context?: {
hook?: SecurityScreenHook;
surface?: string;
Expand Down
5 changes: 3 additions & 2 deletions src/deployment/secret-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const CORE_SECRET_SPECS: readonly RuntimeSecretSpec[] = [

const GATE_PREDICATES: Readonly<Record<SecretGate, (env: NodeJS.ProcessEnv) => boolean>> = {
production: (env) => env.NODE_ENV === "production",
codex: (env) => env.HARNESS?.trim() === "codex",
codex: (env) => env.HARNESS?.trim() === "codex" && !env.CODEX_AUTH_FILE?.trim(),
postgres: (env) => env.SESSION_STORE === "postgres" || env.RUN_STORE === "postgres",
sprites: (env) => env.SANDBOX_BACKEND === "sprites" || env.SANDBOX_SECONDARY_BACKEND === "sprites",
"fly-sandbox": (env) => env.SANDBOX_BACKEND === "fly",
Expand All @@ -51,7 +51,8 @@ const GATE_PREDICATES: Readonly<Record<SecretGate, (env: NodeJS.ProcessEnv) => b
"dropbox-oauth": (env) => Boolean(env.DROPBOX_OAUTH_CLIENT_ID),
"linear-oauth": (env) => Boolean(env.LINEAR_OAUTH_CLIENT_ID),
"model-anthropic": (env) => env.MODEL_PROVIDER?.trim() === "anthropic",
"model-openai": (env) => env.MODEL_PROVIDER?.trim() === "openai",
"model-openai": (env) =>
env.MODEL_PROVIDER?.trim() === "openai" && !(env.HARNESS?.trim() === "codex" && env.CODEX_AUTH_FILE?.trim()),
"model-openrouter": (env) => env.MODEL_PROVIDER?.trim() === "openrouter",
};

Expand Down
Loading