@velum-labs/routekit-tool-codex 0.18.6 → 1.0.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/dist/driver.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { z } from "zod";
2
1
  import type { HarnessDriver } from "@velum-labs/routekit-harness-core";
2
+ import { z } from "zod";
3
3
  export declare const codexDriverConfigSchema: z.ZodObject<{
4
4
  command: z.ZodDefault<z.ZodString>;
5
5
  model: z.ZodOptional<z.ZodString>;
package/dist/driver.js CHANGED
@@ -1,33 +1,11 @@
1
1
  import { rmSync } from "node:fs";
2
- import { z } from "zod";
3
2
  import { Codex } from "@openai/codex-sdk";
4
- import { HarnessError, asHarnessError, buildChildEnv, createCachedHarnessDriver, probeCliVersion, resolveDriverEnv } from "@velum-labs/routekit-harness-core";
5
- import { registerCleanup } from "@velum-labs/routekit-runtime";
3
+ import { asHarnessError, buildChildEnv, createCachedHarnessDriver, HarnessError, nowIso, probeCliVersion, resolveDriverEnv, resumeStringField, SessionResourceRegistry, SingleFlightTurnController } from "@velum-labs/routekit-harness-core";
4
+ import { ResourceScope } from "@velum-labs/routekit-runtime/lifecycle";
5
+ import { z } from "zod";
6
6
  import { createIsolatedCodexHome } from "./launch.js";
7
7
  const RESUME_CURSOR_VERSION = 1;
8
8
  const DEFAULT_COMMAND = "codex";
9
- /**
10
- * Gateway-routed sessions run in an isolated `CODEX_HOME`: the user's own
11
- * `~/.codex/config.toml` (model, reasoning effort, MCP servers, profiles) must
12
- * not leak into requests routed through the gateway, and codex must not
13
- * overwrite the user's real models cache with gateway catalog entries. The
14
- * home is shared per process (not per instance) because codex thread rollouts
15
- * live inside it and resume cursors must survive across panel turns, each of
16
- * which builds a fresh driver instance.
17
- */
18
- let sharedIsolatedHome;
19
- function isolatedCodexHome(env) {
20
- if (sharedIsolatedHome === undefined) {
21
- const home = createIsolatedCodexHome("routekit-codex-driver-", env);
22
- sharedIsolatedHome = home;
23
- registerCleanup(() => {
24
- rmSync(home, { recursive: true, force: true });
25
- if (sharedIsolatedHome === home)
26
- sharedIsolatedHome = undefined;
27
- });
28
- }
29
- return sharedIsolatedHome;
30
- }
31
9
  const providerSchema = z.object({
32
10
  /** OpenAI-compatible base URL the codex model calls go to (e.g. the gateway). */
33
11
  baseUrl: z.string().optional(),
@@ -42,16 +20,11 @@ export const codexDriverConfigSchema = z.object({
42
20
  sandboxMode: z
43
21
  .enum(["read-only", "workspace-write", "danger-full-access"])
44
22
  .default("workspace-write"),
45
- approvalPolicy: z
46
- .enum(["never", "on-request", "on-failure", "untrusted"])
47
- .default("never"),
23
+ approvalPolicy: z.enum(["never", "on-request", "on-failure", "untrusted"]).default("never"),
48
24
  provider: providerSchema.default({}),
49
25
  /** Extra credential env var names forwarded into the codex child. */
50
26
  credentialEnvNames: z.array(z.string()).default([])
51
27
  });
52
- function nowIso() {
53
- return new Date().toISOString();
54
- }
55
28
  function itemTypeFor(item) {
56
29
  switch (item.type) {
57
30
  case "agent_message":
@@ -133,6 +106,8 @@ class CodexSession {
133
106
  #thread;
134
107
  #kind = "codex";
135
108
  #reasoning;
109
+ #turns = new SingleFlightTurnController();
110
+ #stopped = false;
136
111
  constructor(thread, resumedThreadId, reasoning) {
137
112
  this.#thread = thread;
138
113
  // Codex assigns the real thread id on the first turn; until then we track
@@ -144,19 +119,25 @@ class CodexSession {
144
119
  return this.#sessionId;
145
120
  }
146
121
  async *sendTurn(input) {
122
+ if (this.#stopped)
123
+ throw new HarnessError("session_closed", "codex session is stopped");
147
124
  if (input.reasoning !== undefined &&
148
125
  JSON.stringify(input.reasoning) !== JSON.stringify(this.#reasoning)) {
149
126
  throw new HarnessError("invalid_config", "Codex SDK reasoning must be selected before the session starts");
150
127
  }
128
+ const turn = this.#turns.start(input.signal);
151
129
  const base = { kind: this.#kind, sessionId: this.#sessionId, at: nowIso() };
152
130
  let turnId;
153
131
  let streamed;
154
132
  try {
155
- streamed = await this.#thread.runStreamed(input.prompt, {
156
- ...(input.signal !== undefined ? { signal: input.signal } : {})
157
- });
133
+ streamed = await this.#thread.runStreamed(input.prompt, { signal: turn.signal });
158
134
  }
159
135
  catch (error) {
136
+ turn.dispose();
137
+ if (turn.signal.aborted) {
138
+ yield { ...base, type: "turn.completed", endReason: "aborted" };
139
+ return;
140
+ }
160
141
  throw asHarnessError(error);
161
142
  }
162
143
  try {
@@ -168,7 +149,7 @@ class CodexSession {
168
149
  }
169
150
  }
170
151
  catch (error) {
171
- if (input.signal?.aborted === true) {
152
+ if (turn.signal.aborted) {
172
153
  yield {
173
154
  ...base,
174
155
  type: "turn.completed",
@@ -186,6 +167,14 @@ class CodexSession {
186
167
  message: harnessError.message
187
168
  };
188
169
  }
170
+ finally {
171
+ // Returning this outer iterator also returns the SDK's event generator;
172
+ // its finally block kills the child. Avoid aborting after the SDK has
173
+ // removed the child's error listener (which would surface AbortError as
174
+ // an uncaught process error).
175
+ turn.complete();
176
+ turn.dispose();
177
+ }
189
178
  }
190
179
  *#mapEvent(event, turnId) {
191
180
  const raw = { source: "codex.exec.json", method: event.type };
@@ -326,60 +315,78 @@ class CodexSession {
326
315
  throw new HarnessError("protocol_parse", "codex exec does not surface interactive approval requests");
327
316
  }
328
317
  async interrupt() {
329
- // The turn is interrupted by aborting the signal passed to sendTurn; the
330
- // codex-sdk kills the child on abort. Nothing extra to do here.
318
+ this.#turns.interrupt();
331
319
  }
332
320
  resumeCursor() {
333
321
  if (this.#sessionId === "codex:pending")
334
322
  return undefined;
335
- return { version: RESUME_CURSOR_VERSION, kind: this.#kind, data: { threadId: this.#sessionId } };
323
+ return {
324
+ version: RESUME_CURSOR_VERSION,
325
+ kind: this.#kind,
326
+ data: { threadId: this.#sessionId }
327
+ };
336
328
  }
337
329
  async stop() {
338
- // A completed/aborted turn already released the child; there is no
339
- // long-lived process to stop between turns for codex exec.
330
+ if (this.#stopped)
331
+ return;
332
+ this.#stopped = true;
333
+ this.#turns.interrupt(new Error("codex session stopped"));
340
334
  }
341
335
  }
342
336
  function resumeThreadId(resume) {
343
- if (resume === undefined || resume.kind !== "codex")
344
- return undefined;
345
- const data = resume.data;
346
- return typeof data.threadId === "string" ? data.threadId : undefined;
337
+ return resumeStringField(resume, "codex", "threadId");
347
338
  }
348
339
  class CodexInstance {
349
340
  kind = "codex";
350
341
  #config;
351
342
  #context;
352
343
  #status;
344
+ #sessions = new SessionResourceRegistry();
345
+ #resources = new ResourceScope();
346
+ #isolatedHome;
353
347
  constructor(config, context, status) {
354
348
  this.#config = config;
355
349
  this.#context = context;
356
350
  this.#status = status;
351
+ this.#isolatedHome = this.#createOwnedHome();
352
+ // Registered after the home so sessions stop before their rollout files
353
+ // are removed during LIFO disposal.
354
+ this.#resources.own(this.#sessions, {
355
+ finalize: async (sessions) => await sessions.dispose()
356
+ });
357
357
  }
358
358
  status() {
359
359
  return this.#status;
360
360
  }
361
- /** An explicit `CODEX_HOME` in the driver env wins over the isolation. */
362
- #homeFor() {
361
+ /**
362
+ * Gateway-routed sessions use an instance-owned `CODEX_HOME`, preventing the
363
+ * user's config and model cache from leaking into the routed session. An
364
+ * explicitly supplied home remains borrowed and is never removed here.
365
+ */
366
+ #createOwnedHome() {
363
367
  if (this.#config.provider.baseUrl === undefined)
364
368
  return undefined;
365
369
  const env = resolveDriverEnv(this.#context);
366
370
  if (env.CODEX_HOME !== undefined)
367
371
  return undefined;
368
- return isolatedCodexHome(env);
372
+ const home = createIsolatedCodexHome("routekit-codex-driver-", env);
373
+ this.#resources.defer(() => rmSync(home, { recursive: true, force: true }));
374
+ return home;
369
375
  }
370
376
  async startSession(options) {
377
+ this.#sessions.assertOpen();
371
378
  if (options.reasoning !== undefined &&
372
379
  options.reasoning.mode !== "auto" &&
373
380
  options.reasoning.mode !== "effort") {
374
381
  throw new HarnessError("invalid_config", `Codex SDK cannot represent reasoning mode "${options.reasoning.mode}"`);
375
382
  }
376
- const codex = new Codex(codexOptionsFor(this.#config, this.#context, this.#homeFor()));
383
+ const codex = new Codex(codexOptionsFor(this.#config, this.#context, this.#isolatedHome));
377
384
  const threadOptions = {
378
385
  sandboxMode: this.#config.sandboxMode,
379
386
  approvalPolicy: this.#config.approvalPolicy,
380
387
  workingDirectory: options.cwd,
381
388
  skipGitRepoCheck: true,
382
- ...(options.model ?? this.#config.model !== undefined
389
+ ...((options.model ?? this.#config.model !== undefined)
383
390
  ? { model: options.model ?? this.#config.model }
384
391
  : {}),
385
392
  ...(options.reasoning?.mode === "effort"
@@ -392,11 +399,10 @@ class CodexInstance {
392
399
  const thread = resumedId !== undefined
393
400
  ? codex.resumeThread(resumedId, threadOptions)
394
401
  : codex.startThread(threadOptions);
395
- return new CodexSession(thread, resumedId, options.reasoning);
402
+ return this.#sessions.manage(new CodexSession(thread, resumedId, options.reasoning));
396
403
  }
397
404
  async dispose() {
398
- // Sessions own their (short-lived) child processes; the shared isolated
399
- // home outlives the instance so resumable thread rollouts stay available.
405
+ await this.#resources.dispose();
400
406
  }
401
407
  }
402
408
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
1
+ import { gatewayOpenAiBaseUrl } from "@velum-labs/routekit-runtime/network";
2
2
  import { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
3
3
  import { codexLaunchConfigToml, launchCodex } from "./launch.js";
4
4
  const driver = createCodexDriver();
@@ -23,7 +23,7 @@ export const codexTool = {
23
23
  sandboxMode: "danger-full-access",
24
24
  approvalPolicy: "never",
25
25
  provider: {
26
- baseUrl: `${trimTrailingSlashes(route.gatewayUrl)}/v1`,
26
+ baseUrl: gatewayOpenAiBaseUrl(route.gatewayUrl),
27
27
  ...(route.authToken !== undefined ? { apiKey: route.authToken } : {})
28
28
  }
29
29
  })
package/dist/install.d.ts CHANGED
@@ -1,11 +1,6 @@
1
1
  import type { ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
2
2
  export type CodexInstallProfile = {
3
3
  modelId: string;
4
- /**
5
- * Legacy selector for the one persistent profile. New callers should use
6
- * `CodexInstallInput.profileId`; when both are absent it is `routekit`.
7
- */
8
- profileId?: string;
9
4
  description?: string;
10
5
  reasoning?: ModelReasoningCapabilities;
11
6
  };
@@ -19,12 +14,8 @@ export type CodexInstallOwner = {
19
14
  };
20
15
  export type CodexInstallInput = {
21
16
  gatewayUrl: string;
22
- /**
23
- * The RouteKit models made available through Codex's model picker. This
24
- * remains named `profiles` for API compatibility, although persistent
25
- * installs now write one RouteKit profile rather than one file per model.
26
- */
27
- profiles: readonly CodexInstallProfile[];
17
+ /** RouteKit models made available through Codex's model picker. */
18
+ models: readonly CodexInstallProfile[];
28
19
  /** Model selected when the single RouteKit profile is first opened. */
29
20
  defaultModel?: string;
30
21
  /** Safe selector for the one persistent RouteKit profile. */
package/dist/install.js CHANGED
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { parse as tomlParse, stringify as tomlStringify } from "smol-toml";
5
5
  import { SUBSCRIPTIONS } from "@velum-labs/routekit-registry";
6
- import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
6
+ import { gatewayOpenAiBaseUrl } from "@velum-labs/routekit-runtime/network";
7
7
  import { codexPersistentModelCatalogJson, codexProfileFileToml, readCodexHomeModelsCache } from "./launch.js";
8
8
  export function codexIntegrationConfigPath(codexHome) {
9
9
  if (codexHome !== undefined)
@@ -36,13 +36,13 @@ function profileFileName(selector) {
36
36
  return `${selector}.config.toml`;
37
37
  }
38
38
  function selectedProfileId(input) {
39
- return input.profileId ?? input.profiles[0]?.profileId ?? "routekit";
39
+ return input.profileId ?? "routekit";
40
40
  }
41
41
  function selectedDefaultModel(input) {
42
- const model = input.defaultModel ?? input.profiles[0]?.modelId;
42
+ const model = input.defaultModel ?? input.models[0]?.modelId;
43
43
  if (model === undefined)
44
44
  throw new Error("at least one Codex catalog model is required");
45
- if (!input.profiles.some((profile) => profile.modelId === model)) {
45
+ if (!input.models.some((profile) => profile.modelId === model)) {
46
46
  throw new Error(`the Codex default model ${JSON.stringify(model)} is not in the RouteKit catalog`);
47
47
  }
48
48
  return model;
@@ -55,7 +55,7 @@ function orderedCatalogProfiles(profiles, defaultModel) {
55
55
  }
56
56
  /** Serialize one additive, owner-marked Codex provider block. */
57
57
  export function codexIntegrationBlock(input) {
58
- const base = trimTrailingSlashes(input.gatewayUrl);
58
+ const base = gatewayOpenAiBaseUrl(input.gatewayUrl);
59
59
  const begin = marker(input.owner.id, "begin");
60
60
  const end = marker(input.owner.id, "end");
61
61
  const filesComment = profileFilesComment(input.owner.id);
@@ -66,7 +66,7 @@ export function codexIntegrationBlock(input) {
66
66
  model_providers: {
67
67
  [input.owner.providerId]: {
68
68
  name: `${input.owner.displayName} gateway`,
69
- base_url: `${base}/v1`,
69
+ base_url: base,
70
70
  wire_api: "responses",
71
71
  ...(input.auth !== undefined
72
72
  ? {
@@ -119,7 +119,10 @@ function ownedCatalogFile(managed, codexHome, ownerId) {
119
119
  if (line === undefined)
120
120
  return undefined;
121
121
  const file = line.slice(prefix.length).trim();
122
- if (file.length === 0 || file.includes("/") || file.includes("\\") || file !== catalogFileName(ownerId)) {
122
+ if (file.length === 0 ||
123
+ file.includes("/") ||
124
+ file.includes("\\") ||
125
+ file !== catalogFileName(ownerId)) {
123
126
  return undefined;
124
127
  }
125
128
  return join(codexHome, file);
@@ -185,7 +188,7 @@ function assertProfileFileCanBeManaged(path, ownerId) {
185
188
  `rename it, then rerun the RouteKit install`);
186
189
  }
187
190
  export function installCodexIntegration(input) {
188
- if (input.profiles.length === 0)
191
+ if (input.models.length === 0)
189
192
  throw new Error("at least one Codex catalog model is required");
190
193
  const defaultModel = selectedDefaultModel(input);
191
194
  const profileId = selectedProfileId(input);
@@ -212,20 +215,12 @@ export function installCodexIntegration(input) {
212
215
  const persistentProfilePath = join(codexHome, profileFileName(profileId));
213
216
  assertProfileFileCanBeManaged(persistentProfilePath, input.owner.id);
214
217
  mkdirSync(codexHome, { recursive: true });
215
- writeFileSync(catalogPath, codexPersistentModelCatalogJson(orderedCatalogProfiles(input.profiles, defaultModel).map((profile) => ({
218
+ writeFileSync(catalogPath, codexPersistentModelCatalogJson(orderedCatalogProfiles(input.models, defaultModel).map((profile) => ({
216
219
  id: profile.modelId,
217
220
  ...(profile.reasoning !== undefined ? { reasoning: profile.reasoning } : {})
218
221
  })), readCodexHomeModelsCache(codexHome)[0]), { mode: 0o600 });
219
222
  writeFileSync(persistentProfilePath, `# Managed by ${input.owner.id}\n${codexProfileFileToml(defaultModel, input.owner.providerId, catalogPath)}`, { mode: 0o600 });
220
- // Write the new profile before replacing the managed block, then clean up
221
- // legacy per-model files last. An interrupted migration therefore leaves a
222
- // working old or new profile, never a config that points at a missing file.
223
223
  writeFileSync(configPath, next);
224
- const nextFiles = new Set([persistentProfilePath]);
225
- for (const stale of ownedProfileFiles(managed, codexHome, input.owner.id)) {
226
- if (!nextFiles.has(stale))
227
- removeOwnedProfileFile(stale, input.owner.id);
228
- }
229
224
  return {
230
225
  configPath,
231
226
  catalogPath,
package/dist/launch.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { isAbsolute, join } from "node:path";
5
5
  import { codexCompatibility, isCodexPickerEligibleModel, reasoningEffortDescriptors } from "@velum-labs/routekit-contracts";
6
- import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
6
+ import { gatewayOpenAiBaseUrl } from "@velum-labs/routekit-runtime/network";
7
7
  import { stringify as tomlStringify } from "smol-toml";
8
8
  const PROVIDER_ID = "routekit";
9
9
  const CATALOG_FILE = "model-catalog.json";
@@ -89,14 +89,13 @@ function codexModelId(modelId) {
89
89
  }
90
90
  function catalogModels(spec) {
91
91
  return spec.models.filter((model) => {
92
- const isDefault = model.id === spec.defaultModel ||
93
- model.aliases?.includes(spec.defaultModel) === true;
92
+ const isDefault = model.id === spec.defaultModel || model.aliases?.includes(spec.defaultModel) === true;
94
93
  if (spec.modelSelection === undefined) {
95
94
  return isDefault || isCodexPickerEligibleModel(model);
96
95
  }
97
96
  if (isDefault && spec.modelSelection !== "implicit")
98
97
  return true;
99
- return codexCompatibility({
98
+ return (codexCompatibility({
100
99
  id: model.id,
101
100
  ...(model.provider !== undefined ? { provider: model.provider } : {}),
102
101
  ...(model.architecture !== undefined ? { architecture: model.architecture } : {}),
@@ -106,14 +105,12 @@ function catalogModels(spec) {
106
105
  ...(model.features?.tools !== undefined
107
106
  ? {
108
107
  capabilities: {
109
- tools: model.features.tools === "full"
110
- ? "supported"
111
- : model.features.tools
108
+ tools: model.features.tools === "full" ? "supported" : model.features.tools
112
109
  }
113
110
  }
114
111
  : {}),
115
112
  ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {})
116
- }).status === "compatible";
113
+ }).status === "compatible");
117
114
  });
118
115
  }
119
116
  function catalogIds(spec) {
@@ -346,7 +343,7 @@ export function codexLaunchConfigToml(spec, modelCatalogPath, roles = []) {
346
343
  if (modelCatalogPath !== undefined) {
347
344
  lines.push(`model_catalog_json = ${JSON.stringify(modelCatalogPath)}`);
348
345
  }
349
- lines.push("", `[model_providers.${PROVIDER_ID}]`, `name = "RouteKit gateway"`, `base_url = ${JSON.stringify(`${trimTrailingSlashes(spec.gatewayUrl)}/v1`)}`, `wire_api = "responses"`, `requires_openai_auth = false`, ...(spec.auth?.token !== undefined ? [`env_key = "ROUTEKIT_GATEWAY_TOKEN"`] : []), "");
346
+ lines.push("", `[model_providers.${PROVIDER_ID}]`, `name = "RouteKit gateway"`, `base_url = ${JSON.stringify(gatewayOpenAiBaseUrl(spec.gatewayUrl))}`, `wire_api = "responses"`, `requires_openai_auth = false`, ...(spec.auth?.token !== undefined ? [`env_key = "ROUTEKIT_GATEWAY_TOKEN"`] : []), "");
350
347
  if (roles.length > 0) {
351
348
  lines.push("[features]", "multi_agent = true", "", "[agents]", "max_depth = 1", "");
352
349
  for (const role of roles) {
@@ -372,7 +369,7 @@ function codexLaunchOverrides(spec, catalogPath, roles = []) {
372
369
  ["model", codexModelId(spec.defaultModel)],
373
370
  ["model_provider", PROVIDER_ID],
374
371
  [`model_providers.${PROVIDER_ID}.name`, "RouteKit gateway"],
375
- [`model_providers.${PROVIDER_ID}.base_url`, `${trimTrailingSlashes(spec.gatewayUrl)}/v1`],
372
+ [`model_providers.${PROVIDER_ID}.base_url`, gatewayOpenAiBaseUrl(spec.gatewayUrl)],
376
373
  [`model_providers.${PROVIDER_ID}.wire_api`, "responses"],
377
374
  [`model_providers.${PROVIDER_ID}.requires_openai_auth`, false]
378
375
  ];
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "node:test";
@@ -23,7 +23,7 @@ process.stdin.on("end", () => {
23
23
  emit({ type: "thread.started", thread_id: threadId });
24
24
  emit({ type: "turn.started" });
25
25
  emit({ type: "item.started", item: { id: "i1", type: "agent_message", text: "" } });
26
- emit({ type: "item.completed", item: { id: "i1", type: "agent_message", text: "ARGS: " + args.join(" ") + "\\nOK: " + input.trim() } });
26
+ emit({ type: "item.completed", item: { id: "i1", type: "agent_message", text: "ARGS: " + args.join(" ") + "\\nCODEX_HOME: " + (process.env.CODEX_HOME || "") + "\\nOK: " + input.trim() } });
27
27
  emit({ type: "turn.completed", usage: { input_tokens: 3, cached_input_tokens: 0, output_tokens: 2, reasoning_output_tokens: 0 } });
28
28
  process.exit(0);
29
29
  });
@@ -99,6 +99,80 @@ test("codex driver forwards effort as the SDK CLI config", async () => {
99
99
  effortRepo.cleanup();
100
100
  }
101
101
  });
102
+ test("codex driver releases the turn after reasoning validation fails", async () => {
103
+ const driver = createCodexDriver();
104
+ const validationRepo = fakeCodexRepo();
105
+ const instance = await driver.createInstance(driver.configSchema.parse({ command: validationRepo.command }));
106
+ try {
107
+ const session = await instance.startSession({
108
+ cwd: validationRepo.cwd,
109
+ reasoning: { mode: "effort", effort: "low" }
110
+ });
111
+ await assert.rejects(async () => {
112
+ for await (const _event of session.sendTurn({
113
+ prompt: "invalid override",
114
+ reasoning: { mode: "effort", effort: "high" }
115
+ })) {
116
+ // Drain.
117
+ }
118
+ }, /reasoning must be selected before the session starts/);
119
+ const events = [];
120
+ for await (const event of session.sendTurn({ prompt: "valid retry" })) {
121
+ events.push(event);
122
+ }
123
+ assert.equal(events.find((event) => event.type === "turn.completed")?.endReason, "completed");
124
+ }
125
+ finally {
126
+ await instance.dispose();
127
+ validationRepo.cleanup();
128
+ }
129
+ });
130
+ test("gateway-routed codex homes are owned by their harness instance", async () => {
131
+ const driver = createCodexDriver();
132
+ const routedRepo = fakeCodexRepo();
133
+ const userHome = mkdtempSync(join(tmpdir(), "codex-driver-user-"));
134
+ const context = {
135
+ env: { ...process.env, HOME: userHome, CODEX_HOME: undefined }
136
+ };
137
+ const config = driver.configSchema.parse({
138
+ command: routedRepo.command,
139
+ provider: { baseUrl: "http://127.0.0.1:8080/v1" }
140
+ });
141
+ const first = await driver.createInstance(config, context);
142
+ const second = await driver.createInstance(config, context);
143
+ let firstHome;
144
+ let secondHome;
145
+ try {
146
+ const readHome = async (instance) => {
147
+ const session = await instance.startSession({ cwd: routedRepo.cwd });
148
+ const events = [];
149
+ for await (const event of session.sendTurn({ prompt: "show home" })) {
150
+ events.push(event);
151
+ }
152
+ const text = events
153
+ .flatMap((event) => (event.type === "content.delta" ? [event.text] : []))
154
+ .join("");
155
+ const match = /^CODEX_HOME: (.+)$/m.exec(text);
156
+ assert.ok(match?.[1]);
157
+ return match[1];
158
+ };
159
+ firstHome = await readHome(first);
160
+ secondHome = await readHome(second);
161
+ assert.notEqual(firstHome, secondHome);
162
+ assert.ok(firstHome.startsWith(join(userHome, ".cache", "routekit", "codex")));
163
+ assert.ok(secondHome.startsWith(join(userHome, ".cache", "routekit", "codex")));
164
+ await first.dispose();
165
+ assert.equal(existsSync(firstHome), false);
166
+ assert.equal(existsSync(secondHome), true);
167
+ await second.dispose();
168
+ assert.equal(existsSync(secondHome), false);
169
+ }
170
+ finally {
171
+ await Promise.allSettled([first.dispose(), second.dispose()]);
172
+ routedRepo.cleanup();
173
+ rmSync(userHome, { recursive: true, force: true });
174
+ }
175
+ });
102
176
  test("codex driver probe reports version and installed state", async () => {
103
177
  const driver = createCodexDriver();
104
178
  const repo2 = fakeCodexRepo();
@@ -20,7 +20,7 @@ test("Codex managed install adds one picker-backed profile and removes only owne
20
20
  const installed = installCodexIntegration({
21
21
  gatewayUrl: "http://127.0.0.1:9999/",
22
22
  owner: OWNER,
23
- profiles: [
23
+ models: [
24
24
  { modelId: "opaque-primary" },
25
25
  { modelId: "opaque-secondary", description: "Secondary route" }
26
26
  ],
@@ -42,7 +42,7 @@ test("Codex managed install adds one picker-backed profile and removes only owne
42
42
  const updated = installCodexIntegration({
43
43
  gatewayUrl: "http://127.0.0.1:8888",
44
44
  owner: OWNER,
45
- profiles: [{ modelId: "opaque-primary" }],
45
+ models: [{ modelId: "opaque-primary" }],
46
46
  defaultModel: "opaque-primary",
47
47
  codexHome: home
48
48
  });
@@ -64,7 +64,7 @@ test("Codex managed install can use a command-backed bearer token", () => {
64
64
  installCodexIntegration({
65
65
  gatewayUrl: "http://127.0.0.1:9999",
66
66
  owner: OWNER,
67
- profiles: [{ modelId: "opaque-primary" }],
67
+ models: [{ modelId: "opaque-primary" }],
68
68
  auth: {
69
69
  command: "/opt/routekit/node",
70
70
  args: ["/opt/routekit/index.js", "credential", "get", "--tool", "codex"]
@@ -87,7 +87,8 @@ test("Codex's single persistent profile can use a safe custom selector", () => {
87
87
  const result = installCodexIntegration({
88
88
  gatewayUrl: "http://127.0.0.1:9999",
89
89
  owner: OWNER,
90
- profiles: [{ modelId: "provider/model", profileId: "route-1" }],
90
+ models: [{ modelId: "provider/model" }],
91
+ profileId: "route-1",
91
92
  codexHome: home
92
93
  });
93
94
  assert.deepEqual(result.profiles, ["route-1"]);
@@ -98,50 +99,6 @@ test("Codex's single persistent profile can use a safe custom selector", () => {
98
99
  rmSync(home, { recursive: true, force: true });
99
100
  }
100
101
  });
101
- test("Codex reinstall migrates a legacy per-model installation to one RouteKit profile", () => {
102
- const home = mkdtempSync(join(tmpdir(), "routekit-codex-migrate-"));
103
- const configPath = join(home, "config.toml");
104
- const legacyProfiles = ["routekit-model-1.config.toml", "routekit-model-2.config.toml"];
105
- writeFileSync(configPath, [
106
- 'model = "user-default"',
107
- "",
108
- "# >>> example-host integration >>>",
109
- "# example-host-profile-files: routekit-model-1.config.toml routekit-model-2.config.toml",
110
- "# example-host-catalog-file: .example-host-model-catalog.json",
111
- "",
112
- "[model_providers.example_route]",
113
- 'name = "Example Host gateway"',
114
- 'base_url = "http://127.0.0.1:9999/v1"',
115
- 'wire_api = "responses"',
116
- "requires_openai_auth = false",
117
- 'env_key = "ROUTEKIT_GATEWAY_TOKEN"',
118
- "",
119
- "# <<< example-host integration <<<",
120
- ""
121
- ].join("\n"));
122
- for (const profile of legacyProfiles) {
123
- writeFileSync(join(home, profile), "# Managed by example-host\nmodel = \"opaque-primary\"\n");
124
- }
125
- writeFileSync(join(home, ".example-host-model-catalog.json"), "{\"models\":[]}\n");
126
- try {
127
- const result = installCodexIntegration({
128
- gatewayUrl: "http://127.0.0.1:8888",
129
- owner: OWNER,
130
- profiles: [{ modelId: "opaque-primary" }, { modelId: "opaque-secondary" }],
131
- defaultModel: "opaque-secondary",
132
- codexHome: home
133
- });
134
- assert.equal(result.action, "updated");
135
- assert.deepEqual(result.profiles, ["routekit"]);
136
- assert.equal(existsSync(join(home, "routekit.config.toml")), true);
137
- for (const profile of legacyProfiles)
138
- assert.equal(existsSync(join(home, profile)), false);
139
- assert.match(readFileSync(configPath, "utf8"), /example-host-profile-files: routekit\.config\.toml/);
140
- }
141
- finally {
142
- rmSync(home, { recursive: true, force: true });
143
- }
144
- });
145
102
  test("Codex install refuses to overwrite a user-owned routekit profile", () => {
146
103
  const home = mkdtempSync(join(tmpdir(), "routekit-codex-profile-conflict-"));
147
104
  const configPath = join(home, "config.toml");
@@ -150,7 +107,7 @@ test("Codex install refuses to overwrite a user-owned routekit profile", () => {
150
107
  assert.throws(() => installCodexIntegration({
151
108
  gatewayUrl: "http://127.0.0.1:9999",
152
109
  owner: OWNER,
153
- profiles: [{ modelId: "opaque-primary" }],
110
+ models: [{ modelId: "opaque-primary" }],
154
111
  codexHome: home
155
112
  }), /refusing to overwrite an existing Codex profile/);
156
113
  assert.equal(existsSync(configPath), false);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-tool-codex",
3
3
  "private": false,
4
- "version": "0.18.6",
4
+ "version": "1.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -14,6 +14,18 @@
14
14
  ".": {
15
15
  "types": "./dist/index.d.ts",
16
16
  "default": "./dist/index.js"
17
+ },
18
+ "./driver": {
19
+ "types": "./dist/driver.d.ts",
20
+ "default": "./dist/driver.js"
21
+ },
22
+ "./install": {
23
+ "types": "./dist/install.d.ts",
24
+ "default": "./dist/install.js"
25
+ },
26
+ "./launch": {
27
+ "types": "./dist/launch.d.ts",
28
+ "default": "./dist/launch.js"
17
29
  }
18
30
  },
19
31
  "files": [
@@ -29,11 +41,11 @@
29
41
  "@openai/codex-sdk": "0.145.0",
30
42
  "smol-toml": "1.7.0",
31
43
  "zod": "4.4.3",
32
- "@velum-labs/routekit-contracts": "0.18.6",
33
- "@velum-labs/routekit-harness-core": "0.18.6",
34
- "@velum-labs/routekit-registry": "0.18.6",
35
- "@velum-labs/routekit-runtime": "0.18.6",
36
- "@velum-labs/routekit-tools": "0.18.6"
44
+ "@velum-labs/routekit-contracts": "1.0.0",
45
+ "@velum-labs/routekit-harness-core": "1.0.0",
46
+ "@velum-labs/routekit-runtime": "1.0.0",
47
+ "@velum-labs/routekit-registry": "1.0.0",
48
+ "@velum-labs/routekit-tools": "1.0.0"
37
49
  },
38
50
  "keywords": [
39
51
  "llm",
@@ -43,7 +55,7 @@
43
55
  "adapter"
44
56
  ],
45
57
  "scripts": {
46
- "build": "tsc -b",
58
+ "build": "tsc -p tsconfig.json",
47
59
  "clean": "tsc -b --clean",
48
60
  "test": "node --test \"dist/test/*.test.js\""
49
61
  }