@meistrari/agent-core 0.2.0-experimental.5 → 0.2.0-experimental.7

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/README.md CHANGED
@@ -122,6 +122,13 @@ stale managed instruction file.
122
122
  `resolveInferenceModel`, `pluginsEnabled`, and `browserAuth` (a per-run Browser Use
123
123
  credential socket for ChatGPT authentication; off by default).
124
124
 
125
+ API-key and Bedrock logins stay in the app-server's memory (`cli_auth_credentials_store="ephemeral"`),
126
+ so no `auth.json` under `CODEX_HOME` holds the key for the agent's tools to read. An API-key login
127
+ may name `baseUrl`, the root of another OpenAI Responses API (a gateway, or a loopback proxy that
128
+ holds the real credential and gets a placeholder): Codex then reaches it as a model provider of its
129
+ own, named by `modelProvider` (`api-key-endpoint` by default, never a built-in id), with the key as
130
+ its bearer. `codex-api-key-endpoint.cli.spec.ts` pins that contract against the real CLI.
131
+
125
132
  `AgentRun.notify` and `AgentRun.compactContext` are optional on the interface; the Claude and
126
133
  Codex runs implement both. `sendPrompt` accepts `submissionOrder`, defaulting to `in-order`.
127
134
 
@@ -134,7 +141,8 @@ product instructions/tools, and supervisor integration tests. CI type-checks and
134
141
  with Remy's Bun 1.3.10 / Claude SDK 0.3.284 and agent-api's Claude SDK 0.3.257 pins.
135
142
 
136
143
  `bun run test` runs the portable suites. `bun run test:cli` runs the `*.cli.spec.ts` suites
137
- against the real bundled Claude Code CLI and a fake Messages API. CI runs them on Linux; the
144
+ against the real bundled Claude Code CLI and a fake Messages API, and the Codex endpoint spec
145
+ against the pinned Codex CLI (through `bunx`, or `CODEX_EXECUTABLE`) and a fake Responses API. CI runs them on Linux; the
138
146
  process lifecycle spec reads `/proc`, so it fails on macOS. Claude Code rejects its
139
147
  bypass-permissions mode as root, so run them as a regular user.
140
148
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/agent-core",
3
3
  "type": "module",
4
- "version": "0.2.0-experimental.5",
4
+ "version": "0.2.0-experimental.7",
5
5
  "packageManager": "bun@1.3.12",
6
6
  "description": "Shared contracts and runtime modules for Tela coding-agent sandboxes: agent protocol, supervisor wire protocol, resident supervisor, worker runtime client, and Claude/Codex harness adapters.",
7
7
  "license": "UNLICENSED",
@@ -25,7 +25,7 @@ export class ClaudeCodeProcess {
25
25
  private termination: Promise<void> | undefined
26
26
  private stderr = ''
27
27
 
28
- constructor(private readonly exitTimeoutMs = CLAUDE_PROCESS_EXIT_TIMEOUT_MS) {}
28
+ constructor(private readonly exitTimeoutMs = CLAUDE_PROCESS_EXIT_TIMEOUT_MS, private readonly processLauncher?: string) {}
29
29
 
30
30
  /** The SDK `spawnClaudeCodeProcess` hook. A run spawns at most one process. */
31
31
  readonly spawn = (options: SpawnOptions): SpawnedProcess => {
@@ -33,10 +33,10 @@ export class ClaudeCodeProcess {
33
33
  throw new Error('Claude Code process already spawned for this run.')
34
34
  // `detached` makes the CLI a process-group leader. The forwarded signal aborts only after the
35
35
  // SDK's graceful stdin-EOF window, as for the SDK's own spawn.
36
- const child = spawn(options.command, options.args, { cwd: options.cwd, env: options.env, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true })
36
+ const child = spawn(this.processLauncher ?? options.command, this.processLauncher ? [options.command, ...options.args] : options.args, { cwd: options.cwd, env: options.env, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true })
37
37
  this.child = child
38
38
  this.exit = new Promise((resolve) => {
39
- child.once('exit', () => resolve())
39
+ child.once(this.processLauncher ? 'close' : 'exit', () => resolve())
40
40
  // A failed spawn emits `error` without `exit`; there is no process to wait for.
41
41
  child.once('error', () => {
42
42
  if (child.pid === undefined)
@@ -70,15 +70,21 @@ export class ClaudeCodeProcess {
70
70
  const exit = this.exit
71
71
  if (!child || !exit || child.pid === undefined)
72
72
  return
73
- if (await settlesWithin(exit, this.exitTimeoutMs))
73
+ // A fork/wait launcher can exit while its CLI still holds the stdio pipes.
74
+ // For launchers, await close and clean the group even if the wrapper already exited.
75
+ if (await settlesWithin(exit, this.exitTimeoutMs) && !this.processLauncher)
74
76
  return
75
77
  try {
76
78
  process.kill(-child.pid, 'SIGKILL')
77
79
  }
78
- catch {
80
+ catch (error) {
81
+ if (this.processLauncher && process.platform !== 'win32' && (error as NodeJS.ErrnoException).code !== 'ESRCH')
82
+ throw error
79
83
  child.kill('SIGKILL')
80
84
  }
81
- await settlesWithin(exit, killedExitWaitMs)
85
+ const closed = await settlesWithin(exit, killedExitWaitMs)
86
+ if (this.processLauncher && !closed)
87
+ throw new Error('Claude launcher process group did not close after SIGKILL.')
82
88
  }
83
89
  }
84
90
 
@@ -71,6 +71,14 @@ export interface ClaudeProviderConfig {
71
71
  desktop?: ClaudeDesktopConfig | null
72
72
  tools?: readonly AgentTool[]
73
73
  executable?: string
74
+ /**
75
+ * Trusted launcher invoked as <CLI executable> <CLI args>; owns any privilege drop.
76
+ * Preserve stdio and the process group: exec the CLI or keep children in that group.
77
+ * On Windows, the launcher must exec the CLI in place (group signals are unavailable).
78
+ */
79
+ processLauncher?: string
80
+ /** Private provider stores; defaults to the CLI transcript directory when omitted. */
81
+ runtimeStateRoot?: string
74
82
  environment?: Record<string, string | undefined>
75
83
  nativeToolActorBridge?: ClaudeNativeToolActorBridge
76
84
  startupDeadlineMs?: number
@@ -139,6 +147,8 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
139
147
  private readonly desktop: ClaudeDesktopConfig | null
140
148
  private readonly tools: readonly AgentTool[]
141
149
  private readonly executable: string | undefined
150
+ private readonly processLauncher: string | undefined
151
+ private readonly runtimeStateRoot: string | undefined
142
152
  private readonly environment: Record<string, string | undefined>
143
153
  private readonly nativeToolActorBridge: ClaudeNativeToolActorBridge | undefined
144
154
  private readonly startupDeadlineMs: number
@@ -156,6 +166,8 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
156
166
  this.desktop = config.desktop ?? null
157
167
  this.tools = config.tools ?? []
158
168
  this.executable = config.executable
169
+ this.processLauncher = config.processLauncher
170
+ this.runtimeStateRoot = config.runtimeStateRoot
159
171
  this.environment = config.environment ?? {}
160
172
  this.nativeToolActorBridge = config.nativeToolActorBridge
161
173
  this.startupDeadlineMs = config.startupDeadlineMs ?? CLAUDE_STARTUP_DEADLINE_MS
@@ -202,10 +214,10 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
202
214
  const runtime = createClaudeToolRuntimeRef({ turnId: () => state.currentTurnId })
203
215
  // The transcript location is a pre-input fact; session-local stores live beside it.
204
216
  const transcriptPath = join(configDir, 'projects', encodeClaudeProjectPath(input.cwd), `${expectedSessionId}.jsonl`)
205
- const steeredNotifications = ClaudeSteeredNotificationStore.open({ sessionId: input.sessionId, transcriptPath })
217
+ const steeredNotifications = ClaudeSteeredNotificationStore.open({ sessionId: input.sessionId, transcriptPath, rootDir: this.runtimeStateRoot })
206
218
  const resources = await this.openRunResources({ input, expectedSessionId, signal: abortController.signal })
207
219
  const { nativeToolActorRun, workspaceSkills, selectedRepositoryInstructions, browserAuthBroker, browserTurnMetadata } = resources
208
- const claudeProcess = new ClaudeCodeProcess()
220
+ const claudeProcess = new ClaudeCodeProcess(undefined, this.processLauncher)
209
221
  const declaredSubscriptionType = this.auth ? fetchFreeSubscriptionType(this.auth.knownSubscriptionType()) : undefined
210
222
  const runtimeMcpServers = toClaudeMcpServers({
211
223
  tools,
@@ -302,7 +314,7 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
302
314
  const [userInputRequests, initialization, , sessionContext] = await Promise.all([
303
315
  UserInputRequestStore.open({
304
316
  sessionId: metadata.sessionId,
305
- rootDir: userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
317
+ rootDir: this.runtimeStateRoot ?? userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
306
318
  }),
307
319
  awaitClaudeInitialization({ query, signal: abortController.signal, deadlineMs: this.startupDeadlineMs }),
308
320
  this.auth?.acquire({ signal: abortController.signal }),
@@ -314,8 +326,12 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
314
326
  if (this.auth && declaredSubscriptionType !== undefined && declaredSubscriptionType !== fetchFreeSubscriptionType(this.auth.knownSubscriptionType()) && !params.respawnedForSubscriptionType) {
315
327
  query.close()
316
328
  inputQueue.close()
317
- await claudeProcess.terminate()
318
- await closeRunResources(resources)
329
+ try {
330
+ await claudeProcess.terminate()
331
+ }
332
+ finally {
333
+ await closeRunResources(resources)
334
+ }
319
335
  return await this.openRun({ ...params, respawnedForSubscriptionType: true })
320
336
  }
321
337
 
@@ -372,8 +388,12 @@ export class ClaudeProvider implements AgentProvider<'claude'> {
372
388
  query.close()
373
389
  inputQueue.close()
374
390
  // A failed open (for example a missed initialize deadline) must not leave its CLI behind.
375
- await claudeProcess.terminate()
376
- await closeRunResources(resources)
391
+ try {
392
+ await claudeProcess.terminate()
393
+ }
394
+ finally {
395
+ await closeRunResources(resources)
396
+ }
377
397
  throw error
378
398
  }
379
399
  }
@@ -879,18 +879,31 @@ export class ClaudeRun implements AgentRun {
879
879
  cause: error,
880
880
  }))
881
881
  this.ended = this.endAfterProcessExit(reason, error)
882
+ // Autonomous stream failure may finish without a stop caller; keep rejection observed.
883
+ void this.ended.catch(() => undefined)
882
884
  }
883
885
 
884
- // session.ended means the CLI is gone: the terminal events wait for its exit (bounded by
886
+ // A successful session.ended means the CLI is gone: the terminal events wait for its exit (bounded by
885
887
  // CLAUDE_PROCESS_EXIT_TIMEOUT_MS, then a process-group kill). close() is idempotent and sends
886
888
  // the stdin EOF the CLI exits on.
887
889
  private async endAfterProcessExit(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error: AgentError | undefined): Promise<void> {
888
890
  this.closeQuery()
889
- await this.process.terminate()
890
- this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
891
- this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
892
- this.events.end()
893
- void this.onFinished().catch(() => undefined)
891
+ try {
892
+ await this.process.terminate()
893
+ this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
894
+ this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
895
+ }
896
+ catch (cause) {
897
+ const shutdownError = { ...serializeAgentError(cause), code: 'agents.provider-shutdown-failed' }
898
+ this.events.push({ type: 'error', payload: { message: 'Claude CLI shutdown could not be confirmed.', fatal: true, source: 'runtime', error: shutdownError } })
899
+ this.events.push({ type: 'session.ended', payload: { reason: 'failed', error: shutdownError } })
900
+ this.events.push({ type: 'session.state.changed', payload: { state: 'failed' } })
901
+ throw cause
902
+ }
903
+ finally {
904
+ this.events.end()
905
+ await this.onFinished().catch(() => undefined)
906
+ }
894
907
  }
895
908
 
896
909
  // Reports the request's one result; later calls return the reported one. The caller decides
@@ -48,9 +48,9 @@ export interface SteeredNotificationDelivery {
48
48
  export class ClaudeSteeredNotificationStore {
49
49
  private constructor(private readonly filePath: string, private file: StoreFile) {}
50
50
 
51
- static open(input: { sessionId: string, transcriptPath: string }): ClaudeSteeredNotificationStore {
51
+ static open(input: { sessionId: string, transcriptPath: string, rootDir?: string }): ClaudeSteeredNotificationStore {
52
52
  const filePath = join(
53
- dirname(input.transcriptPath),
53
+ input.rootDir ?? dirname(input.transcriptPath),
54
54
  storeDirectoryName,
55
55
  Buffer.from(input.sessionId, 'utf8').toString('base64url'),
56
56
  storeFileName,
@@ -44,9 +44,23 @@ export interface CodexBedrockAuthProvider {
44
44
  getCredentials: (input?: CodexAuthTokenRequest) => Promise<CodexBedrockCredentials>
45
45
  }
46
46
 
47
+ /**
48
+ * An API key login, kept in the app-server's memory only. Without `baseUrl` it reaches the API of the model
49
+ * provider `modelProvider` names (OpenAI's by default). With `baseUrl`, the root of another OpenAI Responses
50
+ * API (`https://gateway.example/v1`, or a loopback proxy that holds the real credential), Codex reaches it as
51
+ * a model provider of its own, named by `modelProvider` (`api-key-endpoint` by default) and given the key as
52
+ * its bearer.
53
+ */
54
+ export interface CodexApiKeyAuthentication {
55
+ kind: 'api-key'
56
+ provider: CodexApiKeyAuthProvider
57
+ modelProvider?: string
58
+ baseUrl?: string
59
+ }
60
+
47
61
  export type CodexAuthentication
48
62
  = | { kind: 'chatgpt', provider: CodexAuthProvider }
49
- | { kind: 'api-key', provider: CodexApiKeyAuthProvider, modelProvider?: string }
63
+ | CodexApiKeyAuthentication
50
64
  | { kind: 'amazon-bedrock', region: string, provider: CodexBedrockAuthProvider }
51
65
  /** Expiry scheduling hint only; this neither authenticates nor verifies a JWT signature. */
52
66
  export function codexAuthTokensExpireSoon(tokens: CodexAuthTokens): boolean {
@@ -59,7 +59,9 @@ export class CodexJsonRpcClient {
59
59
  readonly child: ChildProcessWithoutNullStreams
60
60
  private nextId = 0
61
61
  private stopped = false
62
+ private shutdown: Promise<void> | undefined
62
63
  private readonly closed: Promise<void>
64
+ private readonly processGroup: boolean
63
65
  private readonly pending = new Map<string, {
64
66
  method: ClientRequestMethod
65
67
  resolve: (value: unknown) => void
@@ -70,6 +72,7 @@ export class CodexJsonRpcClient {
70
72
  executable: string,
71
73
  options: {
72
74
  cwd: string
75
+ processLauncher?: string
73
76
  environment?: Record<string, string | undefined>
74
77
  configOverrides?: readonly string[]
75
78
  onMessage: (message: CodexJsonRpcMessage) => void
@@ -78,10 +81,13 @@ export class CodexJsonRpcClient {
78
81
  maxLineBytes?: number
79
82
  },
80
83
  ) {
81
- this.child = spawn(executable, ['app-server', '--listen', 'stdio://', ...(options.configOverrides ?? []).flatMap(value => ['-c', value])], {
84
+ this.processGroup = options.processLauncher !== undefined && process.platform !== 'win32'
85
+ const args = ['app-server', '--listen', 'stdio://', ...(options.configOverrides ?? []).flatMap(value => ['-c', value])]
86
+ this.child = spawn(options.processLauncher ?? executable, options.processLauncher ? [executable, ...args] : args, {
82
87
  cwd: options.cwd,
83
88
  env: options.environment,
84
89
  stdio: 'pipe',
90
+ detached: this.processGroup,
85
91
  })
86
92
  this.closed = new Promise(resolve => this.child.once('close', () => resolve()))
87
93
 
@@ -229,9 +235,16 @@ export class CodexJsonRpcClient {
229
235
  }
230
236
 
231
237
  async closeInputAndStop(): Promise<void> {
238
+ this.shutdown ??= this.drainAndStop()
239
+ await this.shutdown
240
+ }
241
+
242
+ private async drainAndStop(): Promise<void> {
232
243
  if (this.stopped) {
233
244
  if (!await waitForClosed(this.closed, PROCESS_SHUTDOWN_GRACE_MS))
234
- await terminateChild(this.child, this.closed)
245
+ await terminateChild(this.child, this.closed, this.processGroup)
246
+ if (this.processGroup)
247
+ signalProcessGroup(this.child, 'SIGKILL')
235
248
  return
236
249
  }
237
250
  this.stopped = true
@@ -242,9 +255,10 @@ export class CodexJsonRpcClient {
242
255
  // EOF/drain and process exit share one grace window. Never wait separately for a
243
256
  // buffered stdin write: a child that stops reading must still reach signal escalation.
244
257
  this.child.stdin.end()
245
- if (await waitForClosed(this.closed, PROCESS_SHUTDOWN_GRACE_MS))
246
- return
247
- await terminateChild(this.child, this.closed)
258
+ if (!await waitForClosed(this.closed, PROCESS_SHUTDOWN_GRACE_MS))
259
+ await terminateChild(this.child, this.closed, this.processGroup)
260
+ if (this.processGroup)
261
+ signalProcessGroup(this.child, 'SIGKILL')
248
262
  }
249
263
 
250
264
  async stop(): Promise<void> {
@@ -288,7 +302,17 @@ export class CodexJsonRpcClient {
288
302
  }
289
303
  }
290
304
 
291
- async function terminateChild(child: ChildProcessWithoutNullStreams, closed: Promise<void>): Promise<void> {
305
+ async function terminateChild(child: ChildProcessWithoutNullStreams, closed: Promise<void>, processGroup: boolean): Promise<void> {
306
+ if (processGroup) {
307
+ // The launcher may already be gone while the CLI retains its pipes. Signal the
308
+ // retained group regardless of the wrapper's exitCode, and bound the close wait.
309
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGKILL'] as const) {
310
+ signalProcessGroup(child, signal)
311
+ if (await waitForClosed(closed, signal === 'SIGKILL' ? 1_000 : PROCESS_SHUTDOWN_GRACE_MS))
312
+ return
313
+ }
314
+ throw new Error('Codex launcher process group did not close after SIGKILL.')
315
+ }
292
316
  if (child.exitCode !== null || child.signalCode !== null) {
293
317
  await closed
294
318
  return
@@ -308,6 +332,18 @@ async function terminateChild(child: ChildProcessWithoutNullStreams, closed: Pro
308
332
  await closed
309
333
  }
310
334
 
335
+ function signalProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
336
+ if (child.pid === undefined)
337
+ return
338
+ try {
339
+ process.kill(-child.pid, signal)
340
+ }
341
+ catch (error) {
342
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH')
343
+ throw error
344
+ }
345
+ }
346
+
311
347
  async function waitForClosed(closed: Promise<void>, timeoutMs: number): Promise<boolean> {
312
348
  return await new Promise<boolean>((resolve) => {
313
349
  const timer = setTimeout(() => resolve(false), timeoutMs)
@@ -5,7 +5,7 @@ import type { AgentRun } from '../agent-run'
5
5
  import type { AgentTool } from '../agent-tool'
6
6
  import type { InstructionComposer } from '../instructions'
7
7
  import type { SessionContextComposer } from '../session-context'
8
- import type { CodexAuthentication, CodexAuthProvider, CodexAuthTokens, CodexBedrockCredentials } from './codex-auth'
8
+ import type { CodexApiKeyAuthentication, CodexAuthentication, CodexAuthProvider, CodexAuthTokens, CodexBedrockCredentials } from './codex-auth'
9
9
  import type { CodexEventMapperState, CodexNotification } from './codex-event-mapper'
10
10
  import type { CodexJsonRpcMessage, CodexServerRequest } from './codex-json-rpc-client'
11
11
  import type { ClientRequestResponsesByMethod } from './codex-protocol'
@@ -13,7 +13,7 @@ import { realpath } from 'node:fs/promises'
13
13
  import { resolve } from 'node:path'
14
14
  import { codexModelIdSchema } from '../../protocol'
15
15
  import { serializeAgentError } from '../agent-error-serializer'
16
- import { AgentProviderMetadataError, AgentProviderProtocolError, AgentProviderRuntimeError } from '../agent-runtime-error'
16
+ import { AgentProviderMetadataError, AgentProviderProtocolError, AgentProviderRuntimeError, AgentRuntimeValidationError } from '../agent-runtime-error'
17
17
  import { sessionConfiguredEvent } from '../agent-session-events'
18
18
  import { selectAgentTools, toolByName } from '../agent-tool'
19
19
  import { emptyInstructionComposer } from '../instructions'
@@ -33,6 +33,15 @@ import { CodexRunAuth, CodexRunAuthClosedError } from './codex-run-auth'
33
33
  import { catalogRepositorySkillRoots, workspaceSkillRoots } from './codex-skill-roots'
34
34
  import { runCodexDynamicTool, toCodexDynamicTools } from './codex-tool-mapper'
35
35
 
36
+ /** The model provider an API key endpoint gets when `modelProvider` names none. */
37
+ const API_KEY_ENDPOINT_PROVIDER = 'api-key-endpoint'
38
+ /** Codex's own provider ids: an endpoint defined under one of these would not be the one Codex uses. */
39
+ const BUILT_IN_MODEL_PROVIDERS = new Set(['openai', 'amazon-bedrock', 'ollama', 'lmstudio', 'oss'])
40
+ /** A bare TOML key, so the id is safe inside the dotted `-c model_providers.<id>.…` overrides. */
41
+ const MODEL_PROVIDER_ID = /^[\w-]+$/
42
+ /** Where plain http keeps the key on the machine. */
43
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]'])
44
+
36
45
  export interface CodexStartupStageFact {
37
46
  event: 'sandbox.startup-stage'
38
47
  stage: 'codex-initialize' | 'auth-acquisition-login' | 'thread-start' | 'thread-resume'
@@ -45,6 +54,14 @@ export interface CodexProviderConfig {
45
54
  auth: CodexAuthProvider | CodexAuthentication
46
55
  tools?: readonly AgentTool[]
47
56
  executable?: string
57
+ /**
58
+ * Trusted launcher invoked as <CLI executable> <CLI args>; owns any privilege drop.
59
+ * Preserve stdio and the process group: exec the CLI or keep children in that group.
60
+ * On Windows, the launcher must exec the CLI in place (group signals are unavailable).
61
+ */
62
+ processLauncher?: string
63
+ /** Private provider stores; defaults to the CLI transcript directory when omitted. */
64
+ runtimeStateRoot?: string
48
65
  environment?: Record<string, string | undefined>
49
66
  onStartupStage?: (fact: CodexStartupStageFact) => void
50
67
  instructions?: InstructionComposer
@@ -102,7 +119,11 @@ export class CodexProvider implements AgentProvider<'codex'> {
102
119
  private readonly tools: readonly AgentTool[]
103
120
  private readonly executable: string
104
121
  private readonly environment: Record<string, string | undefined>
122
+ private readonly processLauncher: string | undefined
123
+ private readonly runtimeStateRoot: string | undefined
105
124
  private readonly auth: CodexAuthentication
125
+ /** The validated endpoint of an API key login that names one. */
126
+ private readonly apiKeyEndpoint: string | undefined
106
127
  private readonly onStartupStage: ((fact: CodexStartupStageFact) => void) | undefined
107
128
  private readonly instructions: InstructionComposer
108
129
  private readonly sessionContext: SessionContextComposer | undefined
@@ -115,9 +136,12 @@ export class CodexProvider implements AgentProvider<'codex'> {
115
136
 
116
137
  private constructor(config: CodexProviderConfig) {
117
138
  this.auth = normalizeCodexAuthentication(config.auth)
139
+ this.apiKeyEndpoint = this.auth.kind === 'api-key' ? apiKeyEndpoint(this.auth) : undefined
118
140
  this.tools = config.tools ?? []
119
141
  this.executable = config.executable ?? 'codex'
120
142
  this.environment = config.environment ?? {}
143
+ this.processLauncher = config.processLauncher
144
+ this.runtimeStateRoot = config.runtimeStateRoot
121
145
  this.onStartupStage = config.onStartupStage
122
146
  this.instructions = config.instructions ?? emptyInstructionComposer
123
147
  this.sessionContext = config.sessionContext
@@ -422,7 +446,11 @@ export class CodexProvider implements AgentProvider<'codex'> {
422
446
  }
423
447
 
424
448
  private modelProvider(): string | undefined {
425
- return this.auth.kind === 'amazon-bedrock' ? 'amazon-bedrock' : this.auth.kind === 'api-key' ? this.auth.modelProvider : undefined
449
+ if (this.auth.kind === 'amazon-bedrock')
450
+ return 'amazon-bedrock'
451
+ if (this.auth.kind === 'api-key')
452
+ return this.auth.modelProvider ?? (this.apiKeyEndpoint === undefined ? undefined : API_KEY_ENDPOINT_PROVIDER)
453
+ return undefined
426
454
  }
427
455
 
428
456
  private async openConnection(input: {
@@ -442,6 +470,7 @@ export class CodexProvider implements AgentProvider<'codex'> {
442
470
 
443
471
  try {
444
472
  client = new CodexJsonRpcClient(this.executable, {
473
+ processLauncher: this.processLauncher,
445
474
  cwd,
446
475
  environment: holder.browserAuthBroker
447
476
  ? { ...this.environment, CODING_AGENT_BROWSER_AUTH_SOCKET: holder.browserAuthBroker.socketPath }
@@ -450,6 +479,7 @@ export class CodexProvider implements AgentProvider<'codex'> {
450
479
  ...(this.auth.kind === 'amazon-bedrock'
451
480
  ? ['cli_auth_credentials_store="ephemeral"', 'model_provider="amazon-bedrock"', `model_providers.amazon-bedrock.aws.region=${JSON.stringify(this.auth.region)}`, 'model_providers.amazon-bedrock.aws.wire_api="responses"']
452
481
  : []),
482
+ ...(this.auth.kind === 'api-key' ? apiKeyConfigOverrides(this.auth.modelProvider, this.apiKeyEndpoint) : []),
453
483
  ...(this.pluginsEnabled === undefined ? [] : [`features.plugins=${this.pluginsEnabled}`]),
454
484
  ],
455
485
  onMessage: (message) => {
@@ -620,7 +650,7 @@ export class CodexProvider implements AgentProvider<'codex'> {
620
650
  }),
621
651
  UserInputRequestStore.open({
622
652
  sessionId: metadata.sessionId,
623
- rootDir: userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
653
+ rootDir: this.runtimeStateRoot ?? userInputRequestStoreRootFromTranscriptPath(metadata.transcriptPath),
624
654
  }),
625
655
  pendingSessionContext({
626
656
  compose: this.sessionContext,
@@ -895,6 +925,62 @@ function normalizeCodexAuthentication(auth: CodexAuthProvider | CodexAuthenticat
895
925
  return 'kind' in auth ? auth : { kind: 'chatgpt', provider: auth }
896
926
  }
897
927
 
928
+ /**
929
+ * The endpoint as Codex is given it, or none: an http(s) URL with no credentials, query or fragment (not even
930
+ * an empty `?` or `#`), plain http only on loopback, where the key never crosses the network. Without a
931
+ * trailing slash, so Codex appends `/responses` to the path it names.
932
+ */
933
+ function apiKeyEndpoint(auth: CodexApiKeyAuthentication): string | undefined {
934
+ const invalid = (message: string) => new AgentRuntimeValidationError({
935
+ code: 'agents.codex-api-key-endpoint-invalid',
936
+ message,
937
+ publicMessage: 'The Codex API key endpoint is misconfigured.',
938
+ details: { modelProvider: auth.modelProvider },
939
+ })
940
+ // Without an endpoint the provider id only selects a provider configured elsewhere, as it always has.
941
+ if (auth.baseUrl === undefined)
942
+ return undefined
943
+ if (auth.modelProvider !== undefined && !MODEL_PROVIDER_ID.test(auth.modelProvider))
944
+ throw invalid('Codex model provider ids are letters, digits, underscores and hyphens.')
945
+ let url: URL
946
+ try {
947
+ url = new URL(auth.baseUrl)
948
+ }
949
+ catch {
950
+ throw invalid('The Codex API key endpoint is not a URL.')
951
+ }
952
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
953
+ throw invalid('The Codex API key endpoint must be an http or https URL.')
954
+ if (url.protocol === 'http:' && !LOOPBACK_HOSTS.has(url.hostname))
955
+ throw invalid('The Codex API key endpoint sends the key as its bearer: plain http is accepted on loopback only.')
956
+ if (url.username || url.password || /[?#]/u.test(auth.baseUrl))
957
+ throw invalid('The Codex API key endpoint carries no credentials, query or fragment; the key travels as the bearer.')
958
+ if (BUILT_IN_MODEL_PROVIDERS.has(auth.modelProvider ?? API_KEY_ENDPOINT_PROVIDER))
959
+ throw invalid('An API key endpoint needs a model provider id of its own, not one of Codex\'s built-in providers.')
960
+ return `${url.origin}${url.pathname.replace(/\/+$/u, '')}`
961
+ }
962
+
963
+ /**
964
+ * An API key login is always kept in memory, never written to `auth.json` under CODEX_HOME, where the agent's
965
+ * own tools could read it. With an endpoint, Codex reaches it through a Responses provider of its own that
966
+ * authenticates with that login.
967
+ */
968
+ function apiKeyConfigOverrides(modelProvider: string | undefined, endpoint: string | undefined): string[] {
969
+ const ephemeral = ['cli_auth_credentials_store="ephemeral"']
970
+ if (endpoint === undefined)
971
+ return ephemeral
972
+ const id = modelProvider ?? API_KEY_ENDPOINT_PROVIDER
973
+ const provider = `model_providers.${id}`
974
+ return [
975
+ ...ephemeral,
976
+ `model_provider=${JSON.stringify(id)}`,
977
+ `${provider}.name=${JSON.stringify(id)}`,
978
+ `${provider}.base_url=${JSON.stringify(endpoint)}`,
979
+ `${provider}.wire_api="responses"`,
980
+ `${provider}.requires_openai_auth=true`,
981
+ ]
982
+ }
983
+
898
984
  async function requireSelectedRepositoryInstructionSource(input: {
899
985
  instructionBridgePath: string
900
986
  instructionSources?: readonly string[]
@@ -21,6 +21,7 @@ import type { CodexEventMapperState } from './codex-event-mapper'
21
21
  import type { CodexJsonRpcClient } from './codex-json-rpc-client'
22
22
  import type { ClientRequestResponsesByMethod, CodexUserInput } from './codex-protocol'
23
23
  import { serializeAgentNotificationToolOutput } from '../../protocol'
24
+ import { serializeAgentError } from '../agent-error-serializer'
24
25
  import { AgentEventStream } from '../agent-event-stream'
25
26
  import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentPromptDeferredError, AgentRunStateError } from '../agent-runtime-error'
26
27
  import { sessionSettingsChangedEvent } from '../agent-session-events'
@@ -146,7 +147,7 @@ export class CodexRun implements AgentRun {
146
147
  sessionId: this.metadata.sessionId,
147
148
  providerSessionId: this.metadata.providerSessionId,
148
149
  reason: 'aborted',
149
- }))
150
+ }).catch(() => undefined))
150
151
  }
151
152
 
152
153
  pushEvent(draft: AgentEventDraft): void {
@@ -639,8 +640,20 @@ export class CodexRun implements AgentRun {
639
640
  const revocation = new Promise<void>((resolve) => {
640
641
  resolve(this.onStop())
641
642
  })
642
- void Promise.all([this.client.stop(), revocation])
643
- .then(finalize)
643
+ void Promise.allSettled([this.client.stop(), revocation])
644
+ .then((outcomes) => {
645
+ const failure = outcomes.find(outcome => outcome.status === 'rejected')
646
+ if (!failure) {
647
+ finalize()
648
+ return
649
+ }
650
+ const error = { ...serializeAgentError(failure.reason), code: 'agents.provider-shutdown-failed' }
651
+ this.events.push({ type: 'error', payload: { message: 'Codex run shutdown could not be confirmed.', fatal: true, source: 'runtime', error } })
652
+ this.events.push({ type: 'session.ended', payload: { reason: 'failed', error } })
653
+ this.events.push({ type: 'session.state.changed', payload: { state: 'failed' } })
654
+ this.events.end()
655
+ throw failure.reason
656
+ })
644
657
  .then(stopping.resolve, stopping.reject)
645
658
  }
646
659
 
@@ -1,5 +1,5 @@
1
- import type { Buffer } from 'node:buffer'
2
1
  import type { MaterializedAgentInputAttachment } from './materialized-input-attachment'
2
+ import { Buffer } from 'node:buffer'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { AgentInputAttachmentPreparationFailure } from './input-attachment.errors'
5
5
 
@@ -8,7 +8,9 @@ type NativeImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/web
8
8
  export async function readAttachmentBytes(attachment: MaterializedAgentInputAttachment, signal: AbortSignal): Promise<Buffer> {
9
9
  signal.throwIfAborted()
10
10
  try {
11
- const bytes = await readFile(attachment.localPath, { signal })
11
+ const bytes = attachment.readBytes
12
+ ? Buffer.from(await attachment.readBytes(signal))
13
+ : await readFile(attachment.localPath, { signal })
12
14
  signal.throwIfAborted()
13
15
  return bytes
14
16
  }
@@ -6,6 +6,8 @@ export type MaterializedAgentInputAttachment = Readonly<{
6
6
  byteSize: number
7
7
  sha256: string
8
8
  localPath: string
9
+ /** Read as the file owner when the provider uses a different process identity. */
10
+ readBytes?: (signal: AbortSignal) => Promise<Uint8Array>
9
11
  }>
10
12
 
11
13
  export type AgentInputAttachmentProjection = Readonly<{
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/write-provenance.ts. Do not edit by hand.
2
2
  export const generatedProvenance = {
3
- version: "0.2.0-experimental.5",
4
- sha: "eea347fec63bc912885e3201e6463cfcfead865b",
5
- buildTime: "2026-09-29T19:55:41.637Z",
3
+ version: "0.2.0-experimental.7",
4
+ sha: "c27666274441cd3c81ffc5934ca115dffbe234ee",
5
+ buildTime: "2026-10-14T20:20:44.640Z",
6
6
  } as const