Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
c0d77aa
Individual model auth: per-user Claude/ChatGPT accounts drive each turn
time-attack Aug 28, 2026
80f74af
Resolve individual-auth review findings: fail closed, pin runtime, is…
time-attack Aug 28, 2026
756ce00
Merge keychain-custody harness auth; rebase per-user turns onto Codex…
time-attack Aug 28, 2026
fb54c46
Unify per-user AI-account custody onto the keychain
qm-yc Aug 28, 2026
5935a96
Per-user Codex turns run on dedicated ephemeral app-servers
qm-yc Aug 28, 2026
558eff6
Subscription logins ride the keychain's connector-token refresh
qm-yc Aug 28, 2026
a937144
Merge main into subscription-auth
qm-yc Aug 28, 2026
a99612b
Fix truncated CSS rule from merge resolution
qm-yc Aug 28, 2026
037557d
ChatGPT device login rides the Codex binary's native login RPCs
qm-yc Aug 28, 2026
7ae6a31
Fix CI: pin user-model-auth routes to source auth; format 10 files
qm-yc Aug 28, 2026
ac8c6a8
Fix CI: prune exports knip flags as unused
qm-yc Aug 28, 2026
b6527a1
Fix CI: resolve oxlint no-useless-spread / no-useless-fallback-in-spr…
ReganBell Aug 28, 2026
fcda7a4
pi-on-ChatGPT: serve a ChatGPT subscription through the pi harness
qm-yc Aug 28, 2026
b9b0412
Unnest the oauth routing ternary (no-nested-ternary)
qm-yc Aug 28, 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
48 changes: 39 additions & 9 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4397,6 +4397,30 @@ <h2>Fast mode for interactive turns</h2>
><span class="status" id="st-interactive-fast-mode"></span>
</div>
</section>
<section class="card setting-row hidden" id="card-individual-model-auth">
<div class="head">
<h2>Individual authorization for AI usage</h2>
<p>
Org-wide, default off. When on, each user must connect their own Claude or Codex account (API key
or subscription login) before using the assistant. The org's shared model credentials are not used
for their turns.
</p>
</div>
<div class="body">
<label class="setting-toggle">
<input type="checkbox" id="individual-model-auth" />
<span class="setting-switch" aria-hidden="true"></span>
<span class="setting-copy"
><strong>Require each user to connect their own model account</strong
><small>New users are prompted to sign in with Claude or Codex on first use.</small></span
>
</label>
</div>
<div class="foot">
<button class="primary" data-save="individual-model-auth">Apply</button
><span class="status" id="st-individual-model-auth"></span>
</div>
</section>
<section class="card setting-row hidden" id="card-base-model">
<div class="head">
<h2>Default harness and model</h2>
Expand Down Expand Up @@ -6427,6 +6451,9 @@ <h2 id="governance-review-title">Confirm governance change</h2>
const showInteractiveFastMode = scope.startsWith("org:") && "interactiveFastMode" in r.data;
$("card-interactive-fast-mode").classList.toggle("hidden", !showInteractiveFastMode);
if (showInteractiveFastMode) $("interactive-fast-mode").checked = r.data.interactiveFastMode === true;
const showIndividualModelAuth = scope.startsWith("org:") && "individualModelAuth" in r.data;
$("card-individual-model-auth").classList.toggle("hidden", !showIndividualModelAuth);
if (showIndividualModelAuth) $("individual-model-auth").checked = r.data.individualModelAuth === true;
const showHeaderPinDefault = scope.startsWith("org:") && "channelHeaderPinDefault" in r.data;
$("card-channel-header-pin-default").classList.toggle("hidden", !showHeaderPinDefault);
if (showHeaderPinDefault) $("channel-header-pin-default").checked = !!r.data.channelHeaderPinDefault;
Expand Down Expand Up @@ -7253,19 +7280,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 Expand Up @@ -8140,6 +8168,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
"external-slack-participants": () => ({ on: $("external-slack-participants").checked }),
"org-ambient": () => ({ on: $("org-ambient").checked }),
"interactive-fast-mode": () => ({ on: $("interactive-fast-mode").checked }),
"individual-model-auth": () => ({ on: $("individual-model-auth").checked }),
"channel-header-pin-default": () => ({ on: $("channel-header-pin-default").checked }),
runtime: () => ({ harnessId: $("base-harness").value, modelId: $("base-model").value }),
"approved-harnesses": () => ({
Expand Down Expand Up @@ -8175,6 +8204,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
"external-slack-participants": "st-external-slack-participants",
"org-ambient": "st-org-ambient",
"interactive-fast-mode": "st-interactive-fast-mode",
"individual-model-auth": "st-individual-model-auth",
"channel-header-pin-default": "st-channel-header-pin-default",
runtime: "st-runtime",
"approved-harnesses": "st-approved-harnesses",
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
80 changes: 78 additions & 2 deletions plugins/web-ui/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,14 +805,32 @@ const apiRoutes: readonly WebRoute[] = [
handle: async (c) => {
const { req, res, user } = c;
res.setHeader("set-cookie", sessionCookie(user));
const permissions = await userPermissions();
const [permissions, workspaceUrl, authStatus] = await Promise.all([
userPermissions(),
slackWorkspaceUrl(),
coreFetch("GET", `/v1/user-model-auth/status?principalId=${encodeURIComponent(user)}`, "", 5_000).catch(
() => null,
),
]);
if (authStatus === null || authStatus.status !== 200) {
return json(res, 503, {
error: "unavailable",
message: "the assistant is briefly unavailable — retry shortly",
});
}
const parsed = JSON.parse(authStatus.text) as {
individualModelAuth?: boolean;
connections?: { provider: string }[];
};
return json(res, 200, {
user,
org: ORG,
mode: AUTH_MODE,
slackWorkspaceUrl: await slackWorkspaceUrl(),
slackWorkspaceUrl: workspaceUrl,
impersonatedBy: resolveIdentity(req)?.impersonator ?? null,
permissions,
individualModelAuth: parsed.individualModelAuth === true,
modelAuthConnected: (parsed.connections?.length ?? 0) > 0,
});
},
},
Expand Down Expand Up @@ -1455,6 +1473,64 @@ const apiRoutes: readonly WebRoute[] = [
return relayCore(res, "GET", `/v1/connectors/oauth/status?principalId=${encodeURIComponent(user)}`);
},
},
{
method: "GET",
path: "/api/user-model-auth/status",
handle: async (c) =>
relayCore(c.res, "GET", `/v1/user-model-auth/status?principalId=${encodeURIComponent(c.user)}`),
},
{
method: "POST",
path: "/api/user-model-auth/api-key",
handle: async (c) => {
const p = JSON.parse((await readBody(c.req)) || "{}") as { provider?: unknown; apiKey?: unknown };
const body = JSON.stringify({ principalId: c.user, provider: p.provider, apiKey: p.apiKey });
return relayCore(c.res, "POST", "/v1/user-model-auth/api-key", body);
},
},
{
method: "POST",
path: "/api/user-model-auth/disconnect",
handle: async (c) => {
const p = JSON.parse((await readBody(c.req)) || "{}") as { provider?: unknown };
return relayCore(
c.res,
"POST",
"/v1/user-model-auth/disconnect",
JSON.stringify({ principalId: c.user, provider: p.provider }),
);
},
},
{
method: "POST",
path: "/api/user-model-auth/chatgpt/start",
handle: async (c) =>
relayCore(c.res, "POST", "/v1/user-model-auth/chatgpt/start", JSON.stringify({ principalId: c.user })),
},
{
method: "POST",
path: "/api/user-model-auth/chatgpt/poll",
handle: async (c) => {
const p = JSON.parse((await readBody(c.req)) || "{}") as { deviceAuthId?: unknown; userCode?: unknown };
const body = JSON.stringify({ principalId: c.user, deviceAuthId: p.deviceAuthId, userCode: p.userCode });
return relayCore(c.res, "POST", "/v1/user-model-auth/chatgpt/poll", body);
},
},
{
method: "POST",
path: "/api/user-model-auth/claude/start",
handle: async (c) =>
relayCore(c.res, "POST", "/v1/user-model-auth/claude/start", JSON.stringify({ principalId: c.user })),
},
{
method: "POST",
path: "/api/user-model-auth/claude/complete",
handle: async (c) => {
const p = JSON.parse((await readBody(c.req)) || "{}") as { code?: unknown; verifier?: unknown };
const body = JSON.stringify({ principalId: c.user, code: p.code, verifier: p.verifier });
return relayCore(c.res, "POST", "/v1/user-model-auth/claude/complete", body);
},
},
{
method: "POST",
path: "/api/connectors/:provider/start",
Expand Down
Loading