Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
04d3506
fix: count harness-carried model auth in surface config and admin onb…
haramiya Aug 2, 2026
e7bb2bc
Merge pr-128 (harness-carried model auth) into pr-126 (Codex OAuth)
ReganBell Aug 26, 2026
274029e
Merge public/main into subscription-auth branch (codex OAuth + harnes…
ReganBell Aug 26, 2026
ec6fc05
fix: restore lazy custom boot-default resolution in serverDeps (merge…
ReganBell Aug 26, 2026
ce62720
refactor: simplify Codex OAuth lifecycle
16francej Aug 27, 2026
c1dd286
Keychain custody for subscription harness auth
qm-yc Aug 27, 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
10 changes: 8 additions & 2 deletions .codex/skills/dev-instance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,14 @@ 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. Core
refreshes OAuth tokens centrally and hands the Codex child ephemeral material (no
refresh token). Pass `DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring
check. The auth-file path is for local dev instances; deployed production processes
use an API key or a keychain credential (`CODEX_AUTH_CREDENTIAL` /
`CLAUDE_AUTH_CREDENTIAL`), whose secret lives encrypted in its owner's keychain.
- 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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@

HARNESS=pi
# Subscription-backed harness auth. Production path: point at a keychain
# credential id (the login lives encrypted in its owner's keychain; core
# refreshes it centrally and hands harnesses ephemeral derived material).
#CODEX_AUTH_CREDENTIAL=
#CLAUDE_AUTH_CREDENTIAL=
# Local-dev fallback only: a Codex CLI auth.json on this machine
# (defaults to ~/.codex/auth.json when HARNESS=codex). Not allowed in production.
CODEX_AUTH_FILE=
HARNESS_SECURITY_POSTURE=auto

#ANTHROPIC_API_KEY=sk-ant-...
Expand Down
19 changes: 10 additions & 9 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7253,19 +7253,20 @@ <h2 id="governance-review-title">Confirm governance change</h2>
const baseModel = config.data.baseModel || config.data.baseModelDefault || "";
const baseProvider = onboardingProviderForModel(baseModel);
const baseStatus = onboardingModelStatuses.find((item) => item.provider === baseProvider);
onboardingBadge(
"onboarding-model-badge",
baseStatus?.configured ? "Ready" : "Needs a key",
Boolean(baseStatus?.configured),
);
const harnessAuth = models.data.harnessAuth;
const baseHarnessAuth = Boolean(harnessAuth && harnessAuth.provider === baseProvider);
const baseReady = Boolean(baseModel) && (Boolean(baseStatus?.configured) || baseHarnessAuth);
onboardingBadge("onboarding-model-badge", baseReady ? "Ready" : "Needs a key", baseReady);
$("onboarding-model-summary").textContent = !baseModel
? "No base model is configured yet — pick a provider and model below."
: baseStatus?.configured
? baseModel + " · " + (baseStatus.source === "admin" ? "admin-managed key" : "deployment key")
: baseModel +
" cannot run until its " +
(MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) +
" key is configured.";
: baseHarnessAuth
? baseModel + " · authenticated by the " + harnessAuth.harnessId + " harness — no API key needed."
: baseModel +
" cannot run until its " +
(MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) +
" key is configured.";
renderOnboardingProviderOptions(baseProvider);
renderOnboardingModelOptions(baseModel);

Expand Down
92 changes: 92 additions & 0 deletions plugins/admin/test/onboarding-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,98 @@ function resolveView(pathname: string, search: string): string {
return vm.runInContext(src, context);
}

interface FakeElement {
textContent: string;
className: string;
value: string;
placeholder: string;
disabled: boolean;
href: string;
options: Array<{ value?: string; textContent?: string }>;
appendChild(option: { value?: string; textContent?: string }): void;
}

async function runLoadOnboarding(modelProviders: unknown): Promise<Record<string, FakeElement>> {
const src = slice("let onboardingModels = {};", '$("onboarding-model-provider").onchange') + "\nloadOnboarding();";
const elements: Record<string, FakeElement> = {};
const fixtures: Record<string, unknown> = {
"/api/model-providers": modelProviders,
"/api/slack-installation": { configured: false },
"/api/connector-catalog": { catalog: [] },
"/api/scopes/org%3Adefault-org": { baseModel: "claude-opus-5" },
};
const context = vm.createContext({
$: (id: string) =>
(elements[id] ??= {
textContent: "",
className: "",
value: "",
placeholder: "",
disabled: false,
href: "",
options: [],
appendChild(option) {
this.options.push(option);
},
}),
api: async (_method: string, path: string) => ({ ok: true, data: fixtures[path] ?? {} }),
orgScope: () => "org:default-org",
encodeURIComponent,
setStatus: () => {},
connectorName: (id: string) => id,
viewLoadedAt: {},
Date,
document: { createElement: () => ({}) },
});
await vm.runInContext(src, context);
return elements;
}

const UNCONFIGURED_PROVIDERS = [
{ provider: "anthropic", configured: false, source: "absent" },
{ provider: "openai", configured: false, source: "absent" },
{ provider: "openrouter", configured: false, source: "absent" },
];
const ANTHROPIC_MODELS = [{ id: "claude-opus-5", name: "Claude Opus 5", provider: "anthropic" }];

test("harness-carried auth shows the model step as ready without a stored key", async () => {
const elements = await runLoadOnboarding({
providers: UNCONFIGURED_PROVIDERS,
models: ANTHROPIC_MODELS,
harnessAuth: { harnessId: "claude", provider: "anthropic" },
});
assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready");
assert.equal(elements["onboarding-model-badge"]!.className, "badge ok");
assert.equal(
elements["onboarding-model-summary"]!.textContent,
"claude-opus-5 · authenticated by the claude harness — no API key needed.",
);
});

test("without harness auth an unconfigured provider still needs a key", async () => {
const elements = await runLoadOnboarding({
providers: UNCONFIGURED_PROVIDERS,
models: ANTHROPIC_MODELS,
});
assert.equal(elements["onboarding-model-badge"]!.textContent, "Needs a key");
assert.equal(elements["onboarding-model-badge"]!.className, "badge warn");
assert.match(elements["onboarding-model-summary"]!.textContent, /cannot run until its Anthropic key is configured/);
});

test("a stored key keeps its summary even when the harness also carries auth", async () => {
const elements = await runLoadOnboarding({
providers: [
{ provider: "anthropic", configured: true, source: "admin" },
{ provider: "openai", configured: false, source: "absent" },
{ provider: "openrouter", configured: false, source: "absent" },
],
models: ANTHROPIC_MODELS,
harnessAuth: { harnessId: "claude", provider: "anthropic" },
});
assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready");
assert.equal(elements["onboarding-model-summary"]!.textContent, "claude-opus-5 · admin-managed key");
});

test("onboarding is a navigable view", () => {
assert.match(html, /\{ label: "Admin", views: \["onboarding",/);
});
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-file.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 @@ -318,7 +318,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 @@ -58,7 +62,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 @@ -78,7 +82,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 @@ -95,7 +99,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
3 changes: 2 additions & 1 deletion src/api/deps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ModelProviderAvailability } from "../model/pi-models.ts";
import type { ModelProvider, ModelProviderAvailability } from "../model/pi-models.ts";
import type { ModelCredentialStore } from "../model/model-credential-store.ts";
import type { CustomProviderStore } from "../model/custom-provider-store.ts";
import type { McpServerStore } from "../mcp/mcp-server-store.ts";
Expand Down Expand Up @@ -94,6 +94,7 @@ export interface ServerDeps {
refreshCustomProviders?: () => Promise<void>;
brandingDefault?: OrgBranding;
harnessId?: string;
harnessCarriedModelAuth?: ModelProvider;
admin?: AdminService;
rateLimiter?: RateLimiter;
sessions?: SessionStore;
Expand Down
3 changes: 3 additions & 0 deletions src/api/routes/admin/model-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export async function getModelProviders(ctx: ApiCtx): Promise<void> {
return sendJson(ctx.res, 200, {
providers: await ctx.deps.modelCredentials.statuses(),
models: await selectableModelCatalog(ctx.deps.modelCredentialFetch),
...(ctx.deps.harnessCarriedModelAuth
? { harnessAuth: { harnessId: ctx.deps.harnessId ?? "pi", provider: ctx.deps.harnessCarriedModelAuth } }
: {}),
});
}

Expand Down
6 changes: 5 additions & 1 deletion src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,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 All @@ -1079,7 +1081,9 @@ 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) || Boolean(deps.harnessCarriedModelAuth),
}),
externalSlackParticipants,
...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}),
});
Expand Down
Loading
Loading