@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/CHANGELOG.md +11 -0
- package/README.en.md +32 -18
- package/README.i18n.yaml +2 -2
- package/README.md +32 -18
- package/lib/client.js +816 -322
- package/lib/index.js +501 -104
- package/lib/types/client/SettingsCard.d.ts +73 -24
- package/lib/types/client/SettingsCard.js +110 -140
- package/lib/types/client/index.d.ts +6 -26
- package/lib/types/client/index.js +136 -50
- package/lib/types/client/locales.d.ts +34 -24
- package/lib/types/client/locales.js +38 -28
- package/lib/types/codex-cli-provider.d.ts +21 -4
- package/lib/types/codex-cli-provider.js +60 -27
- package/lib/types/endpoint-auth.d.ts +194 -0
- package/lib/types/endpoint-auth.js +300 -0
- package/lib/types/index.d.ts +19 -3
- package/lib/types/index.js +127 -28
- package/lib/types/live-driver.d.ts +9 -0
- package/lib/types/live-driver.js +16 -2
- package/lib/types/live-switch.d.ts +7 -0
- package/lib/types/live-switch.js +1 -0
- package/lib/types/model-broker.d.ts +12 -2
- package/lib/types/model-broker.js +9 -5
- package/lib/types/model-catalog.d.ts +53 -9
- package/lib/types/model-catalog.js +95 -12
- package/lib/types/provision.d.ts +12 -27
- package/lib/types/provision.js +19 -40
- package/lib/types/records.d.ts +0 -7
- package/lib/types/records.js +1 -19
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +20 -21
- package/lib/invariant.js +0 -31
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -33
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({
|
|
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:
|
|
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:
|
|
1612
|
-
*
|
|
1613
|
-
* `
|
|
1614
|
-
* to
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
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
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
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.
|
|
2249
|
+
const label = subagentDelegationLabel(harness?.displayName ?? "codex", request.label || void 0);
|
|
1983
2250
|
childSession.append("subagent/descriptor", {
|
|
1984
|
-
|
|
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
|
|
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({
|
|
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
|
|
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({
|
|
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:
|
|
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.
|
|
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 = [
|
|
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
|
|
3185
|
-
*
|
|
3186
|
-
*
|
|
3187
|
-
*
|
|
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
|
|
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({
|
|
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`),
|
|
3341
|
-
*
|
|
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`
|
|
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
|
|
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:
|
|
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
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
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:
|
|
3511
|
-
|
|
3512
|
-
|
|
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,
|
|
3910
|
+
const [reasoningEffort, endpoint, scopedModel, cliVersion] = await Promise.all([
|
|
3515
3911
|
readCodexReasoningEffort(scopedHome).catch(() => void 0),
|
|
3516
|
-
|
|
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
|
|
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",
|