@khorsheed/dsh-local-agent-codex 0.2.0-rc.1 → 0.2.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, modelDirectoryContextKey, persistChildSession, probeCliVersion, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol } from "@khorsheed/dsh-local-agent";
2
+ import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, firstModelOf, modelDirectoryContextKey, persistChildSession, probeCliVersion, readModelList, readModelListSync, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol, writeModelList } from "@khorsheed/dsh-local-agent";
3
3
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV, endpointHost } from "@khorsheed/dsh-local-agent/types";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
6
6
  import { SessionId } from "@deepseek-ai/dsh-session";
7
- import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
7
+ import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
8
8
  import { StringDecoder } from "node:string_decoder";
9
- import { open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
9
+ import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  //#region lib/types/records.js
12
12
  /**
@@ -38,21 +38,6 @@ const ROLLOUT_WINDOW_SLACK_MS = 3e5;
38
38
  /** Extra future slack for the window end (file timestamps may skew ahead). */
39
39
  const ROLLOUT_WINDOW_FUTURE_MS = 6e4;
40
40
  /**
41
- * Whether the scoped home holds usable Codex credentials: the `auth.json`
42
- * file exists. Absent means the device-code flow has not completed.
43
- * @param homeDir - the `codex` harness's scoped home.
44
- * @returns true when credentials are present.
45
- */
46
- async function codexAuthenticated(homeDir) {
47
- try {
48
- await readFile(join(homeDir, "auth.json"));
49
- return true;
50
- } catch (error) {
51
- if (error.code === "ENOENT") return false;
52
- throw error;
53
- }
54
- }
55
- /**
56
41
  * Parse the first line of a rollout file for the listing fields. Unknown or
57
42
  * malformed values yield an undefined record rather than failing the list.
58
43
  * @param text - the raw rollout file content (only the head is read).
@@ -430,6 +415,293 @@ async function codexRolloutRoundFacts(homeDir, locator) {
430
415
  };
431
416
  }
432
417
  //#endregion
418
+ //#region lib/types/endpoint-auth.js
419
+ /**
420
+ * Endpoint-key authentication — the codex harness's ONLY auth mode. There is
421
+ * no web login anywhere in this package: a delegation round reaches its
422
+ * endpoint with a custom `model_providers` entry on the argv pointing at the
423
+ * relay and the key traveling as `OPENAI_API_KEY` in the process environment
424
+ * (verified against codex-cli 0.163.0: a fresh scoped home with only these
425
+ * runs `codex exec` with no login file at all). With no credential the spawn
426
+ * carries no auth at all — it fails loudly at the endpoint, exactly the
427
+ * honest reading the status shows.
428
+ *
429
+ * Verified against codex-cli 0.163.0 (2026-10-20): a fresh scoped home with
430
+ * only `OPENAI_API_KEY` plus the two `-c` overrides below runs
431
+ * `codex exec -m <model>` end-to-end through an OpenAI-compatible relay
432
+ * (openproxy, `wire_api = "responses"`) — no login, no auth.json, no
433
+ * onboarding gate.
434
+ *
435
+ * Two layers, merged per field, file over config:
436
+ *
437
+ * - `<scopedHome>/endpoint-auth.json` (mode 0600) — the per-scope credential.
438
+ * Its name deliberately matches the eval contract hash's credential denylist
439
+ * (`auth`), so the key material never enters an evaluation's condition
440
+ * fingerprint; the file's mtime serves as the credential stamp, which makes
441
+ * "edit the file" the recovery gesture for a `rejected` grade.
442
+ * - The plugin config's `baseUrl` / `apiKey` — the deployment default every
443
+ * scope without a file shares. Read PER ROUND, so a profile edit applies to
444
+ * the next round (and a host restart clears the in-memory `rejected` mark).
445
+ *
446
+ * `wireApi` is a file-only escape hatch: the settings card never edits it,
447
+ * the default `responses` is codex's native wire (relay-verified), and a
448
+ * hand-edit to `"chat"` survives the card's whole-file writes because the
449
+ * read merges it back. The env layer rides the family's existing plumbing:
450
+ * `delegationEnv` on the host, `containerExecSpawn`'s name-only `-e`
451
+ * forwarding into a container — a containerized round authenticates with the
452
+ * same credential as the host, nothing is authorized inside the unit.
453
+ * @module @khorsheed/dsh-local-agent-codex/endpoint-auth
454
+ */
455
+ /**
456
+ * The scoped-home credential file's name. Chosen to match the eval contract
457
+ * hash's credential-shaped denylist (`/token|key|credential|secret|password|auth|\.env/i`),
458
+ * so the file is excluded from condition fingerprints by name.
459
+ */
460
+ const ENDPOINT_AUTH_FILE = "endpoint-auth.json";
461
+ /** The custom provider's TOML key in the `-c` overrides (bare-key safe). */
462
+ const ENDPOINT_PROVIDER_NAME = "dsh-endpoint";
463
+ /** The env var the custom provider reads the key from (`env_key`). */
464
+ const ENDPOINT_ENV_KEY = "OPENAI_API_KEY";
465
+ /** Trim and normalize one credential field; blank reads as absent. */
466
+ function field(value) {
467
+ if (typeof value !== "string") return void 0;
468
+ const trimmed = value.trim();
469
+ return trimmed === "" ? void 0 : trimmed;
470
+ }
471
+ /** Whether a resolved endpoint carries a usable credential. */
472
+ function hasEndpointCredential(auth) {
473
+ return auth !== void 0 && field(auth.apiKey) !== void 0;
474
+ }
475
+ /**
476
+ * Read the scope's endpoint-auth.json. Absent or unreadable yields undefined
477
+ * (the caller falls back to the plugin config); malformed JSON or a non-object
478
+ * payload also yields undefined — a torn file must not fail a status read.
479
+ * Non-string fields are dropped, never carried through.
480
+ * @param homeDir - the `codex` harness's scoped home.
481
+ * @returns the file's endpoint credential, or undefined.
482
+ */
483
+ async function readEndpointAuthFile(homeDir) {
484
+ let text;
485
+ try {
486
+ text = await readFile(join(homeDir, ENDPOINT_AUTH_FILE), "utf8");
487
+ } catch {
488
+ return;
489
+ }
490
+ try {
491
+ const parsed = JSON.parse(text);
492
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
493
+ const baseUrl = field(parsed["baseUrl"]);
494
+ const apiKey = field(parsed["apiKey"]);
495
+ const wireApi = field(parsed["wireApi"]);
496
+ return {
497
+ ...baseUrl === void 0 ? {} : { baseUrl },
498
+ ...apiKey === void 0 ? {} : { apiKey },
499
+ ...wireApi === void 0 ? {} : { wireApi }
500
+ };
501
+ } catch {
502
+ return;
503
+ }
504
+ }
505
+ /**
506
+ * Resolve one scope's effective endpoint credential: the scope's
507
+ * endpoint-auth.json merged FIELD BY FIELD over the plugin config's
508
+ * `baseUrl` / `apiKey` (a file that names only a key rotates the key while
509
+ * keeping the deployment's relay URL). The result may carry no credential at
510
+ * all — {@link hasEndpointCredential} is the judgment, kept separate so the
511
+ * caller can still route/log the resolved baseUrl.
512
+ * @param homeDir - the scope's scoped home.
513
+ * @param config - the plugin config's deployment-level endpoint pair.
514
+ * @returns the merged endpoint auth.
515
+ */
516
+ async function resolveEndpointAuth(homeDir, config) {
517
+ const file = await readEndpointAuthFile(homeDir);
518
+ const configBaseUrl = field(config.baseUrl);
519
+ const configApiKey = field(config.apiKey);
520
+ return {
521
+ ...configBaseUrl === void 0 ? {} : { baseUrl: configBaseUrl },
522
+ ...configApiKey === void 0 ? {} : { apiKey: configApiKey },
523
+ ...file
524
+ };
525
+ }
526
+ /**
527
+ * The spawn-env layer for a resolved endpoint: the key under the custom
528
+ * provider's `env_key` name, nothing else. A credential-less resolution
529
+ * yields an empty layer — the spread then adds no keys at all, byte-identical
530
+ * to the pre-endpoint env. With no baseUrl the CLI's built-in OpenAI provider
531
+ * reads the same variable, so an official platform key needs no `-c`
532
+ * override either.
533
+ * @param auth - the resolved endpoint auth, or undefined.
534
+ * @returns the explicit env entries for the delegation spawn.
535
+ */
536
+ function endpointAuthEnv(auth) {
537
+ const apiKey = field(auth?.apiKey);
538
+ return apiKey === void 0 ? {} : { [ENDPOINT_ENV_KEY]: apiKey };
539
+ }
540
+ /** Quote one TOML basic string for the `-c` config override. */
541
+ function tomlString$1(value) {
542
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
543
+ }
544
+ /**
545
+ * The `-c` config overrides routing the CLI at the resolved relay: a custom
546
+ * `model_providers` entry plus the `model_provider` selection, as a flat argv
547
+ * fragment (`-c <override>` pairs). Empty unless BOTH a key and a baseUrl are
548
+ * present — a key without a relay URL is served by the built-in provider
549
+ * through the env layer alone, and a relay URL without a key would route
550
+ * every round into a 401. Verified end-to-end against codex-cli 0.163.0.
551
+ * @param auth - the resolved endpoint auth, or undefined.
552
+ * @returns the argv fragment, possibly empty.
553
+ */
554
+ function endpointConfigOverrides(auth) {
555
+ const baseUrl = field(auth?.baseUrl);
556
+ const apiKey = field(auth?.apiKey);
557
+ if (baseUrl === void 0 || apiKey === void 0) return [];
558
+ const wireApi = field(auth?.wireApi) ?? "responses";
559
+ return [
560
+ "-c",
561
+ `model_provider=${tomlString$1(ENDPOINT_PROVIDER_NAME)}`,
562
+ "-c",
563
+ `model_providers.${ENDPOINT_PROVIDER_NAME}={name=${tomlString$1(ENDPOINT_PROVIDER_NAME)},base_url=${tomlString$1(baseUrl)},env_key=${tomlString$1(ENDPOINT_ENV_KEY)},wire_api=${tomlString$1(wireApi)}}`
564
+ ];
565
+ }
566
+ /**
567
+ * The credential marker's modification stamp (epoch ms): the endpoint file's
568
+ * mtime, or undefined when the scope has no file (a config-sourced credential
569
+ * has no marker — rotating it pairs with the profile edit and instance
570
+ * restart that clear the in-memory grade marks). A fresh file write is a
571
+ * fresh stamp, which is how an edited-in key recovers a `rejected` grade.
572
+ * @param homeDir - the scope's scoped home.
573
+ * @returns the stamp, or undefined.
574
+ */
575
+ async function endpointAuthStamp(homeDir) {
576
+ try {
577
+ return (await stat(join(homeDir, ENDPOINT_AUTH_FILE))).mtimeMs;
578
+ } catch {
579
+ return;
580
+ }
581
+ }
582
+ /** Whether the scope carries its own endpoint file at all. */
583
+ async function endpointAuthFileExists(homeDir) {
584
+ return await endpointAuthStamp(homeDir) !== void 0;
585
+ }
586
+ /**
587
+ * Write the scope's endpoint-auth.json (mode 0600, mkdir -p first). The
588
+ * payload preserves the file-only `wireApi` across card writes: callers pass
589
+ * the merged object (current file + edits), never a bare card payload.
590
+ * @param homeDir - the scope's scoped home.
591
+ * @param auth - the endpoint credential to persist.
592
+ */
593
+ async function writeEndpointAuth(homeDir, auth) {
594
+ await mkdir(homeDir, { recursive: true });
595
+ const baseUrl = field(auth.baseUrl);
596
+ const apiKey = field(auth.apiKey);
597
+ const wireApi = field(auth.wireApi);
598
+ const payload = {
599
+ ...baseUrl === void 0 ? {} : { baseUrl },
600
+ ...apiKey === void 0 ? {} : { apiKey },
601
+ ...wireApi === void 0 ? {} : { wireApi }
602
+ };
603
+ await writeFile(join(homeDir, ENDPOINT_AUTH_FILE), JSON.stringify(payload, void 0, 2) + "\n", { mode: 384 });
604
+ }
605
+ /**
606
+ * Clear the scope's OWN endpoint credential (the endpoint-auth.json file).
607
+ * A deployment-level credential from the plugin config is untouched — the
608
+ * core's logout reply then names the profile row as the place to edit.
609
+ * @param homeDir - the scope's scoped home.
610
+ */
611
+ async function clearEndpointAuth(homeDir) {
612
+ await rm(join(homeDir, ENDPOINT_AUTH_FILE), { force: true });
613
+ }
614
+ /** How many served model ids the check reply lists before truncating. */
615
+ const CHECK_MODEL_LIST_LIMIT = 8;
616
+ /** Bound on the error-body excerpt a failed check reports. */
617
+ const CHECK_ERROR_BODY_LIMIT = 200;
618
+ /**
619
+ * `GET <baseUrl>/v1/models` with `Authorization: Bearer <key>` — the
620
+ * token-free endpoint probe shared by the `/codex check` verdict and any
621
+ * future catalog. OpenAI's own API serves this route with the platform key;
622
+ * relays (openproxy verified 2026-10-20, 139 models) proxy it and answer the
623
+ * same OpenAI-list shape. Never throws: every failure mode folds into the
624
+ * result.
625
+ * @param endpoint - the resolved endpoint auth with a decided baseUrl.
626
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
627
+ * @param signal - abort for the whole probe (timeout bundled by the caller).
628
+ * @returns the folded result.
629
+ */
630
+ async function fetchEndpointModels(endpoint, fetchImpl, signal) {
631
+ const headers = {};
632
+ if (endpoint.apiKey !== void 0) headers["authorization"] = `Bearer ${endpoint.apiKey}`;
633
+ const fetcher = fetchImpl ?? globalThis.fetch;
634
+ const root = endpoint.baseUrl.replace(/\/+$/, "");
635
+ const url = `${root}${root.endsWith("/v1") ? "" : "/v1"}/models`;
636
+ let response;
637
+ try {
638
+ response = await fetcher(url, {
639
+ headers,
640
+ signal: signal ?? AbortSignal.timeout(8e3)
641
+ });
642
+ } catch (error) {
643
+ return {
644
+ kind: "unreachable",
645
+ message: error instanceof Error ? error.message : String(error)
646
+ };
647
+ }
648
+ if (!response.ok) return {
649
+ kind: "http",
650
+ status: response.status,
651
+ body: (await response.text().catch(() => "")).slice(0, CHECK_ERROR_BODY_LIMIT)
652
+ };
653
+ let data;
654
+ try {
655
+ data = JSON.parse(await response.text()).data;
656
+ } catch {
657
+ return { kind: "malformed" };
658
+ }
659
+ if (!Array.isArray(data)) return { kind: "malformed" };
660
+ const models = [];
661
+ for (const entry of data) {
662
+ if (typeof entry !== "object" || entry === null) continue;
663
+ const id = entry.id;
664
+ if (typeof id === "string" && id !== "") models.push(id);
665
+ }
666
+ return {
667
+ kind: "models",
668
+ models
669
+ };
670
+ }
671
+ /**
672
+ * Probe the resolved endpoint with a cheap, token-free round-trip: `GET
673
+ * <baseUrl>/v1/models` with the resolved credential's Bearer header. A 200
674
+ * proves both reachability and credential validity — the exact evidence a
675
+ * "verify" button needs — and the served model list rides the reply. One
676
+ * transport-level retry rides along: a local proxy restart (a Surge/clash
677
+ * reload) stalls the first attempt into the timeout and answers the retry
678
+ * immediately. Never throws: every failure mode folds into the reply text,
679
+ * since the reply IS the diagnostic.
680
+ * @param endpoint - the resolved endpoint auth (scope file over settings).
681
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
682
+ * @param timeoutMs - one attempt's deadline (two attempts at most).
683
+ * @returns the human-readable verdict for the check reply.
684
+ */
685
+ async function checkCodexEndpoint(endpoint, fetchImpl, timeoutMs = 2e4) {
686
+ if (!hasEndpointCredential(endpoint)) return "No endpoint credential configured — fill Base URL and API Key on the settings card (or the profile row) first.";
687
+ const baseUrl = (endpoint.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "");
688
+ let result = await fetchEndpointModels({
689
+ ...endpoint,
690
+ baseUrl
691
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
692
+ if (result.kind === "unreachable") result = await fetchEndpointModels({
693
+ ...endpoint,
694
+ baseUrl
695
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
696
+ if (result.kind === "unreachable") return `Endpoint unreachable (${baseUrl}): ${result.message}`;
697
+ if (result.kind === "http") return `Endpoint answered HTTP ${String(result.status)} (${baseUrl})${result.body === "" ? "" : `: ${result.body}`}`;
698
+ if (result.kind === "malformed") return `Endpoint reachable (${baseUrl}) but the model list did not parse — the credential worked, the reply was not /v1/models-shaped.`;
699
+ if (result.models.length === 0) return `Endpoint reachable (${baseUrl}); it serves no models.`;
700
+ const listed = result.models.slice(0, CHECK_MODEL_LIST_LIMIT).join(", ");
701
+ const more = result.models.length > CHECK_MODEL_LIST_LIMIT ? `, … +${String(result.models.length - CHECK_MODEL_LIST_LIMIT)} more` : "";
702
+ return `Endpoint reachable (${baseUrl}): ${String(result.models.length)} model(s) served — ${listed}${more}`;
703
+ }
704
+ //#endregion
433
705
  //#region lib/types/live-driver.js
434
706
  /**
435
707
  * The codex provider's live driver: one resident `codex app-server --stdio`
@@ -948,11 +1220,13 @@ var CodexLiveDriver = class {
948
1220
  const member = registerCodexMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
949
1221
  const startModel = spec.startModel?.trim();
950
1222
  const model = spec.configuration !== void 0 ? spec.configuration.model : startModel !== void 0 && startModel !== "" ? startModel : this.config.model?.(key)?.trim();
1223
+ const endpoint = this.config.endpoint === void 0 ? void 0 : await this.config.endpoint(spec.homeDir).catch(() => void 0);
951
1224
  const spawnSpec = {
952
1225
  argv: [
953
1226
  "codex",
954
1227
  "app-server",
955
1228
  ...member === void 0 ? [] : ["-c", member.configOverride],
1229
+ ...endpointConfigOverrides(endpoint),
956
1230
  ...model === void 0 || model === "" ? [] : ["-c", `model=${JSON.stringify(model)}`],
957
1231
  ...spec.configuration?.effort === void 0 ? [] : ["-c", `model_reasoning_effort=${JSON.stringify(spec.configuration.effort)}`],
958
1232
  "--stdio"
@@ -964,7 +1238,10 @@ var CodexLiveDriver = class {
964
1238
  stderr: "pipe"
965
1239
  },
966
1240
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
967
- env: delegationEnv({ CODEX_HOME: spec.homeDir })
1241
+ env: delegationEnv({
1242
+ CODEX_HOME: spec.homeDir,
1243
+ ...endpointAuthEnv(endpoint)
1244
+ })
968
1245
  };
969
1246
  let child;
970
1247
  try {
@@ -1593,7 +1870,7 @@ var CodexLiveDriver = class {
1593
1870
  });
1594
1871
  await Promise.race([accepted, abortBranch.catch(() => void 0)]);
1595
1872
  return subprocessRunHandle({
1596
- id: childSession.id,
1873
+ id: SessionId(randomUUID()),
1597
1874
  result,
1598
1875
  signal: request.signal,
1599
1876
  onAbort,
@@ -1608,31 +1885,33 @@ var CodexLiveDriver = class {
1608
1885
  //#endregion
1609
1886
  //#region lib/types/provision.js
1610
1887
  /**
1611
- * Codex scoped-home provisioning: pin file-based credential storage so the
1612
- * device-code login writes `auth.json` inside the scoped home. Codex's
1613
- * `cli_auth_credentials_store` defaults to `auto` — on macOS that resolves
1614
- * to the keychain, which would both leak credentials outside the scoped
1615
- * home and defeat the `auth.json` presence check this package's records
1616
- * adapter uses for authentication. An existing config is respected
1617
- * untouched; sign-out removes the credential file.
1888
+ * Codex scoped-home provisioning: a working `config.toml` (a person may edit
1889
+ * it — custom providers, model, effort) plus the one-way sweep of the web
1890
+ * login's leftover `auth.json`. The harness is endpoint-key only: a stale
1891
+ * grant sitting next to an endpoint credential would keep authenticating the
1892
+ * built-in provider while the status reads "unauthenticated", so provisioning
1893
+ * deletes it on every boot (claude-code's OAuth-remnant sweep, same idea).
1618
1894
  * @module @khorsheed/dsh-local-agent-codex/provision
1619
1895
  */
1620
- /** Credential file inside the scoped home; its presence marks auth. */
1621
- const AUTH_FILE = "auth.json";
1622
- /** Minimal scoped config: keep credentials in the scoped home. */
1896
+ /** Minimal scoped config: keep any hand-run login's credentials in the scoped home. */
1623
1897
  const SCOPED_CONFIG = `# Written by dsh-local-agent-codex: keep the device-code
1624
1898
  # credentials inside the scoped home (auth.json) instead of the OS keychain.
1625
1899
  cli_auth_credentials_store = "file"
1626
1900
  `;
1901
+ /** The web login's credential file, swept on every boot (endpoint-key only now). */
1902
+ const LEGACY_AUTH_FILE = "auth.json";
1627
1903
  /**
1628
- * Provision `<homeDir>/config.toml` unless one already exists. A present
1629
- * config is respected untouched (a person may have edited it); otherwise a
1630
- * minimal config pinning file-based credential storage is written so login
1631
- * lands in the scoped home's `auth.json`.
1904
+ * Provision the scoped home: ensure a `config.toml` exists (a minimal one —
1905
+ * codex reads it for custom providers, model, and effort; the historical
1906
+ * `cli_auth_credentials_store = "file"` pin stays so a hand-run `codex login`
1907
+ * under the scoped home can never spill into the macOS keychain), and sweep
1908
+ * the web login's leftover `auth.json` (the harness no longer consults it —
1909
+ * endpoint key or nothing).
1632
1910
  * @param homeDir - the `codex` harness's scoped home.
1633
1911
  * @returns true when a config was written, false when one already existed.
1634
1912
  */
1635
1913
  async function provisionCodexConfig(homeDir) {
1914
+ await rm(join(homeDir, LEGACY_AUTH_FILE), { force: true });
1636
1915
  try {
1637
1916
  await readFile(join(homeDir, "config.toml"));
1638
1917
  return false;
@@ -1767,30 +2046,6 @@ async function listCodexConfigModels(homeDir) {
1767
2046
  }
1768
2047
  return found;
1769
2048
  }
1770
- /**
1771
- * The credential file's modification stamp (epoch ms), undefined when absent.
1772
- * A completed device-code login rewrites `auth.json`, so the stamp
1773
- * distinguishes a fresh login from a leftover (possibly revoked) credential.
1774
- * @param homeDir - the `codex` harness's scoped home.
1775
- * @returns the marker's mtime, or undefined when no credential exists.
1776
- */
1777
- async function codexCredentialStamp(homeDir) {
1778
- try {
1779
- return (await stat(join(homeDir, AUTH_FILE))).mtimeMs;
1780
- } catch {
1781
- return;
1782
- }
1783
- }
1784
- /**
1785
- * Sign out of the scoped account: remove the credential file, so `/<name>
1786
- * status` reports not authenticated and the next login authorizes a fresh
1787
- * account. With `cli_auth_credentials_store = "file"` the credentials live
1788
- * solely in `auth.json`, so removing it is the logout path.
1789
- * @param homeDir - the `codex` harness's scoped home.
1790
- */
1791
- async function codexLogout(homeDir) {
1792
- await rm(join(homeDir, AUTH_FILE), { force: true });
1793
- }
1794
2049
  //#endregion
1795
2050
  //#region lib/types/codex-cli-provider.js
1796
2051
  /**
@@ -1889,6 +2144,7 @@ var CodexCliProvider = class {
1889
2144
  live;
1890
2145
  model;
1891
2146
  overrides;
2147
+ endpoint;
1892
2148
  name = "codex-local";
1893
2149
  capabilities = NO_START_CAPABILITIES;
1894
2150
  inheritsParentContext = false;
@@ -1899,22 +2155,33 @@ var CodexCliProvider = class {
1899
2155
  * while a retiring generation still hosts it).
1900
2156
  * @param model - resolver for the configured model, read PER ROUND so a
1901
2157
  * settings-card write takes effect on the next delegation without a
1902
- * reload. Undefined (the resolver absent, or returning undefined) leaves
1903
- * the argv exactly as it was before the key existed — the scoped
1904
- * config.toml's own `model` then decides, as it always did.
2158
+ * reload; it receives the round's scope (a named scope's curated list
2159
+ * answers first). Undefined (the resolver absent, or returning
2160
+ * undefined) leaves the argv exactly as it was before the key existed —
2161
+ * the scoped config.toml's own `model` then decides, as it always did.
1905
2162
  * @param overrides - resolver for the member's session-level model override
1906
2163
  * (the composer picker, owned by the model broker). It outranks BOTH the
1907
2164
  * delegation's recorded model and the configured one: on the exec path it
1908
2165
  * re-points the next round's `-m`, and on the live path it is the member's
1909
2166
  * start model — a resident runtime bound to a different model is retired
1910
2167
  * so the round respawns onto the override.
2168
+ * @param endpoint - resolver for the scope's endpoint credential, read PER
2169
+ * ROUND so a settings-card write takes effect on the next delegation
2170
+ * without a reload. A resolution carrying a key routes the round at the
2171
+ * relay (a custom `model_providers` entry on the argv, the key in the
2172
+ * env); with no key the spawn carries no credential at all.
1911
2173
  */
1912
- constructor(ctx, sandbox = "workspace-write", live, model, overrides) {
2174
+ constructor(ctx, sandbox = "workspace-write", live, model, overrides, endpoint) {
1913
2175
  this.ctx = ctx;
1914
2176
  this.sandbox = sandbox;
1915
2177
  this.live = live;
1916
2178
  this.model = model;
1917
2179
  this.overrides = overrides;
2180
+ this.endpoint = endpoint;
2181
+ }
2182
+ /** The scope's endpoint credential for this round; absent resolver = never. */
2183
+ resolveEndpoint(homeDir) {
2184
+ return this.endpoint === void 0 ? Promise.resolve(void 0) : this.endpoint(homeDir).catch(() => void 0);
1918
2185
  }
1919
2186
  /** Resolve the live driver for one round's member, if live is on for it. */
1920
2187
  liveDriver(childSessionId) {
@@ -1950,7 +2217,7 @@ var CodexCliProvider = class {
1950
2217
  ...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
1951
2218
  }, (configuration) => this.startCodexResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
1952
2219
  }
1953
- const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
2220
+ const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
1954
2221
  if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
1955
2222
  if (intent?.effort !== void 0) throw new Error("Codex effort requires the configuration admission core");
1956
2223
  return this.startCodexFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
@@ -1979,16 +2246,19 @@ var CodexCliProvider = class {
1979
2246
  delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
1980
2247
  } });
1981
2248
  const harness = this.ctx.localAgent.get("codex");
1982
- const label = subagentDelegationLabel(harness?.displayName ?? "codex", request.descriptor.label);
2249
+ const label = subagentDelegationLabel(harness?.displayName ?? "codex", request.label || void 0);
1983
2250
  childSession.append("subagent/descriptor", {
1984
- ...request.descriptor,
2251
+ version: SUBAGENT_DESCRIPTOR_VERSION,
2252
+ mode: "one-shot",
2253
+ provider: this.name,
1985
2254
  label
1986
2255
  });
1987
2256
  establishSubagentCatalogChild(request.parent.session, childSession.header, label);
1988
2257
  } catch (error) {
1989
2258
  this.ctx.logger.warn(`subagent-codex: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
1990
2259
  }
1991
- const baseUrl = await readCodexBaseUrl(homeDir).catch(() => void 0);
2260
+ const endpoint = await this.resolveEndpoint(homeDir);
2261
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readCodexBaseUrl(homeDir).catch(() => void 0);
1992
2262
  this.ctx.logger.info(`subagent-codex: delegating via ${baseUrl ?? "codex default endpoint"}`);
1993
2263
  const live = exec === void 0 ? this.liveDriver(runId) : void 0;
1994
2264
  if (live !== void 0 && childSession !== void 0 && !live.disabled) {
@@ -2022,11 +2292,15 @@ var CodexCliProvider = class {
2022
2292
  try {
2023
2293
  const run = await startCodexCliRun(request, {
2024
2294
  cwd,
2025
- env: delegationEnv({ CODEX_HOME: homeDir }),
2295
+ env: delegationEnv({
2296
+ CODEX_HOME: homeDir,
2297
+ ...endpointAuthEnv(endpoint)
2298
+ }),
2026
2299
  ...exec === void 0 ? {} : { exec },
2027
2300
  endpointLabel: baseUrl,
2301
+ endpointArgv: endpointConfigOverrides(endpoint),
2028
2302
  sandbox: this.sandbox,
2029
- ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, this.model),
2303
+ ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, () => this.model?.(scope)),
2030
2304
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
2031
2305
  spawn: (spec) => this.ctx.subprocess.spawn(spec),
2032
2306
  onError: (error, stopReason) => {
@@ -2072,7 +2346,8 @@ var CodexCliProvider = class {
2072
2346
  const childSession = this.ctx.get("sessions")?.get(SessionId(intent.childSessionId));
2073
2347
  if (childSession === void 0) throw new Error(`subagent-codex: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
2074
2348
  const nextTurn = childSession.snapshotEvents().filter((event) => event.type === "turn/start").length + 1;
2075
- const baseUrl = await readCodexBaseUrl(homeDir).catch(() => void 0);
2349
+ const endpoint = await this.resolveEndpoint(homeDir);
2350
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readCodexBaseUrl(homeDir).catch(() => void 0);
2076
2351
  this.ctx.logger.info(`subagent-codex: resuming via ${baseUrl ?? "codex default endpoint"}`);
2077
2352
  const live = exec === void 0 ? this.liveDriver(intent.childSessionId) : void 0;
2078
2353
  if (live !== void 0 && !live.disabled) {
@@ -2106,11 +2381,15 @@ var CodexCliProvider = class {
2106
2381
  try {
2107
2382
  run = await startCodexCliRun(request, {
2108
2383
  cwd,
2109
- env: delegationEnv({ CODEX_HOME: homeDir }),
2384
+ env: delegationEnv({
2385
+ CODEX_HOME: homeDir,
2386
+ ...endpointAuthEnv(endpoint)
2387
+ }),
2110
2388
  ...exec === void 0 ? {} : { exec },
2111
2389
  endpointLabel: baseUrl,
2390
+ endpointArgv: endpointConfigOverrides(endpoint),
2112
2391
  sandbox: this.sandbox,
2113
- ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, this.model),
2392
+ ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, () => this.model?.(scope)),
2114
2393
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
2115
2394
  spawn: (spec) => this.ctx.subprocess.spawn(spec),
2116
2395
  onError: (error, stopReason) => {
@@ -2357,11 +2636,13 @@ function startCodexCliRun(request, spec) {
2357
2636
  if (request.signal.aborted) throw new Error("subagent-codex: request was aborted before the CLI started");
2358
2637
  const turn = spec.resume?.turn ?? 1;
2359
2638
  const memberArgv = spec.member === void 0 ? [] : ["-c", spec.member.configOverride];
2639
+ const endpointArgv = spec.endpointArgv ?? [];
2360
2640
  const modelArgv = [...spec.model === void 0 ? [] : ["-m", spec.model], ...spec.effort === void 0 ? [] : ["-c", `model_reasoning_effort=${JSON.stringify(spec.effort)}`]];
2361
2641
  const argv = spec.resume === void 0 ? [
2362
2642
  "codex",
2363
2643
  "exec",
2364
2644
  ...memberArgv,
2645
+ ...endpointArgv,
2365
2646
  ...modelArgv,
2366
2647
  "--sandbox",
2367
2648
  spec.sandbox,
@@ -2372,6 +2653,7 @@ function startCodexCliRun(request, spec) {
2372
2653
  "codex",
2373
2654
  "exec",
2374
2655
  ...memberArgv,
2656
+ ...endpointArgv,
2375
2657
  ...modelArgv,
2376
2658
  "--sandbox",
2377
2659
  spec.sandbox,
@@ -2521,7 +2803,7 @@ function startCodexCliRun(request, spec) {
2521
2803
  return mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
2522
2804
  }, () => {});
2523
2805
  return Promise.resolve(subprocessRunHandle({
2524
- id: spec.childSession?.id ?? SessionId(randomUUID()),
2806
+ id: SessionId(randomUUID()),
2525
2807
  result,
2526
2808
  signal: request.signal,
2527
2809
  onAbort,
@@ -2881,7 +3163,8 @@ var LiveDriverSwitch = class {
2881
3163
  this.active = next.live ? new CodexLiveDriver(this.ctx, {
2882
3164
  ...this.options.sandbox === void 0 ? {} : { sandbox: this.options.sandbox },
2883
3165
  ...this.options.liveIdleMs === void 0 ? {} : { liveIdleMs: this.options.liveIdleMs },
2884
- ...this.options.model === void 0 ? {} : { model: this.options.model }
3166
+ ...this.options.model === void 0 ? {} : { model: this.options.model },
3167
+ ...this.options.endpoint === void 0 ? {} : { endpoint: this.options.endpoint }
2885
3168
  }) : void 0;
2886
3169
  if (retiring !== void 0) {
2887
3170
  this.draining.add(retiring);
@@ -2954,7 +3237,7 @@ var CodexModelBroker = class {
2954
3237
  await this.deps.refreshDirectory?.(home, binding.cwd);
2955
3238
  directory = this.deps.directory?.(home, binding.cwd);
2956
3239
  }
2957
- const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel() ?? await readCodexModel(home) ?? this.deps.catalogDefault(home, binding.cwd);
3240
+ const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel(home) ?? await readCodexModel(home) ?? this.deps.catalogDefault(home, binding.cwd);
2958
3241
  const entry = directory?.entries.find((entry) => entry.value === model);
2959
3242
  const explicitEffort = selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0;
2960
3243
  const effort = explicitEffort ?? await readCodexReasoningEffort(home) ?? entry?.reasoning?.default;
@@ -3019,7 +3302,7 @@ var CodexModelBroker = class {
3019
3302
  if (override !== void 0) return override;
3020
3303
  const recorded = this.deps.ctx.localAgent.getDelegation(childSessionId)?.model?.trim();
3021
3304
  if (recorded !== void 0 && recorded !== "") return recorded;
3022
- return this.deps.settingsModel();
3305
+ return this.deps.settingsModel(this.deps.homeDir(childSessionId));
3023
3306
  }
3024
3307
  /**
3025
3308
  * Read the model surface. The cliDefault and the discovered identifiers come
@@ -3030,8 +3313,8 @@ var CodexModelBroker = class {
3030
3313
  * re-fetches on open, so it never blocks on a CLI boot).
3031
3314
  */
3032
3315
  async modelInfo(childSessionId, delegationModel) {
3033
- const settings = this.deps.settingsModel();
3034
3316
  const homeDir = this.deps.homeDir(childSessionId);
3317
+ const settings = this.deps.settingsModel(homeDir);
3035
3318
  const [cliDefault, discovered] = await Promise.all([readCodexModel(homeDir).catch(() => void 0), listCodexConfigModels(homeDir).catch(() => [])]);
3036
3319
  const override = childSessionId === void 0 ? void 0 : this.deps.overrides.get(childSessionId);
3037
3320
  const delegation = delegationModel?.trim() === "" ? void 0 : delegationModel;
@@ -3039,6 +3322,7 @@ var CodexModelBroker = class {
3039
3322
  const effective = override ?? delegation ?? settings ?? cliDefault ?? (catalogDefaultRaw === void 0 || catalogDefaultRaw === "" ? void 0 : catalogDefaultRaw);
3040
3323
  const source = override !== void 0 ? "override" : delegation !== void 0 ? "delegation" : settings !== void 0 ? "settings" : cliDefault !== void 0 ? "cli-config" : "cli-builtin";
3041
3324
  const inFlight = childSessionId !== void 0 && this.activeDelegations().includes(childSessionId);
3325
+ const curated = this.deps.modelList?.(homeDir) ?? [];
3042
3326
  return {
3043
3327
  ...effective === void 0 ? {} : { effective },
3044
3328
  source,
@@ -3050,8 +3334,8 @@ var CodexModelBroker = class {
3050
3334
  settings,
3051
3335
  cliDefault,
3052
3336
  ...discovered
3053
- ], this.deps.recentModels()) },
3054
- choices: dedupeChoices([
3337
+ ], curated.length > 0 ? [] : this.deps.recentModels()) },
3338
+ choices: curated.length > 0 ? [...curated] : dedupeChoices([
3055
3339
  settings,
3056
3340
  cliDefault,
3057
3341
  ...discovered,
@@ -3083,6 +3367,16 @@ var CodexModelBroker = class {
3083
3367
  };
3084
3368
  /** Grace between stdin EOF and terminate when the probe tears the process down. */
3085
3369
  const PROBE_EOF_GRACE_MS = 1e3;
3370
+ /** One directory answer that names why it is empty. */
3371
+ function unsupported(reason) {
3372
+ return {
3373
+ entries: [],
3374
+ complete: false,
3375
+ customInput: true,
3376
+ unsupported: true,
3377
+ reason
3378
+ };
3379
+ }
3086
3380
  /** Preserve native metadata, including hidden candidates; reject malformed pages. */
3087
3381
  function catalogEntries(result) {
3088
3382
  const page = result;
@@ -3138,7 +3432,7 @@ var CodexModelCatalog = class {
3138
3432
  this.cache = new ModelDirectoryCache({
3139
3433
  load: (key, signal) => {
3140
3434
  const context = JSON.parse(key);
3141
- return this.probe(context.homeDir, context.cwd, signal);
3435
+ return this.load(context.homeDir, context.cwd, signal);
3142
3436
  },
3143
3437
  ttlMs: deps.ttlMs ?? 3e5,
3144
3438
  ...deps.now === void 0 ? {} : { now: deps.now },
@@ -3146,7 +3440,11 @@ var CodexModelCatalog = class {
3146
3440
  });
3147
3441
  }
3148
3442
  key(homeDir, cwd = homeDir) {
3149
- const files = [join(homeDir, "config.toml"), join(homeDir, "auth.json")];
3443
+ const files = [
3444
+ join(homeDir, "config.toml"),
3445
+ join(homeDir, "auth.json"),
3446
+ join(homeDir, ENDPOINT_AUTH_FILE)
3447
+ ];
3150
3448
  for (let directory = cwd;; directory = dirname(directory)) {
3151
3449
  files.push(join(directory, ".codex", "config.toml"));
3152
3450
  if (dirname(directory) === directory) break;
@@ -3181,16 +3479,69 @@ var CodexModelCatalog = class {
3181
3479
  return (await this.cache.refresh(this.key(homeDir, cwd))).entries.filter((entry) => !entry.hidden).map((entry) => entry.value);
3182
3480
  }
3183
3481
  /**
3184
- * One bounded probe: spawn the app-server against the scoped home, answer
3185
- * the initialize handshake exactly as the live driver shapes it, ask
3186
- * `model/list`, then tear the process down (stdin EOF → grace →
3187
- * terminate). The overall timeout kills the process on expiry. Failures propagate to the shared cache, which preserves prior data.
3482
+ * One directory load: the endpoint's served id list is the vocabulary (the
3483
+ * card's add dialog can only offer what the endpoint answers), folded
3484
+ * CONCURRENTLY with the native app-server probe's metadata. The endpoint
3485
+ * answers are deliberate:
3486
+ *
3487
+ * - no credential resolved → empty with a "save a key first" reason;
3488
+ * - 401/403 → empty with the rejection (definitive — cached as data);
3489
+ * - another HTTP status → empty with it (the relay serves no model route);
3490
+ * - a transport failure THROWS, so the cache keeps the last good list and
3491
+ * retries on schedule instead of blanking a momentarily-down endpoint
3492
+ * (the openproxy relay once 500'd and recovered — 2026-10-22).
3493
+ *
3494
+ * The native probe's failure never fails the load: endpoint data alone is
3495
+ * complete (it warns once via the deps sink). The endpoint fetch gets a
3496
+ * 20s ceiling by default (mirrors claude's catalog timeout); the native
3497
+ * probe keeps its own tighter bound internally.
3498
+ */
3499
+ async load(homeDir, cwd, signal) {
3500
+ const endpoint = this.deps.endpoint === void 0 ? void 0 : await this.deps.endpoint(homeDir).catch(() => void 0);
3501
+ if (!hasEndpointCredential(endpoint)) return unsupported("No endpoint credential configured — the model list loads from the endpoint once an API Key is saved");
3502
+ const combined = AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? 2e4)]);
3503
+ const [result, native] = await Promise.all([fetchEndpointModels({
3504
+ ...endpoint,
3505
+ baseUrl: endpoint.baseUrl ?? "https://api.openai.com"
3506
+ }, this.deps.fetchImpl, combined), this.probeNative(homeDir, cwd, combined).catch((error) => {
3507
+ this.deps.warn?.(`local-agent-codex: the native model/list probe failed (metadata only — the endpoint's list stands): ${error instanceof Error ? error.message : String(error)}`);
3508
+ })]);
3509
+ if (result.kind === "unreachable") throw new Error(`endpoint unreachable: ${result.message}`);
3510
+ if (result.kind === "malformed") return unsupported("The endpoint answered but the model list did not parse (not /v1/models-shaped)");
3511
+ if (result.kind === "http") {
3512
+ if (result.status === 401 || result.status === 403) return unsupported(`The endpoint rejected the credential (HTTP ${String(result.status)}) — the list loads once a valid key is saved`);
3513
+ return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
3514
+ }
3515
+ if (result.models.length === 0) return unsupported("The endpoint serves no models");
3516
+ const nativeByValue = new Map((native?.entries ?? []).map((entry) => [entry.value, entry]));
3517
+ const entries = result.models.map((id) => nativeByValue.get(id) ?? {
3518
+ value: id,
3519
+ label: id,
3520
+ source: "native"
3521
+ });
3522
+ const defaultModel = native?.defaultModel;
3523
+ return {
3524
+ entries,
3525
+ complete: true,
3526
+ customInput: true,
3527
+ ...defaultModel === void 0 ? {} : { defaultModel }
3528
+ };
3529
+ }
3530
+ /**
3531
+ * One bounded NATIVE probe — the metadata-enrichment side of {@link load}:
3532
+ * spawn the app-server against the scoped home, answer the initialize
3533
+ * handshake exactly as the live driver shapes it, ask `model/list`, then
3534
+ * tear the process down (stdin EOF → grace → terminate). The overall
3535
+ * timeout kills the process on expiry. Failures propagate to the caller
3536
+ * (load demotes them to a warn).
3188
3537
  */
3189
- async probe(homeDir, cwd, signal) {
3538
+ async probeNative(homeDir, cwd, signal) {
3539
+ const endpoint = this.deps.endpoint === void 0 ? void 0 : await this.deps.endpoint(homeDir).catch(() => void 0);
3190
3540
  const spec = {
3191
3541
  argv: [
3192
3542
  "codex",
3193
3543
  "app-server",
3544
+ ...endpointConfigOverrides(endpoint),
3194
3545
  "--stdio"
3195
3546
  ],
3196
3547
  cwd,
@@ -3200,7 +3551,10 @@ var CodexModelCatalog = class {
3200
3551
  stderr: "pipe"
3201
3552
  },
3202
3553
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
3203
- env: delegationEnv({ CODEX_HOME: homeDir })
3554
+ env: delegationEnv({
3555
+ CODEX_HOME: homeDir,
3556
+ ...endpointAuthEnv(endpoint)
3557
+ })
3204
3558
  };
3205
3559
  const child = this.deps.spawn(spec);
3206
3560
  const decoder = new StringDecoder("utf8");
@@ -3337,8 +3691,8 @@ var CodexModelCatalog = class {
3337
3691
  /**
3338
3692
  * Codex harness instantiation of the local-agent family. Registers the
3339
3693
  * `codex` harness into the core registry: scoped home under the shared homes
3340
- * root (`CODEX_HOME`), device-code login through `codex login --device-auth`
3341
- * (prompt captured from stdout), and rollout-file session records.
3694
+ * root (`CODEX_HOME`), endpoint-key authentication (endpoint-auth.ts — no web
3695
+ * login anywhere), and rollout-file session records.
3342
3696
  * Delegation mounts through the `codex-local` provider (one-shot
3343
3697
  * `codex exec` under the scoped home); the bundle patch's
3344
3698
  * `tool-subagent-codex-local` row puts the tool on the profile root, so
@@ -3350,7 +3704,8 @@ var CodexModelCatalog = class {
3350
3704
  const name = "local-agent-codex";
3351
3705
  /**
3352
3706
  * Boot delay before the model catalog's eager warmup probe fires. The probe
3353
- * spawns a `codex app-server` — cheap, but not free — so it waits out the
3707
+ * fetches the endpoint's served model list AND spawns a `codex app-server`
3708
+ * (the native metadata side) — cheap, but not free — so it waits out the
3354
3709
  * boot storm instead of competing with it, and the FIRST settings-card open
3355
3710
  * usually hits a warm cache. A card opened before the timer fires simply
3356
3711
  * triggers the probe itself (the read path's lazy kick), so the delay never
@@ -3393,6 +3748,8 @@ const Config = z.object({
3393
3748
  z.const("workspace-write"),
3394
3749
  z.const("danger-full-access")
3395
3750
  ]),
3751
+ baseUrl: z.string(),
3752
+ apiKey: z.string(),
3396
3753
  model: volatilize(SETTINGS_FIELDS.model),
3397
3754
  live: volatilize(SETTINGS_FIELDS.live),
3398
3755
  liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
@@ -3449,14 +3806,22 @@ function apply(ctx, config) {
3449
3806
  legacyBase,
3450
3807
  read: readSettings
3451
3808
  });
3452
- const resolveModel = () => {
3809
+ const resolveModelKey = () => {
3453
3810
  const model = face.get().model?.trim();
3454
3811
  return model === void 0 || model === "" ? void 0 : model;
3455
3812
  };
3813
+ const resolveModelFor = (homeDir) => firstModelOf(readModelListSync(homeDir)) ?? resolveModelKey();
3814
+ const resolveModel = (scope) => resolveModelFor(ctx.localAgent.homeDir("codex", scope));
3456
3815
  const memberModelOverrides = /* @__PURE__ */ new Map();
3816
+ const endpointConfig = {
3817
+ ...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
3818
+ ...config.apiKey === void 0 ? {} : { apiKey: config.apiKey }
3819
+ };
3820
+ const resolveEndpoint = (scopeHome) => resolveEndpointAuth(scopeHome, endpointConfig);
3457
3821
  const modelCatalog = new CodexModelCatalog({
3458
3822
  spawn: (spec) => ctx.subprocess.spawn(spec),
3459
- warn: (message) => ctx.logger.warn(message)
3823
+ warn: (message) => ctx.logger.warn(message),
3824
+ endpoint: resolveEndpoint
3460
3825
  });
3461
3826
  const warmup = setTimeout(() => {
3462
3827
  modelCatalog.read(homeDir);
@@ -3464,12 +3829,14 @@ function apply(ctx, config) {
3464
3829
  warmup.unref();
3465
3830
  const liveSwitch = new LiveDriverSwitch(ctx, face, {
3466
3831
  sandbox,
3467
- model: (childSessionId) => memberModelOverrides.get(childSessionId) ?? resolveModel(),
3832
+ model: (childSessionId) => memberModelOverrides.get(childSessionId) ?? resolveModel((ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
3833
+ endpoint: resolveEndpoint,
3468
3834
  ...config.liveIdleMs === void 0 ? {} : { liveIdleMs: config.liveIdleMs }
3469
3835
  });
3470
3836
  const modelBroker = new CodexModelBroker({
3471
3837
  ctx,
3472
- settingsModel: resolveModel,
3838
+ settingsModel: resolveModelFor,
3839
+ modelList: (homeDir) => readModelListSync(homeDir),
3473
3840
  recentModels: () => face.get().recentModels ?? [],
3474
3841
  homeDir: (childSessionId) => ctx.localAgent.homeDir("codex", childSessionId === void 0 ? void 0 : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
3475
3842
  cwd: (childSessionId) => childSessionId === void 0 ? void 0 : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.cwd,
@@ -3483,7 +3850,7 @@ function apply(ctx, config) {
3483
3850
  catalog: (scopedHome, cwd) => modelCatalog.read(scopedHome, cwd),
3484
3851
  catalogDefault: (scopedHome, cwd) => modelCatalog.readDefault(scopedHome, cwd)
3485
3852
  });
3486
- const disposeProvider = ctx.subagents.registerProvider(new CodexCliProvider(ctx, sandbox, liveSwitch.resolve, resolveModel, (childSessionId) => memberModelOverrides.get(childSessionId)));
3853
+ const disposeProvider = ctx.subagents.registerProvider(new CodexCliProvider(ctx, sandbox, liveSwitch.resolve, resolveModel, (childSessionId) => memberModelOverrides.get(childSessionId), resolveEndpoint));
3487
3854
  const disposeHarness = ctx.localAgent.register({
3488
3855
  prepareMember: async ({ binding, childSession, configuration, signal }) => {
3489
3856
  const driver = liveSwitch.resolve(binding.childSessionId);
@@ -3501,23 +3868,53 @@ function apply(ctx, config) {
3501
3868
  homeEnvVar: "CODEX_HOME",
3502
3869
  delegationProvider: "codex-local",
3503
3870
  modelBroker,
3504
- login: { pty: {
3505
- command: "codex",
3506
- args: ["login"]
3507
- } },
3871
+ modelList: {
3872
+ read: (home) => readModelList(home),
3873
+ write: (home, models) => writeModelList(home, models)
3874
+ },
3508
3875
  records: { listSessions: (homeDir) => listCodexSessions(homeDir) },
3509
3876
  provision: (scopedHome) => provisionCodexConfig(scopedHome).then(() => {}),
3510
- isAuthenticated: codexAuthenticated,
3511
- credentialStamp: codexCredentialStamp,
3512
- logout: codexLogout,
3877
+ isAuthenticated: async (home) => hasEndpointCredential(await resolveEndpoint(home)),
3878
+ checkEndpoint: (home) => resolveEndpoint(home).then((endpoint) => checkCodexEndpoint(endpoint)),
3879
+ endpointAuth: {
3880
+ read: async (home) => {
3881
+ const endpoint = await resolveEndpoint(home);
3882
+ return {
3883
+ ...endpoint.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
3884
+ apiKeySet: endpoint.apiKey !== void 0,
3885
+ authTokenSet: false
3886
+ };
3887
+ },
3888
+ write: async (home, fields) => {
3889
+ const next = { ...await readEndpointAuthFile(home) ?? {} };
3890
+ for (const [field, value] of Object.entries(fields)) {
3891
+ if (field !== "baseUrl" && field !== "apiKey") continue;
3892
+ if (value === void 0) continue;
3893
+ const trimmed = typeof value === "string" ? value.trim() : "";
3894
+ if (value === null || trimmed === "") delete next[field];
3895
+ else next[field] = trimmed;
3896
+ }
3897
+ if (Object.keys(next).length === 0) await clearEndpointAuth(home);
3898
+ else await writeEndpointAuth(home, next);
3899
+ }
3900
+ },
3901
+ credentialStamp: endpointAuthStamp,
3902
+ logout: async (home) => {
3903
+ if (await endpointAuthFileExists(home)) {
3904
+ await clearEndpointAuth(home);
3905
+ return;
3906
+ }
3907
+ throw new Error("codex has no scoped endpoint-auth.json to clear — the credential comes from the settings card or the profile row (baseUrl/apiKey); clear it there instead");
3908
+ },
3513
3909
  effectiveSettings: async (scopedHome) => {
3514
- const [reasoningEffort, baseUrl, scopedModel, cliVersion] = await Promise.all([
3910
+ const [reasoningEffort, endpoint, scopedModel, cliVersion] = await Promise.all([
3515
3911
  readCodexReasoningEffort(scopedHome).catch(() => void 0),
3516
- readCodexBaseUrl(scopedHome).catch(() => void 0),
3912
+ resolveEndpoint(scopedHome).catch(() => void 0),
3517
3913
  readCodexModel(scopedHome).catch(() => void 0),
3518
3914
  codexCliVersion(ctx, scopedHome).catch(() => void 0)
3519
3915
  ]);
3520
- const model = resolveModel() ?? scopedModel;
3916
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readCodexBaseUrl(scopedHome).catch(() => void 0);
3917
+ const model = resolveModelFor(scopedHome) ?? scopedModel;
3521
3918
  const baseUrlHost = baseUrl !== void 0 ? endpointHost(baseUrl) : void 0;
3522
3919
  return {
3523
3920
  drive: face.get().live ? "live" : "exec",