ai-maestro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +15 -0
  3. package/README.md +727 -0
  4. package/bin/maestro.mjs +246 -0
  5. package/package.json +29 -0
  6. package/src/checkpoint.mjs +102 -0
  7. package/src/codex-diagnostics.mjs +682 -0
  8. package/src/codex-home-inspect.mjs +252 -0
  9. package/src/codex-home.mjs +258 -0
  10. package/src/commands.mjs +809 -0
  11. package/src/config.mjs +11 -0
  12. package/src/debug.mjs +164 -0
  13. package/src/encoding-guard.mjs +125 -0
  14. package/src/engines/ai-router-engine.mjs +37 -0
  15. package/src/engines/codex-engine.mjs +21 -0
  16. package/src/engines/engine.mjs +21 -0
  17. package/src/engines/registry.mjs +29 -0
  18. package/src/files.mjs +65 -0
  19. package/src/format.mjs +132 -0
  20. package/src/lock.mjs +439 -0
  21. package/src/memory-log.mjs +18 -0
  22. package/src/memory.mjs +1 -0
  23. package/src/orchestration/attempt-chain-runtime.mjs +627 -0
  24. package/src/orchestration/budget-manager.mjs +121 -0
  25. package/src/orchestration/capability-registry.mjs +108 -0
  26. package/src/orchestration/consolidator.mjs +772 -0
  27. package/src/orchestration/delegation-executor.mjs +262 -0
  28. package/src/orchestration/delegation-manager.mjs +116 -0
  29. package/src/orchestration/deployment-intent.mjs +36 -0
  30. package/src/orchestration/engine-history.mjs +110 -0
  31. package/src/orchestration/engine-policy.mjs +73 -0
  32. package/src/orchestration/engine-selector.mjs +187 -0
  33. package/src/orchestration/execution-context.mjs +45 -0
  34. package/src/orchestration/failure-classifier.mjs +136 -0
  35. package/src/orchestration/failure-evidence.mjs +237 -0
  36. package/src/orchestration/fallback-chain-lock.mjs +217 -0
  37. package/src/orchestration/fallback-chain-trail.mjs +218 -0
  38. package/src/orchestration/fallback-executor.mjs +146 -0
  39. package/src/orchestration/fallback-graph.mjs +106 -0
  40. package/src/orchestration/fallback-recommendation.mjs +73 -0
  41. package/src/orchestration/file-conflict-detector.mjs +126 -0
  42. package/src/orchestration/host-validation.mjs +241 -0
  43. package/src/orchestration/orchestration-loop.mjs +1971 -0
  44. package/src/orchestration/orchestration-runtime.mjs +1019 -0
  45. package/src/orchestration/orchestration-scheduler.mjs +135 -0
  46. package/src/orchestration/orchestration-trail.mjs +192 -0
  47. package/src/orchestration/preflight.mjs +212 -0
  48. package/src/orchestration/provider-health.mjs +566 -0
  49. package/src/orchestration/provider-router.mjs +352 -0
  50. package/src/orchestration/rc-check-adapters.mjs +817 -0
  51. package/src/orchestration/rc-check.mjs +544 -0
  52. package/src/orchestration/rc-policy.mjs +200 -0
  53. package/src/orchestration/runtime-gate.mjs +146 -0
  54. package/src/orchestration/sector-managers.mjs +215 -0
  55. package/src/orchestration/self-hosting-canary.mjs +231 -0
  56. package/src/orchestration/self-hosting-readiness.mjs +244 -0
  57. package/src/orchestration/spec-planner.mjs +877 -0
  58. package/src/orchestration/subtask-planner.mjs +176 -0
  59. package/src/orchestration/task-classifier.mjs +241 -0
  60. package/src/orchestration/verifier.mjs +1608 -0
  61. package/src/orchestration-commands.mjs +279 -0
  62. package/src/planner.mjs +38 -0
  63. package/src/project.mjs +1 -0
  64. package/src/run-diagnostics.mjs +641 -0
  65. package/src/runner.mjs +243 -0
  66. package/src/safety.mjs +116 -0
  67. package/src/schema.mjs +371 -0
  68. package/src/session-commands.mjs +521 -0
  69. package/src/shell.mjs +93 -0
  70. package/src/smart-planner.mjs +249 -0
  71. package/src/task-commands.mjs +1182 -0
  72. package/src/tasks.mjs +134 -0
  73. package/src/ui/commands.mjs +76 -0
  74. package/src/ui/events.mjs +45 -0
  75. package/src/ui/public/app.js +600 -0
  76. package/src/ui/public/index.html +88 -0
  77. package/src/ui/public/style.css +460 -0
  78. package/src/ui/server.mjs +112 -0
  79. package/src/ui/state.mjs +504 -0
  80. package/src/usage.mjs +178 -0
  81. package/src/workspace-diff.mjs +228 -0
@@ -0,0 +1,252 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { pathExists } from './files.mjs';
5
+
6
+ export function isSensitivePath(filePath) {
7
+ return /(auth|token|secret|credential|session|cookie|login|key|cache|history|state|account)/i.test(filePath);
8
+ }
9
+
10
+ export function isPolicyLikePath(filePath) {
11
+ return /(trust|trusted|approval|permission|policy|sandbox|rule|instruction|config|profile|project)/i.test(filePath);
12
+ }
13
+
14
+ const inventoryNeedles = /(trust|trusted|approval|approvals|policy|policies|permission|permissions|sandbox|workspace|workspaces|project|projects|rules|config|settings|state|metadata)/i;
15
+ const excludedNeedles = /(auth|token|credential|secret|session|cookie|cache|key|login|account)/i;
16
+
17
+ export function classifyInventoryPath(relPath, entry = {}) {
18
+ const normalized = relPath.replace(/\\/g, '/');
19
+ const size = entry.size || 0;
20
+
21
+ if (/(^|\/)(attachments|vendor_imports|\.tmp|tmp|sessions|rollouts?)(\/|$)/i.test(normalized)) {
22
+ return { classification: 'cache', safeCopyCandidate: false, reason: 'attachments/vendor/tmp/session trees are not copied by probe' };
23
+ }
24
+ if (/(auth|token|credential|secret|key|login|account)/i.test(normalized)) {
25
+ return { classification: 'auth-sensitive', safeCopyCandidate: false, reason: 'path looks like auth/token/credential material' };
26
+ }
27
+ if (/(session|cookie)/i.test(normalized)) {
28
+ return { classification: 'session-sensitive', safeCopyCandidate: false, reason: 'path looks like session or cookie state' };
29
+ }
30
+ if (/(^|\/)(cache|\.tmp|tmp|history)(\/|$)|history|\.log$/i.test(normalized)) {
31
+ return { classification: 'cache', safeCopyCandidate: false, reason: 'cache/tmp/history/log content is not copied by probe' };
32
+ }
33
+ if (/^\.sandbox-bin\//i.test(normalized) || normalized === '.sandbox/setup_marker.json') {
34
+ return { classification: 'sandbox-runtime', safeCopyCandidate: false, reason: 'sandbox runtime already handled by codex-home-repair' };
35
+ }
36
+ if (/(^|\/)\.sandbox-secrets(\/|$)/i.test(normalized)) {
37
+ return { classification: 'auth-sensitive', safeCopyCandidate: false, reason: '.sandbox-secrets is sensitive' };
38
+ }
39
+ if (/(^|\/)\.sandbox(\/|$)/i.test(normalized) && !excludedNeedles.test(normalized)) {
40
+ return { classification: 'policy', safeCopyCandidate: entry.type === 'file' && size > 0 && size <= 1024 * 1024, reason: 'sandbox policy-like file and not excluded by sensitive patterns' };
41
+ }
42
+ if (/rules?/i.test(normalized) && !excludedNeedles.test(normalized)) {
43
+ return { classification: 'rule', safeCopyCandidate: entry.type === 'file' && size > 0 && size <= 512 * 1024, reason: 'rules file and not excluded by sensitive patterns' };
44
+ }
45
+ if (/config|settings/i.test(normalized) && !excludedNeedles.test(normalized)) {
46
+ return { classification: 'config', safeCopyCandidate: entry.type === 'file' && size > 0 && size <= 512 * 1024, reason: 'config/settings file and not excluded by sensitive patterns' };
47
+ }
48
+ if (/trust|trusted/i.test(normalized) && !excludedNeedles.test(normalized)) {
49
+ return { classification: 'trust', safeCopyCandidate: entry.type === 'file' && size > 0 && size <= 512 * 1024, reason: 'trust-like file and not excluded by sensitive patterns' };
50
+ }
51
+ if (/approval|policy|permission|workspace|project/i.test(normalized) && !excludedNeedles.test(normalized)) {
52
+ return { classification: 'policy', safeCopyCandidate: entry.type === 'file' && size > 0 && size <= 512 * 1024, reason: 'policy/workspace/project file and not excluded by sensitive patterns' };
53
+ }
54
+ if (/state|metadata/i.test(normalized)) {
55
+ return { classification: 'unknown-sensitive', safeCopyCandidate: false, reason: 'state/metadata can contain opaque local state; report only' };
56
+ }
57
+ if (inventoryNeedles.test(normalized) && excludedNeedles.test(normalized)) {
58
+ return { classification: 'unknown-sensitive', safeCopyCandidate: false, reason: 'matches policy keywords but also sensitive exclusions' };
59
+ }
60
+ return { classification: 'unknown-sensitive', safeCopyCandidate: false, reason: 'not classified as safely copyable' };
61
+ }
62
+
63
+ export async function sha256File(filePath) {
64
+ const buffer = await fs.readFile(filePath);
65
+ return crypto.createHash('sha256').update(buffer).digest('hex');
66
+ }
67
+
68
+ export function looksTextFile(relPath) {
69
+ return ['.toml', '.json', '.jsonl', '.md', '.txt', '.yaml', '.yml'].includes(path.extname(relPath).toLowerCase());
70
+ }
71
+
72
+ const sensitiveContentPattern = /(api_key|apikey|token|secret|authorization|bearer|session|cookie|credential|password|sk-|OPENAI_API_KEY|ANTHROPIC_API_KEY|NINEROUTER_API_KEY)/i;
73
+
74
+ export async function inspectSensitiveContent(filePath, relPath) {
75
+ if (!looksTextFile(relPath)) {
76
+ return { ok: true };
77
+ }
78
+ try {
79
+ const text = await fs.readFile(filePath, 'utf-8');
80
+ if (sensitiveContentPattern.test(text)) {
81
+ return { ok: false, reason: 'blocked-copy-sensitive-content' };
82
+ }
83
+ } catch (error) {
84
+ return { ok: false, reason: 'blocked-copy-read-error' };
85
+ }
86
+ return { ok: true };
87
+ }
88
+
89
+ export function redactSuspiciousValues(text) {
90
+ return text
91
+ .replace(/(["']?[^"'\s]*(token|secret|key|credential|password|auth|cookie)[^"'\s]*["']?\s*[:=]\s*)["'][^"']+["']/gi, '$1"<redacted>"')
92
+ .replace(/(Bearer\s+)[A-Za-z0-9._-]+/gi, '$1<redacted>')
93
+ .slice(0, 12000);
94
+ }
95
+
96
+ export async function readSafeStructure(root, relPath, classification) {
97
+ if (!looksTextFile(relPath) || /(sensitive|cache)/.test(classification)) {
98
+ return null;
99
+ }
100
+ const fullPath = path.join(root, relPath);
101
+ try {
102
+ const text = await fs.readFile(fullPath, 'utf-8');
103
+ if (relPath.toLowerCase().endsWith('.toml')) {
104
+ return parseTomlShape(text);
105
+ }
106
+ if (relPath.toLowerCase().endsWith('.json')) {
107
+ const parsed = JSON.parse(text);
108
+ return { jsonKeys: Array.isArray(parsed) ? ['<array>'] : Object.keys(parsed).slice(0, 80), preview: redactSuspiciousValues(text).slice(0, 1000) };
109
+ }
110
+ return { preview: redactSuspiciousValues(text).split(/\r?\n/).slice(0, 80).join('\n') };
111
+ } catch (error) {
112
+ return { error: error.message };
113
+ }
114
+ }
115
+
116
+ export async function listHomeFiles(root) {
117
+ const rows = [];
118
+ async function walk(dir) {
119
+ let entries = [];
120
+ try {
121
+ entries = await fs.readdir(dir, { withFileTypes: true });
122
+ } catch (error) {
123
+ return;
124
+ }
125
+ for (const entry of entries) {
126
+ const fullPath = path.join(dir, entry.name);
127
+ const rel = path.relative(root, fullPath).replace(/\\/g, '/');
128
+ if (entry.isDirectory()) {
129
+ rows.push({ path: rel, type: 'dir', sensitive: isSensitivePath(rel), policyLike: isPolicyLikePath(rel) });
130
+ await walk(fullPath);
131
+ } else if (entry.isFile()) {
132
+ const stat = await fs.stat(fullPath);
133
+ rows.push({ path: rel, type: 'file', size: stat.size, sensitive: isSensitivePath(rel), policyLike: isPolicyLikePath(rel) });
134
+ }
135
+ }
136
+ }
137
+ await walk(root);
138
+ return rows.sort((a, b) => a.path.localeCompare(b.path));
139
+ }
140
+
141
+ export function parseTomlShape(content) {
142
+ const sections = [];
143
+ const keys = {};
144
+ let current = 'root';
145
+ for (const rawLine of content.split(/\r?\n/)) {
146
+ const line = rawLine.replace(/#.*/, '').trim();
147
+ if (!line) {
148
+ continue;
149
+ }
150
+ const section = line.match(/^\[([^\]]+)\]$/);
151
+ if (section) {
152
+ current = section[1];
153
+ sections.push(current);
154
+ continue;
155
+ }
156
+ const keyMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.*)$/);
157
+ if (keyMatch) {
158
+ const key = keyMatch[1];
159
+ const rawValue = keyMatch[2].trim();
160
+ const fullKey = current === 'root' ? key : current + '.' + key;
161
+ keys[fullKey] = /(token|secret|key|credential|password|auth|cookie)/i.test(fullKey)
162
+ ? '<redacted>'
163
+ : rawValue.replace(/"(?:[^"\\]|\\.)*"/g, value => value.length > 160 ? '"<long-string>"' : value);
164
+ }
165
+ }
166
+ return { sections, keys };
167
+ }
168
+
169
+ export async function inspectHome(root) {
170
+ const exists = await pathExists(root);
171
+ const files = exists ? await listHomeFiles(root) : [];
172
+ const toml = {};
173
+ for (const file of files.filter(item => item.type === 'file' && item.path.toLowerCase().endsWith('.toml'))) {
174
+ const fullPath = path.join(root, file.path);
175
+ if (file.sensitive) {
176
+ toml[file.path] = { sensitive: true, keys: {}, sections: [] };
177
+ continue;
178
+ }
179
+ try {
180
+ toml[file.path] = parseTomlShape(await fs.readFile(fullPath, 'utf-8'));
181
+ } catch (error) {
182
+ toml[file.path] = { error: error.message, keys: {}, sections: [] };
183
+ }
184
+ }
185
+ return {
186
+ root,
187
+ exists,
188
+ files,
189
+ toml,
190
+ policyLikeFiles: files.filter(file => file.policyLike),
191
+ sensitiveFiles: files.filter(file => file.sensitive).map(file => ({ path: file.path, type: file.type, size: file.size || null }))
192
+ };
193
+ }
194
+
195
+ export function summarizeHomeIssues(home) {
196
+ const config = home.toml['config.toml'] || { keys: {}, sections: [] };
197
+ const profile = home.toml['9router.config.toml'] || { keys: {}, sections: [] };
198
+ const combinedKeys = { ...config.keys, ...profile.keys };
199
+ return {
200
+ hasConfigToml: !!home.toml['config.toml'],
201
+ has9routerConfigToml: !!home.toml['9router.config.toml'],
202
+ profileModelProvider: profile.keys.model_provider || null,
203
+ configModelProvider: config.keys.model_provider || null,
204
+ sandboxMode: combinedKeys.sandbox_mode || null,
205
+ approvalPolicy: combinedKeys.approval_policy || null,
206
+ defaultPermissions: combinedKeys.default_permissions || null,
207
+ hasSandboxWorkspaceWriteSection: Object.keys(combinedKeys).some(key => key.startsWith('sandbox_workspace_write.')) || (profile.sections || []).includes('sandbox_workspace_write') || (config.sections || []).includes('sandbox_workspace_write'),
208
+ hasProvider9routerInProfileFile: (profile.sections || []).includes('model_providers.9router'),
209
+ hasProvider9routerInConfigToml: (config.sections || []).includes('model_providers.9router'),
210
+ hasProfiles9router: (config.sections || []).includes('profiles.9router') || (profile.sections || []).includes('profiles.9router'),
211
+ mixesDefaultPermissionsAndSandboxMode: !!combinedKeys.default_permissions && !!combinedKeys.sandbox_mode
212
+ };
213
+ }
214
+
215
+ export function timestampForPath() {
216
+ const now = new Date();
217
+ const pad = value => String(value).padStart(2, '0');
218
+ return String(now.getFullYear()) + pad(now.getMonth() + 1) + pad(now.getDate()) + '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds());
219
+ }
220
+
221
+ export async function copyDirectorySafe(source, target) {
222
+ await fs.mkdir(target, { recursive: true });
223
+ const entries = await fs.readdir(source, { withFileTypes: true });
224
+ for (const entry of entries) {
225
+ const sourcePath = path.join(source, entry.name);
226
+ const targetPath = path.join(target, entry.name);
227
+ if (entry.isDirectory()) {
228
+ await copyDirectorySafe(sourcePath, targetPath);
229
+ } else if (entry.isFile()) {
230
+ await fs.copyFile(sourcePath, targetPath);
231
+ }
232
+ }
233
+ }
234
+
235
+ export async function removePathSafe(targetPath) {
236
+ try {
237
+ await fs.rm(targetPath, { recursive: true, force: true });
238
+ } catch (error) {
239
+ if (error.code !== 'ENOENT') {
240
+ throw error;
241
+ }
242
+ }
243
+ }
244
+
245
+ export async function restoreDirectoryFromBackup(backupPath, targetPath) {
246
+ await removePathSafe(targetPath);
247
+ await copyDirectorySafe(backupPath, targetPath);
248
+ }
249
+
250
+ export function safeProbeName(relPath) {
251
+ return relPath.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80) || 'candidate';
252
+ }
@@ -0,0 +1,258 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { CONFIG } from './config.mjs';
4
+
5
+ export const DEFAULT_CODEX_PROFILE_CONFIG = `model = "ricardo-codex-agentico"
6
+ model_provider = "9router"
7
+ model_reasoning_effort = "low"
8
+ approval_policy = "never"
9
+ sandbox_mode = "workspace-write"
10
+
11
+ [model_providers.9router]
12
+ name = "9Router Local"
13
+ base_url = "http://localhost:20128/v1"
14
+ env_key = "NINEROUTER_API_KEY"
15
+ wire_api = "responses"
16
+ `;
17
+
18
+ // Hotfix A.2: minimal, isolated config for the Maestro Codex home.
19
+ // Contains ONLY what a Maestro worker needs (9Router provider, agentic
20
+ // model, approval never, low reasoning, trusted workspace). It must never
21
+ // carry Desktop plugins, marketplaces, MCP servers, notify hooks, desktop
22
+ // settings, CUA runtimes, `[windows] sandbox = "elevated"`, or any
23
+ // reference to the normal Codex home — those are exactly what broke the
24
+ // isolated sandbox with `apply deny-read ACLs`.
25
+ export function buildMinimalIsolatedCodexConfig(projectRoot = process.cwd(), extraTrustedProjects = []) {
26
+ const normalizeKey = value => String(value).replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
27
+ const keys = [];
28
+ for (const candidate of [projectRoot, ...extraTrustedProjects]) {
29
+ const key = normalizeKey(candidate);
30
+ if (key && !keys.includes(key)) {
31
+ keys.push(key);
32
+ }
33
+ }
34
+ const projectSections = keys
35
+ .map(key => `[projects.'${key}']\ntrust_level = "trusted"`)
36
+ .join('\n\n');
37
+ // `[windows] sandbox = "unelevated"` is the non-admin Windows sandbox
38
+ // (restricted token). Verified with `codex sandbox` probes on this
39
+ // machine (Codex CLI 0.144.3): it grants real workspace-write inside the
40
+ // project and still denies writes outside the workspace. The Desktop's
41
+ // `sandbox = "elevated"` must never be copied here: its ACL helper is
42
+ // bound to the normal CODEX_HOME and fails with
43
+ // `helper_unknown_error: apply deny-read ACLs` in the isolated home;
44
+ // omitting the section entirely silently downgrades workers to read-only.
45
+ return `model = "ricardo-codex-agentico"
46
+ model_provider = "9router"
47
+ model_reasoning_effort = "low"
48
+ approval_policy = "never"
49
+
50
+ [model_providers.9router]
51
+ name = "9Router Local"
52
+ base_url = "http://localhost:20128/v1"
53
+ env_key = "NINEROUTER_API_KEY"
54
+ wire_api = "responses"
55
+
56
+ [windows]
57
+ sandbox = "unelevated"
58
+
59
+ ${projectSections}
60
+ `;
61
+ }
62
+
63
+ // Matches the normal Codex home path in TOML content whether it is written
64
+ // with single, double (escaped TOML) or forward slashes, without matching
65
+ // the isolated home itself (e.g. `C:\Users\PC\.codex-maestro`).
66
+ function buildNormalHomePattern(normalCodexHome) {
67
+ const segments = String(normalCodexHome)
68
+ .split(/[\\/]+/)
69
+ .filter(Boolean)
70
+ .map(segment => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
71
+ return new RegExp(segments.join('[\\\\/]+') + '(?![-\\w])', 'i');
72
+ }
73
+
74
+ // Static analysis of an isolated-home config.toml: flags every kind of
75
+ // Desktop/normal-home contamination this hotfix removes. `contaminated`
76
+ // means the file must be backed up and rewritten with the minimal config.
77
+ export function analyzeIsolatedCodexConfig(content, { normalCodexHome = CONFIG.normalCodexHome } = {}) {
78
+ const text = String(content || '');
79
+ const flags = {
80
+ referencesNormalCodexHome: buildNormalHomePattern(normalCodexHome).test(text),
81
+ desktopPluginsEnabled: /^\s*\[plugins\./m.test(text),
82
+ marketplacesConfigured: /^\s*\[marketplaces\./m.test(text),
83
+ mcpServersConfigured: /^\s*\[mcp_servers\./m.test(text),
84
+ notifyConfigured: /^\s*notify\s*=/m.test(text),
85
+ desktopSectionPresent: /^\s*\[desktop\]/m.test(text),
86
+ windowsSandboxSectionPresent: /^\s*\[windows\]/m.test(text),
87
+ windowsSandboxElevated: /^\s*\[windows\]/m.test(text) && /sandbox\s*=\s*"elevated"/.test(text),
88
+ desktopRuntimeReferenced: /codex-runtimes|AppData[\\/]+Local[\\/]+OpenAI[\\/]+Codex/i.test(text),
89
+ usesDefaultPermissions: /default_permissions\s*=/.test(text)
90
+ };
91
+ // `[windows] sandbox = "unelevated"` is part of the sanctioned minimal
92
+ // config (see buildMinimalIsolatedCodexConfig), so only the "elevated"
93
+ // value counts as contamination, not the section itself.
94
+ flags.contaminated = flags.referencesNormalCodexHome
95
+ || flags.desktopPluginsEnabled
96
+ || flags.marketplacesConfigured
97
+ || flags.mcpServersConfigured
98
+ || flags.notifyConfigured
99
+ || flags.desktopSectionPresent
100
+ || flags.windowsSandboxElevated
101
+ || flags.desktopRuntimeReferenced;
102
+ return flags;
103
+ }
104
+
105
+ // Preserve `[projects.'...'] trust_level = "trusted"` entries when a
106
+ // contaminated config is rewritten: trust entries are benign and losing
107
+ // them would break other projects that use the isolated home.
108
+ export function extractTrustedProjects(content) {
109
+ const keys = [];
110
+ const pattern = /\[projects\.'([^']+)'\]\s*\r?\n\s*trust_level\s*=\s*"trusted"/g;
111
+ let match;
112
+ while ((match = pattern.exec(String(content || ''))) !== null) {
113
+ keys.push(match[1]);
114
+ }
115
+ return keys;
116
+ }
117
+
118
+ export const LEGACY_CODEX_CONFIG = `model = "ricardo-codex-agentico"
119
+ model_provider = "9router"
120
+ model_reasoning_effort = "low"
121
+
122
+ [model_providers.9router]
123
+ name = "9Router Local"
124
+ base_url = "http://localhost:20128/v1"
125
+ env_key = "NINEROUTER_API_KEY"
126
+ wire_api = "responses"
127
+
128
+ [profiles.9router]
129
+ model = "ricardo-codex-agentico"
130
+ model_provider = "9router"
131
+ model_reasoning_effort = "low"
132
+ `;
133
+
134
+ export function getCodexHome() {
135
+ return CONFIG.codexHome;
136
+ }
137
+
138
+ export function getNormalCodexHome() {
139
+ return CONFIG.normalCodexHome;
140
+ }
141
+
142
+ export function getCodexEnv(extraEnv = {}) {
143
+ return {
144
+ ...process.env,
145
+ ...extraEnv,
146
+ CODEX_HOME: getCodexHome()
147
+ };
148
+ }
149
+
150
+ // Kept only so the doctor can report the legacy file honestly. The
151
+ // workspace-permissions strategy was removed in Hotfix A.2: its profile
152
+ // layer was never actually loaded by any default run ("default_permissions
153
+ // used: false" in maestro doctor), and Codex CLI 0.144.x refuses to combine
154
+ // `default_permissions` with the `sandbox_mode` mechanism this project
155
+ // standardizes on. The old file stays on disk (backed up), but nothing
156
+ // writes or loads it.
157
+ export function getLegacyWorkspacePermissionsConfigPath() {
158
+ return path.join(getCodexHome(), 'workspace-permissions.config.toml');
159
+ }
160
+
161
+ export async function ensureCodexHome(options = {}) {
162
+ const codexHome = getCodexHome();
163
+ const projectRoot = options.projectRoot || process.cwd();
164
+ await fs.mkdir(codexHome, { recursive: true });
165
+
166
+ const configPath = path.join(codexHome, 'config.toml');
167
+ const profilePath = path.join(codexHome, '9router.config.toml');
168
+ let created = false;
169
+ let backedUp = false;
170
+ let sanitized = false;
171
+ let backupPath = null;
172
+ try {
173
+ await fs.access(configPath);
174
+ } catch (error) {
175
+ await fs.writeFile(configPath, buildMinimalIsolatedCodexConfig(projectRoot));
176
+ created = true;
177
+ }
178
+
179
+ let content = await fs.readFile(configPath, 'utf-8');
180
+ let analysis = analyzeIsolatedCodexConfig(content);
181
+ if (analysis.contaminated) {
182
+ // The isolated config was overwritten with a Codex Desktop copy at some
183
+ // point (plugins, node_repl MCP pointing at the normal home, notify to
184
+ // the CUA runtime, `[windows] sandbox = "elevated"`). That mix is the
185
+ // confirmed root cause of `windows sandbox: helper_unknown_error:
186
+ // apply deny-read ACLs`. Back it up (never delete) and rewrite the
187
+ // minimal isolated config, preserving trusted project entries.
188
+ backupPath = configPath + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-');
189
+ await fs.copyFile(configPath, backupPath);
190
+ backedUp = true;
191
+ content = buildMinimalIsolatedCodexConfig(projectRoot, extractTrustedProjects(content));
192
+ await fs.writeFile(configPath, content);
193
+ sanitized = true;
194
+ analysis = analyzeIsolatedCodexConfig(content);
195
+ }
196
+ if (/\[profiles\.9router\]/.test(content)) {
197
+ const legacyBackupPath = configPath + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-');
198
+ await fs.copyFile(configPath, legacyBackupPath);
199
+ backedUp = true;
200
+ content = content.replace(/\n?\[profiles\.9router\][\s\S]*$/m, '').trim() + '\n';
201
+ await fs.writeFile(configPath, content);
202
+ }
203
+ if (!/approval_policy\s*=\s*"never"/.test(content)) {
204
+ const approvalBackupPath = configPath + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-');
205
+ await fs.copyFile(configPath, approvalBackupPath);
206
+ backedUp = true;
207
+ content = content.replace(/(model_reasoning_effort\s*=\s*"low"\s*)/, '$1approval_policy = "never"\n');
208
+ await fs.writeFile(configPath, content);
209
+ }
210
+ if (!/^\s*\[windows\]/m.test(content)) {
211
+ // Without a `[windows]` section Codex silently downgrades workers to
212
+ // read-only on Windows (no sandbox backend available). Heal existing
213
+ // minimal configs by adding the sanctioned non-admin sandbox.
214
+ const windowsBackupPath = configPath + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-');
215
+ await fs.copyFile(configPath, windowsBackupPath);
216
+ backedUp = true;
217
+ content = content.trim() + '\n\n[windows]\nsandbox = "unelevated"\n';
218
+ await fs.writeFile(configPath, content);
219
+ analysis = analyzeIsolatedCodexConfig(content);
220
+ }
221
+
222
+ try {
223
+ await fs.access(profilePath);
224
+ } catch (error) {
225
+ await fs.writeFile(profilePath, DEFAULT_CODEX_PROFILE_CONFIG);
226
+ created = true;
227
+ }
228
+
229
+ let profileContent = await fs.readFile(profilePath, 'utf-8');
230
+ if (!/approval_policy\s*=\s*"never"/.test(profileContent)) {
231
+ const backupPath = profilePath + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-');
232
+ await fs.copyFile(profilePath, backupPath);
233
+ backedUp = true;
234
+ profileContent = profileContent.replace(/(model_reasoning_effort\s*=\s*"low"\s*)/, '$1approval_policy = "never"\n');
235
+ await fs.writeFile(profilePath, profileContent);
236
+ }
237
+ if (!/sandbox_mode\s*=\s*"workspace-write"/.test(profileContent)) {
238
+ const backupPath = profilePath + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-');
239
+ await fs.copyFile(profilePath, backupPath);
240
+ backedUp = true;
241
+ profileContent = profileContent.trim() + '\nsandbox_mode = "workspace-write"\n';
242
+ await fs.writeFile(profilePath, profileContent);
243
+ }
244
+ const checks = validateCodexConfig(content, profileContent);
245
+ return { codexHome, configPath, profilePath, created, backedUp, sanitized, backupPath, checks, analysis, content, profileContent };
246
+ }
247
+
248
+ export function validateCodexConfig(content, profileContent = '') {
249
+ const combined = content + '\n' + profileContent;
250
+ return {
251
+ hasProfile9router: /model_provider\s*=\s*"9router"/.test(profileContent),
252
+ hasProvider9router: /\[model_providers\.9router\]/.test(content),
253
+ hasResponsesWireApi: /wire_api\s*=\s*"responses"/.test(content),
254
+ usesDefaultPermissions: /default_permissions\s*=/.test(combined),
255
+ usesSandboxMode: /sandbox_mode\s*=/.test(combined),
256
+ mixesDefaultPermissionsAndSandboxMode: /default_permissions\s*=/.test(combined) && /sandbox_mode\s*=/.test(combined)
257
+ };
258
+ }