bosun 0.41.2 → 0.41.4

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.
Files changed (73) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-pool.mjs +9 -2
  3. package/agent/agent-prompt-catalog.mjs +971 -0
  4. package/agent/agent-prompts.mjs +2 -970
  5. package/agent/agent-supervisor.mjs +119 -6
  6. package/agent/autofix-git.mjs +33 -0
  7. package/agent/autofix-prompts.mjs +151 -0
  8. package/agent/autofix.mjs +11 -175
  9. package/agent/bosun-skills.mjs +3 -2
  10. package/bosun.config.example.json +17 -0
  11. package/bosun.schema.json +87 -188
  12. package/cli.mjs +34 -1
  13. package/config/config-doctor.mjs +5 -250
  14. package/config/config-file-names.mjs +5 -0
  15. package/config/config.mjs +89 -493
  16. package/config/executor-config.mjs +493 -0
  17. package/config/repo-root.mjs +1 -2
  18. package/config/workspace-health.mjs +242 -0
  19. package/git/git-safety.mjs +15 -0
  20. package/github/github-oauth-portal.mjs +46 -0
  21. package/infra/library-manager-utils.mjs +22 -0
  22. package/infra/library-manager-well-known-sources.mjs +578 -0
  23. package/infra/library-manager.mjs +512 -1030
  24. package/infra/monitor.mjs +35 -9
  25. package/infra/session-tracker.mjs +10 -7
  26. package/kanban/kanban-adapter.mjs +17 -1
  27. package/lib/codebase-audit-manifests.mjs +117 -0
  28. package/lib/codebase-audit.mjs +18 -115
  29. package/package.json +18 -3
  30. package/server/setup-web-server.mjs +58 -5
  31. package/server/ui-server.mjs +1394 -79
  32. package/shell/codex-config-file.mjs +178 -0
  33. package/shell/codex-config.mjs +538 -575
  34. package/task/task-cli.mjs +54 -3
  35. package/task/task-executor.mjs +143 -13
  36. package/task/task-store.mjs +409 -1
  37. package/telegram/telegram-bot.mjs +127 -0
  38. package/tools/apply-pr-suggestions.mjs +401 -0
  39. package/tools/syntax-check.mjs +28 -9
  40. package/ui/app.js +3 -14
  41. package/ui/components/kanban-board.js +227 -4
  42. package/ui/components/session-list.js +85 -5
  43. package/ui/demo-defaults.js +338 -84
  44. package/ui/demo.html +155 -0
  45. package/ui/modules/session-api.js +96 -0
  46. package/ui/modules/settings-schema.js +1 -2
  47. package/ui/modules/state.js +43 -3
  48. package/ui/setup.html +4 -5
  49. package/ui/styles/components.css +58 -4
  50. package/ui/tabs/agents.js +12 -15
  51. package/ui/tabs/control.js +1 -0
  52. package/ui/tabs/library.js +484 -22
  53. package/ui/tabs/manual-flows.js +105 -29
  54. package/ui/tabs/tasks.js +848 -141
  55. package/ui/tabs/telemetry.js +129 -11
  56. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  57. package/ui/tabs/workflows.js +293 -23
  58. package/voice/voice-tool-definitions.mjs +757 -0
  59. package/voice/voice-tools.mjs +34 -778
  60. package/workflow/manual-flow-audit.mjs +165 -0
  61. package/workflow/manual-flows.mjs +164 -259
  62. package/workflow/workflow-engine.mjs +147 -58
  63. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  64. package/workflow/workflow-nodes/transforms.mjs +612 -0
  65. package/workflow/workflow-nodes.mjs +358 -63
  66. package/workflow/workflow-templates.mjs +313 -191
  67. package/workflow-templates/_helpers.mjs +154 -0
  68. package/workflow-templates/agents.mjs +61 -4
  69. package/workflow-templates/code-quality.mjs +7 -7
  70. package/workflow-templates/github.mjs +20 -10
  71. package/workflow-templates/task-batch.mjs +44 -11
  72. package/workflow-templates/task-lifecycle.mjs +31 -6
  73. package/workspace/worktree-manager.mjs +277 -3
@@ -26,11 +26,17 @@
26
26
  * append or patch well-known sections rather than rewriting the whole file.
27
27
  */
28
28
 
29
- import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
29
+ import { existsSync, readFileSync, statSync } from "node:fs";
30
30
  import { resolve, dirname, parse, isAbsolute } from "node:path";
31
- import { homedir } from "node:os";
32
31
  import { fileURLToPath } from "node:url";
33
32
  import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
33
+ import {
34
+ CONFIG_PATH,
35
+ ensureTrustedProjects,
36
+ getConfigPath,
37
+ readCodexConfig,
38
+ writeCodexConfig,
39
+ } from "./codex-config-file.mjs";
34
40
 
35
41
  const __filename = fileURLToPath(import.meta.url);
36
42
  const __dirname = dirname(__filename);
@@ -51,9 +57,6 @@ function getVibeKanbanVersion() {
51
57
 
52
58
  // ── Constants ────────────────────────────────────────────────────────────────
53
59
 
54
- const CODEX_DIR = resolve(homedir(), ".codex");
55
- const CONFIG_PATH = resolve(CODEX_DIR, "config.toml");
56
-
57
60
  /** Minimum recommended stream idle timeout (ms) for complex agentic tasks. */
58
61
  const MIN_STREAM_IDLE_TIMEOUT_MS = 300_000; // 5 minutes
59
62
 
@@ -101,20 +104,6 @@ function buildDefaultAgentSdkBlock(primary = "codex") {
101
104
  ].join("\n");
102
105
  }
103
106
 
104
- /**
105
- * @deprecated No longer used — max_threads is now managed under [agent_sdk].
106
- * The [agents] section in Codex CLI uses serde(flatten) to parse all keys
107
- * as agent role names, so bare scalar keys like max_threads = 12 cause:
108
- * "invalid length 1, expected struct AgentRoleToml with 2 elements"
109
- * Kept only for migration removal of stale [agents] sections.
110
- */
111
- const buildAgentsBlock = (_maxThreads) =>
112
- [
113
- "",
114
- "# ── Agent roles (added by bosun) ──",
115
- AGENTS_HEADER,
116
- "",
117
- ].join("\n");
118
107
 
119
108
  // ── Feature Flags ────────────────────────────────────────────────────────────
120
109
 
@@ -205,109 +194,166 @@ function resolveAgentMaxThreads(envOverrides = process.env) {
205
194
  *
206
195
  * Also migrates any stale max_threads from [agents] if found.
207
196
  */
208
- export function ensureAgentMaxThreads(
209
- toml,
210
- { maxThreads, overwrite = false } = {},
211
- ) {
212
- const result = {
213
- toml,
214
- changed: false,
215
- existing: null,
216
- applied: null,
217
- added: false,
218
- updated: false,
219
- skipped: false,
197
+ function findSectionRange(toml, header) {
198
+ const headerIdx = toml.indexOf(header);
199
+ if (headerIdx === -1) return null;
200
+ const afterHeader = headerIdx + header.length;
201
+ const nextSection = toml.indexOf("\n[", afterHeader);
202
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
203
+ return {
204
+ headerIdx,
205
+ afterHeader,
206
+ sectionEnd,
207
+ section: toml.substring(afterHeader, sectionEnd),
220
208
  };
209
+ }
221
210
 
222
- const desired = parsePositiveInt(maxThreads);
223
- if (!desired) {
224
- result.skipped = true;
225
- return result;
211
+ function stripStaleAgentsMaxThreads(section) {
212
+ const staleRegex = /^[ \t]*#[^\n]*max.*threads[^\n]*\n?|^[ \t]*max_threads\s*=\s*\d+[^\n]*\n?/gm;
213
+ if (!staleRegex.test(section)) {
214
+ return { section, changed: false };
226
215
  }
227
- result.applied = desired;
216
+ return {
217
+ section: section.replace(staleRegex, ""),
218
+ changed: true,
219
+ };
220
+ }
228
221
 
229
- // ── Migration: remove stale max_threads from [agents] section ──
230
- const agentsIdx = toml.indexOf(AGENTS_HEADER);
231
- if (agentsIdx !== -1) {
232
- const afterAgentsHeader = agentsIdx + AGENTS_HEADER.length;
233
- const nextAgentsSection = toml.indexOf("\n[", afterAgentsHeader);
234
- const agentsSectionEnd = nextAgentsSection === -1 ? toml.length : nextAgentsSection;
235
- const agentsSection = toml.substring(afterAgentsHeader, agentsSectionEnd);
236
- const staleRegex = /^[ \t]*#[^\n]*max.*threads[^\n]*\n?|^[ \t]*max_threads\s*=\s*\d+[^\n]*\n?/gm;
237
- if (staleRegex.test(agentsSection)) {
238
- const cleaned = agentsSection.replace(staleRegex, "");
239
- toml = toml.substring(0, afterAgentsHeader) + cleaned + toml.substring(agentsSectionEnd);
240
- result.changed = true;
241
- }
242
- // If [agents] section is now empty (only whitespace/comments about agents),
243
- // remove the whole section to avoid confusing Codex CLI
244
- const updatedAgentsIdx = toml.indexOf(AGENTS_HEADER);
245
- if (updatedAgentsIdx !== -1) {
246
- const afterUpdated = updatedAgentsIdx + AGENTS_HEADER.length;
247
- const nextUpdated = toml.indexOf("\n[", afterUpdated);
248
- const endUpdated = nextUpdated === -1 ? toml.length : nextUpdated;
249
- const remaining = toml.substring(afterUpdated, endUpdated).trim();
250
- // If only whitespace or the bosun comment header remains, remove entire section
251
- if (!remaining || remaining.split(/\r?\n/).every((line) => {
252
- const trimmed = String(line || "").trim();
253
- return !trimmed || trimmed.startsWith("#");
254
- })) {
255
- // Remove from the line before [agents] header to section end
256
- const lineStart = toml.lastIndexOf("\n", updatedAgentsIdx);
257
- const removeFrom = lineStart === -1 ? updatedAgentsIdx : lineStart;
258
- toml = toml.substring(0, removeFrom) + toml.substring(endUpdated);
259
- result.changed = true;
260
- }
261
- }
222
+ function sectionHasOnlyComments(section) {
223
+ const remaining = String(section || "").trim();
224
+ return !remaining || remaining.split(/\r?\n/).every((line) => {
225
+ const trimmed = String(line || "").trim();
226
+ return !trimmed || trimmed.startsWith("#");
227
+ });
228
+ }
229
+
230
+ function removeStaleAgentsMaxThreads(toml) {
231
+ let nextToml = toml;
232
+ const agentsSection = findSectionRange(nextToml, AGENTS_HEADER);
233
+ if (!agentsSection) {
234
+ return { toml: nextToml, changed: false };
262
235
  }
263
236
 
264
- // ── Place max_threads under [agent_sdk] ──
265
- const sdkIdx = toml.indexOf(AGENT_SDK_HEADER);
266
- if (sdkIdx === -1) {
267
- // No [agent_sdk] section yet — it will be created by ensureCodexConfig;
268
- // the DEFAULT_AGENT_SDK_BLOCK already includes max_threads.
269
- result.changed = true;
270
- result.added = true;
271
- return result;
237
+ const strippedSection = stripStaleAgentsMaxThreads(agentsSection.section);
238
+ if (!strippedSection.changed) {
239
+ return { toml: nextToml, changed: false };
240
+ }
241
+
242
+ nextToml =
243
+ nextToml.substring(0, agentsSection.afterHeader) +
244
+ strippedSection.section +
245
+ nextToml.substring(agentsSection.sectionEnd);
246
+
247
+ const updatedAgentsSection = findSectionRange(nextToml, AGENTS_HEADER);
248
+ if (!updatedAgentsSection) {
249
+ return { toml: nextToml, changed: true };
250
+ }
251
+
252
+ if (!sectionHasOnlyComments(updatedAgentsSection.section)) {
253
+ return { toml: nextToml, changed: true };
272
254
  }
273
255
 
256
+ const lineStart = nextToml.lastIndexOf("\n", updatedAgentsSection.headerIdx);
257
+ const removeFrom = lineStart === -1 ? updatedAgentsSection.headerIdx : lineStart;
258
+ return {
259
+ toml: nextToml.substring(0, removeFrom) + nextToml.substring(updatedAgentsSection.sectionEnd),
260
+ changed: true,
261
+ };
262
+ }
263
+ function resolveAgentSdkSectionRange(toml) {
264
+ const sdkIdx = toml.indexOf(AGENT_SDK_HEADER);
265
+ if (sdkIdx === -1) return null;
274
266
  const afterSdkHeader = sdkIdx + AGENT_SDK_HEADER.length;
275
- // Find the end of [agent_sdk] — either [agent_sdk.capabilities] or the next section
276
267
  const capsIdx = toml.indexOf(AGENT_SDK_CAPS_HEADER, afterSdkHeader);
277
268
  const nextSectionIdx = toml.indexOf("\n[", afterSdkHeader);
278
- // Use the capabilities sub-section boundary or next top-level section
279
269
  let sdkSectionEnd;
280
270
  if (capsIdx !== -1 && (nextSectionIdx === -1 || capsIdx <= nextSectionIdx)) {
281
271
  sdkSectionEnd = capsIdx;
282
272
  } else {
283
273
  sdkSectionEnd = nextSectionIdx === -1 ? toml.length : nextSectionIdx;
284
274
  }
275
+ return { afterSdkHeader, sdkSectionEnd };
276
+ }
285
277
 
286
- let sdkSection = toml.substring(afterSdkHeader, sdkSectionEnd);
278
+ function upsertAgentSdkMaxThreads(toml, desired, overwrite) {
279
+ const sdkRange = resolveAgentSdkSectionRange(toml);
280
+ if (!sdkRange) {
281
+ return {
282
+ toml,
283
+ changed: true,
284
+ existing: null,
285
+ added: true,
286
+ updated: false,
287
+ };
288
+ }
287
289
 
290
+ let sdkSection = toml.substring(sdkRange.afterSdkHeader, sdkRange.sdkSectionEnd);
288
291
  const maxThreadsRegex = /^max_threads\s*=\s*(\d+)/m;
289
292
  const match = sdkSection.match(maxThreadsRegex);
290
293
  if (match) {
291
- result.existing = parsePositiveInt(match[1]);
292
- if (overwrite && result.existing !== desired) {
293
- sdkSection = sdkSection.replace(maxThreadsRegex, `max_threads = ${desired}`);
294
- result.changed = true;
295
- result.updated = true;
294
+ const existing = parsePositiveInt(match[1]);
295
+ if (!overwrite || existing === desired) {
296
+ return {
297
+ toml,
298
+ changed: false,
299
+ existing,
300
+ added: false,
301
+ updated: false,
302
+ };
296
303
  }
297
- } else {
298
- // Add max_threads right after [agent_sdk] header, before other keys
299
- sdkSection = sdkSection.trimEnd() + `\nmax_threads = ${desired}\n`;
300
- result.changed = true;
301
- result.added = true;
304
+ sdkSection = sdkSection.replace(maxThreadsRegex, `max_threads = ${desired}`);
305
+ return {
306
+ toml: toml.substring(0, sdkRange.afterSdkHeader) + sdkSection + toml.substring(sdkRange.sdkSectionEnd),
307
+ changed: true,
308
+ existing,
309
+ added: false,
310
+ updated: true,
311
+ };
302
312
  }
303
313
 
304
- if (result.changed) {
305
- result.toml = toml.substring(0, afterSdkHeader) + sdkSection + toml.substring(sdkSectionEnd);
314
+ sdkSection = sdkSection.trimEnd() + `\nmax_threads = ${desired}\n`;
315
+ return {
316
+ toml: toml.substring(0, sdkRange.afterSdkHeader) + sdkSection + toml.substring(sdkRange.sdkSectionEnd),
317
+ changed: true,
318
+ existing: null,
319
+ added: true,
320
+ updated: false,
321
+ };
322
+ }
323
+
324
+ export function ensureAgentMaxThreads(
325
+ toml,
326
+ { maxThreads, overwrite = false } = {},
327
+ ) {
328
+ const result = {
329
+ toml,
330
+ changed: false,
331
+ existing: null,
332
+ applied: null,
333
+ added: false,
334
+ updated: false,
335
+ skipped: false,
336
+ };
337
+
338
+ const desired = parsePositiveInt(maxThreads);
339
+ if (!desired) {
340
+ result.skipped = true;
341
+ return result;
306
342
  }
343
+ result.applied = desired;
344
+
345
+ const migratedAgents = removeStaleAgentsMaxThreads(toml);
346
+ toml = migratedAgents.toml;
347
+ result.changed = migratedAgents.changed;
307
348
 
349
+ const agentSdkUpdate = upsertAgentSdkMaxThreads(toml, desired, overwrite);
350
+ result.toml = agentSdkUpdate.toml;
351
+ result.existing = agentSdkUpdate.existing;
352
+ result.added = agentSdkUpdate.added;
353
+ result.updated = agentSdkUpdate.updated;
354
+ result.changed = result.changed || agentSdkUpdate.changed;
308
355
  return result;
309
356
  }
310
-
311
357
  /**
312
358
  * Check whether config has a [features] section.
313
359
  */
@@ -711,6 +757,20 @@ export function buildSandboxWorkspaceWrite(options = {}) {
711
757
  if (desiredRoots.length === 0) {
712
758
  return "";
713
759
  }
760
+ return buildSandboxWorkspaceWriteBlock({
761
+ desiredRoots,
762
+ networkAccess,
763
+ excludeTmpdirEnvVar,
764
+ excludeSlashTmp,
765
+ });
766
+ }
767
+
768
+ function buildSandboxWorkspaceWriteBlock({
769
+ desiredRoots,
770
+ networkAccess,
771
+ excludeTmpdirEnvVar,
772
+ excludeSlashTmp,
773
+ }) {
714
774
  return [
715
775
  "",
716
776
  "# ── Workspace-write sandbox defaults (added by bosun) ──",
@@ -723,6 +783,66 @@ export function buildSandboxWorkspaceWrite(options = {}) {
723
783
  ].join("\n");
724
784
  }
725
785
 
786
+ function findTomlSection(toml, header) {
787
+ const headerIdx = toml.indexOf(header);
788
+ if (headerIdx === -1) return null;
789
+ const afterHeader = headerIdx + header.length;
790
+ const nextSection = toml.indexOf("\n[", afterHeader);
791
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
792
+ return {
793
+ headerIdx,
794
+ afterHeader,
795
+ sectionEnd,
796
+ section: toml.substring(afterHeader, sectionEnd),
797
+ };
798
+ }
799
+
800
+ function ensureSandboxWorkspaceFlags(section, flags) {
801
+ let nextSection = section;
802
+ let changed = false;
803
+ for (const [key, value] of Object.entries(flags)) {
804
+ const keyRegex = new RegExp(`^${escapeRegex(key)}\\s*=`, "m");
805
+ if (keyRegex.test(nextSection)) continue;
806
+ nextSection = nextSection.trimEnd() + `\n${key} = ${value}\n`;
807
+ changed = true;
808
+ }
809
+ return { section: nextSection, changed };
810
+ }
811
+
812
+ function mergeSandboxWorkspaceRoots(section, desiredRoots, repoRoot) {
813
+ const rootsRegex = /^writable_roots\s*=\s*(\[[^\]]*\])\s*$/m;
814
+ const match = section.match(rootsRegex);
815
+ if (!match) {
816
+ if (desiredRoots.length === 0) {
817
+ return { section, changed: false, rootsAdded: [] };
818
+ }
819
+ return {
820
+ section: section.trimEnd() + `\nwritable_roots = ${formatTomlArray(desiredRoots)}\n`,
821
+ changed: true,
822
+ rootsAdded: desiredRoots,
823
+ };
824
+ }
825
+
826
+ const existingRoots = parseTomlArrayLiteral(match[1]);
827
+ const validExisting = existingRoots.filter((root) => root === "/tmp" || existsSync(root));
828
+ const merged = normalizeWritableRoots(validExisting, { repoRoot, validateExistence: true });
829
+ const rootsAdded = [];
830
+ for (const root of desiredRoots) {
831
+ if (merged.includes(root)) continue;
832
+ merged.push(root);
833
+ rootsAdded.push(root);
834
+ }
835
+
836
+ let changed = existingRoots.some((root) => root !== "/tmp" && !existsSync(root));
837
+ const formatted = formatTomlArray(merged);
838
+ if (formatted !== match[1]) {
839
+ section = section.replace(rootsRegex, `writable_roots = ${formatted}`);
840
+ changed = true;
841
+ }
842
+
843
+ return { section, changed, rootsAdded };
844
+ }
845
+
726
846
  export function ensureSandboxWorkspaceWrite(toml, options = {}) {
727
847
  const {
728
848
  writableRoots = [],
@@ -738,124 +858,86 @@ export function ensureSandboxWorkspaceWrite(toml, options = {}) {
738
858
  if (desiredRoots.length === 0) {
739
859
  return { toml, changed: false, added: false, rootsAdded: [] };
740
860
  }
741
- const block = [
742
- "",
743
- "# ── Workspace-write sandbox defaults (added by bosun) ──",
744
- "[sandbox_workspace_write]",
745
- `network_access = ${networkAccess}`,
746
- `exclude_tmpdir_env_var = ${excludeTmpdirEnvVar}`,
747
- `exclude_slash_tmp = ${excludeSlashTmp}`,
748
- `writable_roots = ${formatTomlArray(desiredRoots)}`,
749
- "",
750
- ].join("\n");
751
861
  return {
752
- toml: toml.trimEnd() + "\n" + block,
862
+ toml: toml.trimEnd() + "\n" + buildSandboxWorkspaceWriteBlock({
863
+ desiredRoots,
864
+ networkAccess,
865
+ excludeTmpdirEnvVar,
866
+ excludeSlashTmp,
867
+ }),
753
868
  changed: true,
754
869
  added: true,
755
870
  rootsAdded: desiredRoots,
756
871
  };
757
872
  }
758
873
 
759
- const header = "[sandbox_workspace_write]";
760
- const headerIdx = toml.indexOf(header);
761
- if (headerIdx === -1) {
874
+ const sectionInfo = findTomlSection(toml, "[sandbox_workspace_write]");
875
+ if (!sectionInfo) {
762
876
  return { toml, changed: false, added: false, rootsAdded: [] };
763
877
  }
764
878
 
765
- const afterHeader = headerIdx + header.length;
766
- const nextSection = toml.indexOf("\n[", afterHeader);
767
- const sectionEnd = nextSection === -1 ? toml.length : nextSection;
768
- let section = toml.substring(afterHeader, sectionEnd);
769
- let changed = false;
770
- let rootsAdded = [];
771
-
772
- const ensureFlag = (key, value) => {
773
- const keyRegex = new RegExp(`^${escapeRegex(key)}\\s*=`, "m");
774
- if (!keyRegex.test(section)) {
775
- section = section.trimEnd() + `\n${key} = ${value}\n`;
776
- changed = true;
777
- }
778
- };
779
-
780
- ensureFlag("network_access", networkAccess);
781
- ensureFlag("exclude_tmpdir_env_var", excludeTmpdirEnvVar);
782
- ensureFlag("exclude_slash_tmp", excludeSlashTmp);
783
-
784
- const rootsRegex = /^writable_roots\s*=\s*(\[[^\]]*\])\s*$/m;
785
- const match = section.match(rootsRegex);
786
- if (match) {
787
- const existingRoots = parseTomlArrayLiteral(match[1]);
788
- // Filter out stale roots that no longer exist on disk
789
- const validExisting = existingRoots.filter((r) => r === "/tmp" || existsSync(r));
790
- const merged = normalizeWritableRoots(validExisting, { repoRoot, validateExistence: true });
791
- for (const root of desiredRoots) {
792
- if (!merged.includes(root)) {
793
- merged.push(root);
794
- rootsAdded.push(root);
795
- }
796
- }
797
- // Track any roots that were removed due to non-existence
798
- const staleRemoved = existingRoots.filter((r) => r !== "/tmp" && !existsSync(r));
799
- if (staleRemoved.length > 0) changed = true;
800
- const formatted = formatTomlArray(merged);
801
- if (formatted !== match[1]) {
802
- section = section.replace(rootsRegex, `writable_roots = ${formatted}`);
803
- changed = true;
804
- }
805
- } else if (desiredRoots.length > 0) {
806
- section = section.trimEnd() + `\nwritable_roots = ${formatTomlArray(desiredRoots)}\n`;
807
- rootsAdded = desiredRoots;
808
- changed = true;
809
- }
810
-
811
- if (!changed) {
879
+ const flagsResult = ensureSandboxWorkspaceFlags(sectionInfo.section, {
880
+ network_access: networkAccess,
881
+ exclude_tmpdir_env_var: excludeTmpdirEnvVar,
882
+ exclude_slash_tmp: excludeSlashTmp,
883
+ });
884
+ const rootsResult = mergeSandboxWorkspaceRoots(flagsResult.section, desiredRoots, repoRoot);
885
+ if (!flagsResult.changed && !rootsResult.changed) {
812
886
  return { toml, changed: false, added: false, rootsAdded: [] };
813
887
  }
814
888
 
815
- const updatedToml =
816
- toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
817
-
818
889
  return {
819
- toml: updatedToml,
890
+ toml:
891
+ toml.substring(0, sectionInfo.afterHeader) +
892
+ rootsResult.section +
893
+ toml.substring(sectionInfo.sectionEnd),
820
894
  changed: true,
821
895
  added: false,
822
- rootsAdded,
896
+ rootsAdded: rootsResult.rootsAdded,
823
897
  };
824
898
  }
825
899
 
826
900
  /**
827
- * Prune writable_roots in [sandbox_workspace_write] that no longer exist on disk.
828
- * Returns the updated TOML and a list of removed paths.
829
- * @param {string} toml
830
- * @returns {{ toml: string, changed: boolean, removed: string[] }}
901
+ * Prunes non-existent entries from the `[sandbox_workspace_write]` writable_roots list.
902
+ *
903
+ * Looks up the `writable_roots` array in the `[sandbox_workspace_write]` section,
904
+ * checks each path on disk, and removes any roots that no longer exist. The `/tmp`
905
+ * root is always preserved, even if it cannot be checked reliably. Returns the
906
+ * updated TOML (if any change was made), a `changed` flag, and the list of roots
907
+ * that were removed.
908
+ *
909
+ * @param {string} toml - The full Codex TOML configuration contents.
910
+ * @returns {{ toml: string, changed: boolean, removed: string[] }} Result of pruning.
831
911
  */
832
912
  export function pruneStaleSandboxRoots(toml) {
833
913
  if (!hasSandboxWorkspaceWrite(toml)) {
834
914
  return { toml, changed: false, removed: [] };
835
915
  }
836
- const header = "[sandbox_workspace_write]";
837
- const headerIdx = toml.indexOf(header);
838
- if (headerIdx === -1) return { toml, changed: false, removed: [] };
839
- const afterHeader = headerIdx + header.length;
840
- const nextSection = toml.indexOf("\n[", afterHeader);
841
- const sectionEnd = nextSection === -1 ? toml.length : nextSection;
842
- let section = toml.substring(afterHeader, sectionEnd);
916
+ const sectionInfo = findTomlSection(toml, "[sandbox_workspace_write]");
917
+ if (!sectionInfo) return { toml, changed: false, removed: [] };
843
918
 
844
919
  const rootsRegex = /^writable_roots\s*=\s*(\[[^\]]*\])\s*$/m;
845
- const match = section.match(rootsRegex);
920
+ const match = sectionInfo.section.match(rootsRegex);
846
921
  if (!match) return { toml, changed: false, removed: [] };
847
922
 
848
923
  const existing = parseTomlArrayLiteral(match[1]);
849
- const valid = existing.filter((r) => r === "/tmp" || existsSync(r));
850
- const removed = existing.filter((r) => r !== "/tmp" && !existsSync(r));
924
+ const valid = existing.filter((root) => root === "/tmp" || existsSync(root));
925
+ const removed = existing.filter((root) => root !== "/tmp" && !existsSync(root));
851
926
  if (removed.length === 0) return { toml, changed: false, removed: [] };
852
927
 
853
- section = section.replace(rootsRegex, `writable_roots = ${formatTomlArray(valid)}`);
854
- const updatedToml =
855
- toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
856
- return { toml: updatedToml, changed: true, removed };
928
+ const nextSection = sectionInfo.section.replace(
929
+ rootsRegex,
930
+ `writable_roots = ${formatTomlArray(valid)}`,
931
+ );
932
+ return {
933
+ toml:
934
+ toml.substring(0, sectionInfo.afterHeader) +
935
+ nextSection +
936
+ toml.substring(sectionInfo.sectionEnd),
937
+ changed: true,
938
+ removed,
939
+ };
857
940
  }
858
-
859
941
  /**
860
942
  * Build the [shell_environment_policy] section.
861
943
  * Default: inherit = "all" so .NET, Go, Node etc. env vars are visible.
@@ -888,38 +970,67 @@ export function hasMicrosoftDocsMcp(toml) {
888
970
  }
889
971
 
890
972
  /**
891
- * Build MCP server blocks for context7 and microsoft-docs.
892
- * These are universally useful for documentation lookups.
973
+ * Build MCP server blocks for common servers: context7, sequential-thinking,
974
+ * playwright, and microsoft-docs. These are universally useful for
975
+ * documentation lookups and related tasks.
893
976
  */
894
- export function buildCommonMcpBlocks() {
977
+ const COMMON_MCP_SERVER_DEFS = [
978
+ {
979
+ name: "context7",
980
+ headerComment: "# ── Common MCP servers (added by bosun) ──",
981
+ lines: [
982
+ "[mcp_servers.context7]",
983
+ "startup_timeout_sec = 120",
984
+ 'command = "npx"',
985
+ 'args = ["-y", "@upstash/context7-mcp"]',
986
+ ],
987
+ isPresent: hasContext7Mcp,
988
+ },
989
+ {
990
+ name: "sequential-thinking",
991
+ lines: [
992
+ "[mcp_servers.sequential-thinking]",
993
+ "startup_timeout_sec = 120",
994
+ 'command = "npx"',
995
+ 'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]',
996
+ ],
997
+ isPresent: (toml) => hasNamedMcpServer(toml, "sequential-thinking"),
998
+ },
999
+ {
1000
+ name: "playwright",
1001
+ lines: [
1002
+ "[mcp_servers.playwright]",
1003
+ "startup_timeout_sec = 120",
1004
+ 'command = "npx"',
1005
+ 'args = ["-y", "@playwright/mcp@latest"]',
1006
+ ],
1007
+ isPresent: (toml) => hasNamedMcpServer(toml, "playwright"),
1008
+ },
1009
+ {
1010
+ name: "microsoft-docs",
1011
+ lines: [
1012
+ "[mcp_servers.microsoft-docs]",
1013
+ 'url = "https://learn.microsoft.com/api/mcp"',
1014
+ '# NOTE: Tool list intentionally limited to avoid Azure Responses API schema-size/parser issues.',
1015
+ 'tools = ["microsoft_docs_search", "microsoft_code_sample_search"]',
1016
+ ],
1017
+ isPresent: hasMicrosoftDocsMcp,
1018
+ },
1019
+ ];
1020
+
1021
+ function buildCommonMcpBlock(definition) {
895
1022
  return [
896
1023
  "",
897
- "# ── Common MCP servers (added by bosun) ──",
898
- "[mcp_servers.context7]",
899
- "startup_timeout_sec = 120",
900
- 'command = "npx"',
901
- 'args = ["-y", "@upstash/context7-mcp"]',
902
- "",
903
- "[mcp_servers.sequential-thinking]",
904
- "startup_timeout_sec = 120",
905
- 'command = "npx"',
906
- 'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]',
907
- "",
908
- "[mcp_servers.playwright]",
909
- "startup_timeout_sec = 120",
910
- 'command = "npx"',
911
- 'args = ["-y", "@playwright/mcp@latest"]',
912
- "",
913
- "[mcp_servers.microsoft-docs]",
914
- 'url = "https://learn.microsoft.com/api/mcp"',
915
- // microsoft_docs_fetch description alone is ~2KB and breaks the Azure
916
- // Responses API JSON parser when combined with other MCP tool schemas.
917
- // Keep only the two search tools which are sufficient for most use cases.
918
- 'tools = ["microsoft_docs_search", "microsoft_code_sample_search"]',
1024
+ ...(definition.headerComment ? [definition.headerComment] : []),
1025
+ ...definition.lines,
919
1026
  "",
920
1027
  ].join("\n");
921
1028
  }
922
1029
 
1030
+ export function buildCommonMcpBlocks() {
1031
+ return COMMON_MCP_SERVER_DEFS.map(buildCommonMcpBlock).join("");
1032
+ }
1033
+
923
1034
  function hasNamedMcpServer(toml, name) {
924
1035
  return new RegExp(`^\\[mcp_servers\\.${escapeRegex(name)}\\]`, "m").test(
925
1036
  toml,
@@ -966,28 +1077,11 @@ function stripDeprecatedSandboxPermissions(toml) {
966
1077
 
967
1078
  // ── Public API ───────────────────────────────────────────────────────────────
968
1079
 
969
- /**
970
- * Read the current config.toml (or return empty string if it doesn't exist).
971
- */
972
- export function readCodexConfig() {
973
- if (!existsSync(CONFIG_PATH)) return "";
974
- return readFileSync(CONFIG_PATH, "utf8");
975
- }
1080
+ export { readCodexConfig };
976
1081
 
977
- /**
978
- * Write the config.toml, creating ~/.codex/ if needed.
979
- */
980
- export function writeCodexConfig(content) {
981
- mkdirSync(CODEX_DIR, { recursive: true });
982
- writeFileSync(CONFIG_PATH, content, "utf8");
983
- }
1082
+ export { writeCodexConfig };
984
1083
 
985
- /**
986
- * Get the path to the Codex config file.
987
- */
988
- export function getConfigPath() {
989
- return CONFIG_PATH;
990
- }
1084
+ export { getConfigPath };
991
1085
 
992
1086
  /**
993
1087
  * Check whether the config already has a [mcp_servers.vibe_kanban] section.
@@ -1327,55 +1421,7 @@ export function ensureRetrySettings(toml, providerName) {
1327
1421
  * @param {object} [opts.env] Environment overrides (defaults to process.env)
1328
1422
  * @param {string} [opts.primarySdk] Primary agent SDK: "codex", "copilot", or "claude"
1329
1423
  */
1330
- export function ensureCodexConfig({
1331
- vkBaseUrl = "http://127.0.0.1:54089",
1332
- skipVk = true,
1333
- manageVkMcp = false,
1334
- dryRun = false,
1335
- env = process.env,
1336
- primarySdk,
1337
- } = {}) {
1338
- const result = {
1339
- path: CONFIG_PATH,
1340
- created: false,
1341
- vkAdded: false,
1342
- vkRemoved: false,
1343
- vkEnvUpdated: false,
1344
- agentSdkAdded: false,
1345
- featuresAdded: [],
1346
- agentMaxThreads: null,
1347
- agentMaxThreadsSkipped: null,
1348
- sandboxAdded: false,
1349
- sandboxWorkspaceAdded: false,
1350
- sandboxWorkspaceUpdated: false,
1351
- sandboxWorkspaceRootsAdded: [],
1352
- sandboxStaleRootsRemoved: [],
1353
- shellEnvAdded: false,
1354
- commonMcpAdded: false,
1355
- profileProvidersAdded: [],
1356
- timeoutsFixed: [],
1357
- retriesAdded: [],
1358
- trustedProjectsAdded: [],
1359
- noChanges: true,
1360
- };
1361
-
1362
- const configExisted = existsSync(CONFIG_PATH);
1363
- const originalToml = readCodexConfig();
1364
- let toml = stripDeprecatedSandboxPermissions(originalToml);
1365
- if (!configExisted) {
1366
- result.created = true;
1367
- toml = "";
1368
- }
1369
-
1370
- const sandboxModeResult = ensureTopLevelSandboxMode(
1371
- toml,
1372
- env.CODEX_SANDBOX_MODE,
1373
- );
1374
- toml = sandboxModeResult.toml;
1375
- if (sandboxModeResult.changed) {
1376
- result.sandboxAdded = true;
1377
- }
1378
-
1424
+ function resolveSandboxWorkspaceOptions(env) {
1379
1425
  const repoRoot =
1380
1426
  env.BOSUN_AGENT_REPO_ROOT ||
1381
1427
  env.REPO_ROOT ||
@@ -1384,160 +1430,149 @@ export function ensureCodexConfig({
1384
1430
  const additionalRoots = env.BOSUN_WORKSPACES_DIR
1385
1431
  ? [env.BOSUN_WORKSPACES_DIR]
1386
1432
  : [];
1387
- const sandboxWorkspaceResult = ensureSandboxWorkspaceWrite(toml, {
1433
+ return {
1388
1434
  repoRoot,
1389
1435
  additionalRoots,
1390
1436
  writableRoots: env.CODEX_SANDBOX_WRITABLE_ROOTS,
1391
- });
1392
- toml = sandboxWorkspaceResult.toml;
1437
+ };
1438
+ }
1439
+
1440
+ function applySandboxDefaults(toml, env, result) {
1441
+ const sandboxModeResult = ensureTopLevelSandboxMode(
1442
+ toml,
1443
+ env.CODEX_SANDBOX_MODE,
1444
+ );
1445
+ let nextToml = sandboxModeResult.toml;
1446
+ if (sandboxModeResult.changed) {
1447
+ result.sandboxAdded = true;
1448
+ }
1449
+
1450
+ const sandboxOptions = resolveSandboxWorkspaceOptions(env);
1451
+ const sandboxWorkspaceResult = ensureSandboxWorkspaceWrite(nextToml, sandboxOptions);
1452
+ nextToml = sandboxWorkspaceResult.toml;
1393
1453
  result.sandboxWorkspaceAdded = sandboxWorkspaceResult.added;
1394
1454
  result.sandboxWorkspaceUpdated =
1395
1455
  sandboxWorkspaceResult.changed && !sandboxWorkspaceResult.added;
1396
1456
  result.sandboxWorkspaceRootsAdded = sandboxWorkspaceResult.rootsAdded;
1397
1457
 
1398
- const pruneResult = pruneStaleSandboxRoots(toml);
1399
- toml = pruneResult.toml;
1458
+ const pruneResult = pruneStaleSandboxRoots(nextToml);
1459
+ nextToml = pruneResult.toml;
1400
1460
  result.sandboxStaleRootsRemoved = pruneResult.removed;
1401
1461
 
1402
- if (!hasShellEnvPolicy(toml)) {
1403
- toml += buildShellEnvPolicy(env.CODEX_SHELL_ENV_POLICY || "all");
1462
+ if (!hasShellEnvPolicy(nextToml)) {
1463
+ nextToml += buildShellEnvPolicy(env.CODEX_SHELL_ENV_POLICY || "all");
1404
1464
  result.shellEnvAdded = true;
1405
1465
  }
1406
1466
 
1467
+ return { toml: nextToml, ...sandboxOptions };
1468
+ }
1469
+
1470
+ function normalizePrimarySdkName(primarySdk, env) {
1407
1471
  const rawPrimary = String(primarySdk || env.PRIMARY_AGENT || "codex")
1408
1472
  .trim()
1409
1473
  .toLowerCase();
1410
- const normalizedPrimary =
1411
- rawPrimary === "copilot" || rawPrimary.includes("copilot")
1412
- ? "copilot"
1413
- : rawPrimary === "claude" || rawPrimary.includes("claude")
1414
- ? "claude"
1415
- : rawPrimary === "codex" || rawPrimary.includes("codex")
1416
- ? "codex"
1417
- : "codex";
1418
- if (!hasAgentSdkConfig(toml)) {
1419
- toml += buildAgentSdkBlock({ primary: normalizedPrimary });
1474
+ if (rawPrimary === "copilot" || rawPrimary.includes("copilot")) return "copilot";
1475
+ if (rawPrimary === "claude" || rawPrimary.includes("claude")) return "claude";
1476
+ if (rawPrimary === "codex" || rawPrimary.includes("codex")) return "codex";
1477
+ return "codex";
1478
+ }
1479
+
1480
+ function applyAgentSdkDefaults(toml, env, primarySdk, result) {
1481
+ let nextToml = toml;
1482
+ const normalizedPrimary = normalizePrimarySdkName(primarySdk, env);
1483
+ if (!hasAgentSdkConfig(nextToml)) {
1484
+ nextToml += buildAgentSdkBlock({ primary: normalizedPrimary });
1420
1485
  result.agentSdkAdded = true;
1421
1486
  }
1422
1487
 
1423
1488
  const maxThreads = resolveAgentMaxThreads(env);
1424
1489
  if (maxThreads.explicit && !maxThreads.value) {
1425
1490
  result.agentMaxThreadsSkipped = String(maxThreads.raw);
1426
- } else {
1427
- const maxThreadsResult = ensureAgentMaxThreads(toml, {
1428
- maxThreads: maxThreads.value,
1429
- overwrite: maxThreads.explicit,
1430
- });
1431
- toml = maxThreadsResult.toml;
1432
- if (maxThreadsResult.changed && !maxThreadsResult.skipped) {
1433
- result.agentMaxThreads = {
1434
- from: maxThreadsResult.existing,
1435
- to: maxThreadsResult.applied,
1436
- explicit: maxThreads.explicit,
1437
- };
1438
- } else if (maxThreadsResult.skipped && maxThreads.explicit) {
1439
- result.agentMaxThreadsSkipped = String(maxThreads.raw);
1440
- }
1491
+ return nextToml;
1441
1492
  }
1442
1493
 
1443
- const featureResult = ensureFeatureFlags(toml, env);
1444
- result.featuresAdded = featureResult.added;
1445
- toml = featureResult.toml;
1494
+ const maxThreadsResult = ensureAgentMaxThreads(nextToml, {
1495
+ maxThreads: maxThreads.value,
1496
+ overwrite: maxThreads.explicit,
1497
+ });
1498
+ nextToml = maxThreadsResult.toml;
1499
+ if (maxThreadsResult.changed && !maxThreadsResult.skipped) {
1500
+ result.agentMaxThreads = {
1501
+ from: maxThreadsResult.existing,
1502
+ to: maxThreadsResult.applied,
1503
+ explicit: maxThreads.explicit,
1504
+ };
1505
+ } else if (maxThreadsResult.skipped && maxThreads.explicit) {
1506
+ result.agentMaxThreadsSkipped = String(maxThreads.raw);
1507
+ }
1508
+
1509
+ return nextToml;
1510
+ }
1446
1511
 
1512
+ function applyVibeKanbanDefaults(toml, { manageVkMcp, skipVk, vkBaseUrl }, result) {
1513
+ let nextToml = toml;
1447
1514
  const shouldManageGlobalVkMcp = Boolean(manageVkMcp) && !skipVk;
1448
1515
  if (!shouldManageGlobalVkMcp) {
1449
- if (hasVibeKanbanMcp(toml)) {
1450
- toml = removeVibeKanbanMcp(toml);
1516
+ if (hasVibeKanbanMcp(nextToml)) {
1517
+ nextToml = removeVibeKanbanMcp(nextToml);
1451
1518
  result.vkRemoved = true;
1452
1519
  }
1453
- } else if (!hasVibeKanbanMcp(toml)) {
1454
- toml += buildVibeKanbanBlock({ vkBaseUrl });
1520
+ return nextToml;
1521
+ }
1522
+
1523
+ if (!hasVibeKanbanMcp(nextToml)) {
1524
+ nextToml += buildVibeKanbanBlock({ vkBaseUrl });
1455
1525
  result.vkAdded = true;
1526
+ return nextToml;
1527
+ }
1528
+
1529
+ const vkEnvValues = {
1530
+ VK_BASE_URL: vkBaseUrl,
1531
+ VK_ENDPOINT_URL: vkBaseUrl,
1532
+ };
1533
+ const beforeVkEnv = nextToml;
1534
+ if (!hasVibeKanbanEnv(nextToml)) {
1535
+ nextToml =
1536
+ nextToml.trimEnd() +
1537
+ "\n\n[mcp_servers.vibe_kanban.env]\n" +
1538
+ 'VK_BASE_URL = "' + vkBaseUrl + '"\n' +
1539
+ 'VK_ENDPOINT_URL = "' + vkBaseUrl + '"\n';
1456
1540
  } else {
1457
- const vkEnvValues = {
1458
- VK_BASE_URL: vkBaseUrl,
1459
- VK_ENDPOINT_URL: vkBaseUrl,
1460
- };
1461
- const beforeVkEnv = toml;
1462
- if (!hasVibeKanbanEnv(toml)) {
1463
- toml =
1464
- toml.trimEnd() +
1465
- "\n\n[mcp_servers.vibe_kanban.env]\n" +
1466
- `VK_BASE_URL = "${vkBaseUrl}"\n` +
1467
- `VK_ENDPOINT_URL = "${vkBaseUrl}"\n`;
1468
- } else {
1469
- toml = updateVibeKanbanEnv(toml, vkEnvValues);
1470
- }
1471
- if (toml !== beforeVkEnv) {
1472
- result.vkEnvUpdated = true;
1473
- }
1541
+ nextToml = updateVibeKanbanEnv(nextToml, vkEnvValues);
1474
1542
  }
1543
+ if (nextToml !== beforeVkEnv) {
1544
+ result.vkEnvUpdated = true;
1545
+ }
1546
+ return nextToml;
1547
+ }
1475
1548
 
1476
- const commonMcpBlocks = [
1477
- {
1478
- present: hasContext7Mcp(toml),
1479
- block: [
1480
- "",
1481
- "# ── Common MCP servers (added by bosun) ──",
1482
- "[mcp_servers.context7]",
1483
- "startup_timeout_sec = 120",
1484
- 'command = "npx"',
1485
- 'args = ["-y", "@upstash/context7-mcp"]',
1486
- "",
1487
- ].join("\n"),
1488
- },
1489
- {
1490
- present: hasNamedMcpServer(toml, "sequential-thinking"),
1491
- block: [
1492
- "",
1493
- "[mcp_servers.sequential-thinking]",
1494
- "startup_timeout_sec = 120",
1495
- 'command = "npx"',
1496
- 'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]',
1497
- "",
1498
- ].join("\n"),
1499
- },
1500
- {
1501
- present: hasNamedMcpServer(toml, "playwright"),
1502
- block: [
1503
- "",
1504
- "[mcp_servers.playwright]",
1505
- "startup_timeout_sec = 120",
1506
- 'command = "npx"',
1507
- 'args = ["-y", "@playwright/mcp@latest"]',
1508
- "",
1509
- ].join("\n"),
1510
- },
1511
- {
1512
- present: hasMicrosoftDocsMcp(toml),
1513
- block: [
1514
- "",
1515
- "[mcp_servers.microsoft-docs]",
1516
- 'url = "https://learn.microsoft.com/api/mcp"',
1517
- 'tools = ["microsoft_docs_search", "microsoft_code_sample_search"]',
1518
- "",
1519
- ].join("\n"),
1520
- },
1521
- ];
1522
- for (const item of commonMcpBlocks) {
1523
- if (item.present) continue;
1524
- toml += item.block;
1525
- result.commonMcpAdded = true;
1549
+ function ensureCommonMcpDefaults(toml, result) {
1550
+ let nextToml = toml;
1551
+ for (const definition of COMMON_MCP_SERVER_DEFS) {
1552
+ if (!definition.isPresent(nextToml)) {
1553
+ nextToml += buildCommonMcpBlock(definition);
1554
+ result.commonMcpAdded = true;
1555
+ }
1526
1556
  }
1527
1557
 
1528
1558
  for (const serverName of ["context7", "sequential-thinking", "playwright"]) {
1529
- const timeoutResult = ensureMcpStartupTimeout(toml, serverName, 120);
1530
- toml = timeoutResult.toml;
1559
+ const timeoutResult = ensureMcpStartupTimeout(nextToml, serverName, 120);
1560
+ nextToml = timeoutResult.toml;
1531
1561
  }
1532
1562
 
1533
- const providerResult = ensureModelProviderSectionsFromEnv(toml, env);
1534
- toml = providerResult.toml;
1563
+ return nextToml;
1564
+ }
1565
+
1566
+ function applyModelProviderDefaults(toml, env, result) {
1567
+ let nextToml = toml;
1568
+ const providerResult = ensureModelProviderSectionsFromEnv(nextToml, env);
1569
+ nextToml = providerResult.toml;
1535
1570
  result.profileProvidersAdded = providerResult.added;
1536
1571
 
1537
- const timeoutAudit = auditStreamTimeouts(toml);
1572
+ const timeoutAudit = auditStreamTimeouts(nextToml);
1538
1573
  for (const item of timeoutAudit) {
1539
1574
  if (!item.needsUpdate) continue;
1540
- toml = setStreamTimeout(toml, item.provider, RECOMMENDED_STREAM_IDLE_TIMEOUT_MS);
1575
+ nextToml = setStreamTimeout(nextToml, item.provider, RECOMMENDED_STREAM_IDLE_TIMEOUT_MS);
1541
1576
  result.timeoutsFixed.push({
1542
1577
  provider: item.provider,
1543
1578
  from: item.currentValue,
@@ -1545,25 +1580,18 @@ export function ensureCodexConfig({
1545
1580
  });
1546
1581
  }
1547
1582
 
1548
- const providers = auditStreamTimeouts(toml).map((item) => item.provider);
1549
- for (const provider of providers) {
1550
- const beforeRetry = toml;
1551
- toml = ensureRetrySettings(toml, provider);
1552
- if (toml !== beforeRetry) {
1583
+ for (const provider of auditStreamTimeouts(nextToml).map((item) => item.provider)) {
1584
+ const beforeRetry = nextToml;
1585
+ nextToml = ensureRetrySettings(nextToml, provider);
1586
+ if (nextToml !== beforeRetry) {
1553
1587
  result.retriesAdded.push(provider);
1554
1588
  }
1555
1589
  }
1556
1590
 
1557
- const changed = toml !== originalToml;
1558
- result.noChanges = !result.created && !changed;
1559
-
1560
- if (!dryRun && (result.created || changed)) {
1561
- writeCodexConfig(toml);
1562
- }
1591
+ return nextToml;
1592
+ }
1563
1593
 
1564
- // Keep project-level .codex/config.toml files active by trusting the
1565
- // current execution roots in the global user config. Without this, Codex CLI
1566
- // warns that project config is disabled and ignores repo-scoped settings.
1594
+ function applyTrustedProjectDefaults(repoRoot, additionalRoots, dryRun, result) {
1567
1595
  const trustPaths = [repoRoot, ...additionalRoots]
1568
1596
  .map((p) => String(p || "").trim())
1569
1597
  .filter(Boolean)
@@ -1572,22 +1600,101 @@ export function ensureCodexConfig({
1572
1600
  const trustResult = ensureTrustedProjects(trustPaths, { dryRun });
1573
1601
  result.trustedProjectsAdded = trustResult.added;
1574
1602
  }
1603
+ }
1575
1604
 
1576
- return result;
1605
+ function createEnsureCodexConfigResult() {
1606
+ return {
1607
+ path: CONFIG_PATH,
1608
+ created: false,
1609
+ vkAdded: false,
1610
+ vkRemoved: false,
1611
+ vkEnvUpdated: false,
1612
+ agentSdkAdded: false,
1613
+ featuresAdded: [],
1614
+ agentMaxThreads: null,
1615
+ agentMaxThreadsSkipped: null,
1616
+ sandboxAdded: false,
1617
+ sandboxWorkspaceAdded: false,
1618
+ sandboxWorkspaceUpdated: false,
1619
+ sandboxWorkspaceRootsAdded: [],
1620
+ sandboxStaleRootsRemoved: [],
1621
+ shellEnvAdded: false,
1622
+ commonMcpAdded: false,
1623
+ profileProvidersAdded: [],
1624
+ timeoutsFixed: [],
1625
+ retriesAdded: [],
1626
+ trustedProjectsAdded: [],
1627
+ noChanges: true,
1628
+ };
1577
1629
  }
1578
1630
 
1579
- /**
1580
- * Print a human-friendly summary of what ensureCodexConfig() did.
1581
- * @param {object} result Return value from ensureCodexConfig()
1582
- * @param {(msg: string) => void} [log] Logger (default: console.log)
1583
- */
1584
- export function printConfigSummary(result, log = console.log) {
1585
- if (result.noChanges) {
1586
- log(" :check: Codex CLI config is already up to date");
1587
- log(` ${result.path}`);
1588
- return;
1631
+ function initializeCodexConfigState(result) {
1632
+ const configExisted = existsSync(CONFIG_PATH);
1633
+ const originalToml = readCodexConfig();
1634
+ if (!configExisted) {
1635
+ result.created = true;
1636
+ return { originalToml, toml: "" };
1589
1637
  }
1638
+ return {
1639
+ originalToml,
1640
+ toml: stripDeprecatedSandboxPermissions(originalToml),
1641
+ };
1642
+ }
1590
1643
 
1644
+ function applyEnsureCodexConfigDefaults(toml, env, primarySdk, vibeKanbanOptions, result) {
1645
+ const sandboxState = applySandboxDefaults(toml, env, result);
1646
+ let nextToml = sandboxState.toml;
1647
+
1648
+ nextToml = applyAgentSdkDefaults(nextToml, env, primarySdk, result);
1649
+
1650
+ const featureResult = ensureFeatureFlags(nextToml, env);
1651
+ result.featuresAdded = featureResult.added;
1652
+ nextToml = featureResult.toml;
1653
+
1654
+ nextToml = applyVibeKanbanDefaults(nextToml, vibeKanbanOptions, result);
1655
+ nextToml = ensureCommonMcpDefaults(nextToml, result);
1656
+ nextToml = applyModelProviderDefaults(nextToml, env, result);
1657
+
1658
+ return { sandboxState, toml: nextToml };
1659
+ }
1660
+
1661
+ function persistCodexConfigIfChanged(toml, originalToml, dryRun, result) {
1662
+ const changed = toml !== originalToml;
1663
+ result.noChanges = !result.created && !changed;
1664
+ if (!dryRun && (result.created || changed)) {
1665
+ writeCodexConfig(toml);
1666
+ }
1667
+ }
1668
+
1669
+ export function ensureCodexConfig({
1670
+ vkBaseUrl = "http://127.0.0.1:54089",
1671
+ skipVk = true,
1672
+ manageVkMcp = false,
1673
+ dryRun = false,
1674
+ env = process.env,
1675
+ primarySdk,
1676
+ } = {}) {
1677
+ const result = createEnsureCodexConfigResult();
1678
+ const { originalToml, toml: initialToml } = initializeCodexConfigState(result);
1679
+ const { sandboxState, toml } = applyEnsureCodexConfigDefaults(
1680
+ initialToml,
1681
+ env,
1682
+ primarySdk,
1683
+ { manageVkMcp, skipVk, vkBaseUrl },
1684
+ result,
1685
+ );
1686
+
1687
+ persistCodexConfigIfChanged(toml, originalToml, dryRun, result);
1688
+ applyTrustedProjectDefaults(
1689
+ sandboxState.repoRoot,
1690
+ sandboxState.additionalRoots,
1691
+ dryRun,
1692
+ result,
1693
+ );
1694
+ return result;
1695
+ }
1696
+
1697
+ function logConfigSummaryHeader(result, log) {
1591
1698
  if (result.created) {
1592
1699
  log(" :edit: Created new Codex CLI config");
1593
1700
  }
@@ -1614,7 +1721,9 @@ export function printConfigSummary(result, log = console.log) {
1614
1721
  : `${result.featuresAdded.length} feature flags`;
1615
1722
  log(` :check: Added feature flags: ${key}`);
1616
1723
  }
1724
+ }
1617
1725
 
1726
+ function logSandboxSummary(result, log) {
1618
1727
  if (result.sandboxAdded) {
1619
1728
  log(" :check: Added sandbox permissions (disk-full-write-access)");
1620
1729
  }
@@ -1643,7 +1752,9 @@ export function printConfigSummary(result, log = console.log) {
1643
1752
  if (result.shellEnvAdded) {
1644
1753
  log(" :check: Added shell environment policy (inherit=all)");
1645
1754
  }
1755
+ }
1646
1756
 
1757
+ function logAgentSdkSummary(result, log) {
1647
1758
  if (result.agentMaxThreads) {
1648
1759
  const fromLabel =
1649
1760
  result.agentMaxThreads.from === null
@@ -1657,7 +1768,9 @@ export function printConfigSummary(result, log = console.log) {
1657
1768
  ` :alert: Skipped agents.max_threads (invalid value: ${result.agentMaxThreadsSkipped})`,
1658
1769
  );
1659
1770
  }
1771
+ }
1660
1772
 
1773
+ function logProviderSummary(result, log) {
1661
1774
  if (result.commonMcpAdded) {
1662
1775
  log(
1663
1776
  " :check: Added common MCP servers (context7, sequential-thinking, playwright, microsoft-docs)",
@@ -1682,179 +1795,27 @@ export function printConfigSummary(result, log = console.log) {
1682
1795
  for (const p of result.retriesAdded) {
1683
1796
  log(` :check: Added retry settings to [${p}]`);
1684
1797
  }
1685
-
1686
- log(` Config: ${result.path}`);
1687
- }
1688
-
1689
- // ── Trusted Projects ─────────────────────────────────────────────────────────
1690
-
1691
- /**
1692
- * Escape a string for use inside a double-quoted TOML basic string.
1693
- * Handles backslashes (Windows paths) and double-quote characters.
1694
- */
1695
- function tomlEscapeStr(s) {
1696
- return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1697
- }
1698
-
1699
- /**
1700
- * Format an array of strings as a TOML array literal, correctly escaping
1701
- * backslashes so Windows paths are stored faithfully.
1702
- *
1703
- * Example output: ["C:\\Users\\jon\\bosun", "/home/jon/bosun"]
1704
- */
1705
- function formatTomlArrayEscaped(values) {
1706
- return `[${values.map((v) => `"${tomlEscapeStr(v)}"`).join(", ")}]`;
1707
- }
1708
-
1709
- function toWindowsNamespacePath(pathValue) {
1710
- const value = String(pathValue || "").trim();
1711
- if (!value) return null;
1712
- if (value.startsWith("\\\\?\\")) return value;
1713
- const drivePath = toWindowsDrivePath(value);
1714
- if (drivePath) return `\\\\?\\${drivePath}`;
1715
- return null;
1716
- }
1717
-
1718
- function toWindowsDrivePath(pathValue) {
1719
- const raw = String(pathValue || "").trim();
1720
- if (!raw) return null;
1721
- let value = raw.replace(/\//g, "\\");
1722
- if (value.startsWith("\\\\?\\")) value = value.slice(4);
1723
- if (/^[a-zA-Z]:\\/.test(value)) return value;
1724
- const wslMatch = raw.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
1725
- if (wslMatch) {
1726
- const drive = wslMatch[1].toUpperCase();
1727
- const rest = wslMatch[2].replace(/\//g, "\\");
1728
- return `${drive}:\\${rest}`;
1729
- }
1730
- return null;
1731
- }
1732
-
1733
- function normalizeTrustedPathForCompare(pathValue) {
1734
- const trimTrailingPathSeparators = (value) => {
1735
- let out = String(value || "");
1736
- while (out.endsWith("/") || out.endsWith("\\")) out = out.slice(0, -1);
1737
- return out;
1738
- };
1739
- const raw = String(pathValue || "").trim();
1740
- if (!raw) return "";
1741
- const windowsDrivePath = toWindowsDrivePath(raw);
1742
- if (windowsDrivePath) {
1743
- return trimTrailingPathSeparators(windowsDrivePath).toLowerCase();
1744
- }
1745
- if (process.platform === "win32") {
1746
- let normalized = raw.replace(/\//g, "\\");
1747
- if (normalized.startsWith("\\\\?\\UNC\\")) {
1748
- normalized = "\\\\" + normalized.slice(8);
1749
- } else if (normalized.startsWith("\\\\?\\")) {
1750
- normalized = normalized.slice(4);
1751
- }
1752
- normalized = trimTrailingPathSeparators(normalized);
1753
- return normalized.toLowerCase();
1754
- }
1755
- return trimTrailingPathSeparators(resolve(raw));
1756
- }
1757
-
1758
- function buildTrustedPathVariants(pathValue) {
1759
- const base = resolve(pathValue);
1760
- const variants = [base];
1761
- const namespaced = toWindowsNamespacePath(base);
1762
- if (namespaced && namespaced !== base) variants.push(namespaced);
1763
- return variants;
1764
1798
  }
1765
1799
 
1766
1800
  /**
1767
- * Parse a TOML basic-string array literal, unescaping backslash sequences.
1768
- */
1769
- function parseTomlArrayLiteralEscaped(raw) {
1770
- if (!raw) return [];
1771
- const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
1772
- if (!inner.trim()) return [];
1773
- // Split on commas that are NOT inside quotes
1774
- const items = [];
1775
- let buf = "";
1776
- let inStr = false;
1777
- for (let i = 0; i < inner.length; i++) {
1778
- const ch = inner[i];
1779
- if (ch === "\\" && inStr) { buf += ch + (inner[++i] || ""); continue; }
1780
- if (ch === '"') { inStr = !inStr; buf += ch; continue; }
1781
- if (ch === "," && !inStr) { items.push(buf.trim()); buf = ""; continue; }
1782
- buf += ch;
1783
- }
1784
- if (buf.trim()) items.push(buf.trim());
1785
- return items
1786
- .map((item) => item.replace(/^"(.*)"$/s, "$1")) // strip outer quotes
1787
- .map((item) => item.replace(/\\(["\\])/g, "$1")) // unescape \" and \\
1788
- .filter(Boolean);
1789
- }
1790
-
1791
- /**
1792
- * Ensure the given directory paths are listed in the `trusted_projects`
1793
- * top-level key in ~/.codex/config.toml.
1794
- *
1795
- * Codex refuses to load a per-project .codex/config.toml unless the project
1796
- * directory appears in this list — producing warnings like:
1797
- * ":alert: Project config.toml files are disabled … add <dir> as a trusted project"
1798
- *
1799
- * Paths are stored as-is (forward or back slashes preserved) with proper TOML
1800
- * escaping so Windows paths survive round-trips through the file.
1801
- *
1802
- * @param {string[]} paths Absolute directories to trust (e.g. [bosunHome])
1803
- * @param {{ dryRun?: boolean }} [opts]
1804
- * @returns {{ added: string[], already: string[], path: string }}
1801
+ * Print a human-friendly summary of what ensureCodexConfig() did.
1802
+ * @param {object} result Return value from ensureCodexConfig()
1803
+ * @param {(msg: string) => void} [log] Logger (default: console.log)
1805
1804
  */
1806
- export function ensureTrustedProjects(paths, { dryRun = false } = {}) {
1807
- const result = { added: [], already: [], path: CONFIG_PATH };
1808
- const desired = (paths || [])
1809
- .flatMap((p) => buildTrustedPathVariants(p))
1810
- .filter(Boolean);
1811
- if (desired.length === 0) return result;
1812
-
1813
- let toml = readCodexConfig() || "";
1814
-
1815
- // Parse existing trusted_projects (multi-line arrays may span lines)
1816
- const existingMatch = toml.match(/^trusted_projects\s*=\s*(\[[^\]]*\])/m);
1817
- const existing = existingMatch ? parseTomlArrayLiteralEscaped(existingMatch[1]) : [];
1818
- const existingNormalized = new Set(
1819
- existing.map((p) => normalizeTrustedPathForCompare(p)).filter(Boolean),
1820
- );
1821
-
1822
- let changed = false;
1823
- for (const p of desired) {
1824
- const normalized = normalizeTrustedPathForCompare(p);
1825
- if (!normalized) continue;
1826
- if (existingNormalized.has(normalized)) {
1827
- result.already.push(p);
1828
- } else {
1829
- existing.push(p);
1830
- existingNormalized.add(normalized);
1831
- result.added.push(p);
1832
- changed = true;
1833
- }
1834
- }
1835
-
1836
- if (!changed) return result;
1837
- if (dryRun) return result;
1838
-
1839
- const newLine = `trusted_projects = ${formatTomlArrayEscaped(existing)}`;
1840
-
1841
- if (existingMatch) {
1842
- toml = toml.replace(/^trusted_projects\s*=\s*\[[^\]]*\]/m, newLine);
1843
- } else {
1844
- // Insert before the first section header (or at top if no sections)
1845
- const firstSection = toml.search(/^\[/m);
1846
- if (firstSection === -1) {
1847
- toml = `${newLine}\n${toml}`;
1848
- } else {
1849
- toml = `${toml.slice(0, firstSection)}${newLine}\n\n${toml.slice(firstSection)}`;
1850
- }
1805
+ export function printConfigSummary(result, log = console.log) {
1806
+ if (result.noChanges) {
1807
+ log(" :check: Codex CLI config is already up to date");
1808
+ log(` ${result.path}`);
1809
+ return;
1851
1810
  }
1852
1811
 
1853
- mkdirSync(CODEX_DIR, { recursive: true });
1854
- writeFileSync(CONFIG_PATH, toml, "utf8");
1855
- return result;
1812
+ logConfigSummaryHeader(result, log);
1813
+ logSandboxSummary(result, log);
1814
+ logAgentSdkSummary(result, log);
1815
+ logProviderSummary(result, log);
1816
+ log(` Config: ${result.path}`);
1856
1817
  }
1857
-
1818
+ export { ensureTrustedProjects };
1858
1819
  // ── Internal Helpers ─────────────────────────────────────────────────────────
1859
1820
 
1860
1821
  function escapeRegex(str) {
@@ -1866,3 +1827,5 @@ function parseBoolEnv(value) {
1866
1827
  if (["0", "false", "no", "off", "n"].includes(raw)) return false;
1867
1828
  return true;
1868
1829
  }
1830
+
1831
+