@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.
@@ -39,6 +39,42 @@ function dedupeChoices(layers) {
39
39
  }
40
40
  return choices;
41
41
  }
42
+ /**
43
+ * Scope the served directory to the surface asking for it (the same helper
44
+ * lives in each provider's broker — fold into the family core when the
45
+ * directory faces next evolve):
46
+ *
47
+ * - memberless (the card's add-model dialog, the invite picker): the
48
+ * endpoint's served catalog only — the configuration/history extras the
49
+ * broker's info extension adds for STATUS display must never become add
50
+ * candidates;
51
+ * - a member whose harness has a curated list: the list IS the vocabulary,
52
+ * in the list's own order (the same rule `choices` already follows) — a
53
+ * curated value the endpoint no longer serves stays as a bare row (the
54
+ * card shows it the same way), and the member's current effective model is
55
+ * kept visible as a bare entry when it sits outside the list, so a pre-list
56
+ * binding never renders blank;
57
+ * - a member without a curated list: the legacy extended vocabulary.
58
+ */
59
+ function scopeServedDirectory(directory, opts) {
60
+ if (opts.member && opts.curated.length === 0)
61
+ return directory;
62
+ const served = directory.entries.filter(entry => entry.source === 'native');
63
+ if (!opts.member)
64
+ return { ...directory, entries: served };
65
+ const byValue = new Map(served.flatMap(entry => entry.resolvedModel === undefined
66
+ ? [[entry.value, entry]]
67
+ : [[entry.value, entry], [entry.resolvedModel, entry]]));
68
+ // Curated ORDER leads (the card's first row is the default); a curated value
69
+ // the endpoint no longer serves stays listed as a bare row, exactly like the
70
+ // card shows it.
71
+ const entries = opts.curated.map(value => byValue.get(value) ?? { value, label: value, source: 'configuration' });
72
+ const current = opts.current?.trim();
73
+ if (current !== undefined && current !== '' && !entries.some(entry => entry.value === current || entry.resolvedModel === current)) {
74
+ entries.push({ value: current, label: current, source: 'configuration' });
75
+ }
76
+ return { ...directory, entries };
77
+ }
42
78
  export class CodexModelBroker {
43
79
  deps;
44
80
  constructor(deps) {
@@ -54,7 +90,7 @@ export class CodexModelBroker {
54
90
  }
55
91
  const model = selection.model.mode === 'value' ? selection.model.value
56
92
  : (selection.model.mode === 'inherit' ? binding.model : undefined)
57
- ?? this.deps.settingsModel() ?? await readCodexModel(home)
93
+ ?? this.deps.settingsModel(home) ?? await readCodexModel(home)
58
94
  ?? this.deps.catalogDefault(home, binding.cwd);
59
95
  const entry = directory?.entries.find(entry => entry.value === model);
60
96
  const explicitEffort = selection.effort.mode === 'value' ? selection.effort.value
@@ -88,7 +124,12 @@ export class CodexModelBroker {
88
124
  if (refresh)
89
125
  await this.deps.refreshDirectory?.(home, this.deps.cwd?.(childSessionId));
90
126
  const info = await this.modelInfo(childSessionId, childSessionId === undefined ? undefined : this.deps.ctx.localAgent.getDelegation(childSessionId)?.model);
91
- return info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
127
+ const directory = info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
128
+ return scopeServedDirectory(directory, {
129
+ member: childSessionId !== undefined,
130
+ curated: this.deps.modelList?.(home) ?? [],
131
+ current: info.effective,
132
+ });
92
133
  }
93
134
  async *followModelDirectory(childSessionId, signal) {
94
135
  if (this.deps.followDirectory === undefined) {
@@ -113,7 +154,7 @@ export class CodexModelBroker {
113
154
  const recorded = record?.model?.trim();
114
155
  if (recorded !== undefined && recorded !== '')
115
156
  return recorded;
116
- return this.deps.settingsModel();
157
+ return this.deps.settingsModel(this.deps.homeDir(childSessionId));
117
158
  }
118
159
  /**
119
160
  * Read the model surface. The cliDefault and the discovered identifiers come
@@ -124,8 +165,8 @@ export class CodexModelBroker {
124
165
  * re-fetches on open, so it never blocks on a CLI boot).
125
166
  */
126
167
  async modelInfo(childSessionId, delegationModel) {
127
- const settings = this.deps.settingsModel();
128
168
  const homeDir = this.deps.homeDir(childSessionId);
169
+ const settings = this.deps.settingsModel(homeDir);
129
170
  const [cliDefault, discovered] = await Promise.all([
130
171
  readCodexModel(homeDir).catch(() => undefined),
131
172
  listCodexConfigModels(homeDir).catch(() => []),
@@ -144,6 +185,10 @@ export class CodexModelBroker {
144
185
  : settings !== undefined ? 'settings'
145
186
  : cliDefault !== undefined ? 'cli-config' : 'cli-builtin';
146
187
  const inFlight = childSessionId !== undefined && this.activeDelegations().includes(childSessionId);
188
+ // The curated list is the whole vocabulary when non-empty — nothing from
189
+ // outside the endpoint's served catalog sneaks in. Empty falls back to
190
+ // the pre-list chain (native candidates plus configuration and history).
191
+ const curated = this.deps.modelList?.(homeDir) ?? [];
147
192
  return {
148
193
  ...effective === undefined ? {} : { effective },
149
194
  source,
@@ -151,8 +196,8 @@ export class CodexModelBroker {
151
196
  ...delegation === undefined ? {} : { delegation },
152
197
  ...settings === undefined ? {} : { settings },
153
198
  ...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()]),
199
+ ...this.deps.directory === undefined ? {} : { directory: extendModelDirectory(this.deps.directory(homeDir, this.deps.cwd?.(childSessionId)), [settings, cliDefault, ...discovered], curated.length > 0 ? [] : this.deps.recentModels()) },
200
+ choices: curated.length > 0 ? [...curated] : dedupeChoices([settings, cliDefault, ...discovered, ...this.deps.catalog(homeDir, this.deps.cwd?.(childSessionId)), ...this.deps.recentModels()]),
156
201
  live: this.deps.live(),
157
202
  switchable: !inFlight,
158
203
  ...inFlight ? { reason: '成员有进行中的委派轮次,等其完成后再切换模型' } : {},
@@ -1,8 +1,21 @@
1
- /** Native app-server model/list discovery. Shared core cache owns refresh,
2
- * stale data and subscriptions; this provider owns protocol and pagination. */
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
- /** Default overall bound for one catalog probe (spawn → model/list answer). */
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
- /** Overall probe bound; tests inject a small value. */
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 bounded probe: spawn the app-server against the scoped home, answer
38
- * the initialize handshake exactly as the live driver shapes it, ask
39
- * `model/list`, then tear the process down (stdin EOF → grace →
40
- * terminate). The overall timeout kills the process on expiry. Failures propagate to the shared cache, which preserves prior data.
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 probe;
86
+ private probeNative;
43
87
  }
44
88
  //# sourceMappingURL=model-catalog.d.ts.map
@@ -1,15 +1,32 @@
1
- /** Native app-server model/list discovery. Shared core cache owns refresh,
2
- * stale data and subscriptions; this provider owns protocol and pagination. */
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
- /** Default overall bound for one catalog probe (spawn → model/list answer). */
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.probe(context.homeDir, context.cwd, signal);
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
- const files = [join(homeDir, 'config.toml'), join(homeDir, 'auth.json')];
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 bounded probe: spawn the app-server against the scoped home, answer
94
- * the initialize handshake exactly as the live driver shapes it, ask
95
- * `model/list`, then tear the process down (stdin EOF → grace →
96
- * terminate). The overall timeout kills the process on expiry. Failures propagate to the shared cache, which preserves prior data.
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 probe(homeDir, cwd, signal) {
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');
@@ -1,18 +1,19 @@
1
1
  /**
2
- * Codex scoped-home provisioning: pin file-based credential storage so the
3
- * device-code login writes `auth.json` inside the scoped home. Codex's
4
- * `cli_auth_credentials_store` defaults to `auto` — on macOS that resolves
5
- * to the keychain, which would both leak credentials outside the scoped
6
- * home and defeat the `auth.json` presence check this package's records
7
- * adapter uses for authentication. An existing config is respected
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 `<homeDir>/config.toml` unless one already exists. A present
13
- * config is respected untouched (a person may have edited it); otherwise a
14
- * minimal config pinning file-based credential storage is written so login
15
- * lands in the scoped home's `auth.json`.
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
@@ -1,31 +1,35 @@
1
1
  /**
2
- * Codex scoped-home provisioning: pin file-based credential storage so the
3
- * device-code login writes `auth.json` inside the scoped home. Codex's
4
- * `cli_auth_credentials_store` defaults to `auto` — on macOS that resolves
5
- * to the keychain, which would both leak credentials outside the scoped
6
- * home and defeat the `auth.json` presence check this package's records
7
- * adapter uses for authentication. An existing config is respected
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, stat, writeFile } from 'node:fs/promises';
10
+ import { readFile, rm, writeFile } from 'node:fs/promises';
12
11
  import { join } from 'node:path';
13
- /** Credential file inside the scoped home; its presence marks auth. */
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 `<homeDir>/config.toml` unless one already exists. A present
22
- * config is respected untouched (a person may have edited it); otherwise a
23
- * minimal config pinning file-based credential storage is written so login
24
- * lands in the scoped home's `auth.json`.
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
- }
@@ -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.
@@ -42,10 +35,12 @@ export declare function listCodexSessions(homeDir: string): Promise<readonly Loc
42
35
  * `input_tokens` is the TOTAL input including cache hits (OpenAI-style
43
36
  * accounting, confirmed against `total_tokens` in the rollout token_count),
44
37
  * and `cached_input_tokens` is the cache-read subset — so the uncached
45
- * bucket subtracts the cached portion to avoid double counting. There is no
46
- * cache-write concept, so that bucket is omitted (the shared four-bucket
47
- * contract: uncached input, output, cache read, cache write — codex fills
48
- * the first three).
38
+ * bucket subtracts the cached portion to avoid double counting.
39
+ * `cached_input_tokens` rides every payload (zero included), so
40
+ * `cacheReadTokens` is written whenever the field is present — a reader can
41
+ * tell "no hits" from "not reported". Codex also reports cache creation
42
+ * (`cache_write_input_tokens`), mapped to `cacheWriteTokens` (the shared
43
+ * four-bucket contract: uncached input, output, cache read, cache write).
49
44
  * @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
50
45
  * rollout `token_count` `info.last_token_usage`/`total_token_usage`).
51
46
  * @returns the shared usage record.
@@ -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, readFile, readdir } from 'node:fs/promises';
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.
@@ -167,10 +149,12 @@ export async function listCodexSessions(homeDir) {
167
149
  * `input_tokens` is the TOTAL input including cache hits (OpenAI-style
168
150
  * accounting, confirmed against `total_tokens` in the rollout token_count),
169
151
  * and `cached_input_tokens` is the cache-read subset — so the uncached
170
- * bucket subtracts the cached portion to avoid double counting. There is no
171
- * cache-write concept, so that bucket is omitted (the shared four-bucket
172
- * contract: uncached input, output, cache read, cache write — codex fills
173
- * the first three).
152
+ * bucket subtracts the cached portion to avoid double counting.
153
+ * `cached_input_tokens` rides every payload (zero included), so
154
+ * `cacheReadTokens` is written whenever the field is present — a reader can
155
+ * tell "no hits" from "not reported". Codex also reports cache creation
156
+ * (`cache_write_input_tokens`), mapped to `cacheWriteTokens` (the shared
157
+ * four-bucket contract: uncached input, output, cache read, cache write).
174
158
  * @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
175
159
  * rollout `token_count` `info.last_token_usage`/`total_token_usage`).
176
160
  * @returns the shared usage record.
@@ -179,9 +163,12 @@ export function usageFromCodex(usage) {
179
163
  const raw = usage;
180
164
  const input = Number(raw.input_tokens);
181
165
  const cached = Number(raw.cached_input_tokens);
166
+ const cacheWrite = Number(raw.cache_write_input_tokens);
182
167
  const output = Number(raw.output_tokens);
168
+ // `input_tokens` already contains both cache buckets (total = input +
169
+ // output); the shared contract keeps the three input buckets disjoint.
183
170
  const uncached = Number.isFinite(input) && Number.isFinite(cached)
184
- ? Math.max(0, input - cached)
171
+ ? Math.max(0, input - cached - (Number.isFinite(cacheWrite) ? cacheWrite : 0))
185
172
  : Number.isFinite(input)
186
173
  ? input
187
174
  : 0;
@@ -189,8 +176,10 @@ export function usageFromCodex(usage) {
189
176
  inputTokens: uncached,
190
177
  outputTokens: Number.isFinite(output) ? output : 0,
191
178
  };
192
- if (Number.isFinite(cached) && cached > 0)
179
+ if (Number.isFinite(cached))
193
180
  usageRecord.cacheReadTokens = cached;
181
+ if (Number.isFinite(cacheWrite) && cacheWrite > 0)
182
+ usageRecord.cacheWriteTokens = cacheWrite;
194
183
  return usageRecord;
195
184
  }
196
185
  /**
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Codex Delegation",
4
- "description": "Delegate coding tasks to your locally installed Codex CLI."
3
+ "title": "Local Codex Delegation",
4
+ "description": "Delegate coding tasks to this machine's Codex CLI."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Codex 委派",
4
- "description": "把编码任务委派给本地 Codex CLI,在隔离作用域中运行。"
3
+ "title": "本机 Codex 委派",
4
+ "description": "把编码任务委派给本机的 Codex CLI,在隔离作用域中运行。"
5
5
  }
6
6
  }