@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.
- package/CHANGELOG.md +25 -0
- package/README.en.md +23 -122
- package/README.i18n.yaml +2 -2
- package/README.md +23 -122
- package/lib/client.js +811 -317
- package/lib/index.js +818 -252
- package/lib/types/client/SettingsCard.d.ts +73 -24
- package/lib/types/client/SettingsCard.js +110 -140
- package/lib/types/client/icons.d.ts +1 -1
- 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 +54 -8
- package/lib/types/codex-cli-provider.js +204 -70
- 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 -5
- package/lib/types/live-driver.js +247 -108
- 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 +51 -6
- 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 +6 -11
- package/lib/types/records.js +14 -25
- 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
|
@@ -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
|
+
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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`),
|
|
5
|
-
*
|
|
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`
|
|
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
|
package/lib/types/index.js
CHANGED
|
@@ -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`),
|
|
5
|
-
*
|
|
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 {
|
|
22
|
-
import {
|
|
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`
|
|
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
|
-
//
|
|
97
|
-
//
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
225
|
-
//
|
|
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,
|
|
325
|
+
const [reasoningEffort, endpoint, scopedModel, cliVersion] = await Promise.all([
|
|
233
326
|
readCodexReasoningEffort(scopedHome).catch(() => undefined),
|
|
234
|
-
|
|
327
|
+
resolveEndpoint(scopedHome).catch(() => undefined),
|
|
235
328
|
readCodexModel(scopedHome).catch(() => undefined),
|
|
236
329
|
codexCliVersion(ctx, scopedHome).catch(() => undefined),
|
|
237
330
|
]);
|
|
238
|
-
|
|
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;
|