@velum-labs/routekit-tool-codex 1.3.2 → 1.5.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/README.md CHANGED
@@ -21,7 +21,8 @@ import { codexTool } from "@velum-labs/routekit-tool-codex";
21
21
 
22
22
  ## Native client ownership
23
23
 
24
- The launcher uses the user's normal `CODEX_HOME`, applies gateway routing as
25
- process-scoped configuration, and keeps credentials in the environment. RouteKit
26
- does not observe, store, resume, or delete Codex sessions; use Codex's native
27
- history and commands directly.
24
+ The launcher uses a RouteKit-owned `CODEX_HOME`, pins Codex authentication to
25
+ its private file store, applies gateway routing as process-scoped configuration,
26
+ and keeps gateway credentials in the environment. RouteKit does not observe,
27
+ store, resume, or delete Codex sessions; use Codex's native history and commands
28
+ directly.
package/dist/driver.js CHANGED
@@ -1,4 +1,5 @@
1
- import { rmSync } from "node:fs";
1
+ import { rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { Codex } from "@openai/codex-sdk";
3
4
  import { asHarnessError, buildChildEnv, createCachedHarnessDriver, HarnessError, nowIso, probeCliVersion, resolveDriverEnv, resumeStringField, SessionResourceRegistry, SingleFlightTurnController } from "@velum-labs/routekit-harness-core";
4
5
  import { ResourceScope } from "@velum-labs/routekit-runtime/lifecycle";
@@ -81,9 +82,10 @@ function codexOptionsFor(config, context, isolatedHome) {
81
82
  childEnv.CODEX_HOME = isolatedHome;
82
83
  return {
83
84
  codexPathOverride: config.command,
84
- ...(config.provider.baseUrl !== undefined
85
- ? {
86
- config: {
85
+ config: {
86
+ cli_auth_credentials_store: "file",
87
+ ...(config.provider.baseUrl !== undefined
88
+ ? {
87
89
  model_provider: "routekit",
88
90
  model_providers: {
89
91
  routekit: {
@@ -95,8 +97,8 @@ function codexOptionsFor(config, context, isolatedHome) {
95
97
  }
96
98
  }
97
99
  }
98
- }
99
- : {}),
100
+ : {})
101
+ },
100
102
  ...(apiKey !== undefined ? { apiKey } : {}),
101
103
  env: childEnv
102
104
  };
@@ -359,17 +361,18 @@ class CodexInstance {
359
361
  return this.#status;
360
362
  }
361
363
  /**
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.
364
+ * Every RouteKit Codex session uses an instance-owned `CODEX_HOME`, preventing
365
+ * the user's config, auth state, and model cache from leaking into the child.
366
+ * An explicitly supplied home remains borrowed and is never removed here.
365
367
  */
366
368
  #createOwnedHome() {
367
- if (this.#config.provider.baseUrl === undefined)
368
- return undefined;
369
369
  const env = resolveDriverEnv(this.#context);
370
370
  if (env.CODEX_HOME !== undefined)
371
371
  return undefined;
372
372
  const home = createIsolatedCodexHome("routekit-codex-driver-", env);
373
+ writeFileSync(join(home, "config.toml"), 'cli_auth_credentials_store = "file"\n', {
374
+ mode: 0o600
375
+ });
373
376
  this.#resources.defer(() => rmSync(home, { recursive: true, force: true }));
374
377
  return home;
375
378
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { ToolIntegration } from "@velum-labs/routekit-tools";
2
2
  export declare const codexTool: ToolIntegration;
3
3
  export type { CodexDriverConfig } from "./driver.js";
4
4
  export { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
5
- export type { CodexInstallInput, CodexInstallOwner, CodexInstallProfile, CodexInstallResult } from "./install.js";
6
- export { codexIntegrationBlock, codexIntegrationConfigPath, installCodexIntegration, uninstallCodexIntegration } from "./install.js";
5
+ export type { CodexConfigLockScope, CodexInstallInput, CodexInstallOwner, CodexInstallProfile, CodexInstallResult } from "./install.js";
6
+ export { acquireCodexConfigLock, codexIntegrationBlock, codexIntegrationConfigPath, installCodexIntegration, uninstallCodexIntegration, withCodexConfigLock } from "./install.js";
7
7
  export type { CodexAgentRole, CodexModelPreset } from "./launch.js";
8
- export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexPersistentModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexHomeModelsCache, readCodexModelsCache } from "./launch.js";
8
+ export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexPersistentModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexHomeModelsCache, readCodexModelsCache, resolveRouteKitCodexHome } from "./launch.js";
package/dist/index.js CHANGED
@@ -36,5 +36,5 @@ export const codexTool = {
36
36
  }
37
37
  };
38
38
  export { codexDriverConfigSchema, createCodexDriver } from "./driver.js";
39
- export { codexIntegrationBlock, codexIntegrationConfigPath, installCodexIntegration, uninstallCodexIntegration } from "./install.js";
40
- export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexPersistentModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexHomeModelsCache, readCodexModelsCache } from "./launch.js";
39
+ export { acquireCodexConfigLock, codexIntegrationBlock, codexIntegrationConfigPath, installCodexIntegration, uninstallCodexIntegration, withCodexConfigLock } from "./install.js";
40
+ export { codexAgentRoles, codexAgentRoleToml, codexAuthPath, codexCatalogEntries, codexLaunchConfigToml, codexListedStockSlugs, codexModelCatalogJson, codexPersistentModelCatalogJson, codexProfileFiles, codexProfileFileToml, hasCodexLogin, isCodexConfigFailure, launchCodex, readCodexCatalogTemplate, readCodexHomeModelsCache, readCodexModelsCache, resolveRouteKitCodexHome } from "./launch.js";
package/dist/install.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
1
+ import type { ModelReasoningCapabilities, ModelServiceTierCapabilities } from "@velum-labs/routekit-contracts";
2
2
  export type CodexInstallProfile = {
3
3
  modelId: string;
4
4
  description?: string;
5
5
  reasoning?: ModelReasoningCapabilities;
6
+ serviceTiers?: ModelServiceTierCapabilities;
6
7
  };
7
8
  export type CodexInstallOwner = {
8
9
  id: string;
@@ -18,6 +19,11 @@ export type CodexInstallInput = {
18
19
  models: readonly CodexInstallProfile[];
19
20
  /** Model selected when the single RouteKit profile is first opened. */
20
21
  defaultModel?: string;
22
+ /**
23
+ * Repository policy context for this managed provider. Codex sends provider
24
+ * headers for every model, so explicit-model requests carry this context too.
25
+ */
26
+ repositoryConnectionId?: string;
21
27
  /** Safe selector for the one persistent RouteKit profile. */
22
28
  profileId?: string;
23
29
  /**
@@ -49,3 +55,22 @@ export declare function uninstallCodexIntegration(input: {
49
55
  configPath: string;
50
56
  removed: boolean;
51
57
  };
58
+ export type CodexConfigLockScope = Readonly<{
59
+ install(input: CodexInstallInput): CodexInstallResult;
60
+ uninstall(input: {
61
+ ownerId: string;
62
+ codexHome?: string;
63
+ }): {
64
+ configPath: string;
65
+ removed: boolean;
66
+ };
67
+ /** Restore interrupted owner writes without beginning a new install or uninstall. */
68
+ recover(ownerId: string): void;
69
+ release(): void;
70
+ }>;
71
+ /**
72
+ * Hold the same owner-neutral home lock across a larger async CLI transaction.
73
+ * Scoped methods avoid reacquiring the non-reentrant synchronous lock.
74
+ */
75
+ export declare function acquireCodexConfigLock(codexHome?: string): Promise<CodexConfigLockScope>;
76
+ export declare function withCodexConfigLock<A>(codexHome: string | undefined, callback: (scope: CodexConfigLockScope) => Promise<A> | A): Promise<A>;
package/dist/install.js CHANGED
@@ -1,9 +1,11 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
3
+ import { basename, dirname, join, resolve } 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 { writeFileAtomic } from "@velum-labs/routekit-runtime/filesystem";
6
7
  import { gatewayOpenAiBaseUrl } from "@velum-labs/routekit-runtime/network";
8
+ import { acquireLifecycleLock, acquireLifecycleLockSync } from "@velum-labs/routekit-runtime/service";
7
9
  import { codexPersistentModelCatalogJson, codexProfileFileToml, readCodexHomeModelsCache } from "./launch.js";
8
10
  export function codexIntegrationConfigPath(codexHome) {
9
11
  if (codexHome !== undefined)
@@ -35,9 +37,214 @@ function profileFileName(selector) {
35
37
  }
36
38
  return `${selector}.config.toml`;
37
39
  }
40
+ function pendingPath(codexHome, ownerId) {
41
+ catalogFileName(ownerId);
42
+ return join(codexHome, `.${ownerId}-install-pending.json`);
43
+ }
44
+ function uninstallPendingPath(codexHome, ownerId) {
45
+ catalogFileName(ownerId);
46
+ return join(codexHome, `.${ownerId}-uninstall-pending.json`);
47
+ }
48
+ function configLockPath(codexHome) {
49
+ return join(resolve(codexHome), ".routekit-codex-config.lock");
50
+ }
51
+ function snapshot(path) {
52
+ let entry;
53
+ try {
54
+ entry = lstatSync(path);
55
+ }
56
+ catch (cause) {
57
+ if (cause.code === "ENOENT") {
58
+ return { content: null, mode: null };
59
+ }
60
+ throw cause;
61
+ }
62
+ if (!entry.isFile() || entry.isSymbolicLink()) {
63
+ throw new Error(`Codex integration refuses a non-regular file: ${path}`);
64
+ }
65
+ return { content: readFileSync(path, "utf8"), mode: entry.mode & 0o777 };
66
+ }
67
+ function applySnapshot(path, value) {
68
+ if (value.content === null) {
69
+ rmSync(path, { force: true });
70
+ return;
71
+ }
72
+ const mode = value.mode ?? 0o600;
73
+ writeFileAtomic(path, value.content, { mode });
74
+ }
75
+ function sameSnapshot(left, right) {
76
+ return left.content === right.content && left.mode === right.mode;
77
+ }
78
+ function validSnapshot(value) {
79
+ if (!isRecord(value))
80
+ return false;
81
+ if (!((typeof value.content === "string" && Number.isInteger(value.mode)) ||
82
+ (value.content === null && value.mode === null)))
83
+ return false;
84
+ return value.mode === null || (typeof value.mode === "number" && value.mode >= 0 && value.mode <= 0o777);
85
+ }
86
+ function readPending(path) {
87
+ const entry = (() => {
88
+ try {
89
+ return lstatSync(path);
90
+ }
91
+ catch (cause) {
92
+ if (cause.code === "ENOENT")
93
+ return undefined;
94
+ throw cause;
95
+ }
96
+ })();
97
+ if (entry === undefined)
98
+ return;
99
+ if (!entry.isFile() || entry.isSymbolicLink()) {
100
+ throw new Error(`Codex pending integration metadata is not a regular file: ${path}`);
101
+ }
102
+ try {
103
+ return JSON.parse(readFileSync(path, "utf8"));
104
+ }
105
+ catch {
106
+ throw new Error(`Codex pending integration metadata is corrupt: ${path}`);
107
+ }
108
+ }
109
+ function recoverFiles(codexHome, path, files, committed) {
110
+ if (committed) {
111
+ // A completed operation must never be rolled back merely because journal
112
+ // cleanup failed after its commit marker was atomically published.
113
+ rmSync(path);
114
+ return;
115
+ }
116
+ // Validate every current file before restoring any: an external edit must
117
+ // never be partially rolled back, even after a process crash.
118
+ for (const file of files) {
119
+ const current = snapshot(join(codexHome, file.name));
120
+ if (!sameSnapshot(current, file.before) && !sameSnapshot(current, file.target)) {
121
+ throw new Error(`Codex settings changed during interrupted integration (${file.name}); ` +
122
+ "RouteKit refused to overwrite the external edit");
123
+ }
124
+ }
125
+ for (const file of files)
126
+ applySnapshot(join(codexHome, file.name), file.before);
127
+ rmSync(path);
128
+ }
129
+ function completeJournal(path, journal) {
130
+ writeFileAtomic(path, `${JSON.stringify({ ...journal, phase: "committed" })}\n`, {
131
+ mode: 0o600
132
+ });
133
+ try {
134
+ rmSync(path);
135
+ }
136
+ catch {
137
+ // The committed marker is published. Retain it for the next locked operation
138
+ // to clean up; reporting a failed install here would misstate its outcome.
139
+ }
140
+ }
141
+ function recoverPendingInstall(codexHome, ownerId) {
142
+ const path = pendingPath(codexHome, ownerId);
143
+ const parsed = readPending(path);
144
+ if (parsed === undefined)
145
+ return;
146
+ if (!isRecord(parsed) || parsed.version !== 1 || parsed.ownerId !== ownerId ||
147
+ (parsed.phase !== undefined && parsed.phase !== "committed") ||
148
+ !Array.isArray(parsed.files) || parsed.files.length !== 3) {
149
+ throw new Error(`Codex pending installation metadata is corrupt: ${path}`);
150
+ }
151
+ const files = [];
152
+ for (const value of parsed.files) {
153
+ if (!isRecord(value) || typeof value.name !== "string" ||
154
+ !validSnapshot(value.before) || !validSnapshot(value.target) ||
155
+ value.target.content === null) {
156
+ throw new Error(`Codex pending installation metadata is corrupt: ${path}`);
157
+ }
158
+ files.push({ name: value.name, before: value.before, target: value.target });
159
+ }
160
+ const names = files.map((value) => value.name);
161
+ if (names[0] !== catalogFileName(ownerId) ||
162
+ names[1] === undefined || !names[1].endsWith(".config.toml") ||
163
+ names[1].includes("/") || names[1].includes("\\") ||
164
+ names[2] !== "config.toml" || new Set(names).size !== 3) {
165
+ throw new Error(`Codex pending installation metadata is corrupt: ${path}`);
166
+ }
167
+ recoverFiles(codexHome, path, files, parsed.phase === "committed");
168
+ }
169
+ function withoutManagedBlock(before, ownerId) {
170
+ const split = splitManagedBlock(before, ownerId);
171
+ if (split.managed === undefined)
172
+ return undefined;
173
+ const head = normalize(split.before);
174
+ const tail = normalize(split.after);
175
+ return `${head}${head.length > 0 && tail.length > 0 ? "\n" : ""}${tail}`;
176
+ }
177
+ function recoverPendingUninstall(codexHome, ownerId) {
178
+ const path = uninstallPendingPath(codexHome, ownerId);
179
+ const parsed = readPending(path);
180
+ if (parsed === undefined)
181
+ return;
182
+ if (!isRecord(parsed) || parsed.version !== 1 || parsed.ownerId !== ownerId ||
183
+ (parsed.phase !== undefined && parsed.phase !== "committed") ||
184
+ !Array.isArray(parsed.files) || parsed.files.length < 1) {
185
+ throw new Error(`Codex pending uninstallation metadata is corrupt: ${path}`);
186
+ }
187
+ const files = [];
188
+ for (const value of parsed.files) {
189
+ if (!isRecord(value) || typeof value.name !== "string" ||
190
+ !validSnapshot(value.before) || !validSnapshot(value.target)) {
191
+ throw new Error(`Codex pending uninstallation metadata is corrupt: ${path}`);
192
+ }
193
+ files.push({ name: value.name, before: value.before, target: value.target });
194
+ }
195
+ const config = files[0];
196
+ const desired = config.before.content === null
197
+ ? undefined
198
+ : withoutManagedBlock(config.before.content, ownerId);
199
+ if (config.name !== "config.toml" || desired === undefined ||
200
+ config.target.content !== desired || config.target.mode !== config.before.mode) {
201
+ throw new Error(`Codex pending uninstallation metadata is corrupt: ${path}`);
202
+ }
203
+ const managed = splitManagedBlock(config.before.content, ownerId).managed;
204
+ const profileNames = new Set(ownedProfileFiles(managed, codexHome, ownerId).map((file) => basename(file)));
205
+ const catalog = ownedCatalogFile(managed, codexHome, ownerId);
206
+ const catalogName = catalog === undefined ? undefined : basename(catalog);
207
+ const names = new Set();
208
+ for (const file of files) {
209
+ if (names.has(file.name))
210
+ throw new Error(`Codex pending uninstallation metadata is corrupt: ${path}`);
211
+ names.add(file.name);
212
+ }
213
+ for (const file of files.slice(1)) {
214
+ const ownedProfile = profileNames.has(file.name) &&
215
+ file.before.content?.includes(`Managed by ${ownerId}`) === true;
216
+ if ((!ownedProfile && file.name !== catalogName) ||
217
+ file.before.content === null || file.target.content !== null || file.target.mode !== null) {
218
+ throw new Error(`Codex pending uninstallation metadata is corrupt: ${path}`);
219
+ }
220
+ }
221
+ recoverFiles(codexHome, path, files, parsed.phase === "committed");
222
+ }
223
+ function recoverPendingChanges(codexHome, ownerId) {
224
+ recoverPendingInstall(codexHome, ownerId);
225
+ recoverPendingUninstall(codexHome, ownerId);
226
+ }
227
+ function reachedInstallBoundary(boundary) {
228
+ const testingGlobal = globalThis;
229
+ testingGlobal.__routekitCodexInstallFaultInjector?.(boundary);
230
+ }
231
+ function reachedUninstallBoundary(boundary) {
232
+ const testingGlobal = globalThis;
233
+ testingGlobal.__routekitCodexUninstallFaultInjector?.(boundary);
234
+ }
38
235
  function selectedProfileId(input) {
39
236
  return input.profileId ?? "routekit";
40
237
  }
238
+ function repositoryConnectionHeader(connectionId) {
239
+ if (connectionId === undefined)
240
+ return undefined;
241
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(connectionId)) {
242
+ throw new Error("Codex repository connection id must be a UUID");
243
+ }
244
+ return {
245
+ "X-RouteKit-Repository-Connection-Id": connectionId.toLowerCase()
246
+ };
247
+ }
41
248
  function selectedDefaultModel(input) {
42
249
  const model = input.defaultModel ?? input.models[0]?.modelId;
43
250
  if (model === undefined)
@@ -56,6 +263,7 @@ function orderedCatalogProfiles(profiles, defaultModel) {
56
263
  /** Serialize one additive, owner-marked Codex provider block. */
57
264
  export function codexIntegrationBlock(input) {
58
265
  const base = gatewayOpenAiBaseUrl(input.gatewayUrl);
266
+ const httpHeaders = repositoryConnectionHeader(input.repositoryConnectionId);
59
267
  const begin = marker(input.owner.id, "begin");
60
268
  const end = marker(input.owner.id, "end");
61
269
  const filesComment = profileFilesComment(input.owner.id);
@@ -63,11 +271,13 @@ export function codexIntegrationBlock(input) {
63
271
  const catalogFile = catalogFileName(input.owner.id);
64
272
  const profileId = selectedProfileId(input);
65
273
  const body = tomlStringify({
274
+ cli_auth_credentials_store: "file",
66
275
  model_providers: {
67
276
  [input.owner.providerId]: {
68
277
  name: `${input.owner.displayName} gateway`,
69
278
  base_url: base,
70
279
  wire_api: "responses",
280
+ ...(httpHeaders !== undefined ? { http_headers: httpHeaders } : {}),
71
281
  ...(input.auth !== undefined
72
282
  ? {
73
283
  auth: {
@@ -167,18 +377,6 @@ function normalize(content) {
167
377
  const trimmed = content.replace(/\s+$/, "");
168
378
  return trimmed.length === 0 ? "" : `${trimmed}\n`;
169
379
  }
170
- function removeOwnedProfileFile(path, ownerId) {
171
- try {
172
- if (!existsSync(path))
173
- return;
174
- if (!readFileSync(path, "utf8").includes(`Managed by ${ownerId}`))
175
- return;
176
- rmSync(path);
177
- }
178
- catch {
179
- // Best-effort cleanup; an orphaned profile does not alter the main config.
180
- }
181
- }
182
380
  function assertProfileFileCanBeManaged(path, ownerId) {
183
381
  if (!existsSync(path))
184
382
  return;
@@ -187,13 +385,14 @@ function assertProfileFileCanBeManaged(path, ownerId) {
187
385
  throw new Error(`refusing to overwrite an existing Codex profile: ${path}; ` +
188
386
  `rename it, then rerun the RouteKit install`);
189
387
  }
190
- export function installCodexIntegration(input) {
388
+ function installCodexIntegrationUnlocked(input) {
191
389
  if (input.models.length === 0)
192
390
  throw new Error("at least one Codex catalog model is required");
193
391
  const defaultModel = selectedDefaultModel(input);
194
392
  const profileId = selectedProfileId(input);
195
393
  const configPath = codexIntegrationConfigPath(input.codexHome);
196
394
  const codexHome = dirname(configPath);
395
+ recoverPendingChanges(codexHome, input.owner.id);
197
396
  const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
198
397
  const { before, managed, after } = splitManagedBlock(existing, input.owner.id);
199
398
  const outside = parseTomlOrThrow(`${normalize(before)}\n${normalize(after)}`, `your Codex config (${configPath})`);
@@ -215,12 +414,44 @@ export function installCodexIntegration(input) {
215
414
  const persistentProfilePath = join(codexHome, profileFileName(profileId));
216
415
  assertProfileFileCanBeManaged(persistentProfilePath, input.owner.id);
217
416
  mkdirSync(codexHome, { recursive: true });
218
- writeFileSync(catalogPath, codexPersistentModelCatalogJson(orderedCatalogProfiles(input.models, defaultModel).map((profile) => ({
219
- id: profile.modelId,
220
- ...(profile.reasoning !== undefined ? { reasoning: profile.reasoning } : {})
221
- })), readCodexHomeModelsCache(codexHome)[0]), { mode: 0o600 });
222
- writeFileSync(persistentProfilePath, `# Managed by ${input.owner.id}\n${codexProfileFileToml(defaultModel, input.owner.providerId, catalogPath)}`, { mode: 0o600 });
223
- writeFileSync(configPath, next);
417
+ const files = [
418
+ {
419
+ name: catalogFileName(input.owner.id),
420
+ before: snapshot(catalogPath),
421
+ target: {
422
+ content: codexPersistentModelCatalogJson(orderedCatalogProfiles(input.models, defaultModel).map((profile) => ({
423
+ id: profile.modelId,
424
+ ...(profile.reasoning !== undefined ? { reasoning: profile.reasoning } : {}),
425
+ ...(profile.serviceTiers !== undefined ? { serviceTiers: profile.serviceTiers } : {})
426
+ })), readCodexHomeModelsCache(codexHome)[0]),
427
+ mode: 0o600
428
+ }
429
+ },
430
+ {
431
+ name: profileFileName(profileId),
432
+ before: snapshot(persistentProfilePath),
433
+ target: {
434
+ content: `# Managed by ${input.owner.id}\n${codexProfileFileToml(defaultModel, input.owner.providerId, catalogPath)}`,
435
+ mode: 0o600
436
+ }
437
+ },
438
+ {
439
+ name: "config.toml",
440
+ before: snapshot(configPath),
441
+ target: { content: next, mode: snapshot(configPath).mode ?? 0o600 }
442
+ }
443
+ ];
444
+ const journal = pendingPath(codexHome, input.owner.id);
445
+ const pending = {
446
+ version: 1, ownerId: input.owner.id, files
447
+ };
448
+ writeFileAtomic(journal, `${JSON.stringify(pending)}\n`, { mode: 0o600 });
449
+ reachedInstallBoundary("pending");
450
+ for (const [index, file] of files.entries()) {
451
+ applySnapshot(join(codexHome, file.name), file.target);
452
+ reachedInstallBoundary(index === 0 ? "catalog" : index === 1 ? "profile" : "config");
453
+ }
454
+ completeJournal(journal, pending);
224
455
  return {
225
456
  configPath,
226
457
  catalogPath,
@@ -228,22 +459,119 @@ export function installCodexIntegration(input) {
228
459
  profiles: [profileId]
229
460
  };
230
461
  }
231
- export function uninstallCodexIntegration(input) {
462
+ export function installCodexIntegration(input) {
463
+ const lock = acquireLifecycleLockSync(configLockPath(dirname(codexIntegrationConfigPath(input.codexHome))));
464
+ try {
465
+ return installCodexIntegrationUnlocked(input);
466
+ }
467
+ finally {
468
+ lock.release();
469
+ }
470
+ }
471
+ function uninstallCodexIntegrationUnlocked(input) {
232
472
  const configPath = codexIntegrationConfigPath(input.codexHome);
473
+ const codexHome = dirname(configPath);
474
+ recoverPendingChanges(codexHome, input.ownerId);
233
475
  if (!existsSync(configPath))
234
476
  return { configPath, removed: false };
235
477
  const existing = readFileSync(configPath, "utf8");
236
478
  const { before, managed, after } = splitManagedBlock(existing, input.ownerId);
237
479
  if (managed === undefined)
238
480
  return { configPath, removed: false };
239
- for (const owned of ownedProfileFiles(managed, dirname(configPath), input.ownerId)) {
240
- removeOwnedProfileFile(owned, input.ownerId);
481
+ const desired = withoutManagedBlock(existing, input.ownerId);
482
+ const config = snapshot(configPath);
483
+ const files = [
484
+ {
485
+ name: "config.toml",
486
+ before: config,
487
+ target: { content: desired, mode: config.mode }
488
+ }
489
+ ];
490
+ for (const path of ownedProfileFiles(managed, codexHome, input.ownerId)) {
491
+ const before = snapshot(path);
492
+ if (before.content?.includes(`Managed by ${input.ownerId}`) === true) {
493
+ files.push({
494
+ name: basename(path),
495
+ before,
496
+ target: { content: null, mode: null }
497
+ });
498
+ }
241
499
  }
242
- const catalogPath = ownedCatalogFile(managed, dirname(configPath), input.ownerId);
243
- if (catalogPath !== undefined)
244
- rmSync(catalogPath, { force: true });
245
- const head = normalize(before);
246
- const tail = normalize(after);
247
- writeFileSync(configPath, `${head}${head.length > 0 && tail.length > 0 ? "\n" : ""}${tail}`);
500
+ const catalogPath = ownedCatalogFile(managed, codexHome, input.ownerId);
501
+ if (catalogPath !== undefined) {
502
+ const before = snapshot(catalogPath);
503
+ if (before.content !== null) {
504
+ files.push({
505
+ name: basename(catalogPath),
506
+ before,
507
+ target: { content: null, mode: null }
508
+ });
509
+ }
510
+ }
511
+ const journal = uninstallPendingPath(codexHome, input.ownerId);
512
+ const pending = {
513
+ version: 1, ownerId: input.ownerId, files
514
+ };
515
+ writeFileAtomic(journal, `${JSON.stringify(pending)}\n`, { mode: 0o600 });
516
+ reachedUninstallBoundary("pending");
517
+ for (const [index, file] of files.entries()) {
518
+ applySnapshot(join(codexHome, file.name), file.target);
519
+ reachedUninstallBoundary(index === 0 ? "config" : file.name === catalogFileName(input.ownerId) ? "catalog" : "profile");
520
+ }
521
+ completeJournal(journal, pending);
248
522
  return { configPath, removed: true };
249
523
  }
524
+ export function uninstallCodexIntegration(input) {
525
+ const lock = acquireLifecycleLockSync(configLockPath(dirname(codexIntegrationConfigPath(input.codexHome))));
526
+ try {
527
+ return uninstallCodexIntegrationUnlocked(input);
528
+ }
529
+ finally {
530
+ lock.release();
531
+ }
532
+ }
533
+ /**
534
+ * Hold the same owner-neutral home lock across a larger async CLI transaction.
535
+ * Scoped methods avoid reacquiring the non-reentrant synchronous lock.
536
+ */
537
+ export async function acquireCodexConfigLock(codexHome) {
538
+ const configPath = resolve(codexIntegrationConfigPath(codexHome));
539
+ const lock = await acquireLifecycleLock(configLockPath(dirname(configPath)));
540
+ let released = false;
541
+ const assertScope = (inputHome) => {
542
+ if (released)
543
+ throw new Error("Codex configuration lock has already been released");
544
+ if (resolve(codexIntegrationConfigPath(inputHome)) !== configPath) {
545
+ throw new Error("Codex integration target does not match the locked home");
546
+ }
547
+ };
548
+ return {
549
+ install(input) {
550
+ assertScope(input.codexHome);
551
+ return installCodexIntegrationUnlocked(input);
552
+ },
553
+ uninstall(input) {
554
+ assertScope(input.codexHome);
555
+ return uninstallCodexIntegrationUnlocked(input);
556
+ },
557
+ recover(ownerId) {
558
+ assertScope(codexHome);
559
+ recoverPendingChanges(dirname(configPath), ownerId);
560
+ },
561
+ release() {
562
+ if (released)
563
+ return;
564
+ lock.release();
565
+ released = true;
566
+ }
567
+ };
568
+ }
569
+ export async function withCodexConfigLock(codexHome, callback) {
570
+ const scope = await acquireCodexConfigLock(codexHome);
571
+ try {
572
+ return await callback(scope);
573
+ }
574
+ finally {
575
+ scope.release();
576
+ }
577
+ }
package/dist/launch.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { type ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
2
+ import { type ModelReasoningCapabilities, type ModelServiceTierCapabilities } from "@velum-labs/routekit-contracts";
3
3
  import type { AgentProfile, ToolLaunchContext, ToolLaunchSpec } from "@velum-labs/routekit-tools";
4
4
  export type CodexModelPreset = Record<string, unknown>;
5
5
  /** Metadata persisted beside a RouteKit-owned Codex profile. */
@@ -7,6 +7,7 @@ export type CodexPersistentCatalogModel = {
7
7
  id: string;
8
8
  label?: string;
9
9
  reasoning?: ModelReasoningCapabilities;
10
+ serviceTiers?: ModelServiceTierCapabilities;
10
11
  };
11
12
  export declare function isCodexConfigFailure(code: number, stderr: string): boolean;
12
13
  export declare function tomlKey(name: string): string;
@@ -23,6 +24,8 @@ export declare function hasCodexLogin(home?: string): boolean;
23
24
  * cannot read or mutate the user's real Codex configuration.
24
25
  */
25
26
  export declare function createIsolatedCodexHome(prefix: string, env?: Record<string, string | undefined>): string;
27
+ /** Stable RouteKit-owned Codex home used by interactive launchers. */
28
+ export declare function resolveRouteKitCodexHome(env?: Record<string, string | undefined>): string;
26
29
  export declare function codexListedStockSlugs(home?: string): string[];
27
30
  export declare function codexCatalogEntries(spec: Pick<ToolLaunchSpec, "defaultModel" | "modelSelection" | "models">, template: CodexModelPreset, stockModels?: readonly CodexModelPreset[], options?: {
28
31
  appendUnlistedStock?: boolean;
@@ -48,6 +51,5 @@ export type CodexLaunchDependencies = {
48
51
  spawnProcess?: typeof spawn;
49
52
  env?: Record<string, string | undefined>;
50
53
  };
51
- export declare function resolveCodexHome(env?: Record<string, string | undefined>): string;
52
- /** Launch against the user's real Codex home; only generated catalog data is temporary. */
54
+ /** Launch against a RouteKit-owned Codex home with file-only auth persistence. */
53
55
  export declare function launchCodex(ctx: ToolLaunchContext, deps?: CodexLaunchDependencies): Promise<number>;