@khorsheed/dsh-local-agent-codex 0.2.0-rc.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.en.md +23 -122
- package/README.i18n.yaml +2 -2
- package/README.md +23 -122
- package/lib/client.js +811 -317
- package/lib/index.js +818 -252
- package/lib/types/client/SettingsCard.d.ts +73 -24
- package/lib/types/client/SettingsCard.js +110 -140
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/index.d.ts +6 -26
- package/lib/types/client/index.js +136 -50
- package/lib/types/client/locales.d.ts +34 -24
- package/lib/types/client/locales.js +38 -28
- package/lib/types/codex-cli-provider.d.ts +54 -8
- package/lib/types/codex-cli-provider.js +204 -70
- package/lib/types/endpoint-auth.d.ts +194 -0
- package/lib/types/endpoint-auth.js +300 -0
- package/lib/types/index.d.ts +19 -3
- package/lib/types/index.js +127 -28
- package/lib/types/live-driver.d.ts +9 -5
- package/lib/types/live-driver.js +247 -108
- package/lib/types/live-switch.d.ts +7 -0
- package/lib/types/live-switch.js +1 -0
- package/lib/types/model-broker.d.ts +12 -2
- package/lib/types/model-broker.js +51 -6
- package/lib/types/model-catalog.d.ts +53 -9
- package/lib/types/model-catalog.js +95 -12
- package/lib/types/provision.d.ts +12 -27
- package/lib/types/provision.js +19 -40
- package/lib/types/records.d.ts +6 -11
- package/lib/types/records.js +14 -25
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +20 -21
- package/lib/invariant.js +0 -31
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -33
|
@@ -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
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
264
|
+
version: SUBAGENT_DESCRIPTOR_VERSION,
|
|
265
|
+
mode: 'one-shot',
|
|
266
|
+
provider: this.name,
|
|
237
267
|
label,
|
|
238
268
|
});
|
|
239
|
-
// The
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
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
|
-
|
|
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`,
|
|
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
|
|
899
|
-
//
|
|
900
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
id
|
|
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).
|
|
939
|
-
*
|
|
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
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
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:
|
|
1090
|
+
arguments: args,
|
|
955
1091
|
});
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|