diff --git a/package-lock.json b/package-lock.json
index 6760a0a13..55ab73d29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,7 @@
"@earendil-works/pi-ai": "0.82.0",
"@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz",
"@fly/sprites": "0.0.1",
- "@openai/codex": "0.144.5",
+ "@openai/codex": "0.153.4",
"@opencode-ai/plugin": "1.17.18",
"@opencode-ai/sdk": "1.17.18",
"@slack/bolt": "^4.7.3",
@@ -3653,9 +3653,9 @@
}
},
"node_modules/@openai/codex": {
- "version": "0.144.5",
- "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5.tgz",
- "integrity": "sha512-jjB+K+OMv572mKhS+2QuLxWXDJNdpwbPenf+V+8bdq7wg4Scqt3cn6WEekD8wPqDVZqck0HSX17K9rD9kbDJQA==",
+ "version": "0.153.4",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4.tgz",
+ "integrity": "sha512-wbHDmit7S/YvBGVX1DQmk13xtWblZ2cApeJ/pB7xDZ10Cna+DZc5ij7f0F4OxdsXN4FW1oLT48OpogUI1+8Y2w==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
@@ -3664,19 +3664,19 @@
"node": ">=16"
},
"optionalDependencies": {
- "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.5-darwin-arm64",
- "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.5-darwin-x64",
- "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.5-linux-arm64",
- "@openai/codex-linux-x64": "npm:@openai/codex@0.144.5-linux-x64",
- "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.5-win32-arm64",
- "@openai/codex-win32-x64": "npm:@openai/codex@0.144.5-win32-x64"
+ "@openai/codex-darwin-arm64": "npm:@openai/codex@0.153.4-darwin-arm64",
+ "@openai/codex-darwin-x64": "npm:@openai/codex@0.153.4-darwin-x64",
+ "@openai/codex-linux-arm64": "npm:@openai/codex@0.153.4-linux-arm64",
+ "@openai/codex-linux-x64": "npm:@openai/codex@0.153.4-linux-x64",
+ "@openai/codex-win32-arm64": "npm:@openai/codex@0.153.4-win32-arm64",
+ "@openai/codex-win32-x64": "npm:@openai/codex@0.153.4-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
- "version": "0.144.5-darwin-arm64",
- "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-darwin-arm64.tgz",
- "integrity": "sha512-zcT6NfBCqLFt+BReNSETTZW6v6PdbH0dzNtm9j7l7mDGqwPbKZDGJdnpkBao2389I0ZacyIKgSZoI0vez1d4Dw==",
+ "version": "0.153.4-darwin-arm64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-darwin-arm64.tgz",
+ "integrity": "sha512-B1qhN3fa1ay0R0wGziXqgwSkB5icpYChNKHhtBHff/0UtSTC7z+l8aTtvMlGjH3E8HEvY3+njIJelM9CAAoVWg==",
"cpu": [
"arm64"
],
@@ -3691,9 +3691,9 @@
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
- "version": "0.144.5-darwin-x64",
- "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-darwin-x64.tgz",
- "integrity": "sha512-//Mo0m1MwaoT6psu5xsmofXpKx4/0irIkeq10xJvk59+886EG355ibjA+ZmlRcKhE3bLjsKD7p81nTbAdRL/bw==",
+ "version": "0.153.4-darwin-x64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-darwin-x64.tgz",
+ "integrity": "sha512-vnSbbPzfoDZmmyzsxswsDDXQ06IVFBzkQU7/hroB3ji93Ok2utcsq8Psfk2tjF5r9mEx8RWFJhzuTGHG26/NDA==",
"cpu": [
"x64"
],
@@ -3708,9 +3708,9 @@
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
- "version": "0.144.5-linux-arm64",
- "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-linux-arm64.tgz",
- "integrity": "sha512-zAHggxVwR2TBxKmybXY7ZMiB0G8DMonY2YPdwNNjwXcf+LOIqNGgswwNCDMbP/HEe6r8j+R9ZX/yYoo8f+n/RQ==",
+ "version": "0.153.4-linux-arm64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-arm64.tgz",
+ "integrity": "sha512-QKdjYLYV4hXIuUQDP3P6F4NXuWFoKo9WUoV4nAREIx55kiUyi8UsYdsVobkeXir5n/maEQgYMCKLHVma4rNPiw==",
"cpu": [
"arm64"
],
@@ -3725,9 +3725,9 @@
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
- "version": "0.144.5-linux-x64",
- "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-linux-x64.tgz",
- "integrity": "sha512-FalLJlBQGFdK8Gc3kj9sa/ekNdgkHhUawLaKkvy5CtB18JaP2YxtTP/Pe1pD2iBiq8mMUliRnafpF6AdBdQMbg==",
+ "version": "0.153.4-linux-x64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-x64.tgz",
+ "integrity": "sha512-x1EcwBlY3AObM1VTUHNM2AzAJQsyreGdagpF+qFiYi/Oa30VBktvvG0C6tLtCzqW6hjZNWkGZQWmeVk7MuJKWg==",
"cpu": [
"x64"
],
@@ -3742,9 +3742,9 @@
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
- "version": "0.144.5-win32-arm64",
- "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-win32-arm64.tgz",
- "integrity": "sha512-0Pj7iqjEOEvPQPO3kFfCy9vGX4BTu76ChFFZHr2eNNIfVc3FOENAv/X98u4L+iIUtDOK9DbqmfUudW3DPapshg==",
+ "version": "0.153.4-win32-arm64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-win32-arm64.tgz",
+ "integrity": "sha512-/FBh42976ltF1kxDoPQBg1Q6+hwChRU5/sm5dfeC8kFVQMvOCGoGeY5d8rRZGVJE8XojlXo74VQb0sHowcfgBw==",
"cpu": [
"arm64"
],
@@ -3759,9 +3759,9 @@
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
- "version": "0.144.5-win32-x64",
- "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-win32-x64.tgz",
- "integrity": "sha512-DnsSTlnnzleTxvLwIGnBitKInscxn2I7qASqosS8Fv+qysBygd+ZiBn/SQsRCgQ28PAlsNzmd3Gf3ZTecolAmg==",
+ "version": "0.153.4-win32-x64",
+ "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-win32-x64.tgz",
+ "integrity": "sha512-lMkB43kJZH0VFr+hoXc11qqR7QtQIbkr07ALgj4urKL1osNyUyuy1iXd3Vzz2iCYvBUCSw7I0l/W1cEPGx9euQ==",
"cpu": [
"x64"
],
diff --git a/package.json b/package.json
index a5ec15fab..32346bb9b 100644
--- a/package.json
+++ b/package.json
@@ -64,7 +64,7 @@
"@earendil-works/pi-ai": "0.82.0",
"@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz",
"@fly/sprites": "0.0.1",
- "@openai/codex": "0.144.5",
+ "@openai/codex": "0.153.4",
"@opencode-ai/plugin": "1.17.18",
"@opencode-ai/sdk": "1.17.18",
"@slack/bolt": "^4.7.3",
diff --git a/plugins/web-ui/src/composer.ts b/plugins/web-ui/src/composer.ts
index 9a346637b..d74afece6 100644
--- a/plugins/web-ui/src/composer.ts
+++ b/plugins/web-ui/src/composer.ts
@@ -456,7 +456,6 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface {
}
const compact = Boolean(ctx.pane) || isPhone();
- const showRuntimeControls = !appState.me?.individualModelAuth;
const runtimeControls = compact
? settingsControl(agent, selectedModel, inputBlocked)
: html`
@@ -653,7 +652,7 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface {
`
}
-
${showRuntimeControls ? runtimeControls : nothing} ${sendControls(agent)}
+ ${runtimeControls} ${sendControls(agent)}
${composerNotice}
diff --git a/plugins/web-ui/test/composer-source.test.ts b/plugins/web-ui/test/composer-source.test.ts
index 625cf42b6..911248377 100644
--- a/plugins/web-ui/test/composer-source.test.ts
+++ b/plugins/web-ui/test/composer-source.test.ts
@@ -18,7 +18,7 @@ test("composer-right keeps its control order: make default, use org default, mod
composer.indexOf("return html`", composer.indexOf("const runtimeControls =")),
);
const rendered = composer.slice(composer.indexOf('class="composer-right"'));
- assert.match(rendered, /showRuntimeControls \? runtimeControls : nothing/);
+ assert.match(rendered, /\$\{runtimeControls\}/);
const right = runtime + rendered;
const makeDefault = right.indexOf("Make default");
const orgDefault = right.indexOf("Use org default");
diff --git a/src/api/app-turn.ts b/src/api/app-turn.ts
index 829fbf8d4..535954711 100644
--- a/src/api/app-turn.ts
+++ b/src/api/app-turn.ts
@@ -25,6 +25,7 @@ import { swallow, swallowAs } from "../util/errors.ts";
import { sleep } from "../util/async.ts";
import { GENERIC_FAILURE_CLAUSE } from "../../plugins/chassis/src/failure-copy.ts";
+import { runtimeConfigBody } from "./runtime-config.ts";
import type { App, AppDeps } from "./app-types.ts";
import { STALE_LEASE_GRACE_MS } from "./app-types.ts";
@@ -145,6 +146,8 @@ export function createTurnMethods(
}
const individualAuth = !!deps.userModelCredentials && (await deps.config.getIndividualModelAuthDurable());
+ let selectedHarness = individualAuth ? undefined : req.harness;
+ let selectedModel = individualAuth ? undefined : req.model;
if (req.surface === "web") {
const threadRef = req.conversation.threadRef;
const existing = await deps.sessions.getByThread(threadRef);
@@ -163,7 +166,25 @@ export function createTurnMethods(
harnessId: fallbackHarness,
modelId: defaultModelForHarness(fallbackHarness),
};
- if (!individualAuth) {
+ if (individualAuth) {
+ const available = await runtimeConfigBody(
+ { deps: { ...deps, baseModelDefault: runtimeFallback.modelId } },
+ targetScope,
+ actor.id,
+ );
+ selectedHarness = req.harness ?? available.effective.harnessId;
+ selectedModel = req.model ?? available.effective.modelId;
+ if (!available.modelsByHarness[selectedHarness]?.includes(selectedModel))
+ return {
+ status: "refused",
+ reason: "that model isn't available for your connected AI account or isn't approved",
+ };
+ const invalidModelOption = validateWebTurnModelOptions(
+ { ...req, model: selectedModel },
+ available.modelsByHarness[selectedHarness] ?? [],
+ );
+ if (invalidModelOption) return { status: "refused", reason: invalidModelOption };
+ } else {
const configuredKeys = deps.providerKeys ??
deps.modelProviders ?? { anthropic: false, openai: false, openrouter: false };
const managedKeys = deps.modelCredentials ? await deps.modelCredentials.availability() : configuredKeys;
@@ -268,8 +289,8 @@ export function createTurnMethods(
...(req.detectOpener ? { detectOpener: req.detectOpener } : {}),
...(req.attachments?.length ? { attachments: req.attachments } : {}),
...(req.inboundNotes?.length ? { inboundNotes: req.inboundNotes } : {}),
- ...(!individualAuth && req.harness ? { harness: req.harness } : {}),
- ...(!individualAuth && req.model ? { model: req.model } : {}),
+ ...(selectedHarness ? { harness: selectedHarness } : {}),
+ ...(selectedModel ? { model: selectedModel } : {}),
...turnModelOptions(req),
...(req.readOnly ? { readOnly: true } : {}),
...(req.skipMemory ? { skipMemory: true } : {}),
diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts
index 7ab77322c..cc4264884 100644
--- a/src/api/routes/surface.ts
+++ b/src/api/routes/surface.ts
@@ -1125,7 +1125,7 @@ async function getRuntimeConfig(ctx: ApiCtx): Promise {
const target = await runtimeTarget(ctx);
if (!target) return sendJson(ctx.res, 403, { error: "forbidden" });
await ctx.deps.refreshModels?.();
- return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope));
+ return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope, target.actorId));
}
async function putRuntimeConfig(ctx: ApiCtx): Promise {
@@ -1163,6 +1163,11 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise {
if (typeof modelId !== "string" || !modelSupportedByHarness(modelId, harnessId))
return sendJson(ctx.res, 400, { error: "model_not_supported" });
if (!(await webuiModelEnabled(ctx, modelId))) return sendJson(ctx.res, 400, { error: "model_not_enabled" });
+ if (ctx.deps.userModelCredentials && (await config.getIndividualModelAuthDurable())) {
+ const available = await runtimeConfigBody(ctx, target.scope, target.actorId);
+ if (!available.modelsByHarness[harnessId]?.includes(modelId))
+ return sendJson(ctx.res, 400, { error: "model_not_available_for_account" });
+ }
const effortLevel = ctx.body.effortLevel ?? "auto";
if (typeof effortLevel !== "string" || !(THINKING_LEVELS as readonly string[]).includes(effortLevel))
return sendJson(ctx.res, 400, { error: "effort_not_supported" });
@@ -1177,7 +1182,7 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise {
resource: "runtime-config",
scopeLabel: target.scope,
});
- return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope));
+ return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope, target.actorId));
}
async function getChannelHeaderPin(ctx: ApiCtx): Promise {
diff --git a/src/api/runtime-config.ts b/src/api/runtime-config.ts
index 8cf677629..f8d94f65b 100644
--- a/src/api/runtime-config.ts
+++ b/src/api/runtime-config.ts
@@ -1,6 +1,8 @@
-import type { ServerDeps } from "./deps.ts";
+import type { ScopedConfigStore } from "../resolution/config-store.ts";
+import type { ModelCredentialStore } from "../model/model-credential-store.ts";
+import type { UserModelCredentialStore } from "../model/user-model-credential-store.ts";
import type { ScopeId } from "../types.ts";
-import { orgScope } from "./routes/shared.ts";
+import { orgScope } from "../config.ts";
import {
defaultModelForHarness,
isHarnessId,
@@ -15,30 +17,32 @@ import {
thinkingLevelsForHarness,
harnessSupportsFastMode,
type HarnessId,
+ type ModelProviderAvailability,
} from "../model/pi-models.ts";
import { builtInModelCatalog, selectableCatalogForHarness, selectableModelCatalog } from "../model/model-catalog.ts";
+import { resolveIndividualAuthRouting } from "../core/individual-auth-routing.ts";
import type { RuntimeChoice } from "../harness/harness.ts";
-export type RuntimeDeps = Pick<
- ServerDeps,
- | "config"
- | "harnessId"
- | "baseModelDefault"
- | "providerKeys"
- | "modelCredentials"
- | "modelCredentialFetch"
- | "refreshModels"
->;
+export interface RuntimeDeps {
+ config?: ScopedConfigStore;
+ harnessId?: string;
+ baseModelDefault?: string;
+ providerKeys?: ModelProviderAvailability;
+ modelCredentials?: ModelCredentialStore;
+ modelCredentialFetch?: typeof fetch;
+ refreshModels?: () => Promise;
+ userModelCredentials?: UserModelCredentialStore;
+}
export function runtimeFallback(ctx: { deps: RuntimeDeps }): { harnessId: HarnessId; modelId: string } {
const harnessId = isHarnessId(ctx.deps.harnessId) ? ctx.deps.harnessId : "pi";
return { harnessId, modelId: ctx.deps.baseModelDefault ?? defaultModelForHarness(harnessId) };
}
-export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: ScopeId) {
+export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: ScopeId, principalId?: string) {
const config = ctx.deps.config!;
const fallback = runtimeFallback(ctx);
- const org = orgScope(ctx.deps);
+ const org = orgScope();
const approvedHarnesses = ((await config.getApprovedHarnessesDurable()) ?? [fallback.harnessId]).filter(isHarnessId);
const configuredKeys = ctx.deps.providerKeys ?? ALL_PROVIDERS_AVAILABLE;
const managedKeys = ctx.deps.modelCredentials ? await ctx.deps.modelCredentials.availability() : configuredKeys;
@@ -87,7 +91,23 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope
} else if (legacyModel) {
scopeOverride = { harnessId: fallback.harnessId, modelId: legacyModel, orgRevision: 0 };
}
- const effective = scopeOverride ?? orgDefault;
+ const individualAuth = Boolean(
+ principalId && ctx.deps.userModelCredentials && (await config.getIndividualModelAuthDurable()),
+ );
+ const [anthCred, oaiCred] = individualAuth
+ ? await Promise.all([
+ ctx.deps.userModelCredentials!.get(principalId!, "anthropic"),
+ ctx.deps.userModelCredentials!.get(principalId!, "openai"),
+ ])
+ : [null, null];
+ let effective = scopeOverride ?? orgDefault;
+ if (individualAuth) {
+ const requested = scopeOverride ?? orgDefault;
+ const routing =
+ resolveIndividualAuthRouting(anthCred, oaiCred, requested.modelId, requested.harnessId) ??
+ (scopeOverride ? null : resolveIndividualAuthRouting(anthCred, oaiCred, undefined, fallback.harnessId));
+ if (routing?.model) effective = { ...requested, harnessId: routing.harness, modelId: routing.model };
+ }
const selected = [orgDefault, scopeOverride, effective].filter((choice) => choice !== null);
const allowlist = await config.getWebuiModelsDurable(org);
const modelsByHarness = Object.fromEntries(
@@ -100,6 +120,7 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope
.map((model) => model.id);
for (const choice of selected) {
if (
+ (!individualAuth || allowlist == null) &&
allowlist?.length !== 0 &&
choice.harnessId === harnessId &&
modelSupportedByHarness(choice.modelId, harnessId) &&
@@ -107,7 +128,15 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope
)
ids.push(choice.modelId);
}
- return [harnessId, serviceableModelIds(ids, providersFor(harnessId))];
+ return [
+ harnessId,
+ individualAuth
+ ? ids.filter((modelId) => {
+ const routing = resolveIndividualAuthRouting(anthCred, oaiCred, modelId, harnessId);
+ return routing?.harness === harnessId && routing.model === modelId;
+ })
+ : serviceableModelIds(ids, providersFor(harnessId)),
+ ];
}),
);
const advertisedModelIds = new Set(Object.values(modelsByHarness).flat());
@@ -119,7 +148,9 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope
);
return {
scopeId: scope,
- approvedHarnesses,
+ approvedHarnesses: individualAuth
+ ? approvedHarnesses.filter((id) => modelsByHarness[id]?.length)
+ : approvedHarnesses,
modelsByHarness,
modelCatalog,
orgDefault,
@@ -155,10 +186,10 @@ export function validateRuntimeChoice(choice: RuntimeChoice): string | null {
export async function webuiModelEnabled(ctx: { deps: RuntimeDeps }, modelId: string): Promise {
modelId = modelId.replace(/^codex\//, "");
const config = ctx.deps.config!;
- const picker = await config.getWebuiModelsDurable(orgScope(ctx.deps));
+ const picker = await config.getWebuiModelsDurable(orgScope());
if (picker == null || picker.includes(modelId)) return true;
if (picker.length === 0) return false;
- const org = orgScope(ctx.deps);
+ const org = orgScope();
const stored = await config.getRuntimeSelectionDurable(org);
const orgModel = stored?.modelId ?? (await config.getBaseModelOwnDurable(org)) ?? runtimeFallback(ctx).modelId;
return modelId === orgModel;
diff --git a/src/core/individual-auth-routing.ts b/src/core/individual-auth-routing.ts
index f5f0e9d61..f72462875 100644
--- a/src/core/individual-auth-routing.ts
+++ b/src/core/individual-auth-routing.ts
@@ -31,6 +31,7 @@ export function resolveIndividualAuthRouting(
const pick = ((): { provider: "anthropic" | "openai"; cred: UserModelCredential } | null => {
if (requestedProvider === "anthropic" && anthCred) return { provider: "anthropic", cred: anthCred };
if (requestedProvider === "openai" && oaiCred) return { provider: "openai", cred: oaiCred };
+ if (requestedProvider) return null;
if (anthCred) return { provider: "anthropic", cred: anthCred };
if (oaiCred) return { provider: "openai", cred: oaiCred };
return null;
diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts
index 09dd6ade1..2784ca901 100644
--- a/src/harness/codex-harness.ts
+++ b/src/harness/codex-harness.ts
@@ -13,6 +13,7 @@ import { asError, swallow } from "../util/errors.ts";
import { countTokens } from "../util/tokens.ts";
import { CodexAppServer, CodexRpcError, redactCodexDiagnostics } from "./codex-app-server.ts";
import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "./codex-auth.ts";
+import { codexOAuthJwtAccountId } from "./codex-auth-file.ts";
import {
childCodexAuthFromDerived,
childCodexOAuthAuth,
@@ -808,6 +809,19 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
} finally {
if (startTimer) clearTimeout(startTimer);
}
+ await awaitSetup(
+ server.request(
+ "account/login/start",
+ {
+ type: "chatgptAuthTokens",
+ accessToken: turn.codexAuth.accessToken,
+ chatgptAccountId: codexOAuthJwtAccountId(userAuth),
+ },
+ (value): value is { type: "chatgptAuthTokens" } =>
+ Boolean(value && typeof value === "object" && "type" in value && value.type === "chatgptAuthTokens"),
+ AbortSignal.timeout(opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS),
+ ),
+ );
rt = ephemeral;
} catch (error) {
if (!ephemeral) rmSync(jail, { recursive: true, force: true });
diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts
index ed7ebeb08..0be540407 100644
--- a/test/codex-harness.test.ts
+++ b/test/codex-harness.test.ts
@@ -405,9 +405,21 @@ rl.on("line", (line) => {
const msg = JSON.parse(line);
if (msg.method === "initialize") return send({ id: msg.id, result: {} });
if (msg.method === "initialized") return;
+ if (msg.method === "account/login/start") {
+ const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
+ if (msg.params.type !== "chatgptAuthTokens" || msg.params.accessToken !== auth.tokens.access_token ||
+ msg.params.chatgptAccountId !== auth.tokens.account_id || "refreshToken" in msg.params) {
+ return send({ id: msg.id, error: { code: -1, message: "incorrect external account login" } });
+ }
+ fs.writeFileSync(path.join(process.env.CODEX_HOME, "external-login"), "ready");
+ return send({ id: msg.id, result: { type: "chatgptAuthTokens" } });
+ }
if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-" + process.pid } } });
if (msg.method === "turn/start") {
const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
+ if (auth.tokens.account_id.startsWith("acct-") && !fs.existsSync(path.join(process.env.CODEX_HOME, "external-login"))) {
+ return send({ id: msg.id, error: { code: -1, message: "external account login missing" } });
+ }
const reply = String(auth.tokens.account_id ?? "none") + ":" + String(Boolean(auth.tokens.refresh_token));
send({ id: msg.id, result: { turn: { id: "turn-" + process.pid, status: "inProgress", items: [] } } });
return setTimeout(() => send({ method: "turn/completed", params: { threadId: "thread-" + process.pid, turn: { id: "turn-" + process.pid, status: "completed", items: [{ type: "agentMessage", text: reply, phase: "final_answer" }] } } }), ${delayMs});
diff --git a/test/subscription-model-turn.test.ts b/test/subscription-model-turn.test.ts
new file mode 100644
index 000000000..ddcf85075
--- /dev/null
+++ b/test/subscription-model-turn.test.ts
@@ -0,0 +1,72 @@
+import "./support/auto-fake-sprites.ts";
+import assert from "node:assert/strict";
+import { test, mock } from "node:test";
+import { createMockHarness } from "../src/harness/mock-harness.ts";
+import type { HarnessTurnInput } from "../src/harness/harness.ts";
+import { testConfig } from "./support/test-config.ts";
+
+const codex = await import("../src/harness/codex-harness.ts");
+const seen: HarnessTurnInput[] = [];
+mock.module("../src/harness/codex-harness.ts", {
+ namedExports: {
+ ...codex,
+ createCodexHarness: () => {
+ const harness = createMockHarness();
+ return {
+ ...harness,
+ id: "codex",
+ turns: {
+ ...harness.turns,
+ runTurn: async (turn: HarnessTurnInput) => {
+ seen.push(turn);
+ return { reply: "subscription model selected", modelCalls: 1 };
+ },
+ },
+ };
+ },
+ },
+});
+const { buildApp } = await import("../src/wiring.ts");
+
+test("web Astra selection reaches Codex with personal OAuth through app and orchestrator", async () => {
+ const built = buildApp(testConfig({ harness: "mock", seedSkills: false }));
+ try {
+ built.config.setIndividualModelAuth(true);
+ built.config.setApprovedHarnesses(["mock", "codex", "claude"]);
+ await built.config.flushScope("org:default-org");
+ await built.config.setRuntimeSelectionLatest("org:default-org", {
+ harnessId: "claude",
+ modelId: "claude-sonnet-5",
+ });
+ await built.userModelCredentials.setOAuth("internal:alice", "openai", {
+ accessToken: "test-access",
+ idToken: "test-id",
+ accountId: "test-account",
+ expiresAt: Date.now() + 3600000,
+ });
+ const request = {
+ surface: "web",
+ liveActor: true,
+ actor: { externalId: "internal:alice" },
+ conversation: { kind: "dm" as const, threadRef: "subscription-astra-test" },
+ text: "Test the selected model",
+ harness: "codex",
+ model: "gpt-6-astra",
+ skipMemory: true,
+ };
+ const result = await built.app.turn(request);
+ assert.equal(result.status, "ok", JSON.stringify(result));
+ assert.equal(seen.length, 1);
+ assert.equal(seen[0]?.runtime?.modelId, "gpt-6-astra");
+ assert.equal(seen[0]?.codexAuth?.accountId, "test-account");
+ await built.userModelCredentials.delete("internal:alice", "openai");
+ assert.equal(
+ (await built.app.turn({ ...request, conversation: { ...request.conversation, threadRef: "disconnected-test" } }))
+ .status,
+ "refused",
+ );
+ assert.equal(seen.length, 1);
+ } finally {
+ await built.runtime.stop();
+ }
+});
diff --git a/test/subscription-runtime-config.test.ts b/test/subscription-runtime-config.test.ts
new file mode 100644
index 000000000..bda37cd57
--- /dev/null
+++ b/test/subscription-runtime-config.test.ts
@@ -0,0 +1,67 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { runtimeConfigBody } from "../src/api/runtime-config.ts";
+import { createMemoryConfigStore } from "../src/resolution/config-store.ts";
+import { createMemoryMap } from "../src/persistence/durable-map.ts";
+import { createKeychain } from "../src/credentials/keychain.ts";
+import { deriveConnectorKey } from "../src/connectors/connector-client-store.ts";
+import { createUserModelCredentialStore } from "../src/model/user-model-credential-store.ts";
+
+async function setup() {
+ const config = createMemoryConfigStore("default-org");
+ config.setApprovedHarnesses(["claude", "codex"]);
+ config.setIndividualModelAuth(true);
+ await config.flushScope("org:default-org");
+ const userModelCredentials = createUserModelCredentialStore({
+ keychain: createKeychain({
+ creds: createMemoryMap(),
+ grants: createMemoryMap(),
+ asks: createMemoryMap(),
+ key: deriveConnectorKey("subscription-runtime-config-test-key"),
+ }),
+ });
+ await userModelCredentials.setOAuth("alice", "openai", { accessToken: "test-access", idToken: "test-id" });
+ const ctx = {
+ deps: {
+ config,
+ userModelCredentials,
+ harnessId: "claude",
+ providerKeys: { anthropic: false, openai: false, openrouter: false },
+ },
+ };
+ return { config, userModelCredentials, ctx };
+}
+
+test("Codex subscription advertises Astra without deployment API keys and preserves a saved selection", async () => {
+ const { config, ctx } = await setup();
+ const initial = await runtimeConfigBody(ctx, "personal:alice", "alice");
+ assert.deepEqual(initial.approvedHarnesses, ["codex"]);
+ assert.ok(initial.modelsByHarness.codex?.includes("gpt-6-astra"));
+ assert.ok(initial.modelsByHarness.codex?.includes("gpt-5.6-sol"));
+ assert.deepEqual(initial.effective, { harnessId: "codex", modelId: "gpt-5.6-sol" });
+ await config.setRuntimeSelectionLatest("personal:alice", { harnessId: "codex", modelId: "gpt-6-astra" });
+ assert.equal((await runtimeConfigBody(ctx, "personal:alice", "alice")).effective.modelId, "gpt-6-astra");
+ const other = await runtimeConfigBody(ctx, "personal:bob", "bob");
+ assert.deepEqual(other.approvedHarnesses, []);
+ assert.deepEqual(other.modelsByHarness.codex, []);
+});
+
+test("subscription models obey the current org allowlist even when a disallowed model was saved", async () => {
+ const { config, ctx } = await setup();
+ await config.setRuntimeSelectionLatest("personal:alice", { harnessId: "codex", modelId: "gpt-6-astra" });
+ config.setWebuiModels("org:default-org", ["gpt-5.6-sol"]);
+ await config.flushScope("org:default-org");
+ const result = await runtimeConfigBody(ctx, "personal:alice", "alice");
+ assert.deepEqual(result.modelsByHarness.codex, ["gpt-5.6-sol"]);
+ assert.ok(result.unavailableReason);
+ config.setApprovedHarnesses(["claude"]);
+ await config.flushScope("org:default-org");
+ assert.deepEqual((await runtimeConfigBody(ctx, "personal:alice", "alice")).approvedHarnesses, []);
+});
+
+test("disconnecting ChatGPT removes its models even when deployment keys are available", async () => {
+ const { ctx, userModelCredentials } = await setup();
+ await userModelCredentials.delete("alice", "openai");
+ ctx.deps.providerKeys = { anthropic: true, openai: true, openrouter: true };
+ assert.deepEqual((await runtimeConfigBody(ctx, "personal:alice", "alice")).approvedHarnesses, []);
+});
diff --git a/test/user-model-auth-injection.test.ts b/test/user-model-auth-injection.test.ts
index 375018af0..c3d23f76e 100644
--- a/test/user-model-auth-injection.test.ts
+++ b/test/user-model-auth-injection.test.ts
@@ -242,3 +242,9 @@ test("individual OAuth routing preserves exact supported runtime selections", ()
assert.equal(resolveIndividualAuthRouting(null, oauth("openai"), "codex/gpt-5.5", "pi")?.model, "codex/gpt-5.5");
assert.equal(resolveIndividualAuthRouting(null, apikey("openai", "test"), "codex/gpt-5.5", "pi"), null);
});
+
+test("routing: Astra uses the connected Codex subscription and cannot fall back to another provider", () => {
+ const result = resolveIndividualAuthRouting(null, oauth("openai"), "gpt-6-astra", "codex");
+ assert.deepEqual(result, { kind: "oauth", provider: "openai", harness: "codex", model: "gpt-6-astra" });
+ assert.equal(resolveIndividualAuthRouting(oauth("anthropic"), null, "gpt-6-astra", "codex"), null);
+});