@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/types/live-driver.js
CHANGED
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
import { randomUUID } from 'node:crypto';
|
|
25
25
|
import { StringDecoder } from 'node:string_decoder';
|
|
26
26
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
27
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
27
28
|
import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
|
|
28
29
|
import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
|
|
29
30
|
import { appendCodexTranscriptLine, codexAssistantEvent, codexLineText, DEFAULT_DISPOSE_GRACE_MS, registerCodexMemberRun, textTask, } from './codex-cli-provider.js';
|
|
30
31
|
import { codexRolloutRoundFacts } from './records.js';
|
|
32
|
+
import { endpointAuthEnv, endpointConfigOverrides } from './endpoint-auth.js';
|
|
31
33
|
/** Default idle lifetime of an unused resident runtime before reclaim. */
|
|
32
34
|
export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
|
|
33
35
|
/** Default timeout for a wire request acknowledgement. */
|
|
@@ -552,10 +554,18 @@ export class CodexLiveDriver {
|
|
|
552
554
|
const startModel = spec.startModel?.trim();
|
|
553
555
|
const model = spec.configuration !== undefined ? spec.configuration.model
|
|
554
556
|
: startModel !== undefined && startModel !== '' ? startModel : this.config.model?.(key)?.trim();
|
|
557
|
+
// The endpoint credential routes the resident process too: with a key the
|
|
558
|
+
// app-server spawns onto the relay's custom provider (the same `-c`
|
|
559
|
+
// overrides the exec path carries), so a live round never consults
|
|
560
|
+
// auth.json either. Read per spawn — a card write reaches the next respawn.
|
|
561
|
+
const endpoint = this.config.endpoint === undefined
|
|
562
|
+
? undefined
|
|
563
|
+
: await this.config.endpoint(spec.homeDir).catch(() => undefined);
|
|
555
564
|
const spawnSpec = {
|
|
556
565
|
argv: [
|
|
557
566
|
'codex', 'app-server',
|
|
558
567
|
...member === undefined ? [] : ['-c', member.configOverride],
|
|
568
|
+
...endpointConfigOverrides(endpoint),
|
|
559
569
|
...model === undefined || model === '' ? [] : ['-c', `model=${JSON.stringify(model)}`],
|
|
560
570
|
...spec.configuration?.effort === undefined ? [] : ['-c', `model_reasoning_effort=${JSON.stringify(spec.configuration.effort)}`],
|
|
561
571
|
'--stdio',
|
|
@@ -563,7 +573,7 @@ export class CodexLiveDriver {
|
|
|
563
573
|
cwd: spec.cwd,
|
|
564
574
|
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
565
575
|
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
566
|
-
env: delegationEnv({ CODEX_HOME: spec.homeDir }),
|
|
576
|
+
env: delegationEnv({ CODEX_HOME: spec.homeDir, ...endpointAuthEnv(endpoint) }),
|
|
567
577
|
};
|
|
568
578
|
let child;
|
|
569
579
|
try {
|
|
@@ -1261,7 +1271,11 @@ export class CodexLiveDriver {
|
|
|
1261
1271
|
// hostage to the slow windows — a cancel returns the run immediately.
|
|
1262
1272
|
await Promise.race([accepted, abortBranch.catch(() => undefined)]);
|
|
1263
1273
|
return subprocessRunHandle({
|
|
1264
|
-
|
|
1274
|
+
// Host 0.2.1: a fresh PER-EXECUTION run id — the activation manager
|
|
1275
|
+
// rejects a run id naming a live Session (this child session is live),
|
|
1276
|
+
// and resume rounds reuse the child session id. The family's durable
|
|
1277
|
+
// tracking key (the child session id) rides the staged intent instead.
|
|
1278
|
+
id: SessionId(randomUUID()),
|
|
1265
1279
|
result,
|
|
1266
1280
|
signal: request.signal,
|
|
1267
1281
|
onAbort,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { Context } from '@deepseek-ai/cordis';
|
|
14
14
|
import type { SettingsFace } from '@khorsheed/dsh-local-agent';
|
|
15
15
|
import type { Config } from './index.ts';
|
|
16
|
+
import type { CodexEndpointAuth } from './endpoint-auth.ts';
|
|
16
17
|
import { CodexLiveDriver } from './live-driver.ts';
|
|
17
18
|
import type { CodexLiveMirrorGranularity } from './live-driver.ts';
|
|
18
19
|
/** The resolved live settings (schema defaults ← YAML base ← user layer). */
|
|
@@ -41,6 +42,12 @@ export declare class LiveDriverSwitch {
|
|
|
41
42
|
* outrank the settings value.
|
|
42
43
|
*/
|
|
43
44
|
model?: (childSessionId: string) => string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Per-spawn endpoint credential resolver, handed to every driver
|
|
47
|
+
* generation: a resolution carrying a key routes the app-server at the
|
|
48
|
+
* relay (see the driver's own contract).
|
|
49
|
+
*/
|
|
50
|
+
endpoint?: (scopeHome: string) => Promise<CodexEndpointAuth>;
|
|
44
51
|
});
|
|
45
52
|
/**
|
|
46
53
|
* Resolve the live driver for one round's member (the provider's call
|
package/lib/types/live-switch.js
CHANGED
|
@@ -69,6 +69,7 @@ export class LiveDriverSwitch {
|
|
|
69
69
|
...this.options.sandbox === undefined ? {} : { sandbox: this.options.sandbox },
|
|
70
70
|
...this.options.liveIdleMs === undefined ? {} : { liveIdleMs: this.options.liveIdleMs },
|
|
71
71
|
...this.options.model === undefined ? {} : { model: this.options.model },
|
|
72
|
+
...this.options.endpoint === undefined ? {} : { endpoint: this.options.endpoint },
|
|
72
73
|
})
|
|
73
74
|
: undefined;
|
|
74
75
|
if (retiring !== undefined) {
|
|
@@ -31,8 +31,18 @@ import type { LocalAgentModelBroker, LocalAgentModelInfo, LocalAgentModelDirecto
|
|
|
31
31
|
export interface CodexModelBrokerDeps {
|
|
32
32
|
/** Host context carrying the family registry (in-flight reads, delegation records). */
|
|
33
33
|
readonly ctx: Context;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The settings-layer model for one scoped home: the curated model list's
|
|
36
|
+
* FIRST entry (models.json, edited on the card), then the plugin-config
|
|
37
|
+
* `model` key — blank reads unset.
|
|
38
|
+
*/
|
|
39
|
+
readonly settingsModel: (homeDir: string) => string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The scope's curated model list (models.json) for the choice vocabulary:
|
|
42
|
+
* non-empty it is the COMPLETE vocabulary (nothing outside the endpoint's
|
|
43
|
+
* served catalog sneaks in); empty falls back to the pre-list chain.
|
|
44
|
+
*/
|
|
45
|
+
readonly modelList?: (homeDir: string) => readonly string[];
|
|
36
46
|
/** The card's recent-model memory (the suggestion vocabulary's tail). */
|
|
37
47
|
readonly recentModels: () => readonly string[];
|
|
38
48
|
/**
|
|
@@ -54,7 +54,7 @@ export class CodexModelBroker {
|
|
|
54
54
|
}
|
|
55
55
|
const model = selection.model.mode === 'value' ? selection.model.value
|
|
56
56
|
: (selection.model.mode === 'inherit' ? binding.model : undefined)
|
|
57
|
-
?? this.deps.settingsModel() ?? await readCodexModel(home)
|
|
57
|
+
?? this.deps.settingsModel(home) ?? await readCodexModel(home)
|
|
58
58
|
?? this.deps.catalogDefault(home, binding.cwd);
|
|
59
59
|
const entry = directory?.entries.find(entry => entry.value === model);
|
|
60
60
|
const explicitEffort = selection.effort.mode === 'value' ? selection.effort.value
|
|
@@ -113,7 +113,7 @@ export class CodexModelBroker {
|
|
|
113
113
|
const recorded = record?.model?.trim();
|
|
114
114
|
if (recorded !== undefined && recorded !== '')
|
|
115
115
|
return recorded;
|
|
116
|
-
return this.deps.settingsModel();
|
|
116
|
+
return this.deps.settingsModel(this.deps.homeDir(childSessionId));
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
119
|
* Read the model surface. The cliDefault and the discovered identifiers come
|
|
@@ -124,8 +124,8 @@ export class CodexModelBroker {
|
|
|
124
124
|
* re-fetches on open, so it never blocks on a CLI boot).
|
|
125
125
|
*/
|
|
126
126
|
async modelInfo(childSessionId, delegationModel) {
|
|
127
|
-
const settings = this.deps.settingsModel();
|
|
128
127
|
const homeDir = this.deps.homeDir(childSessionId);
|
|
128
|
+
const settings = this.deps.settingsModel(homeDir);
|
|
129
129
|
const [cliDefault, discovered] = await Promise.all([
|
|
130
130
|
readCodexModel(homeDir).catch(() => undefined),
|
|
131
131
|
listCodexConfigModels(homeDir).catch(() => []),
|
|
@@ -144,6 +144,10 @@ export class CodexModelBroker {
|
|
|
144
144
|
: settings !== undefined ? 'settings'
|
|
145
145
|
: cliDefault !== undefined ? 'cli-config' : 'cli-builtin';
|
|
146
146
|
const inFlight = childSessionId !== undefined && this.activeDelegations().includes(childSessionId);
|
|
147
|
+
// The curated list is the whole vocabulary when non-empty — nothing from
|
|
148
|
+
// outside the endpoint's served catalog sneaks in. Empty falls back to
|
|
149
|
+
// the pre-list chain (native candidates plus configuration and history).
|
|
150
|
+
const curated = this.deps.modelList?.(homeDir) ?? [];
|
|
147
151
|
return {
|
|
148
152
|
...effective === undefined ? {} : { effective },
|
|
149
153
|
source,
|
|
@@ -151,8 +155,8 @@ export class CodexModelBroker {
|
|
|
151
155
|
...delegation === undefined ? {} : { delegation },
|
|
152
156
|
...settings === undefined ? {} : { settings },
|
|
153
157
|
...cliDefault === undefined ? {} : { cliDefault },
|
|
154
|
-
...this.deps.directory === undefined ? {} : { directory: extendModelDirectory(this.deps.directory(homeDir, this.deps.cwd?.(childSessionId)), [settings, cliDefault, ...discovered], this.deps.recentModels()) },
|
|
155
|
-
choices: dedupeChoices([settings, cliDefault, ...discovered, ...this.deps.catalog(homeDir, this.deps.cwd?.(childSessionId)), ...this.deps.recentModels()]),
|
|
158
|
+
...this.deps.directory === undefined ? {} : { directory: extendModelDirectory(this.deps.directory(homeDir, this.deps.cwd?.(childSessionId)), [settings, cliDefault, ...discovered], curated.length > 0 ? [] : this.deps.recentModels()) },
|
|
159
|
+
choices: curated.length > 0 ? [...curated] : dedupeChoices([settings, cliDefault, ...discovered, ...this.deps.catalog(homeDir, this.deps.cwd?.(childSessionId)), ...this.deps.recentModels()]),
|
|
156
160
|
live: this.deps.live(),
|
|
157
161
|
switchable: !inFlight,
|
|
158
162
|
...inFlight ? { reason: '成员有进行中的委派轮次,等其完成后再切换模型' } : {},
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
1
|
+
/**
|
|
2
|
+
* The codex model directory: the VOCABULARY comes from the configured
|
|
3
|
+
* endpoint (`GET <baseUrl>/v1/models` with the resolved credential — the same
|
|
4
|
+
* probe the settings card's verify button runs), never from the CLI's local
|
|
5
|
+
* registry: a local list cannot prove what the endpoint serves, and showing
|
|
6
|
+
* it made a credential that does not work look valid. The native app-server
|
|
7
|
+
* `model/list` probe survives as METADATA ENRICHMENT only — it alone carries
|
|
8
|
+
* the display names, the hidden candidates, the reasoning-effort options and
|
|
9
|
+
* the `isDefault` slug (the CLI's compiled default), so it still runs
|
|
10
|
+
* CONCURRENTLY and folds into the entries whose ids the endpoint serves; its
|
|
11
|
+
* failure warns once and the endpoint data stands alone.
|
|
12
|
+
* Shared core cache owns refresh, stale data and subscriptions; this provider
|
|
13
|
+
* owns protocol and pagination (the native side).
|
|
14
|
+
*/
|
|
3
15
|
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess';
|
|
4
16
|
import type { LocalAgentModelDirectory } from '@khorsheed/dsh-local-agent/types';
|
|
5
|
-
|
|
17
|
+
import { type CodexEndpointAuth, type EndpointCheckFetch } from './endpoint-auth.ts';
|
|
18
|
+
/** Default overall bound for one NATIVE catalog probe (spawn → model/list answer). */
|
|
6
19
|
export declare const DEFAULT_CATALOG_PROBE_TIMEOUT_MS = 5000;
|
|
7
20
|
/** Default lifetime of a completed probe's cached answer, per scoped home. */
|
|
8
21
|
export declare const DEFAULT_CATALOG_TTL_MS: number;
|
|
@@ -12,7 +25,17 @@ export interface CodexModelCatalogDeps {
|
|
|
12
25
|
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle;
|
|
13
26
|
/** Diagnostic sink; a degraded probe warns once, never throws. */
|
|
14
27
|
readonly warn?: (message: string) => void;
|
|
15
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The scope's endpoint credential resolver: the endpoint probe (the
|
|
30
|
+
* directory's vocabulary) reads the credential through it, and the native
|
|
31
|
+
* probe routes its spawn at the same relay (custom provider overrides plus
|
|
32
|
+
* the key in the env), or model/list fails for a scope that has no other
|
|
33
|
+
* credential. Absent = the pre-endpoint probe shape.
|
|
34
|
+
*/
|
|
35
|
+
readonly endpoint?: (scopeHome: string) => Promise<CodexEndpointAuth>;
|
|
36
|
+
/** The HTTP fetch for the endpoint probe (injected in tests). */
|
|
37
|
+
readonly fetchImpl?: EndpointCheckFetch;
|
|
38
|
+
/** The native probe's overall bound; when set it also bounds the endpoint fetch (unset, the fetch gets 4× the native default — 20s, mirroring claude's catalog timeout). Tests inject a small value. */
|
|
16
39
|
readonly timeoutMs?: number;
|
|
17
40
|
/** Completed-probe cache lifetime; tests inject a small value. */
|
|
18
41
|
readonly ttlMs?: number;
|
|
@@ -34,11 +57,32 @@ export declare class CodexModelCatalog {
|
|
|
34
57
|
dispose(): void;
|
|
35
58
|
refresh(homeDir: string, cwd?: string): Promise<readonly string[]>;
|
|
36
59
|
/**
|
|
37
|
-
* One
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
60
|
+
* One directory load: the endpoint's served id list is the vocabulary (the
|
|
61
|
+
* card's add dialog can only offer what the endpoint answers), folded
|
|
62
|
+
* CONCURRENTLY with the native app-server probe's metadata. The endpoint
|
|
63
|
+
* answers are deliberate:
|
|
64
|
+
*
|
|
65
|
+
* - no credential resolved → empty with a "save a key first" reason;
|
|
66
|
+
* - 401/403 → empty with the rejection (definitive — cached as data);
|
|
67
|
+
* - another HTTP status → empty with it (the relay serves no model route);
|
|
68
|
+
* - a transport failure THROWS, so the cache keeps the last good list and
|
|
69
|
+
* retries on schedule instead of blanking a momentarily-down endpoint
|
|
70
|
+
* (the openproxy relay once 500'd and recovered — 2026-10-22).
|
|
71
|
+
*
|
|
72
|
+
* The native probe's failure never fails the load: endpoint data alone is
|
|
73
|
+
* complete (it warns once via the deps sink). The endpoint fetch gets a
|
|
74
|
+
* 20s ceiling by default (mirrors claude's catalog timeout); the native
|
|
75
|
+
* probe keeps its own tighter bound internally.
|
|
76
|
+
*/
|
|
77
|
+
private load;
|
|
78
|
+
/**
|
|
79
|
+
* One bounded NATIVE probe — the metadata-enrichment side of {@link load}:
|
|
80
|
+
* spawn the app-server against the scoped home, answer the initialize
|
|
81
|
+
* handshake exactly as the live driver shapes it, ask `model/list`, then
|
|
82
|
+
* tear the process down (stdin EOF → grace → terminate). The overall
|
|
83
|
+
* timeout kills the process on expiry. Failures propagate to the caller
|
|
84
|
+
* (load demotes them to a warn).
|
|
41
85
|
*/
|
|
42
|
-
private
|
|
86
|
+
private probeNative;
|
|
43
87
|
}
|
|
44
88
|
//# sourceMappingURL=model-catalog.d.ts.map
|
|
@@ -1,15 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
1
|
+
/**
|
|
2
|
+
* The codex model directory: the VOCABULARY comes from the configured
|
|
3
|
+
* endpoint (`GET <baseUrl>/v1/models` with the resolved credential — the same
|
|
4
|
+
* probe the settings card's verify button runs), never from the CLI's local
|
|
5
|
+
* registry: a local list cannot prove what the endpoint serves, and showing
|
|
6
|
+
* it made a credential that does not work look valid. The native app-server
|
|
7
|
+
* `model/list` probe survives as METADATA ENRICHMENT only — it alone carries
|
|
8
|
+
* the display names, the hidden candidates, the reasoning-effort options and
|
|
9
|
+
* the `isDefault` slug (the CLI's compiled default), so it still runs
|
|
10
|
+
* CONCURRENTLY and folds into the entries whose ids the endpoint serves; its
|
|
11
|
+
* failure warns once and the endpoint data stands alone.
|
|
12
|
+
* Shared core cache owns refresh, stale data and subscriptions; this provider
|
|
13
|
+
* owns protocol and pagination (the native side).
|
|
14
|
+
*/
|
|
3
15
|
import { dirname, join } from 'node:path';
|
|
4
16
|
import { StringDecoder } from 'node:string_decoder';
|
|
5
17
|
import { ModelDirectoryCache, modelDirectoryContextKey, delegationEnv } from '@khorsheed/dsh-local-agent';
|
|
6
18
|
import { DEFAULT_DISPOSE_GRACE_MS } from './codex-cli-provider.js';
|
|
7
|
-
|
|
19
|
+
import { ENDPOINT_AUTH_FILE, endpointAuthEnv, endpointConfigOverrides, fetchEndpointModels, hasEndpointCredential, OFFICIAL_BASE_URL, } from './endpoint-auth.js';
|
|
20
|
+
/** Default overall bound for one NATIVE catalog probe (spawn → model/list answer). */
|
|
8
21
|
export const DEFAULT_CATALOG_PROBE_TIMEOUT_MS = 5_000;
|
|
9
22
|
/** Default lifetime of a completed probe's cached answer, per scoped home. */
|
|
10
23
|
export const DEFAULT_CATALOG_TTL_MS = 5 * 60_000;
|
|
11
24
|
/** Grace between stdin EOF and terminate when the probe tears the process down. */
|
|
12
25
|
const PROBE_EOF_GRACE_MS = 1_000;
|
|
26
|
+
/** One directory answer that names why it is empty. */
|
|
27
|
+
function unsupported(reason) {
|
|
28
|
+
return { entries: [], complete: false, customInput: true, unsupported: true, reason };
|
|
29
|
+
}
|
|
13
30
|
/** Preserve native metadata, including hidden candidates; reject malformed pages. */
|
|
14
31
|
function catalogEntries(result) {
|
|
15
32
|
const page = result;
|
|
@@ -61,7 +78,7 @@ export class CodexModelCatalog {
|
|
|
61
78
|
this.cache = new ModelDirectoryCache({
|
|
62
79
|
load: (key, signal) => {
|
|
63
80
|
const context = JSON.parse(key);
|
|
64
|
-
return this.
|
|
81
|
+
return this.load(context.homeDir, context.cwd, signal);
|
|
65
82
|
},
|
|
66
83
|
ttlMs: deps.ttlMs ?? DEFAULT_CATALOG_TTL_MS,
|
|
67
84
|
...deps.now === undefined ? {} : { now: deps.now },
|
|
@@ -69,7 +86,9 @@ export class CodexModelCatalog {
|
|
|
69
86
|
});
|
|
70
87
|
}
|
|
71
88
|
key(homeDir, cwd = homeDir) {
|
|
72
|
-
|
|
89
|
+
// endpoint-auth.json re-keys the cache: a card credential write changes
|
|
90
|
+
// which catalog the probe would answer, so the next read re-probes.
|
|
91
|
+
const files = [join(homeDir, 'config.toml'), join(homeDir, 'auth.json'), join(homeDir, ENDPOINT_AUTH_FILE)];
|
|
73
92
|
for (let directory = cwd;; directory = dirname(directory)) {
|
|
74
93
|
files.push(join(directory, '.codex', 'config.toml'));
|
|
75
94
|
if (dirname(directory) === directory)
|
|
@@ -90,18 +109,82 @@ export class CodexModelCatalog {
|
|
|
90
109
|
return snapshot.entries.filter(entry => !entry.hidden).map(entry => entry.value);
|
|
91
110
|
}
|
|
92
111
|
/**
|
|
93
|
-
* One
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
112
|
+
* One directory load: the endpoint's served id list is the vocabulary (the
|
|
113
|
+
* card's add dialog can only offer what the endpoint answers), folded
|
|
114
|
+
* CONCURRENTLY with the native app-server probe's metadata. The endpoint
|
|
115
|
+
* answers are deliberate:
|
|
116
|
+
*
|
|
117
|
+
* - no credential resolved → empty with a "save a key first" reason;
|
|
118
|
+
* - 401/403 → empty with the rejection (definitive — cached as data);
|
|
119
|
+
* - another HTTP status → empty with it (the relay serves no model route);
|
|
120
|
+
* - a transport failure THROWS, so the cache keeps the last good list and
|
|
121
|
+
* retries on schedule instead of blanking a momentarily-down endpoint
|
|
122
|
+
* (the openproxy relay once 500'd and recovered — 2026-10-22).
|
|
123
|
+
*
|
|
124
|
+
* The native probe's failure never fails the load: endpoint data alone is
|
|
125
|
+
* complete (it warns once via the deps sink). The endpoint fetch gets a
|
|
126
|
+
* 20s ceiling by default (mirrors claude's catalog timeout); the native
|
|
127
|
+
* probe keeps its own tighter bound internally.
|
|
128
|
+
*/
|
|
129
|
+
async load(homeDir, cwd, signal) {
|
|
130
|
+
const endpoint = this.deps.endpoint === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: await this.deps.endpoint(homeDir).catch(() => undefined);
|
|
133
|
+
if (!hasEndpointCredential(endpoint)) {
|
|
134
|
+
return unsupported('No endpoint credential configured — the model list loads from the endpoint once an API Key is saved');
|
|
135
|
+
}
|
|
136
|
+
const combined = AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? DEFAULT_CATALOG_PROBE_TIMEOUT_MS * 4)]);
|
|
137
|
+
const [result, native] = await Promise.all([
|
|
138
|
+
fetchEndpointModels({ ...endpoint, baseUrl: endpoint.baseUrl ?? OFFICIAL_BASE_URL }, this.deps.fetchImpl, combined),
|
|
139
|
+
this.probeNative(homeDir, cwd, combined).catch((error) => {
|
|
140
|
+
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)}`);
|
|
141
|
+
return undefined;
|
|
142
|
+
}),
|
|
143
|
+
]);
|
|
144
|
+
if (result.kind === 'unreachable') {
|
|
145
|
+
// Not a verdict: the cache keeps the last good list and retries.
|
|
146
|
+
throw new Error(`endpoint unreachable: ${result.message}`);
|
|
147
|
+
}
|
|
148
|
+
if (result.kind === 'malformed')
|
|
149
|
+
return unsupported('The endpoint answered but the model list did not parse (not /v1/models-shaped)');
|
|
150
|
+
if (result.kind === 'http') {
|
|
151
|
+
if (result.status === 401 || result.status === 403) {
|
|
152
|
+
return unsupported(`The endpoint rejected the credential (HTTP ${String(result.status)}) — the list loads once a valid key is saved`);
|
|
153
|
+
}
|
|
154
|
+
return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
|
|
155
|
+
}
|
|
156
|
+
if (result.models.length === 0)
|
|
157
|
+
return unsupported('The endpoint serves no models');
|
|
158
|
+
const nativeByValue = new Map((native?.entries ?? []).map(entry => [entry.value, entry]));
|
|
159
|
+
// Served ids the native registry also knows carry its metadata over
|
|
160
|
+
// (label, description, hidden, reasoning — source stays 'native', which
|
|
161
|
+
// they are: the harness's own vocabulary, just read from the endpoint in
|
|
162
|
+
// force); ids only the endpoint serves are plain.
|
|
163
|
+
const entries = result.models.map(id => nativeByValue.get(id) ?? { value: id, label: id, source: 'native' });
|
|
164
|
+
const defaultModel = native?.defaultModel;
|
|
165
|
+
return {
|
|
166
|
+
entries, complete: true, customInput: true,
|
|
167
|
+
...defaultModel === undefined ? {} : { defaultModel },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* One bounded NATIVE probe — the metadata-enrichment side of {@link load}:
|
|
172
|
+
* spawn the app-server against the scoped home, answer the initialize
|
|
173
|
+
* handshake exactly as the live driver shapes it, ask `model/list`, then
|
|
174
|
+
* tear the process down (stdin EOF → grace → terminate). The overall
|
|
175
|
+
* timeout kills the process on expiry. Failures propagate to the caller
|
|
176
|
+
* (load demotes them to a warn).
|
|
97
177
|
*/
|
|
98
|
-
async
|
|
178
|
+
async probeNative(homeDir, cwd, signal) {
|
|
179
|
+
const endpoint = this.deps.endpoint === undefined
|
|
180
|
+
? undefined
|
|
181
|
+
: await this.deps.endpoint(homeDir).catch(() => undefined);
|
|
99
182
|
const spec = {
|
|
100
|
-
argv: ['codex', 'app-server', '--stdio'],
|
|
183
|
+
argv: ['codex', 'app-server', ...endpointConfigOverrides(endpoint), '--stdio'],
|
|
101
184
|
cwd,
|
|
102
185
|
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
103
186
|
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
104
|
-
env: delegationEnv({ CODEX_HOME: homeDir }),
|
|
187
|
+
env: delegationEnv({ CODEX_HOME: homeDir, ...endpointAuthEnv(endpoint) }),
|
|
105
188
|
};
|
|
106
189
|
const child = this.deps.spawn(spec);
|
|
107
190
|
const decoder = new StringDecoder('utf8');
|
package/lib/types/provision.d.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex scoped-home provisioning:
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
* to
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* untouched; sign-out removes the credential file.
|
|
2
|
+
* Codex scoped-home provisioning: a working `config.toml` (a person may edit
|
|
3
|
+
* it — custom providers, model, effort) plus the one-way sweep of the web
|
|
4
|
+
* login's leftover `auth.json`. The harness is endpoint-key only: a stale
|
|
5
|
+
* grant sitting next to an endpoint credential would keep authenticating the
|
|
6
|
+
* built-in provider while the status reads "unauthenticated", so provisioning
|
|
7
|
+
* deletes it on every boot (claude-code's OAuth-remnant sweep, same idea).
|
|
9
8
|
* @module @khorsheed/dsh-local-agent-codex/provision
|
|
10
9
|
*/
|
|
11
10
|
/**
|
|
12
|
-
* Provision
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Provision the scoped home: ensure a `config.toml` exists (a minimal one —
|
|
12
|
+
* codex reads it for custom providers, model, and effort; the historical
|
|
13
|
+
* `cli_auth_credentials_store = "file"` pin stays so a hand-run `codex login`
|
|
14
|
+
* under the scoped home can never spill into the macOS keychain), and sweep
|
|
15
|
+
* the web login's leftover `auth.json` (the harness no longer consults it —
|
|
16
|
+
* endpoint key or nothing).
|
|
16
17
|
* @param homeDir - the `codex` harness's scoped home.
|
|
17
18
|
* @returns true when a config was written, false when one already existed.
|
|
18
19
|
*/
|
|
@@ -61,20 +62,4 @@ export declare function readCodexModel(homeDir: string): Promise<string | undefi
|
|
|
61
62
|
* @returns the model identifiers the file carries.
|
|
62
63
|
*/
|
|
63
64
|
export declare function listCodexConfigModels(homeDir: string): Promise<string[]>;
|
|
64
|
-
/**
|
|
65
|
-
* The credential file's modification stamp (epoch ms), undefined when absent.
|
|
66
|
-
* A completed device-code login rewrites `auth.json`, so the stamp
|
|
67
|
-
* distinguishes a fresh login from a leftover (possibly revoked) credential.
|
|
68
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
69
|
-
* @returns the marker's mtime, or undefined when no credential exists.
|
|
70
|
-
*/
|
|
71
|
-
export declare function codexCredentialStamp(homeDir: string): Promise<number | undefined>;
|
|
72
|
-
/**
|
|
73
|
-
* Sign out of the scoped account: remove the credential file, so `/<name>
|
|
74
|
-
* status` reports not authenticated and the next login authorizes a fresh
|
|
75
|
-
* account. With `cli_auth_credentials_store = "file"` the credentials live
|
|
76
|
-
* solely in `auth.json`, so removing it is the logout path.
|
|
77
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
78
|
-
*/
|
|
79
|
-
export declare function codexLogout(homeDir: string): Promise<void>;
|
|
80
65
|
//# sourceMappingURL=provision.d.ts.map
|
package/lib/types/provision.js
CHANGED
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex scoped-home provisioning:
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
* to
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* untouched; sign-out removes the credential file.
|
|
2
|
+
* Codex scoped-home provisioning: a working `config.toml` (a person may edit
|
|
3
|
+
* it — custom providers, model, effort) plus the one-way sweep of the web
|
|
4
|
+
* login's leftover `auth.json`. The harness is endpoint-key only: a stale
|
|
5
|
+
* grant sitting next to an endpoint credential would keep authenticating the
|
|
6
|
+
* built-in provider while the status reads "unauthenticated", so provisioning
|
|
7
|
+
* deletes it on every boot (claude-code's OAuth-remnant sweep, same idea).
|
|
9
8
|
* @module @khorsheed/dsh-local-agent-codex/provision
|
|
10
9
|
*/
|
|
11
|
-
import { readFile, rm,
|
|
10
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
12
11
|
import { join } from 'node:path';
|
|
13
|
-
/**
|
|
14
|
-
const AUTH_FILE = 'auth.json';
|
|
15
|
-
/** Minimal scoped config: keep credentials in the scoped home. */
|
|
12
|
+
/** Minimal scoped config: keep any hand-run login's credentials in the scoped home. */
|
|
16
13
|
const SCOPED_CONFIG = `# Written by dsh-local-agent-codex: keep the device-code
|
|
17
14
|
# credentials inside the scoped home (auth.json) instead of the OS keychain.
|
|
18
15
|
cli_auth_credentials_store = "file"
|
|
19
16
|
`;
|
|
17
|
+
/** The web login's credential file, swept on every boot (endpoint-key only now). */
|
|
18
|
+
const LEGACY_AUTH_FILE = 'auth.json';
|
|
20
19
|
/**
|
|
21
|
-
* Provision
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* Provision the scoped home: ensure a `config.toml` exists (a minimal one —
|
|
21
|
+
* codex reads it for custom providers, model, and effort; the historical
|
|
22
|
+
* `cli_auth_credentials_store = "file"` pin stays so a hand-run `codex login`
|
|
23
|
+
* under the scoped home can never spill into the macOS keychain), and sweep
|
|
24
|
+
* the web login's leftover `auth.json` (the harness no longer consults it —
|
|
25
|
+
* endpoint key or nothing).
|
|
25
26
|
* @param homeDir - the `codex` harness's scoped home.
|
|
26
27
|
* @returns true when a config was written, false when one already existed.
|
|
27
28
|
*/
|
|
28
29
|
export async function provisionCodexConfig(homeDir) {
|
|
30
|
+
// The sweep runs even when the config already exists: a leftover grant from
|
|
31
|
+
// the web-login era must not keep working past the upgrade.
|
|
32
|
+
await rm(join(homeDir, LEGACY_AUTH_FILE), { force: true });
|
|
29
33
|
try {
|
|
30
34
|
await readFile(join(homeDir, 'config.toml'));
|
|
31
35
|
return false;
|
|
@@ -186,28 +190,3 @@ export async function listCodexConfigModels(homeDir) {
|
|
|
186
190
|
}
|
|
187
191
|
return found;
|
|
188
192
|
}
|
|
189
|
-
/**
|
|
190
|
-
* The credential file's modification stamp (epoch ms), undefined when absent.
|
|
191
|
-
* A completed device-code login rewrites `auth.json`, so the stamp
|
|
192
|
-
* distinguishes a fresh login from a leftover (possibly revoked) credential.
|
|
193
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
194
|
-
* @returns the marker's mtime, or undefined when no credential exists.
|
|
195
|
-
*/
|
|
196
|
-
export async function codexCredentialStamp(homeDir) {
|
|
197
|
-
try {
|
|
198
|
-
return (await stat(join(homeDir, AUTH_FILE))).mtimeMs;
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
return undefined;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Sign out of the scoped account: remove the credential file, so `/<name>
|
|
206
|
-
* status` reports not authenticated and the next login authorizes a fresh
|
|
207
|
-
* account. With `cli_auth_credentials_store = "file"` the credentials live
|
|
208
|
-
* solely in `auth.json`, so removing it is the logout path.
|
|
209
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
210
|
-
*/
|
|
211
|
-
export async function codexLogout(homeDir) {
|
|
212
|
-
await rm(join(homeDir, AUTH_FILE), { force: true });
|
|
213
|
-
}
|
package/lib/types/records.d.ts
CHANGED
|
@@ -13,13 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
15
15
|
import type { LocalAgentSessionRecord } from '@khorsheed/dsh-local-agent';
|
|
16
|
-
/**
|
|
17
|
-
* Whether the scoped home holds usable Codex credentials: the `auth.json`
|
|
18
|
-
* file exists. Absent means the device-code flow has not completed.
|
|
19
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
20
|
-
* @returns true when credentials are present.
|
|
21
|
-
*/
|
|
22
|
-
export declare function codexAuthenticated(homeDir: string): Promise<boolean>;
|
|
23
16
|
/**
|
|
24
17
|
* Parse the first line of a rollout file for the listing fields. Unknown or
|
|
25
18
|
* malformed values yield an undefined record rather than failing the list.
|
package/lib/types/records.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* contents are always untrusted input.
|
|
12
12
|
* @module @khorsheed/dsh-local-agent-codex/records
|
|
13
13
|
*/
|
|
14
|
-
import { open,
|
|
14
|
+
import { open, readdir } from 'node:fs/promises';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
16
|
/** Rollout files live under this directory inside the scoped home. */
|
|
17
17
|
const SESSIONS_ROOT = 'sessions';
|
|
@@ -28,24 +28,6 @@ const FULL_BYTES = 8 * 1024 * 1024;
|
|
|
28
28
|
const ROLLOUT_WINDOW_SLACK_MS = 5 * 60_000;
|
|
29
29
|
/** Extra future slack for the window end (file timestamps may skew ahead). */
|
|
30
30
|
const ROLLOUT_WINDOW_FUTURE_MS = 60_000;
|
|
31
|
-
/**
|
|
32
|
-
* Whether the scoped home holds usable Codex credentials: the `auth.json`
|
|
33
|
-
* file exists. Absent means the device-code flow has not completed.
|
|
34
|
-
* @param homeDir - the `codex` harness's scoped home.
|
|
35
|
-
* @returns true when credentials are present.
|
|
36
|
-
*/
|
|
37
|
-
export async function codexAuthenticated(homeDir) {
|
|
38
|
-
try {
|
|
39
|
-
await readFile(join(homeDir, 'auth.json'));
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
// No auth file yet: not authenticated.
|
|
44
|
-
if (error.code === 'ENOENT')
|
|
45
|
-
return false;
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
31
|
/**
|
|
50
32
|
* Parse the first line of a rollout file for the listing fields. Unknown or
|
|
51
33
|
* malformed values yield an undefined record rather than failing the list.
|
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED