@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.
@@ -16,12 +16,21 @@
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
18
18
  import { SessionId } from '@deepseek-ai/dsh-session';
19
- import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
19
+ import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
20
20
  import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, persistChildSession, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
21
21
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
22
22
  import { LiveChannelUnavailableError } from './live-driver.js';
23
+ import { endpointAuthEnv, endpointConfigOverrides, hasEndpointCredential } from './endpoint-auth.js';
23
24
  import { readCodexBaseUrl } from './provision.js';
24
25
  import { codexRolloutRoundFacts, usageFromCodex } from './records.js';
26
+ /**
27
+ * The synthesized result for a call whose result the stream never recorded
28
+ * (a killed run, or a result-less item type like web_search_call). The
29
+ * released-log contract resolves every advertised call before its step
30
+ * closes, so an interrupted call folds with this error result. Same text as
31
+ * the kimi provider's placeholder, for family consistency.
32
+ */
33
+ export const CODEX_INTERRUPTED_TOOL_RESULT = '(工具调用被中断,未能记录返回结果)';
25
34
  /** Quote one TOML basic string for the `-c` config override. */
26
35
  function tomlString(value) {
27
36
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
@@ -49,7 +58,10 @@ export function registerCodexMemberRun(ctx, childSessionId, parentSessionId) {
49
58
  + `command=${tomlString(bridge.command)},`
50
59
  + `args=[${bridge.args.map(tomlString).join(',')}],`
51
60
  + `env={${MEMBER_BRIDGE_SOCKET_ENV}=${tomlString(registry.memberBridgeSocketPath())},${MEMBER_BRIDGE_TOKEN_ENV}=${tomlString(token)}},`
52
- + `default_tools_approval_mode=${tomlString('approve')}`
61
+ + `default_tools_approval_mode=${tomlString('approve')},`
62
+ // A bridge call may wait for the human (ask_user_question, a mode tool's
63
+ // approval): Codex's 60s MCP default would fail it first.
64
+ + `tool_timeout_sec=1800`
53
65
  + `}`;
54
66
  let released = false;
55
67
  return {
@@ -116,6 +128,7 @@ export class CodexCliProvider {
116
128
  live;
117
129
  model;
118
130
  overrides;
131
+ endpoint;
119
132
  name = 'codex-local';
120
133
  capabilities = NO_START_CAPABILITIES;
121
134
  inheritsParentContext = false;
@@ -126,22 +139,33 @@ export class CodexCliProvider {
126
139
  * while a retiring generation still hosts it).
127
140
  * @param model - resolver for the configured model, read PER ROUND so a
128
141
  * settings-card write takes effect on the next delegation without a
129
- * reload. Undefined (the resolver absent, or returning undefined) leaves
130
- * the argv exactly as it was before the key existed — the scoped
131
- * config.toml's own `model` then decides, as it always did.
142
+ * reload; it receives the round's scope (a named scope's curated list
143
+ * answers first). Undefined (the resolver absent, or returning
144
+ * undefined) leaves the argv exactly as it was before the key existed —
145
+ * the scoped config.toml's own `model` then decides, as it always did.
132
146
  * @param overrides - resolver for the member's session-level model override
133
147
  * (the composer picker, owned by the model broker). It outranks BOTH the
134
148
  * delegation's recorded model and the configured one: on the exec path it
135
149
  * re-points the next round's `-m`, and on the live path it is the member's
136
150
  * start model — a resident runtime bound to a different model is retired
137
151
  * so the round respawns onto the override.
152
+ * @param endpoint - resolver for the scope's endpoint credential, read PER
153
+ * ROUND so a settings-card write takes effect on the next delegation
154
+ * without a reload. A resolution carrying a key routes the round at the
155
+ * relay (a custom `model_providers` entry on the argv, the key in the
156
+ * env); with no key the spawn carries no credential at all.
138
157
  */
139
- constructor(ctx, sandbox = 'workspace-write', live, model, overrides) {
158
+ constructor(ctx, sandbox = 'workspace-write', live, model, overrides, endpoint) {
140
159
  this.ctx = ctx;
141
160
  this.sandbox = sandbox;
142
161
  this.live = live;
143
162
  this.model = model;
144
163
  this.overrides = overrides;
164
+ this.endpoint = endpoint;
165
+ }
166
+ /** The scope's endpoint credential for this round; absent resolver = never. */
167
+ resolveEndpoint(homeDir) {
168
+ return this.endpoint === undefined ? Promise.resolve(undefined) : this.endpoint(homeDir).catch(() => undefined);
145
169
  }
146
170
  /** Resolve the live driver for one round's member, if live is on for it. */
147
171
  liveDriver(childSessionId) {
@@ -198,7 +222,7 @@ export class CodexCliProvider {
198
222
  ...record?.configurationLock === undefined ? {} : { configurationLock: record.configurationLock },
199
223
  }, configuration => this.startCodexResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
200
224
  }
201
- const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
225
+ const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
202
226
  if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function') {
203
227
  if (intent?.effort !== undefined)
204
228
  throw new Error('Codex effort requires the configuration admission core');
@@ -231,14 +255,20 @@ export class CodexCliProvider {
231
255
  },
232
256
  });
233
257
  const harness = this.ctx.localAgent.get('codex');
234
- const label = subagentDelegationLabel(harness?.displayName ?? 'codex', request.descriptor.label);
258
+ // Host 0.2.1 no longer resolves a descriptor onto the request (the
259
+ // runtime's one-shot snapshot is gone), so the provider stamps the same
260
+ // payload itself; the activation spec's required label arrives as the
261
+ // empty string when the caller named none.
262
+ const label = subagentDelegationLabel(harness?.displayName ?? 'codex', request.label || undefined);
235
263
  childSession.append('subagent/descriptor', {
236
- ...request.descriptor,
264
+ version: SUBAGENT_DESCRIPTOR_VERSION,
265
+ mode: 'one-shot',
266
+ provider: this.name,
237
267
  label,
238
268
  });
239
- // The official runtime appends the parent-side subagent/catalog
240
- // discovery row only for in-process children (run.localAgent); this run
241
- // is remote, so the provider appends it here — once per child, where
269
+ // The 0.2.1 manager appends the parent-side subagent/catalog discovery
270
+ // row only for parent-delivered children; the family runs caller
271
+ // delivery, so the provider appends it here — once per child, where
242
272
  // the descriptor lands. A failure degrades to the warn below; a missing
243
273
  // row never blocks the delegation.
244
274
  establishSubagentCatalogChild(request.parent.session, childSession.header, label);
@@ -251,11 +281,15 @@ export class CodexCliProvider {
251
281
  catch (error) {
252
282
  this.ctx.logger.warn(`subagent-codex: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
253
283
  }
254
- // Resolve the effective custom endpoint from the scoped config.toml for
255
- // diagnostics: codex reads it directly (a user manually editing the
256
- // config to route through a custom provider is authoritative). Logged
257
- // BEFORE the drive-mode branch so live rounds report their endpoint too.
258
- const baseUrl = await readCodexBaseUrl(homeDir).catch(() => undefined);
284
+ // The round's endpoint: a configured endpoint credential (the scope's
285
+ // endpoint-auth.json over the plugin config) routes the argv through a
286
+ // custom provider entry and its key rides the env. With no credential the
287
+ // scoped config.toml's own custom provider answers for the diagnostic
288
+ // exactly as before (a user-edited value is authoritative). Logged BEFORE
289
+ // the drive-mode branch so live rounds report their endpoint too.
290
+ const endpoint = await this.resolveEndpoint(homeDir);
291
+ const routed = hasEndpointCredential(endpoint);
292
+ const baseUrl = routed ? endpoint.baseUrl : await readCodexBaseUrl(homeDir).catch(() => undefined);
259
293
  this.ctx.logger.info(`subagent-codex: delegating via ${baseUrl ?? 'codex default endpoint'}`);
260
294
  // Live driver: the round goes to the resident app-server process (one per
261
295
  // member). A channel that fails at spawn/handshake falls through to the
@@ -314,11 +348,12 @@ export class CodexCliProvider {
314
348
  try {
315
349
  const run = await startCodexCliRun(request, {
316
350
  cwd,
317
- env: delegationEnv({ CODEX_HOME: homeDir }),
351
+ env: delegationEnv({ CODEX_HOME: homeDir, ...endpointAuthEnv(endpoint) }),
318
352
  ...exec === undefined ? {} : { exec },
319
353
  endpointLabel: baseUrl,
354
+ endpointArgv: endpointConfigOverrides(endpoint),
320
355
  sandbox: this.sandbox,
321
- ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, this.model)),
356
+ ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, () => this.model?.(scope))),
322
357
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
323
358
  spawn: spec => this.ctx.subprocess.spawn(spec),
324
359
  onError: (error, stopReason) => {
@@ -383,7 +418,11 @@ export class CodexCliProvider {
383
418
  throw new Error(`subagent-codex: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
384
419
  }
385
420
  const nextTurn = childSession.snapshotEvents().filter(event => event.type === 'turn/start').length + 1;
386
- const baseUrl = await readCodexBaseUrl(homeDir).catch(() => undefined);
421
+ // Same endpoint precedence as the fresh path: a configured endpoint
422
+ // credential routes the round at the relay, else the scoped
423
+ // config.toml's own custom provider answers for the diagnostic.
424
+ const endpoint = await this.resolveEndpoint(homeDir);
425
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readCodexBaseUrl(homeDir).catch(() => undefined);
387
426
  // Logged BEFORE the drive-mode branch so live rounds report their endpoint too.
388
427
  this.ctx.logger.info(`subagent-codex: resuming via ${baseUrl ?? 'codex default endpoint'}`);
389
428
  // Live driver: continue the member's resident app-server thread. Channel
@@ -423,11 +462,12 @@ export class CodexCliProvider {
423
462
  try {
424
463
  run = await startCodexCliRun(request, {
425
464
  cwd,
426
- env: delegationEnv({ CODEX_HOME: homeDir }),
465
+ env: delegationEnv({ CODEX_HOME: homeDir, ...endpointAuthEnv(endpoint) }),
427
466
  ...exec === undefined ? {} : { exec },
428
467
  endpointLabel: baseUrl,
468
+ endpointArgv: endpointConfigOverrides(endpoint),
429
469
  sandbox: this.sandbox,
430
- ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, this.model)),
470
+ ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, () => this.model?.(scope))),
431
471
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
432
472
  spawn: spec => this.ctx.subprocess.spawn(spec),
433
473
  onError: (error, stopReason) => {
@@ -622,6 +662,40 @@ function foldCodexStreamLine(state, raw) {
622
662
  }
623
663
  }
624
664
  }
665
+ else if (item.type === 'custom_tool_call') {
666
+ // Custom tools (the deferred `exec` discovery tool, dynamic tools): the
667
+ // call rides `input`, its result the paired custom_tool_call_output.
668
+ // Without this branch a tool-loop round mirrored as two bare adjacent
669
+ // replies with the call between them invisible.
670
+ countToolCall(state, 'custom_tool_call');
671
+ state.lines.push({
672
+ kind: 'tool',
673
+ id: typeof item.id === 'string' ? item.id : `codex-tool-${state.lines.length}`,
674
+ name: item.name ?? 'custom',
675
+ ...typeof item.input === 'string' && item.input !== '' ? { args: item.input } : {},
676
+ });
677
+ }
678
+ else if (item.type === 'custom_tool_call_output') {
679
+ // {call_id, output: string | content blocks} — pair by recency, the same
680
+ // idiom function_call_output uses (the stream orders a result right
681
+ // behind its call).
682
+ const output = typeof item.output === 'string' ? item.output
683
+ : Array.isArray(item.output)
684
+ ? item.output
685
+ .filter((block) => typeof block === 'object' && block !== null && typeof block.text === 'string')
686
+ .map(block => block.text)
687
+ .join('\n')
688
+ : undefined;
689
+ if (output !== undefined && output.trim() !== '') {
690
+ const last = state.lines[state.lines.length - 1];
691
+ if (last !== undefined && last.kind === 'tool') {
692
+ state.lines[state.lines.length - 1] = {
693
+ ...last,
694
+ result: last.result === undefined ? output : `${last.result}\n${output}`,
695
+ };
696
+ }
697
+ }
698
+ }
625
699
  }
626
700
  /**
627
701
  * Incremental `codex exec --json` parser: feed stdout chunks as they arrive
@@ -656,7 +730,8 @@ export class CodexStreamParser {
656
730
  * not put one on the wire; the settle path then reads the rollout's
657
731
  * turn_context). Item types covered: `reasoning` (thinking), `agent_message`
658
732
  * (reply text), `command_execution` (shell command with aggregated output),
659
- * `web_search_call`, and `function_call_output`. The last `agent_message`
733
+ * `web_search_call`, `custom_tool_call`/`custom_tool_call_output` (deferred
734
+ * and dynamic tools), and `function_call_output`. The last `agent_message`
660
735
  * wins as the run output (the final answer), and `turn.completed` carries the
661
736
  * turn's usage. Malformed lines are skipped.
662
737
  * @param stream - the collected stdout NDJSON text.
@@ -697,7 +772,9 @@ export function startCodexCliRun(request, spec) {
697
772
  const turn = spec.resume?.turn ?? 1;
698
773
  // The member bridge rides a per-process `-c` override placed right after
699
774
  // `exec` (the spike-verified position; `codex exec resume` accepts it too).
775
+ // The endpoint credential's overrides sit next to it — `-c` pairs commute.
700
776
  const memberArgv = spec.member === undefined ? [] : ['-c', spec.member.configOverride];
777
+ const endpointArgv = spec.endpointArgv ?? [];
701
778
  // `--skip-git-repo-check` rides every exec argv. Codex refuses to start
702
779
  // outside a Git repository ("Not inside a trusted directory and
703
780
  // --skip-git-repo-check was not specified") and exits before producing a
@@ -718,8 +795,8 @@ export function startCodexCliRun(request, spec) {
718
795
  ...spec.effort === undefined ? [] : ['-c', `model_reasoning_effort=${JSON.stringify(spec.effort)}`],
719
796
  ];
720
797
  const argv = spec.resume === undefined
721
- ? ['codex', 'exec', ...memberArgv, ...modelArgv, '--sandbox', spec.sandbox, '--skip-git-repo-check', '--json', task]
722
- : ['codex', 'exec', ...memberArgv, ...modelArgv, '--sandbox', spec.sandbox, '--skip-git-repo-check', '--json', 'resume', spec.resume.cliSessionId, task];
798
+ ? ['codex', 'exec', ...memberArgv, ...endpointArgv, ...modelArgv, '--sandbox', spec.sandbox, '--skip-git-repo-check', '--json', task]
799
+ : ['codex', 'exec', ...memberArgv, ...endpointArgv, ...modelArgv, '--sandbox', spec.sandbox, '--skip-git-repo-check', '--json', 'resume', spec.resume.cliSessionId, task];
723
800
  // Container target: the same argv, wrapped in `docker exec`. The host cwd
724
801
  // still applies — it is the docker CLIENT's working directory now, while
725
802
  // the CLI's own is the target's in-container workdir.
@@ -741,6 +818,17 @@ export function startCodexCliRun(request, spec) {
741
818
  // The turn opens at the real spawn moment so the timing projection
742
819
  // measures actual CLI runtime, not the post-hoc append time.
743
820
  spec.childSession?.append('turn/start', { turn });
821
+ // The provider's own launch facts are the child log's request config —
822
+ // codex's stream never carries model/effort, so the argv the provider
823
+ // spawns with is the authoritative source. The header contract requires a
824
+ // model: an unconfigured round records nothing rather than inventing one.
825
+ if (spec.model !== undefined && spec.childSession !== undefined) {
826
+ appendCodexRequestHeader(spec.childSession, {
827
+ provider: 'codex-local',
828
+ model: spec.model,
829
+ ...spec.effort === undefined ? {} : { reasoningEffort: spec.effort },
830
+ });
831
+ }
744
832
  // Live mirror: fold the NDJSON stream as chunks arrive so the child session
745
833
  // shows the run's progress before settle; the settle mirror below resumes
746
834
  // from the live counters and stays a no-op when live mirroring kept up.
@@ -865,29 +953,12 @@ export function startCodexCliRun(request, spec) {
865
953
  signal: request.signal,
866
954
  onAbort,
867
955
  }).then((settled) => {
868
- // Every terminal path closes the turn so the timing window never stays
869
- // open on a failed or cancelled run: success settles 'completed', a
870
- // non-zero exit 'error', and a locally cancelled run 'aborted'. The
871
- // turn/end timestamp is the real settle moment.
872
956
  // A completed round proves the scoped credential is live — the endpoint
873
957
  // answered. Reported here rather than from the mirror so it lands whether
874
- // or not the round is session-backed.
958
+ // or not the round is session-backed. The turn boundary itself is the
959
+ // post-exit chain's job (it must follow the settle mirror).
875
960
  if (settled.stopReason === 'completed')
876
961
  spec.onAuthSuccess?.();
877
- if (spec.childSession !== undefined) {
878
- if (settled.stopReason === 'completed') {
879
- spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
880
- }
881
- else if (settled.stopReason === 'aborted') {
882
- spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
883
- }
884
- else {
885
- spec.childSession.append('turn/end', {
886
- turn,
887
- reason: { kind: 'error', error: { message: `codex exec exited with code ${String(exitCode)}`, code: 'UNKNOWN' } },
888
- });
889
- }
890
- }
891
962
  return settled;
892
963
  });
893
964
  // After the child EXITS — however it ended (completed, killed by dispose, or
@@ -895,9 +966,13 @@ export function startCodexCliRun(request, spec) {
895
966
  // subagent session, so a cancelled round still preserves its partial work
896
967
  // (reasoning, commands, replies) and real token usage. The thread id is
897
968
  // recorded even on abort so the partial thread stays resumable. Waits for
898
- // the settle chain first (so turn/end is already appended) AND for the
899
- // process to actually exit (so stdout is drained before parsing).
900
- void result.then(() => child.done).then(() => {
969
+ // the settle chain first AND for the process to actually exit (so stdout is
970
+ // drained before parsing). The turn closes AFTER the settle mirror, still
971
+ // serialized behind the live mirror's queue: every mirrored step must land
972
+ // before turn/end — the released-log reader rejects a step outside an open
973
+ // turn on every cold read. The run's result still settles at the real
974
+ // moment (abort latency unchanged); only the boundary event moves.
975
+ void result.then(() => child.done).then(async () => {
901
976
  if (spec.onAuthFailure !== undefined) {
902
977
  // The seam's collected buffers are complete at process exit; the
903
978
  // streamed variables can lag `done` by a tick.
@@ -908,13 +983,38 @@ export function startCodexCliRun(request, spec) {
908
983
  spec.onAuthFailure(authDetail.split('\n').find(line => line.trim() !== '') ?? 'auth failure');
909
984
  }
910
985
  }
911
- return mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
986
+ await mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
987
+ // Every terminal path closes the turn so the timing window never stays
988
+ // open on a failed or cancelled run: success settles 'completed', a
989
+ // non-zero exit 'error', and a locally cancelled run 'aborted'.
990
+ if (spec.childSession !== undefined) {
991
+ const settled = await result;
992
+ if (settled.stopReason === 'completed') {
993
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
994
+ }
995
+ else if (settled.stopReason === 'aborted') {
996
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
997
+ }
998
+ else {
999
+ spec.childSession.append('turn/end', {
1000
+ turn,
1001
+ reason: { kind: 'error', error: { message: `codex exec exited with code ${String(exitCode)}`, code: 'UNKNOWN' } },
1002
+ });
1003
+ }
1004
+ if (spec.ctx !== undefined) {
1005
+ await persistChildSession(spec.ctx, spec.childSession).catch((error) => {
1006
+ spec.ctx?.logger.warn(`subagent-codex: turn/end persistence failed: ${thrown(error).message}`);
1007
+ });
1008
+ }
1009
+ }
912
1010
  }, () => { });
913
1011
  return Promise.resolve(subprocessRunHandle({
914
- // A session-backed run's id is the child session id (the seam's local-run
915
- // contract), so the tool's resume self-description names the same handle
916
- // the delegation registry records.
917
- id: spec.childSession?.id ?? SessionId(randomUUID()),
1012
+ // Host 0.2.1: the published run id is a fresh PER-EXECUTION identity —
1013
+ // the activation manager rejects a run id naming a live Session (the
1014
+ // mirrored child session is exactly that), and resume rounds reuse the
1015
+ // child session id. The durable child identity the family tracks rides
1016
+ // the staged intent instead (spec.childSession.id == its childSessionId).
1017
+ id: SessionId(randomUUID()),
918
1018
  result,
919
1019
  signal: request.signal,
920
1020
  onAbort,
@@ -922,6 +1022,27 @@ export function startCodexCliRun(request, spec) {
922
1022
  teardown: disposeProcess,
923
1023
  }));
924
1024
  }
1025
+ /**
1026
+ * Write the round's request/header when the config differs from the child
1027
+ * log's last one (the host logs changed snapshots, not per-call duplicates):
1028
+ * the first write is `initial`, a differing one `change`, an identical one
1029
+ * skipped. Context Lens reads the child log's config from these headers.
1030
+ */
1031
+ export function appendCodexRequestHeader(childSession, config) {
1032
+ const serialized = JSON.stringify(config);
1033
+ let last;
1034
+ for (const event of childSession.snapshotEvents()) {
1035
+ if (event.type === 'request/header') {
1036
+ last = JSON.stringify(event.data.header?.config);
1037
+ }
1038
+ }
1039
+ if (last === serialized)
1040
+ return;
1041
+ childSession.append('request/header', {
1042
+ header: { config },
1043
+ reason: last === undefined ? 'initial' : 'change',
1044
+ });
1045
+ }
925
1046
  /** One assistant-role message event, attributed to the codex route. */
926
1047
  export function codexAssistantEvent(blocks) {
927
1048
  return createAssistantMessage({
@@ -935,35 +1056,48 @@ export function codexAssistantEvent(blocks) {
935
1056
  * assembler requires: without a boundary the step never enters the location
936
1057
  * index, the line's assistant message resolves to a turn-level location, and
937
1058
  * the real-time view renders nothing (only a full rebuild, which mints step
938
- * drafts from the explicit coordinates, recovers it). Tool cards escape that
939
- * fate only because their definition does not consult the step location.
1059
+ * drafts from the explicit coordinates, recovers it).
1060
+ *
1061
+ * A tool line folds the NATIVE lifecycle inside its one step: an assistant
1062
+ * message advertising the tool-call block, the `tool/call`, then its
1063
+ * `tool/result` — the released-log reader pairs call and result against the
1064
+ * advertisement and resolves every advertised call before the step closes.
1065
+ * Every fold sees a COMPLETED item (the exec live mirror holds the volatile
1066
+ * last line; the live driver folds at item/completed), so a result the
1067
+ * stream has not recorded by fold time will never arrive — the call folds
1068
+ * with the synthesized interrupted result instead of leaving the step
1069
+ * unresolvable.
940
1070
  * @returns whether the line folded (false when the options skipped it).
941
1071
  */
942
1072
  export function appendCodexTranscriptLine(childSession, turn, step, line, usage) {
943
1073
  childSession.append('step/start', { turn, step });
944
1074
  if (line.kind === 'tool') {
945
- // Native tool card: the call event now, the result event when the stream
946
- // already carries it. Tool lines never carry the round's usage — the
947
- // callers attach usage to the round's final line, which is an agent
948
- // message on every well-formed stream.
1075
+ // A tool-only call's usage rides the advertising assistant/message (the
1076
+ // tool/call and tool/result events themselves have no usage slot).
1077
+ const args = line.args ?? '';
1078
+ childSession.append('assistant/message', {
1079
+ turn,
1080
+ step,
1081
+ message: codexAssistantEvent([{ type: 'tool-call', id: line.id, name: line.name, arguments: args }]),
1082
+ stream: [],
1083
+ ...usage === undefined ? {} : { usage },
1084
+ }, { surfaceOp: 'append' });
949
1085
  const call = childSession.append('tool/call', {
950
1086
  turn,
951
1087
  step,
952
1088
  callId: line.id,
953
1089
  name: line.name,
954
- arguments: line.args ?? '',
1090
+ arguments: args,
955
1091
  });
956
- if (line.result !== undefined) {
957
- childSession.append('tool/result', {
958
- turn,
959
- step,
960
- message: createToolResultMessage({
961
- callId: line.id,
962
- content: [{ type: 'text', text: line.result }],
963
- isError: false,
964
- }),
965
- }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
966
- }
1092
+ childSession.append('tool/result', {
1093
+ turn,
1094
+ step,
1095
+ message: createToolResultMessage({
1096
+ callId: line.id,
1097
+ content: [{ type: 'text', text: line.result ?? CODEX_INTERRUPTED_TOOL_RESULT }],
1098
+ isError: line.result === undefined,
1099
+ }),
1100
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
967
1101
  childSession.append('step/end', { turn, step });
968
1102
  return true;
969
1103
  }
@@ -0,0 +1,194 @@
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
+ /**
38
+ * The scoped-home credential file's name. Chosen to match the eval contract
39
+ * hash's credential-shaped denylist (`/token|key|credential|secret|password|auth|\.env/i`),
40
+ * so the file is excluded from condition fingerprints by name.
41
+ */
42
+ export declare const ENDPOINT_AUTH_FILE = "endpoint-auth.json";
43
+ /** The custom provider's TOML key in the `-c` overrides (bare-key safe). */
44
+ export declare const ENDPOINT_PROVIDER_NAME = "dsh-endpoint";
45
+ /** The env var the custom provider reads the key from (`env_key`). */
46
+ export declare const ENDPOINT_ENV_KEY = "OPENAI_API_KEY";
47
+ /**
48
+ * One scope's endpoint credential. `baseUrl` is optional (absent means the
49
+ * CLI's own default OpenAI endpoint — an official platform key needs no
50
+ * relay); a non-empty `apiKey` is what makes the scope endpoint-authenticated.
51
+ * `wireApi` selects the custom provider's wire protocol; absent defaults to
52
+ * `responses` (codex's native wire, relay-verified).
53
+ */
54
+ export interface CodexEndpointAuth {
55
+ readonly baseUrl?: string;
56
+ /** Sent as the custom provider's `env_key` environment value (Bearer). */
57
+ readonly apiKey?: string;
58
+ /** `responses` (default) or `chat`; file-only, never card-edited. */
59
+ readonly wireApi?: string;
60
+ }
61
+ /** Whether a resolved endpoint carries a usable credential. */
62
+ export declare function hasEndpointCredential(auth: CodexEndpointAuth | undefined): auth is CodexEndpointAuth & {
63
+ apiKey: string;
64
+ };
65
+ /**
66
+ * Read the scope's endpoint-auth.json. Absent or unreadable yields undefined
67
+ * (the caller falls back to the plugin config); malformed JSON or a non-object
68
+ * payload also yields undefined — a torn file must not fail a status read.
69
+ * Non-string fields are dropped, never carried through.
70
+ * @param homeDir - the `codex` harness's scoped home.
71
+ * @returns the file's endpoint credential, or undefined.
72
+ */
73
+ export declare function readEndpointAuthFile(homeDir: string): Promise<CodexEndpointAuth | undefined>;
74
+ /**
75
+ * Resolve one scope's effective endpoint credential: the scope's
76
+ * endpoint-auth.json merged FIELD BY FIELD over the plugin config's
77
+ * `baseUrl` / `apiKey` (a file that names only a key rotates the key while
78
+ * keeping the deployment's relay URL). The result may carry no credential at
79
+ * all — {@link hasEndpointCredential} is the judgment, kept separate so the
80
+ * caller can still route/log the resolved baseUrl.
81
+ * @param homeDir - the scope's scoped home.
82
+ * @param config - the plugin config's deployment-level endpoint pair.
83
+ * @returns the merged endpoint auth.
84
+ */
85
+ export declare function resolveEndpointAuth(homeDir: string, config: CodexEndpointAuth): Promise<CodexEndpointAuth>;
86
+ /**
87
+ * The spawn-env layer for a resolved endpoint: the key under the custom
88
+ * provider's `env_key` name, nothing else. A credential-less resolution
89
+ * yields an empty layer — the spread then adds no keys at all, byte-identical
90
+ * to the pre-endpoint env. With no baseUrl the CLI's built-in OpenAI provider
91
+ * reads the same variable, so an official platform key needs no `-c`
92
+ * override either.
93
+ * @param auth - the resolved endpoint auth, or undefined.
94
+ * @returns the explicit env entries for the delegation spawn.
95
+ */
96
+ export declare function endpointAuthEnv(auth: CodexEndpointAuth | undefined): NodeJS.ProcessEnv;
97
+ /**
98
+ * The `-c` config overrides routing the CLI at the resolved relay: a custom
99
+ * `model_providers` entry plus the `model_provider` selection, as a flat argv
100
+ * fragment (`-c <override>` pairs). Empty unless BOTH a key and a baseUrl are
101
+ * present — a key without a relay URL is served by the built-in provider
102
+ * through the env layer alone, and a relay URL without a key would route
103
+ * every round into a 401. Verified end-to-end against codex-cli 0.163.0.
104
+ * @param auth - the resolved endpoint auth, or undefined.
105
+ * @returns the argv fragment, possibly empty.
106
+ */
107
+ export declare function endpointConfigOverrides(auth: CodexEndpointAuth | undefined): string[];
108
+ /**
109
+ * The credential marker's modification stamp (epoch ms): the endpoint file's
110
+ * mtime, or undefined when the scope has no file (a config-sourced credential
111
+ * has no marker — rotating it pairs with the profile edit and instance
112
+ * restart that clear the in-memory grade marks). A fresh file write is a
113
+ * fresh stamp, which is how an edited-in key recovers a `rejected` grade.
114
+ * @param homeDir - the scope's scoped home.
115
+ * @returns the stamp, or undefined.
116
+ */
117
+ export declare function endpointAuthStamp(homeDir: string): Promise<number | undefined>;
118
+ /** Whether the scope carries its own endpoint file at all. */
119
+ export declare function endpointAuthFileExists(homeDir: string): Promise<boolean>;
120
+ /**
121
+ * Write the scope's endpoint-auth.json (mode 0600, mkdir -p first). The
122
+ * payload preserves the file-only `wireApi` across card writes: callers pass
123
+ * the merged object (current file + edits), never a bare card payload.
124
+ * @param homeDir - the scope's scoped home.
125
+ * @param auth - the endpoint credential to persist.
126
+ */
127
+ export declare function writeEndpointAuth(homeDir: string, auth: CodexEndpointAuth): Promise<void>;
128
+ /**
129
+ * Clear the scope's OWN endpoint credential (the endpoint-auth.json file).
130
+ * A deployment-level credential from the plugin config is untouched — the
131
+ * core's logout reply then names the profile row as the place to edit.
132
+ * @param homeDir - the scope's scoped home.
133
+ */
134
+ export declare function clearEndpointAuth(homeDir: string): Promise<void>;
135
+ /** The official endpoint used when no baseUrl is configured anywhere. */
136
+ export declare const OFFICIAL_BASE_URL = "https://api.openai.com";
137
+ /** The probe's fetch shape (injected in tests). */
138
+ export type EndpointCheckFetch = (url: string, init: {
139
+ headers: Record<string, string>;
140
+ signal: AbortSignal;
141
+ }) => Promise<{
142
+ ok: boolean;
143
+ status: number;
144
+ text(): Promise<string>;
145
+ }>;
146
+ /**
147
+ * The outcome of one `GET <baseUrl>/v1/models`: the parsed id list, the HTTP
148
+ * status when the endpoint answered, or the transport error's message. No
149
+ * case throws.
150
+ */
151
+ export type EndpointModelsResult = {
152
+ readonly kind: 'models';
153
+ readonly models: readonly string[];
154
+ } | {
155
+ readonly kind: 'http';
156
+ readonly status: number;
157
+ readonly body: string;
158
+ } | {
159
+ readonly kind: 'unreachable';
160
+ readonly message: string;
161
+ } | {
162
+ readonly kind: 'malformed';
163
+ };
164
+ /**
165
+ * `GET <baseUrl>/v1/models` with `Authorization: Bearer <key>` — the
166
+ * token-free endpoint probe shared by the `/codex check` verdict and any
167
+ * future catalog. OpenAI's own API serves this route with the platform key;
168
+ * relays (openproxy verified 2026-10-20, 139 models) proxy it and answer the
169
+ * same OpenAI-list shape. Never throws: every failure mode folds into the
170
+ * result.
171
+ * @param endpoint - the resolved endpoint auth with a decided baseUrl.
172
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
173
+ * @param signal - abort for the whole probe (timeout bundled by the caller).
174
+ * @returns the folded result.
175
+ */
176
+ export declare function fetchEndpointModels(endpoint: CodexEndpointAuth & {
177
+ readonly baseUrl: string;
178
+ }, fetchImpl?: EndpointCheckFetch, signal?: AbortSignal): Promise<EndpointModelsResult>;
179
+ /**
180
+ * Probe the resolved endpoint with a cheap, token-free round-trip: `GET
181
+ * <baseUrl>/v1/models` with the resolved credential's Bearer header. A 200
182
+ * proves both reachability and credential validity — the exact evidence a
183
+ * "verify" button needs — and the served model list rides the reply. One
184
+ * transport-level retry rides along: a local proxy restart (a Surge/clash
185
+ * reload) stalls the first attempt into the timeout and answers the retry
186
+ * immediately. Never throws: every failure mode folds into the reply text,
187
+ * since the reply IS the diagnostic.
188
+ * @param endpoint - the resolved endpoint auth (scope file over settings).
189
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
190
+ * @param timeoutMs - one attempt's deadline (two attempts at most).
191
+ * @returns the human-readable verdict for the check reply.
192
+ */
193
+ export declare function checkCodexEndpoint(endpoint: CodexEndpointAuth, fetchImpl?: EndpointCheckFetch, timeoutMs?: number): Promise<string>;
194
+ //# sourceMappingURL=endpoint-auth.d.ts.map