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

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.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Endpoint-key authentication — the codex harness's ONLY auth mode. There is
3
+ * no web login anywhere in this package: a delegation round reaches its
4
+ * endpoint with a custom `model_providers` entry on the argv pointing at the
5
+ * relay and the key traveling as `OPENAI_API_KEY` in the process environment
6
+ * (verified against codex-cli 0.163.0: a fresh scoped home with only these
7
+ * runs `codex exec` with no login file at all). With no credential the spawn
8
+ * carries no auth at all — it fails loudly at the endpoint, exactly the
9
+ * honest reading the status shows.
10
+ *
11
+ * Verified against codex-cli 0.163.0 (2026-10-20): a fresh scoped home with
12
+ * only `OPENAI_API_KEY` plus the two `-c` overrides below runs
13
+ * `codex exec -m <model>` end-to-end through an OpenAI-compatible relay
14
+ * (openproxy, `wire_api = "responses"`) — no login, no auth.json, no
15
+ * onboarding gate.
16
+ *
17
+ * Two layers, merged per field, file over config:
18
+ *
19
+ * - `<scopedHome>/endpoint-auth.json` (mode 0600) — the per-scope credential.
20
+ * Its name deliberately matches the eval contract hash's credential denylist
21
+ * (`auth`), so the key material never enters an evaluation's condition
22
+ * fingerprint; the file's mtime serves as the credential stamp, which makes
23
+ * "edit the file" the recovery gesture for a `rejected` grade.
24
+ * - The plugin config's `baseUrl` / `apiKey` — the deployment default every
25
+ * scope without a file shares. Read PER ROUND, so a profile edit applies to
26
+ * the next round (and a host restart clears the in-memory `rejected` mark).
27
+ *
28
+ * `wireApi` is a file-only escape hatch: the settings card never edits it,
29
+ * the default `responses` is codex's native wire (relay-verified), and a
30
+ * hand-edit to `"chat"` survives the card's whole-file writes because the
31
+ * read merges it back. The env layer rides the family's existing plumbing:
32
+ * `delegationEnv` on the host, `containerExecSpawn`'s name-only `-e`
33
+ * forwarding into a container — a containerized round authenticates with the
34
+ * same credential as the host, nothing is authorized inside the unit.
35
+ * @module @khorsheed/dsh-local-agent-codex/endpoint-auth
36
+ */
37
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
38
+ import { join } from 'node:path';
39
+ /**
40
+ * The scoped-home credential file's name. Chosen to match the eval contract
41
+ * hash's credential-shaped denylist (`/token|key|credential|secret|password|auth|\.env/i`),
42
+ * so the file is excluded from condition fingerprints by name.
43
+ */
44
+ export const ENDPOINT_AUTH_FILE = 'endpoint-auth.json';
45
+ /** The custom provider's TOML key in the `-c` overrides (bare-key safe). */
46
+ export const ENDPOINT_PROVIDER_NAME = 'dsh-endpoint';
47
+ /** The env var the custom provider reads the key from (`env_key`). */
48
+ export const ENDPOINT_ENV_KEY = 'OPENAI_API_KEY';
49
+ /** Trim and normalize one credential field; blank reads as absent. */
50
+ function field(value) {
51
+ if (typeof value !== 'string')
52
+ return undefined;
53
+ const trimmed = value.trim();
54
+ return trimmed === '' ? undefined : trimmed;
55
+ }
56
+ /** Whether a resolved endpoint carries a usable credential. */
57
+ export function hasEndpointCredential(auth) {
58
+ return auth !== undefined && field(auth.apiKey) !== undefined;
59
+ }
60
+ /**
61
+ * Read the scope's endpoint-auth.json. Absent or unreadable yields undefined
62
+ * (the caller falls back to the plugin config); malformed JSON or a non-object
63
+ * payload also yields undefined — a torn file must not fail a status read.
64
+ * Non-string fields are dropped, never carried through.
65
+ * @param homeDir - the `codex` harness's scoped home.
66
+ * @returns the file's endpoint credential, or undefined.
67
+ */
68
+ export async function readEndpointAuthFile(homeDir) {
69
+ let text;
70
+ try {
71
+ text = await readFile(join(homeDir, ENDPOINT_AUTH_FILE), 'utf8');
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ try {
77
+ const parsed = JSON.parse(text);
78
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
79
+ return undefined;
80
+ const baseUrl = field(parsed['baseUrl']);
81
+ const apiKey = field(parsed['apiKey']);
82
+ const wireApi = field(parsed['wireApi']);
83
+ return {
84
+ ...baseUrl === undefined ? {} : { baseUrl },
85
+ ...apiKey === undefined ? {} : { apiKey },
86
+ ...wireApi === undefined ? {} : { wireApi },
87
+ };
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ /**
94
+ * Resolve one scope's effective endpoint credential: the scope's
95
+ * endpoint-auth.json merged FIELD BY FIELD over the plugin config's
96
+ * `baseUrl` / `apiKey` (a file that names only a key rotates the key while
97
+ * keeping the deployment's relay URL). The result may carry no credential at
98
+ * all — {@link hasEndpointCredential} is the judgment, kept separate so the
99
+ * caller can still route/log the resolved baseUrl.
100
+ * @param homeDir - the scope's scoped home.
101
+ * @param config - the plugin config's deployment-level endpoint pair.
102
+ * @returns the merged endpoint auth.
103
+ */
104
+ export async function resolveEndpointAuth(homeDir, config) {
105
+ const file = await readEndpointAuthFile(homeDir);
106
+ const configBaseUrl = field(config.baseUrl);
107
+ const configApiKey = field(config.apiKey);
108
+ return {
109
+ ...configBaseUrl === undefined ? {} : { baseUrl: configBaseUrl },
110
+ ...configApiKey === undefined ? {} : { apiKey: configApiKey },
111
+ ...file,
112
+ };
113
+ }
114
+ /**
115
+ * The spawn-env layer for a resolved endpoint: the key under the custom
116
+ * provider's `env_key` name, nothing else. A credential-less resolution
117
+ * yields an empty layer — the spread then adds no keys at all, byte-identical
118
+ * to the pre-endpoint env. With no baseUrl the CLI's built-in OpenAI provider
119
+ * reads the same variable, so an official platform key needs no `-c`
120
+ * override either.
121
+ * @param auth - the resolved endpoint auth, or undefined.
122
+ * @returns the explicit env entries for the delegation spawn.
123
+ */
124
+ export function endpointAuthEnv(auth) {
125
+ const apiKey = field(auth?.apiKey);
126
+ return apiKey === undefined ? {} : { [ENDPOINT_ENV_KEY]: apiKey };
127
+ }
128
+ /** Quote one TOML basic string for the `-c` config override. */
129
+ function tomlString(value) {
130
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
131
+ }
132
+ /**
133
+ * The `-c` config overrides routing the CLI at the resolved relay: a custom
134
+ * `model_providers` entry plus the `model_provider` selection, as a flat argv
135
+ * fragment (`-c <override>` pairs). Empty unless BOTH a key and a baseUrl are
136
+ * present — a key without a relay URL is served by the built-in provider
137
+ * through the env layer alone, and a relay URL without a key would route
138
+ * every round into a 401. Verified end-to-end against codex-cli 0.163.0.
139
+ * @param auth - the resolved endpoint auth, or undefined.
140
+ * @returns the argv fragment, possibly empty.
141
+ */
142
+ export function endpointConfigOverrides(auth) {
143
+ const baseUrl = field(auth?.baseUrl);
144
+ const apiKey = field(auth?.apiKey);
145
+ if (baseUrl === undefined || apiKey === undefined)
146
+ return [];
147
+ const wireApi = field(auth?.wireApi) ?? 'responses';
148
+ return [
149
+ '-c', `model_provider=${tomlString(ENDPOINT_PROVIDER_NAME)}`,
150
+ '-c', `model_providers.${ENDPOINT_PROVIDER_NAME}={`
151
+ + `name=${tomlString(ENDPOINT_PROVIDER_NAME)},`
152
+ + `base_url=${tomlString(baseUrl)},`
153
+ + `env_key=${tomlString(ENDPOINT_ENV_KEY)},`
154
+ + `wire_api=${tomlString(wireApi)}`
155
+ + `}`,
156
+ ];
157
+ }
158
+ /**
159
+ * The credential marker's modification stamp (epoch ms): the endpoint file's
160
+ * mtime, or undefined when the scope has no file (a config-sourced credential
161
+ * has no marker — rotating it pairs with the profile edit and instance
162
+ * restart that clear the in-memory grade marks). A fresh file write is a
163
+ * fresh stamp, which is how an edited-in key recovers a `rejected` grade.
164
+ * @param homeDir - the scope's scoped home.
165
+ * @returns the stamp, or undefined.
166
+ */
167
+ export async function endpointAuthStamp(homeDir) {
168
+ try {
169
+ return (await stat(join(homeDir, ENDPOINT_AUTH_FILE))).mtimeMs;
170
+ }
171
+ catch {
172
+ return undefined;
173
+ }
174
+ }
175
+ /** Whether the scope carries its own endpoint file at all. */
176
+ export async function endpointAuthFileExists(homeDir) {
177
+ return (await endpointAuthStamp(homeDir)) !== undefined;
178
+ }
179
+ /**
180
+ * Write the scope's endpoint-auth.json (mode 0600, mkdir -p first). The
181
+ * payload preserves the file-only `wireApi` across card writes: callers pass
182
+ * the merged object (current file + edits), never a bare card payload.
183
+ * @param homeDir - the scope's scoped home.
184
+ * @param auth - the endpoint credential to persist.
185
+ */
186
+ export async function writeEndpointAuth(homeDir, auth) {
187
+ await mkdir(homeDir, { recursive: true });
188
+ const baseUrl = field(auth.baseUrl);
189
+ const apiKey = field(auth.apiKey);
190
+ const wireApi = field(auth.wireApi);
191
+ const payload = {
192
+ ...baseUrl === undefined ? {} : { baseUrl },
193
+ ...apiKey === undefined ? {} : { apiKey },
194
+ ...wireApi === undefined ? {} : { wireApi },
195
+ };
196
+ await writeFile(join(homeDir, ENDPOINT_AUTH_FILE), JSON.stringify(payload, undefined, 2) + '\n', { mode: 0o600 });
197
+ }
198
+ /**
199
+ * Clear the scope's OWN endpoint credential (the endpoint-auth.json file).
200
+ * A deployment-level credential from the plugin config is untouched — the
201
+ * core's logout reply then names the profile row as the place to edit.
202
+ * @param homeDir - the scope's scoped home.
203
+ */
204
+ export async function clearEndpointAuth(homeDir) {
205
+ await rm(join(homeDir, ENDPOINT_AUTH_FILE), { force: true });
206
+ }
207
+ /** The official endpoint used when no baseUrl is configured anywhere. */
208
+ export const OFFICIAL_BASE_URL = 'https://api.openai.com';
209
+ /** How many served model ids the check reply lists before truncating. */
210
+ const CHECK_MODEL_LIST_LIMIT = 8;
211
+ /** Bound on the error-body excerpt a failed check reports. */
212
+ const CHECK_ERROR_BODY_LIMIT = 200;
213
+ /**
214
+ * `GET <baseUrl>/v1/models` with `Authorization: Bearer <key>` — the
215
+ * token-free endpoint probe shared by the `/codex check` verdict and any
216
+ * future catalog. OpenAI's own API serves this route with the platform key;
217
+ * relays (openproxy verified 2026-10-20, 139 models) proxy it and answer the
218
+ * same OpenAI-list shape. Never throws: every failure mode folds into the
219
+ * result.
220
+ * @param endpoint - the resolved endpoint auth with a decided baseUrl.
221
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
222
+ * @param signal - abort for the whole probe (timeout bundled by the caller).
223
+ * @returns the folded result.
224
+ */
225
+ export async function fetchEndpointModels(endpoint, fetchImpl, signal) {
226
+ const headers = {};
227
+ if (endpoint.apiKey !== undefined)
228
+ headers['authorization'] = `Bearer ${endpoint.apiKey}`;
229
+ const fetcher = fetchImpl ?? globalThis.fetch;
230
+ // The OpenAI convention names the base WITH `/v1` (codex's own
231
+ // `model_providers.*.base_url` does), the relay convention sometimes omits
232
+ // it — accept both instead of doubling the segment.
233
+ const root = endpoint.baseUrl.replace(/\/+$/, '');
234
+ const url = `${root}${root.endsWith('/v1') ? '' : '/v1'}/models`;
235
+ let response;
236
+ try {
237
+ response = await fetcher(url, { headers, signal: signal ?? AbortSignal.timeout(8_000) });
238
+ }
239
+ catch (error) {
240
+ return { kind: 'unreachable', message: error instanceof Error ? error.message : String(error) };
241
+ }
242
+ if (!response.ok) {
243
+ return { kind: 'http', status: response.status, body: (await response.text().catch(() => '')).slice(0, CHECK_ERROR_BODY_LIMIT) };
244
+ }
245
+ let data;
246
+ try {
247
+ data = JSON.parse(await response.text()).data;
248
+ }
249
+ catch {
250
+ return { kind: 'malformed' };
251
+ }
252
+ if (!Array.isArray(data))
253
+ return { kind: 'malformed' };
254
+ const models = [];
255
+ for (const entry of data) {
256
+ if (typeof entry !== 'object' || entry === null)
257
+ continue;
258
+ const id = entry.id;
259
+ if (typeof id === 'string' && id !== '')
260
+ models.push(id);
261
+ }
262
+ return { kind: 'models', models };
263
+ }
264
+ /**
265
+ * Probe the resolved endpoint with a cheap, token-free round-trip: `GET
266
+ * <baseUrl>/v1/models` with the resolved credential's Bearer header. A 200
267
+ * proves both reachability and credential validity — the exact evidence a
268
+ * "verify" button needs — and the served model list rides the reply. One
269
+ * transport-level retry rides along: a local proxy restart (a Surge/clash
270
+ * reload) stalls the first attempt into the timeout and answers the retry
271
+ * immediately. Never throws: every failure mode folds into the reply text,
272
+ * since the reply IS the diagnostic.
273
+ * @param endpoint - the resolved endpoint auth (scope file over settings).
274
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
275
+ * @param timeoutMs - one attempt's deadline (two attempts at most).
276
+ * @returns the human-readable verdict for the check reply.
277
+ */
278
+ export async function checkCodexEndpoint(endpoint, fetchImpl, timeoutMs = 20_000) {
279
+ if (!hasEndpointCredential(endpoint)) {
280
+ return 'No endpoint credential configured — fill Base URL and API Key on the settings card (or the profile row) first.';
281
+ }
282
+ const baseUrl = (endpoint.baseUrl ?? OFFICIAL_BASE_URL).replace(/\/+$/, '');
283
+ let result = await fetchEndpointModels({ ...endpoint, baseUrl }, fetchImpl, AbortSignal.timeout(timeoutMs));
284
+ if (result.kind === 'unreachable') {
285
+ result = await fetchEndpointModels({ ...endpoint, baseUrl }, fetchImpl, AbortSignal.timeout(timeoutMs));
286
+ }
287
+ if (result.kind === 'unreachable')
288
+ return `Endpoint unreachable (${baseUrl}): ${result.message}`;
289
+ if (result.kind === 'http') {
290
+ return `Endpoint answered HTTP ${String(result.status)} (${baseUrl})${result.body === '' ? '' : `: ${result.body}`}`;
291
+ }
292
+ if (result.kind === 'malformed') {
293
+ return `Endpoint reachable (${baseUrl}) but the model list did not parse — the credential worked, the reply was not /v1/models-shaped.`;
294
+ }
295
+ if (result.models.length === 0)
296
+ return `Endpoint reachable (${baseUrl}); it serves no models.`;
297
+ const listed = result.models.slice(0, CHECK_MODEL_LIST_LIMIT).join(', ');
298
+ const more = result.models.length > CHECK_MODEL_LIST_LIMIT ? `, … +${String(result.models.length - CHECK_MODEL_LIST_LIMIT)} more` : '';
299
+ return `Endpoint reachable (${baseUrl}): ${String(result.models.length)} model(s) served — ${listed}${more}`;
300
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Codex harness instantiation of the local-agent family. Registers the
3
3
  * `codex` harness into the core registry: scoped home under the shared homes
4
- * root (`CODEX_HOME`), device-code login through `codex login --device-auth`
5
- * (prompt captured from stdout), and rollout-file session records.
4
+ * root (`CODEX_HOME`), endpoint-key authentication (endpoint-auth.ts — no web
5
+ * login anywhere), and rollout-file session records.
6
6
  * Delegation mounts through the `codex-local` provider (one-shot
7
7
  * `codex exec` under the scoped home); the bundle patch's
8
8
  * `tool-subagent-codex-local` row puts the tool on the profile root, so
@@ -16,7 +16,8 @@ import z from '@deepseek-ai/schemastery';
16
16
  export declare const name = "local-agent-codex";
17
17
  /**
18
18
  * Boot delay before the model catalog's eager warmup probe fires. The probe
19
- * spawns a `codex app-server` — cheap, but not free — so it waits out the
19
+ * fetches the endpoint's served model list AND spawns a `codex app-server`
20
+ * (the native metadata side) — cheap, but not free — so it waits out the
20
21
  * boot storm instead of competing with it, and the FIRST settings-card open
21
22
  * usually hits a warm cache. A card opened before the timer fires simply
22
23
  * triggers the probe itself (the read path's lazy kick), so the delay never
@@ -29,6 +30,21 @@ export declare const inject: string[];
29
30
  export interface Config {
30
31
  /** Codex sandbox policy for `codex exec` / app-server threads; defaults to workspace-write. */
31
32
  sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
33
+ /**
34
+ * Deployment-level endpoint credential: the relay's base URL (an
35
+ * OpenAI-compatible `/v1` root, e.g. a proxy relay). Merged per field UNDER
36
+ * the scope's own `endpoint-auth.json` (the settings card writes that file
37
+ * through the endpointAuth face). A scope with an endpoint key
38
+ * authenticates by endpoint — the round's argv routes at the relay; a scope
39
+ * without one is unauthenticated (there is no login flow).
40
+ */
41
+ baseUrl?: string;
42
+ /**
43
+ * Deployment-level endpoint key (the custom provider's `env_key` value,
44
+ * sent as `Authorization: Bearer`). See {@link baseUrl} for the merge
45
+ * order. Absent everywhere = unauthenticated.
46
+ */
47
+ apiKey?: string;
32
48
  /**
33
49
  * The model every delegation round starts the CLI with (`codex exec -m
34
50
  * <model>`, and `-c model=…` for the resident app-server). Absent — the
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Codex harness instantiation of the local-agent family. Registers the
3
3
  * `codex` harness into the core registry: scoped home under the shared homes
4
- * root (`CODEX_HOME`), device-code login through `codex login --device-auth`
5
- * (prompt captured from stdout), and rollout-file session records.
4
+ * root (`CODEX_HOME`), endpoint-key authentication (endpoint-auth.ts — no web
5
+ * login anywhere), and rollout-file session records.
6
6
  * Delegation mounts through the `codex-local` provider (one-shot
7
7
  * `codex exec` under the scoped home); the bundle patch's
8
8
  * `tool-subagent-codex-local` row puts the tool on the profile root, so
@@ -11,20 +11,22 @@
11
11
  * @module @khorsheed/dsh-local-agent-codex
12
12
  */
13
13
  import z from '@deepseek-ai/schemastery';
14
- import { settingsFace, vol } from '@khorsheed/dsh-local-agent';
14
+ import { firstModelOf, readModelList, readModelListSync, settingsFace, vol, writeModelList } from '@khorsheed/dsh-local-agent';
15
15
  import { endpointHost } from '@khorsheed/dsh-local-agent/types';
16
16
  import { CodexCliProvider, codexCliVersion } from './codex-cli-provider.js';
17
+ import { checkCodexEndpoint, clearEndpointAuth, endpointAuthFileExists, endpointAuthStamp, hasEndpointCredential, readEndpointAuthFile, resolveEndpointAuth, writeEndpointAuth, } from './endpoint-auth.js';
17
18
  import { DEFAULT_LIVE_IDLE_MS } from './live-driver.js';
18
19
  import { LiveDriverSwitch } from './live-switch.js';
19
20
  import { CodexModelBroker } from './model-broker.js';
20
21
  import { CodexModelCatalog } from './model-catalog.js';
21
- import { codexAuthenticated, listCodexSessions } from './records.js';
22
- import { codexCredentialStamp, codexLogout, provisionCodexConfig, readCodexBaseUrl, readCodexModel, readCodexReasoningEffort } from './provision.js';
22
+ import { listCodexSessions } from './records.js';
23
+ import { provisionCodexConfig, readCodexBaseUrl, readCodexModel, readCodexReasoningEffort } from './provision.js';
23
24
  /** Stable Cordis plugin name; the bundle patch row id. */
24
25
  export const name = 'local-agent-codex';
25
26
  /**
26
27
  * Boot delay before the model catalog's eager warmup probe fires. The probe
27
- * spawns a `codex app-server` — cheap, but not free — so it waits out the
28
+ * fetches the endpoint's served model list AND spawns a `codex app-server`
29
+ * (the native metadata side) — cheap, but not free — so it waits out the
28
30
  * boot storm instead of competing with it, and the FIRST settings-card open
29
31
  * usually hits a warm cache. A card opened before the timer fires simply
30
32
  * triggers the probe itself (the read path's lazy kick), so the delay never
@@ -65,6 +67,10 @@ export const Config = z.object({
65
67
  z.const('workspace-write'),
66
68
  z.const('danger-full-access'),
67
69
  ]),
70
+ // The deployment-level endpoint pair is plain config (never volatile): it
71
+ // is the profile row's business, and the card writes the scope's own file.
72
+ baseUrl: z.string(),
73
+ apiKey: z.string(),
68
74
  model: volatilize(SETTINGS_FIELDS.model),
69
75
  live: volatilize(SETTINGS_FIELDS.live),
70
76
  liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
@@ -93,8 +99,8 @@ export function apply(ctx, config) {
93
99
  ctx.effect(() => {
94
100
  const sandbox = config.sandbox ?? DEFAULT_SANDBOX;
95
101
  const homeDir = ctx.localAgent.homeDir('codex');
96
- // Codex's default credential store on macOS is the keychain; pin
97
- // file-based storage so login lands in the scoped home's auth.json.
102
+ // Provision the scoped config (and sweep the web-login era's leftover
103
+ // auth.json — the harness no longer consults it).
98
104
  void provisionCodexConfig(homeDir).catch((error) => {
99
105
  ctx.logger.warn(`local-agent-codex: config.toml provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
100
106
  });
@@ -134,10 +140,16 @@ export function apply(ctx, config) {
134
140
  // delegation without a plugin reload — exactly like the live toggle.
135
141
  // Blank is not a model: a whitespace-only value means "unset", which is
136
142
  // the pre-key argv.
137
- const resolveModel = () => {
143
+ const resolveModelKey = () => {
138
144
  const model = face.get().model?.trim();
139
145
  return model === undefined || model === '' ? undefined : model;
140
146
  };
147
+ // The settings layer for one scoped home: the curated model list's FIRST
148
+ // entry (models.json, edited on the card), ahead of the settings key; an
149
+ // empty list leaves the pre-existing chain untouched. Read sync per call
150
+ // — one small file, and a card write always reaches the next round.
151
+ const resolveModelFor = (homeDir) => firstModelOf(readModelListSync(homeDir)) ?? resolveModelKey();
152
+ const resolveModel = (scope) => resolveModelFor(ctx.localAgent.homeDir('codex', scope));
141
153
  // The member-level model surface: the composer picker's session-level
142
154
  // overrides (in-memory, deliberately lost on a host restart), shared by
143
155
  // reference with the broker (which writes them), the exec provider (which
@@ -146,9 +158,20 @@ export function apply(ctx, config) {
146
158
  // different model retires that runtime — the next round respawns onto the
147
159
  // new model while the codex thread itself resumes.
148
160
  const memberModelOverrides = new Map();
161
+ // The endpoint credential's deployment level: the plugin config pair,
162
+ // merged per field UNDER the scope's own endpoint-auth.json at resolve
163
+ // time. Resolved PER READ against the scope's home — a card write (which
164
+ // lands in the file through the endpointAuth face) takes effect on the
165
+ // next round with no reload.
166
+ const endpointConfig = {
167
+ ...config.baseUrl === undefined ? {} : { baseUrl: config.baseUrl },
168
+ ...config.apiKey === undefined ? {} : { apiKey: config.apiKey },
169
+ };
170
+ const resolveEndpoint = (scopeHome) => resolveEndpointAuth(scopeHome, endpointConfig);
149
171
  const modelCatalog = new CodexModelCatalog({
150
172
  spawn: spec => ctx.subprocess.spawn(spec),
151
173
  warn: message => ctx.logger.warn(message),
174
+ endpoint: resolveEndpoint,
152
175
  });
153
176
  // Eager warmup: kick one background probe of the DEFAULT scoped home
154
177
  // shortly after apply completes (never blocking it — read() is the
@@ -164,12 +187,18 @@ export function apply(ctx, config) {
164
187
  warmup.unref();
165
188
  const liveSwitch = new LiveDriverSwitch(ctx, face, {
166
189
  sandbox,
167
- model: childSessionId => memberModelOverrides.get(childSessionId) ?? resolveModel(),
190
+ // The member's scope decides whose list answers: a named scope's
191
+ // rounds read that scope's models.json, exactly like its endpoint
192
+ // credential.
193
+ model: childSessionId => memberModelOverrides.get(childSessionId)
194
+ ?? resolveModel((ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
195
+ endpoint: resolveEndpoint,
168
196
  ...config.liveIdleMs === undefined ? {} : { liveIdleMs: config.liveIdleMs },
169
197
  });
170
198
  const modelBroker = new CodexModelBroker({
171
199
  ctx,
172
- settingsModel: resolveModel,
200
+ settingsModel: resolveModelFor,
201
+ modelList: homeDir => readModelListSync(homeDir),
173
202
  recentModels: () => face.get().recentModels ?? [],
174
203
  homeDir: childSessionId => ctx.localAgent.homeDir('codex', childSessionId === undefined ? undefined : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
175
204
  cwd: childSessionId => childSessionId === undefined ? undefined : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.cwd,
@@ -189,7 +218,7 @@ export function apply(ctx, config) {
189
218
  // cache.
190
219
  catalogDefault: (scopedHome, cwd) => modelCatalog.readDefault(scopedHome, cwd),
191
220
  });
192
- const disposeProvider = ctx.subagents.registerProvider(new CodexCliProvider(ctx, sandbox, liveSwitch.resolve, resolveModel, childSessionId => memberModelOverrides.get(childSessionId)));
221
+ const disposeProvider = ctx.subagents.registerProvider(new CodexCliProvider(ctx, sandbox, liveSwitch.resolve, resolveModel, childSessionId => memberModelOverrides.get(childSessionId), resolveEndpoint));
193
222
  const disposeHarness = ctx.localAgent.register({
194
223
  prepareMember: async ({ binding, childSession, configuration, signal }) => {
195
224
  const driver = liveSwitch.resolve(binding.childSessionId);
@@ -203,39 +232,109 @@ export function apply(ctx, config) {
203
232
  homeEnvVar: 'CODEX_HOME',
204
233
  delegationProvider: 'codex-local',
205
234
  modelBroker,
206
- // A PTY run of plain `codex login` starts a localhost callback server
207
- // (no device code, no paste) and opens the browser itself — the
208
- // first-class flow. `--device-auth` remains the documented headless
209
- // fallback for remote terminals.
210
- login: { pty: { command: 'codex', args: ['login'] } },
235
+ // The settings card's curated model list (the default scope's
236
+ // models.json; named scopes carry their own, read per round).
237
+ modelList: {
238
+ read: home => readModelList(home),
239
+ write: (home, models) => writeModelList(home, models),
240
+ },
241
+ // No `login` declaration: the harness authenticates by endpoint key
242
+ // (endpoint-auth.ts), so the core reports the harness as not loginable
243
+ // and `/codex login` answers that there is no login flow — no browser,
244
+ // no device code, nothing to authorize inside a container.
211
245
  records: { listSessions: homeDir => listCodexSessions(homeDir) },
212
246
  // A NAMED scope's directory is provisioned through this hook when the
213
247
  // registry materializes it — the same `config.toml` storage pin the
214
- // default scope gets from the apply above, so a scope's first login
215
- // lands in its own `auth.json` instead of the macOS keychain.
248
+ // default scope gets from the apply above.
216
249
  provision: scopedHome => provisionCodexConfig(scopedHome).then(() => { }),
217
- isAuthenticated: codexAuthenticated,
218
- credentialStamp: codexCredentialStamp,
219
- logout: codexLogout,
250
+ // Endpoint-key only: a scope is authenticated when its resolved
251
+ // endpoint (scope file over plugin config) carries a non-empty key.
252
+ // The web login is gone — provisioning sweeps its leftover auth.json,
253
+ // so a stale grant can never keep a round alive past the upgrade.
254
+ // The verdict rides the family's credential grades untouched — a
255
+ // completed round grades `verified`, a round's 401 grades `rejected`
256
+ // until the credential marker's mtime moves past the mark.
257
+ isAuthenticated: async (home) => hasEndpointCredential(await resolveEndpoint(home)),
258
+ // The settings card's verify button: a cheap token-free probe of the
259
+ // SAVED endpoint (the reply tells the user to save first when a
260
+ // credential is missing). Session-less via the gateway Remote.
261
+ checkEndpoint: home => resolveEndpoint(home).then(endpoint => checkCodexEndpoint(endpoint)),
262
+ // The settings card's credential form: read the resolved per-field
263
+ // state (the key as a boolean), write edits into the scope's own
264
+ // endpoint-auth.json — the harness's 0600 store, never the profile
265
+ // patch document. A cleared field falls back to the config pair; the
266
+ // file-only `wireApi` survives card writes through the merge.
267
+ endpointAuth: {
268
+ read: async (home) => {
269
+ const endpoint = await resolveEndpoint(home);
270
+ return {
271
+ ...endpoint.baseUrl === undefined ? {} : { baseUrl: endpoint.baseUrl },
272
+ apiKeySet: endpoint.apiKey !== undefined,
273
+ authTokenSet: false,
274
+ };
275
+ },
276
+ write: async (home, fields) => {
277
+ const current = await readEndpointAuthFile(home) ?? {};
278
+ const next = { ...current };
279
+ // The card's vocabulary is baseUrl/apiKey; an authToken field never
280
+ // applies to codex's Bearer key and is refused loudly by the merge
281
+ // below (ignored here so the shared face stays usable).
282
+ for (const [field, value] of Object.entries(fields)) {
283
+ if (field !== 'baseUrl' && field !== 'apiKey')
284
+ continue;
285
+ if (value === undefined)
286
+ continue;
287
+ const trimmed = typeof value === 'string' ? value.trim() : '';
288
+ if (value === null || trimmed === '')
289
+ delete next[field];
290
+ else
291
+ next[field] = trimmed;
292
+ }
293
+ if (Object.keys(next).length === 0)
294
+ await clearEndpointAuth(home);
295
+ else
296
+ await writeEndpointAuth(home, next);
297
+ },
298
+ },
299
+ // The grade marker is the endpoint file's mtime (editing it IS the
300
+ // re-login gesture); a config-sourced credential has no marker, and a
301
+ // host restart clears the in-memory `rejected` mark.
302
+ credentialStamp: endpointAuthStamp,
303
+ // Logout clears the scope's OWN endpoint file. A config-sourced
304
+ // credential outlives it — said out loud, or the status would flip
305
+ // right back and read as a failed logout.
306
+ logout: async (home) => {
307
+ if (await endpointAuthFileExists(home)) {
308
+ await clearEndpointAuth(home);
309
+ return;
310
+ }
311
+ 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');
312
+ },
220
313
  // The eval snapshot: the sandbox policy comes from the plugin config
221
314
  // (it rides every spawn argv); effort and endpoint are read live from
222
315
  // the scoped config, which codex itself reads — a person-edited value
223
316
  // is exactly what the rounds run with. The model follows the family's
224
- // fixed order: the plugin config key (it overrides the file on every
225
- // argv) before the scoped config's own `model`, absent when neither
226
- // names one.
317
+ // fixed order: the scope's curated list (its first entry) before the
318
+ // plugin config key before the scoped config's own `model`, absent
319
+ // when none names one.
227
320
  // The directory is a PARAMETER, not the apply-time capture: a status
228
321
  // read (or an evaluation snapshot) of a named scope must report the
229
322
  // config that scope's rounds would run with, which is the config in
230
323
  // that scope's own directory.
231
324
  effectiveSettings: async (scopedHome) => {
232
- const [reasoningEffort, baseUrl, scopedModel, cliVersion] = await Promise.all([
325
+ const [reasoningEffort, endpoint, scopedModel, cliVersion] = await Promise.all([
233
326
  readCodexReasoningEffort(scopedHome).catch(() => undefined),
234
- readCodexBaseUrl(scopedHome).catch(() => undefined),
327
+ resolveEndpoint(scopedHome).catch(() => undefined),
235
328
  readCodexModel(scopedHome).catch(() => undefined),
236
329
  codexCliVersion(ctx, scopedHome).catch(() => undefined),
237
330
  ]);
238
- const model = resolveModel() ?? scopedModel;
331
+ // The endpoint in effect: an endpoint credential's own baseUrl (the
332
+ // relay the rounds route at) wins; with no credential the scoped
333
+ // config.toml's custom provider answers, as it always did.
334
+ const baseUrl = hasEndpointCredential(endpoint)
335
+ ? endpoint.baseUrl
336
+ : await readCodexBaseUrl(scopedHome).catch(() => undefined);
337
+ const model = resolveModelFor(scopedHome) ?? scopedModel;
239
338
  const baseUrlHost = baseUrl !== undefined ? endpointHost(baseUrl) : undefined;
240
339
  return {
241
340
  drive: face.get().live ? 'live' : 'exec',
@@ -27,6 +27,7 @@ import { type SubagentRun, type SubagentStartRequest } from '@deepseek-ai/dsh-su
27
27
  import type { Config } from './index.ts';
28
28
  import type { LocalAgentResolvedConfiguration } from '@khorsheed/dsh-local-agent/types';
29
29
  import { type CodexTranscriptLine } from './codex-cli-provider.ts';
30
+ import { type CodexEndpointAuth } from './endpoint-auth.ts';
30
31
  /** Default idle lifetime of an unused resident runtime before reclaim. */
31
32
  export declare const DEFAULT_LIVE_IDLE_MS: number;
32
33
  /** Default timeout for a wire request acknowledgement. */
@@ -41,11 +42,6 @@ export declare const DEFAULT_LIVE_CHANNEL_RETRY_MS: number;
41
42
  export declare const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 50;
42
43
  /** Default minimum text growth between one streaming item's snapshot messages. */
43
44
  export declare const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
44
- /**
45
- * The app-server channel could not come up (spawn failure or handshake
46
- * timeout/mismatch). The provider catches exactly this and falls back to the
47
- * exec one-shot; any other error is a real round failure.
48
- */
49
45
  export declare class LiveChannelUnavailableError extends Error {
50
46
  constructor(message: string);
51
47
  }
@@ -146,6 +142,14 @@ export declare class CodexLiveDriver {
146
142
  * argv unchanged.
147
143
  */
148
144
  model?: (childSessionId: string) => string | undefined;
145
+ /**
146
+ * Resolver for the scope's endpoint credential, read at each RUNTIME
147
+ * SPAWN so a settings-card write reaches the next respawn without a
148
+ * reload. A resolution carrying a key routes the app-server at the
149
+ * relay (custom `model_providers` `-c` overrides plus the key in the
150
+ * process env), exactly as on the exec path.
151
+ */
152
+ endpoint?: (scopeHome: string) => Promise<CodexEndpointAuth>;
149
153
  }, timeouts?: CodexLiveDriverTimeouts);
150
154
  /** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
151
155
  get disabled(): boolean;