agentsw 0.7.1 → 0.7.2

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/rename.js CHANGED
@@ -10,6 +10,8 @@ import { commitFileChanges } from "./config-transaction.js";
10
10
  import { endpointKey } from "./import.js";
11
11
  import { classifyApi, entryApi } from "./targets/wire.js";
12
12
  import { envAssignments } from "./envfile.js";
13
+ import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "./provider-identity.js";
14
+ import { workbuddyBaseUrl } from "./targets/workbuddy.js";
13
15
  function readExisting(file) {
14
16
  try {
15
17
  return fs.readFileSync(file, "utf8");
@@ -206,7 +208,6 @@ function tomlEditor(file, before) {
206
208
  },
207
209
  };
208
210
  }
209
- const managedKey = (id) => `AGENTSW_${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
210
211
  /** Explicit provider ID migration; never recreates providers or edits session history. */
211
212
  export async function renameProvider(oldId, newId, opts = {}) {
212
213
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(newId))
@@ -225,9 +226,9 @@ export async function renameProvider(oldId, newId, opts = {}) {
225
226
  const provider = object(providers[oldId], configFile);
226
227
  const changes = [];
227
228
  const visited = new Set();
228
- const oldKey = managedKey(oldId);
229
- const newKey = managedKey(newId);
230
- const assertConnection = (editor, entry) => {
229
+ const oldKeys = [managedCredentialRef(oldId), legacyManagedCredentialRef(oldId)];
230
+ const newKey = managedCredentialRef(newId);
231
+ const assertConnection = (editor, entry, envLookup = false) => {
231
232
  const options = isJsonObject(entry.options) ? entry.options : {};
232
233
  const endpoint = entry.baseUrl ?? entry.baseURL ?? entry.base_url ?? options.baseURL ?? (entry.transport ? entry.api : undefined);
233
234
  if (typeof endpoint === "string" && /^https?:\/\//i.test(endpoint) && typeof provider.baseUrl === "string" && endpointKey(endpoint) !== endpointKey(provider.baseUrl)) {
@@ -241,10 +242,10 @@ export async function renameProvider(oldId, newId, opts = {}) {
241
242
  if (protocol && provider.protocol && protocol !== provider.protocol)
242
243
  throw new Error(`${editor.file}: source provider protocol conflicts with the store`);
243
244
  const rawKey = entry.apiKey ?? options.apiKey;
244
- if (typeof rawKey === "string" && rawKey) {
245
+ if (typeof rawKey === "string") {
245
246
  const ref = rawKey.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/)?.[1] ?? rawKey.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1];
246
247
  // Command/file references remain unresolved; no shell execution or secret-file reads.
247
- const key = ref ? process.env[ref] : /^(?:!|\{file:)/.test(rawKey) ? undefined : /^[A-Z][A-Z0-9_]*$/.test(rawKey) ? process.env[rawKey] : rawKey;
248
+ const key = ref ? process.env[ref] : /^(?:!|\{file:)/.test(rawKey) ? undefined : envLookup ? process.env[rawKey] ?? rawKey : rawKey;
248
249
  if (key !== undefined && typeof provider.apiKey === "string" && key !== provider.apiKey)
249
250
  throw new Error(`${editor.file}: source provider credentials conflict with the store`);
250
251
  }
@@ -262,7 +263,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
262
263
  const value = get(editor.value, at);
263
264
  return value === undefined ? [] : Object.entries(object(value, editor.file));
264
265
  };
265
- const renameMap = (editor, at, names = ["id", "name"]) => {
266
+ const renameMap = (editor, at, names = ["id", "name"], envLookup = false) => {
266
267
  const map = get(editor.value, at);
267
268
  if (map === undefined)
268
269
  return;
@@ -271,7 +272,13 @@ export async function renameProvider(oldId, newId, opts = {}) {
271
272
  throw new Error(`${editor.file}: destination provider ID already exists`);
272
273
  if (!Object.hasOwn(entries, oldId))
273
274
  return;
274
- assertConnection(editor, object(entries[oldId], editor.file));
275
+ const entry = object(entries[oldId], editor.file);
276
+ assertConnection(editor, entry, envLookup);
277
+ if (Array.isArray(entry.models))
278
+ for (const model of entry.models) {
279
+ if (isJsonObject(model))
280
+ assertConnection(editor, model, envLookup);
281
+ }
275
282
  for (const key of names)
276
283
  exact(editor, [...at, oldId, key]);
277
284
  editor.move(at, oldId, newId);
@@ -323,7 +330,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
323
330
  });
324
331
  const ompDir = path.join(home, ".omp", "agent");
325
332
  for (const name of ["models.yml", "models.yaml"])
326
- visit(path.join(ompDir, name), "yaml", (editor) => renameMap(editor, ["providers"]));
333
+ visit(path.join(ompDir, name), "yaml", (editor) => renameMap(editor, ["providers"], undefined, true));
327
334
  for (const name of ["config.yml", "config.yaml"])
328
335
  visit(path.join(ompDir, name), "yaml", (editor) => {
329
336
  compound(editor, ["model"]);
@@ -332,7 +339,7 @@ export async function renameProvider(oldId, newId, opts = {}) {
332
339
  });
333
340
  for (const [env, fallback] of [["PI_CODING_AGENT_DIR", ".pi/agent"], ["PRIME_AGENT_CODING_AGENT_DIR", ".prime/agent"]]) {
334
341
  const dir = process.env[env] ? expandHome(process.env[env]) : path.join(home, fallback);
335
- visit(path.join(dir, "models.json"), "json", (editor) => renameMap(editor, ["providers"]));
342
+ visit(path.join(dir, "models.json"), "json", (editor) => renameMap(editor, ["providers"], undefined, env === "PRIME_AGENT_CODING_AGENT_DIR"));
336
343
  visit(path.join(dir, "settings.json"), "json", (editor) => {
337
344
  exact(editor, ["defaultProvider"]);
338
345
  const recent = get(editor.value, ["recentModels"]);
@@ -370,71 +377,87 @@ export async function renameProvider(oldId, newId, opts = {}) {
370
377
  list.forEach((_value, index) => exact(editor, [key, index]));
371
378
  }
372
379
  });
380
+ const migrationKey = (file, local, maps, field) => {
381
+ const sourceRefs = maps.flatMap((map) => map.filter(([id]) => id === oldId).map(([, entry]) => get(entry, [field])));
382
+ const eligible = oldKeys.filter((key) => local.has(key) &&
383
+ (sourceRefs.includes(key) || sourceRefs.every((ref) => ref === undefined)) &&
384
+ !Object.keys(providers).some((id) => id !== oldId && isManagedCredentialRef(key, id)) &&
385
+ !maps.some((map) => map.some(([id, entry]) => id !== oldId &&
386
+ (get(entry, [field]) === key || isManagedCredentialRef(key, id)))));
387
+ if (eligible.length > 1)
388
+ throw new Error(`${file}: ambiguous managed credential references`);
389
+ const key = eligible[0];
390
+ if (!key)
391
+ return undefined;
392
+ if (local.has(newKey) || process.env[newKey] !== undefined ||
393
+ maps.some((map) => map.some(([, entry]) => get(entry, [field]) === newKey))) {
394
+ throw new Error(`${file}: destination credential reference already exists`);
395
+ }
396
+ if (process.env[key] !== undefined && process.env[key] !== local.get(key))
397
+ throw new Error(`${file}: inherited credential overrides the local definition`);
398
+ if (typeof provider.apiKey === "string" && local.get(key) !== provider.apiKey)
399
+ throw new Error(`${file}: source provider credentials conflict with the store`);
400
+ return key;
401
+ };
373
402
  const hermesDir = process.env.HERMES_HOME?.trim() ? expandHome(process.env.HERMES_HOME.trim()) : localAppDataDir("hermes");
374
403
  const envFile = path.join(hermesDir, ".env");
375
404
  const envBefore = readExisting(envFile);
376
405
  const assignments = envBefore === undefined ? [] : envAssignments(envFile, envBefore);
377
406
  const envValues = new Map(assignments.map((assignment) => [assignment.name, assignment.value]));
378
- const migrateEnv = oldKey !== newKey && assignments.some((assignment) => assignment.name === oldKey);
379
- if (migrateEnv) {
380
- if (assignments.some((assignment) => assignment.name === newKey) || process.env[newKey] !== undefined)
381
- throw new Error(`${envFile}: destination credential reference already exists`);
382
- if (process.env[oldKey] !== undefined && process.env[oldKey] !== envValues.get(oldKey))
383
- throw new Error(`${envFile}: inherited credential overrides the local definition`);
384
- const after = applyEdits(envBefore, assignments.filter((assignment) => assignment.name === oldKey).map((assignment) => ({ offset: assignment.offset, length: oldKey.length, content: newKey })));
385
- changes.push({ file: envFile, before: envBefore, after, mode: 0o600 });
386
- }
387
- if (!migrateEnv)
388
- changes.push({ file: envFile, before: envBefore, after: envBefore });
407
+ let envKey;
408
+ let hermesPresent = false;
389
409
  visit(path.join(hermesDir, "config.yaml"), "yaml", (editor) => {
410
+ hermesPresent = true;
390
411
  const keyRef = get(editor.value, ["providers", oldId, "key_env"]);
391
412
  const key = typeof keyRef === "string" ? process.env[keyRef] ?? envValues.get(keyRef) : undefined;
392
413
  if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
393
414
  throw new Error(`${editor.file}: source provider credentials conflict with the store`);
394
- if (migrateEnv)
395
- for (const [id, entry] of entries(editor, ["providers"])) {
396
- if (get(entry, ["key_env"]) === newKey)
397
- throw new Error(`${editor.file}: destination credential reference already exists`);
398
- exact(editor, ["providers", id, "key_env"], oldKey, newKey);
399
- }
415
+ envKey = migrationKey(envFile, envValues, [entries(editor, ["providers"])], "key_env");
416
+ if (envKey)
417
+ exact(editor, ["providers", oldId, "key_env"], envKey, newKey);
400
418
  renameMap(editor, ["providers"]);
401
419
  exact(editor, ["model", "provider"]);
402
420
  });
421
+ if (!hermesPresent)
422
+ envKey = migrationKey(envFile, envValues, [], "key_env");
423
+ const envAfter = envKey ? applyEdits(envBefore, assignments.filter((assignment) => assignment.name === envKey)
424
+ .map((assignment) => ({ offset: assignment.offset, length: envKey.length, content: newKey }))) : envBefore;
425
+ changes.push({ file: envFile, before: envBefore, after: envAfter, mode: 0o600 });
403
426
  const dshDir = process.env.DSH_HOME?.trim() ? expandHome(process.env.DSH_HOME.trim()) : localAppDataDir("dsh");
427
+ const settings = [];
428
+ for (const name of ["settings.yaml", "settings.yml", "settings.json"]) {
429
+ const file = path.join(dshDir, name);
430
+ const before = readExisting(file);
431
+ if (before === undefined)
432
+ changes.push({ file, before, after: before });
433
+ else
434
+ settings.push({ editor: name.endsWith("json") ? jsonEditor(file, before) : yamlEditor(file, before), before });
435
+ }
436
+ const dshMaps = settings.map(({ editor }) => entries(editor, ["llm-pi-ai", "providers"]));
404
437
  const credentialFile = path.join(dshDir, ".credentials.yaml");
405
- let migrateCredential = false;
438
+ let credentialKey;
406
439
  let credentialRefs = {};
407
440
  visit(credentialFile, "yaml", (editor) => {
408
441
  const version = get(editor.value, ["version"]);
409
442
  if (version !== undefined && version !== 1)
410
443
  throw new Error(`${credentialFile}: unsupported credential layout version`);
411
444
  const at = version === undefined && get(editor.value, ["refs"]) === undefined ? [] : ["refs"];
412
- const refs = object(get(editor.value, at), credentialFile);
413
- credentialRefs = refs;
414
- if (oldKey === newKey || !Object.hasOwn(refs, oldKey))
415
- return;
416
- if (Object.hasOwn(refs, newKey) || process.env[newKey] !== undefined)
417
- throw new Error(`${credentialFile}: destination credential reference already exists`);
418
- if (process.env[oldKey] !== undefined && process.env[oldKey] !== refs[oldKey])
419
- throw new Error(`${credentialFile}: inherited credential overrides the local definition`);
420
- editor.move(at, oldKey, newKey);
421
- migrateCredential = true;
445
+ credentialRefs = object(get(editor.value, at), credentialFile);
446
+ credentialKey = migrationKey(credentialFile, new Map(Object.entries(credentialRefs)), dshMaps, "apiKeyEnv");
447
+ if (credentialKey)
448
+ editor.move(at, credentialKey, newKey);
422
449
  }, 0o600);
423
- for (const name of ["settings.yaml", "settings.yml", "settings.json"])
424
- visit(path.join(dshDir, name), name.endsWith("json") ? "json" : "yaml", (editor) => {
425
- const keyRef = get(editor.value, ["llm-pi-ai", "providers", oldId, "apiKeyEnv"]);
426
- const key = typeof keyRef === "string" ? process.env[keyRef] ?? credentialRefs[keyRef] : undefined;
427
- if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
428
- throw new Error(`${editor.file}: source provider credentials conflict with the store`);
429
- if (migrateCredential)
430
- for (const [id, entry] of entries(editor, ["llm-pi-ai", "providers"])) {
431
- if (get(entry, ["apiKeyEnv"]) === newKey)
432
- throw new Error(`${editor.file}: destination credential reference already exists`);
433
- exact(editor, ["llm-pi-ai", "providers", id, "apiKeyEnv"], oldKey, newKey);
434
- }
435
- renameMap(editor, ["llm-pi-ai", "providers"], ["id", "displayName"]);
436
- exact(editor, ["agent-default-model", "provider"]);
437
- });
450
+ for (const { editor, before } of settings) {
451
+ const keyRef = get(editor.value, ["llm-pi-ai", "providers", oldId, "apiKeyEnv"]);
452
+ const key = typeof keyRef === "string" ? process.env[keyRef] ?? credentialRefs[keyRef] : undefined;
453
+ if (key !== undefined && provider.apiKey !== undefined && key !== provider.apiKey)
454
+ throw new Error(`${editor.file}: source provider credentials conflict with the store`);
455
+ if (credentialKey)
456
+ exact(editor, ["llm-pi-ai", "providers", oldId, "apiKeyEnv"], credentialKey, newKey);
457
+ renameMap(editor, ["llm-pi-ai", "providers"], ["id", "displayName"]);
458
+ exact(editor, ["agent-default-model", "provider"]);
459
+ finish(editor, before);
460
+ }
438
461
  const workbuddyEnv = process.env.WORKBUDDY_CONFIG_DIR?.trim() ?? process.env.CODEBUDDY_CONFIG_DIR?.trim();
439
462
  const workbuddyDir = workbuddyEnv ? expandHome(workbuddyEnv) : process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy");
440
463
  visit(path.join(workbuddyDir, "models.json"), "json", (editor) => {
@@ -444,12 +467,22 @@ export async function renameProvider(oldId, newId, opts = {}) {
444
467
  return;
445
468
  if (!Array.isArray(rows))
446
469
  throw new Error(`${editor.file}: expected WorkBuddy models array`);
447
- const base = typeof provider.baseUrl === "string" ? provider.baseUrl.replace(/\/+$/, "") : "";
448
- const url = /\/v\d+$/.test(base) ? `${base}/chat/completions` : `${base}/v1/chat/completions`;
449
- if (provider.name !== oldId)
450
- return;
451
470
  rows.forEach((row, index) => {
452
- if (isJsonObject(row) && row.url === url && row.apiKey === provider.apiKey)
471
+ if (!isJsonObject(row))
472
+ return;
473
+ if (row.agentswProviderId === newId)
474
+ throw new Error(`${editor.file}: destination provider ID already exists`);
475
+ const owned = row.agentswProviderId === oldId;
476
+ const endpoint = typeof row.url === "string" ? workbuddyBaseUrl(row.url) : undefined;
477
+ const matches = typeof endpoint === "string" && typeof provider.baseUrl === "string" &&
478
+ endpointKey(endpoint) === endpointKey(provider.baseUrl) && row.apiKey === provider.apiKey;
479
+ if (owned && !matches)
480
+ throw new Error(`${editor.file}: source provider connection conflicts with the store`);
481
+ if (!matches || (row.agentswProviderId !== undefined && !owned))
482
+ return;
483
+ if (owned)
484
+ exact(editor, [...at, index, "agentswProviderId"]);
485
+ if (provider.name === oldId)
453
486
  exact(editor, [...at, index, "vendor"]);
454
487
  });
455
488
  }, undefined, true);
package/dist/store.js CHANGED
@@ -1,17 +1,48 @@
1
1
  import path from "node:path";
2
- import { appDataDir, readJsonIfExists, writeFileAtomic } from "./fsutil.js";
2
+ import { appDataDir, isDryRun, readFileSnapshot, recordPendingWrites } from "./fsutil.js";
3
+ import { commitFileChanges } from "./config-transaction.js";
3
4
  export const configDir = appDataDir("agentsw");
4
5
  export const configFile = path.join(configDir, "config.json");
6
+ const loadedSnapshots = new WeakMap();
5
7
  export function loadStore() {
6
- const store = readJsonIfExists(configFile);
7
- if (!store)
8
- return { version: 1, providers: {} };
9
- if (!store.providers)
10
- store.providers = {};
8
+ const snapshot = readFileSnapshot(configFile);
9
+ let store;
10
+ if (snapshot.text === undefined)
11
+ store = { version: 1, providers: {} };
12
+ else {
13
+ try {
14
+ store = JSON.parse(snapshot.text);
15
+ }
16
+ catch {
17
+ throw new Error(`${configFile}: invalid provider store JSON`);
18
+ }
19
+ if (!store || typeof store !== "object" || Array.isArray(store)) {
20
+ throw new Error(`${configFile}: expected a provider store object`);
21
+ }
22
+ if (store.providers === undefined)
23
+ store.providers = {};
24
+ if (!store.providers || typeof store.providers !== "object" || Array.isArray(store.providers)) {
25
+ throw new Error(`${configFile}: expected a providers object`);
26
+ }
27
+ }
28
+ loadedSnapshots.set(store, snapshot);
11
29
  return store;
12
30
  }
13
31
  export function saveStore(store) {
14
- writeFileAtomic(configFile, JSON.stringify(store, null, 2) + "\n", 0o600);
32
+ // A snapshot stays associated with this object across asynchronous discovery and repeated saves.
33
+ const expected = loadedSnapshots.get(store) ?? readFileSnapshot(configFile);
34
+ const after = JSON.stringify(store, null, 2) + "\n";
35
+ const changes = [{ file: configFile, before: expected.text, after, mode: 0o600, expected }];
36
+ const dryRun = isDryRun();
37
+ commitFileChanges(changes, { dryRun });
38
+ if (dryRun) {
39
+ recordPendingWrites(changes);
40
+ return;
41
+ }
42
+ const saved = readFileSnapshot(configFile);
43
+ if (saved.text !== after)
44
+ throw new Error(`${configFile}: configuration changed during store save`);
45
+ loadedSnapshots.set(store, saved);
15
46
  }
16
47
  export function getProvider(store, id) {
17
48
  const p = store.providers[id];
@@ -0,0 +1,25 @@
1
+ import { commitFileChanges } from "./config-transaction.js";
2
+ import { recordPendingWrites, stageFileWrites } from "./fsutil.js";
3
+ const wrappedTargets = new WeakSet();
4
+ /** Validate every adapter input before committing any of its configuration writes. */
5
+ export function transactionalTarget(target) {
6
+ if (wrappedTargets.has(target))
7
+ return target;
8
+ const wrapped = Object.create(Object.getPrototypeOf(target), Object.getOwnPropertyDescriptors(target));
9
+ for (const method of ["apply", "prune"]) {
10
+ const operation = target[method];
11
+ wrapped[method] = async function (provider) {
12
+ const staged = await stageFileWrites(() => operation.call(this, provider));
13
+ const committed = commitFileChanges(staged.changes, { dryRun: staged.dryRun, reads: staged.reads });
14
+ if (staged.dryRun)
15
+ recordPendingWrites(staged.changes);
16
+ return {
17
+ ...staged.result,
18
+ changed: committed.files,
19
+ notes: committed.backupDir ? [...staged.result.notes, `backup: ${committed.backupDir}`] : staged.result.notes,
20
+ };
21
+ };
22
+ }
23
+ wrappedTargets.add(wrapped);
24
+ return wrapped;
25
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { isJsonObject } from "../jsonc.js";
5
+ import { transactionalTarget } from "../target-transaction.js";
4
6
  import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
5
7
  import { stripApiVersion } from "./wire.js";
6
8
  const dir = path.join(home, ".claude");
@@ -9,7 +11,7 @@ const settingsFile = path.join(dir, "settings.json");
9
11
  * Claude Code reads Anthropic-protocol endpoints via env vars in ~/.claude/settings.json.
10
12
  * We merge the "env" block and leave every other setting untouched.
11
13
  */
12
- export const claudecode = {
14
+ export const claudecode = transactionalTarget({
13
15
  id: "claude",
14
16
  name: "Claude Code",
15
17
  protocols: ["anthropic"],
@@ -17,7 +19,11 @@ export const claudecode = {
17
19
  detect: () => fs.existsSync(dir),
18
20
  async apply(provider) {
19
21
  const notes = [];
20
- const settings = readJsonIfExists(settingsFile) ?? {};
22
+ const settingsValue = readJsonIfExists(settingsFile);
23
+ const settings = settingsValue === undefined ? {} : settingsValue;
24
+ if (!isJsonObject(settings) || (settings.env !== undefined && !isJsonObject(settings.env))) {
25
+ throw new Error(`${settingsFile}: expected settings and env to be JSON objects`);
26
+ }
21
27
  const env = { ...settings.env };
22
28
  env.ANTHROPIC_BASE_URL = stripApiVersion(provider.baseUrl);
23
29
  env.ANTHROPIC_AUTH_TOKEN = provider.apiKey;
@@ -38,6 +44,9 @@ export const claudecode = {
38
44
  },
39
45
  async prune(provider) {
40
46
  const settings = readJsonIfExists(settingsFile);
47
+ if (settings !== undefined && (!isJsonObject(settings) || (settings.env !== undefined && !isJsonObject(settings.env)))) {
48
+ throw new Error(`${settingsFile}: expected settings and env to be JSON objects`);
49
+ }
41
50
  const env = settings?.env;
42
51
  if (!settings || !env || env.ANTHROPIC_BASE_URL !== stripApiVersion(provider.baseUrl)) {
43
52
  return { app: this.id, changed: [], notes: [], skipped: "claude env does not point at this provider" };
@@ -95,4 +104,4 @@ export const claudecode = {
95
104
  },
96
105
  ];
97
106
  },
98
- };
107
+ });
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4
4
  import { backupFile, home, readJsonIfExists, readTextIfExists, writeFileAtomic } from "../fsutil.js";
5
+ import { isJsonObject } from "../jsonc.js";
6
+ import { transactionalTarget } from "../target-transaction.js";
5
7
  const dir = path.join(home, ".codex");
6
8
  const configFile = path.join(dir, "config.toml");
7
9
  const authFile = path.join(dir, "auth.json");
@@ -10,7 +12,7 @@ const authFile = path.join(dir, "auth.json");
10
12
  * OpenAI-protocol endpoint; the key ships via ~/.codex/auth.json OPENAI_API_KEY.
11
13
  * TOML round-trip via smol-toml preserves data but not comments (backed up first).
12
14
  */
13
- export const codex = {
15
+ export const codex = transactionalTarget({
14
16
  id: "codex",
15
17
  name: "Codex CLI",
16
18
  protocols: ["openai"],
@@ -20,6 +22,13 @@ export const codex = {
20
22
  const notes = [];
21
23
  const text = readTextIfExists(configFile);
22
24
  const config = (text ? parseToml(text) : {});
25
+ const authValue = readJsonIfExists(authFile);
26
+ const auth = authValue === undefined ? {} : authValue;
27
+ if (!isJsonObject(auth))
28
+ throw new Error(`${authFile}: expected a JSON object`);
29
+ if (config.model_providers !== undefined && !isJsonObject(config.model_providers)) {
30
+ throw new Error(`${configFile}: expected model_providers to be a table`);
31
+ }
23
32
  config.model_provider = provider.id;
24
33
  config.model = provider.defaultModel;
25
34
  const model = provider.models.find((m) => m.id === provider.defaultModel);
@@ -29,7 +38,27 @@ export const codex = {
29
38
  // Codex only speaks the OpenAI Responses API (wire_api = "chat" was removed Feb 2026).
30
39
  // requires_openai_auth = true makes Codex take the key from auth.json instead of an env var.
31
40
  const providers = (config.model_providers ?? {});
41
+ const previous = providers[provider.id];
42
+ if (previous !== undefined && !isJsonObject(previous)) {
43
+ throw new Error(`${configFile}: expected the selected model provider to be a table`);
44
+ }
45
+ const kept = { ...previous };
46
+ for (const key of ["env_key", "env_key_instructions", "experimental_bearer_token"])
47
+ delete kept[key];
48
+ for (const key of ["http_headers", "env_http_headers"]) {
49
+ if (kept[key] === undefined)
50
+ continue;
51
+ if (!isJsonObject(kept[key]))
52
+ throw new Error(`${configFile}: expected ${key} to be a table`);
53
+ const headers = { ...kept[key] };
54
+ for (const name of Object.keys(headers)) {
55
+ if (["authorization", "x-api-key", "api-key"].includes(name.toLowerCase()))
56
+ delete headers[name];
57
+ }
58
+ kept[key] = headers;
59
+ }
32
60
  providers[provider.id] = {
61
+ ...kept,
33
62
  name: provider.name,
34
63
  base_url: provider.baseUrl.replace(/\/$/, ""),
35
64
  wire_api: "responses",
@@ -43,7 +72,6 @@ export const codex = {
43
72
  if (text?.includes("#"))
44
73
  notes.push("config.toml comments are not preserved by TOML round-trip");
45
74
  writeFileAtomic(configFile, stringifyToml(config) + "\n");
46
- const auth = readJsonIfExists(authFile) ?? {};
47
75
  auth.auth_mode = "apikey";
48
76
  auth.OPENAI_API_KEY = provider.apiKey;
49
77
  const authBackup = backupFile(authFile);
@@ -57,6 +85,9 @@ export const codex = {
57
85
  if (!text)
58
86
  return { app: this.id, changed: [], notes: [], skipped: "no config.toml" };
59
87
  const config = parseToml(text);
88
+ if (config.model_providers !== undefined && !isJsonObject(config.model_providers)) {
89
+ throw new Error(`${configFile}: expected model_providers to be a table`);
90
+ }
60
91
  const providers = config.model_providers;
61
92
  if (!providers?.[provider.id]) {
62
93
  return { app: this.id, changed: [], notes: [], skipped: `no model_providers.${provider.id} entry` };
@@ -133,4 +164,4 @@ export const codex = {
133
164
  ];
134
165
  });
135
166
  },
136
- };
167
+ });