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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +15 -0
- package/README.md +727 -0
- package/bin/maestro.mjs +246 -0
- package/package.json +29 -0
- package/src/checkpoint.mjs +102 -0
- package/src/codex-diagnostics.mjs +682 -0
- package/src/codex-home-inspect.mjs +252 -0
- package/src/codex-home.mjs +258 -0
- package/src/commands.mjs +809 -0
- package/src/config.mjs +11 -0
- package/src/debug.mjs +164 -0
- package/src/encoding-guard.mjs +125 -0
- package/src/engines/ai-router-engine.mjs +37 -0
- package/src/engines/codex-engine.mjs +21 -0
- package/src/engines/engine.mjs +21 -0
- package/src/engines/registry.mjs +29 -0
- package/src/files.mjs +65 -0
- package/src/format.mjs +132 -0
- package/src/lock.mjs +439 -0
- package/src/memory-log.mjs +18 -0
- package/src/memory.mjs +1 -0
- package/src/orchestration/attempt-chain-runtime.mjs +627 -0
- package/src/orchestration/budget-manager.mjs +121 -0
- package/src/orchestration/capability-registry.mjs +108 -0
- package/src/orchestration/consolidator.mjs +772 -0
- package/src/orchestration/delegation-executor.mjs +262 -0
- package/src/orchestration/delegation-manager.mjs +116 -0
- package/src/orchestration/deployment-intent.mjs +36 -0
- package/src/orchestration/engine-history.mjs +110 -0
- package/src/orchestration/engine-policy.mjs +73 -0
- package/src/orchestration/engine-selector.mjs +187 -0
- package/src/orchestration/execution-context.mjs +45 -0
- package/src/orchestration/failure-classifier.mjs +136 -0
- package/src/orchestration/failure-evidence.mjs +237 -0
- package/src/orchestration/fallback-chain-lock.mjs +217 -0
- package/src/orchestration/fallback-chain-trail.mjs +218 -0
- package/src/orchestration/fallback-executor.mjs +146 -0
- package/src/orchestration/fallback-graph.mjs +106 -0
- package/src/orchestration/fallback-recommendation.mjs +73 -0
- package/src/orchestration/file-conflict-detector.mjs +126 -0
- package/src/orchestration/host-validation.mjs +241 -0
- package/src/orchestration/orchestration-loop.mjs +1971 -0
- package/src/orchestration/orchestration-runtime.mjs +1019 -0
- package/src/orchestration/orchestration-scheduler.mjs +135 -0
- package/src/orchestration/orchestration-trail.mjs +192 -0
- package/src/orchestration/preflight.mjs +212 -0
- package/src/orchestration/provider-health.mjs +566 -0
- package/src/orchestration/provider-router.mjs +352 -0
- package/src/orchestration/rc-check-adapters.mjs +817 -0
- package/src/orchestration/rc-check.mjs +544 -0
- package/src/orchestration/rc-policy.mjs +200 -0
- package/src/orchestration/runtime-gate.mjs +146 -0
- package/src/orchestration/sector-managers.mjs +215 -0
- package/src/orchestration/self-hosting-canary.mjs +231 -0
- package/src/orchestration/self-hosting-readiness.mjs +244 -0
- package/src/orchestration/spec-planner.mjs +877 -0
- package/src/orchestration/subtask-planner.mjs +176 -0
- package/src/orchestration/task-classifier.mjs +241 -0
- package/src/orchestration/verifier.mjs +1608 -0
- package/src/orchestration-commands.mjs +279 -0
- package/src/planner.mjs +38 -0
- package/src/project.mjs +1 -0
- package/src/run-diagnostics.mjs +641 -0
- package/src/runner.mjs +243 -0
- package/src/safety.mjs +116 -0
- package/src/schema.mjs +371 -0
- package/src/session-commands.mjs +521 -0
- package/src/shell.mjs +93 -0
- package/src/smart-planner.mjs +249 -0
- package/src/task-commands.mjs +1182 -0
- package/src/tasks.mjs +134 -0
- package/src/ui/commands.mjs +76 -0
- package/src/ui/events.mjs +45 -0
- package/src/ui/public/app.js +600 -0
- package/src/ui/public/index.html +88 -0
- package/src/ui/public/style.css +460 -0
- package/src/ui/server.mjs +112 -0
- package/src/ui/state.mjs +504 -0
- package/src/usage.mjs +178 -0
- 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
|
+
}
|