@kbediako/codex-orchestrator 0.1.33 → 0.1.34

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.
@@ -0,0 +1,22 @@
1
+ import { join } from 'node:path';
2
+ import process from 'node:process';
3
+ export const REPO_CONFIG_REQUIRED_ENV_KEY = 'CODEX_ORCHESTRATOR_REPO_CONFIG_REQUIRED';
4
+ const REPO_CONFIG_REQUIRED_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
5
+ export function isRepoConfigRequired(env = process.env) {
6
+ const raw = env[REPO_CONFIG_REQUIRED_ENV_KEY];
7
+ if (typeof raw !== 'string') {
8
+ return false;
9
+ }
10
+ const normalized = raw.trim().toLowerCase();
11
+ if (!normalized) {
12
+ return false;
13
+ }
14
+ return REPO_CONFIG_REQUIRED_TRUE_VALUES.has(normalized);
15
+ }
16
+ export function formatRepoConfigRequiredError(repoRoot) {
17
+ return [
18
+ `Repo-local codex.orchestrator.json is required when ${REPO_CONFIG_REQUIRED_ENV_KEY}=1.`,
19
+ `Expected: ${join(repoRoot, 'codex.orchestrator.json')}.`,
20
+ 'Run `codex-orchestrator init codex` to scaffold repo-local config.'
21
+ ].join(' ');
22
+ }
@@ -2,17 +2,21 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { logger } from '../../logger.js';
4
4
  import { findPackageRoot } from '../utils/packageInfo.js';
5
- export async function loadRepoConfig(env) {
5
+ export async function loadRepoConfig(env, options = {}) {
6
6
  const repoConfigPath = join(env.repoRoot, 'codex.orchestrator.json');
7
7
  const repoConfig = await readConfig(repoConfigPath);
8
8
  if (repoConfig) {
9
- logger.info(`[codex-config] Loaded user config from ${repoConfigPath}`);
9
+ if (!options.quiet) {
10
+ logger.info(`[codex-config] Loaded user config from ${repoConfigPath}`);
11
+ }
10
12
  return normalizeUserConfig(repoConfig, 'repo');
11
13
  }
12
- logger.warn(`[codex-config] Missing codex.orchestrator.json at ${repoConfigPath}`);
14
+ if (!options.quiet) {
15
+ logger.warn(`[codex-config] Missing codex.orchestrator.json at ${repoConfigPath}`);
16
+ }
13
17
  return null;
14
18
  }
15
- export async function loadPackageConfig(env) {
19
+ export async function loadPackageConfig(env, options = {}) {
16
20
  const repoConfigPath = join(env.repoRoot, 'codex.orchestrator.json');
17
21
  const packageRoot = findPackageRoot();
18
22
  const packageConfigPath = join(packageRoot, 'codex.orchestrator.json');
@@ -21,18 +25,25 @@ export async function loadPackageConfig(env) {
21
25
  }
22
26
  const packageConfig = await readConfig(packageConfigPath);
23
27
  if (packageConfig) {
24
- logger.info(`[codex-config] Loaded user config from ${packageConfigPath}`);
28
+ if (!options.quiet) {
29
+ logger.info(`[codex-config] Loaded user config from ${packageConfigPath}`);
30
+ }
25
31
  return normalizeUserConfig(packageConfig, 'package');
26
32
  }
27
- logger.warn(`[codex-config] Missing codex.orchestrator.json at ${packageConfigPath}`);
33
+ if (!options.quiet) {
34
+ logger.warn(`[codex-config] Missing package config at ${packageConfigPath}`);
35
+ }
28
36
  return null;
29
37
  }
30
- export async function loadUserConfig(env) {
31
- const repoConfig = await loadRepoConfig(env);
38
+ export async function loadUserConfig(env, options = {}) {
39
+ const repoConfig = await loadRepoConfig(env, options);
32
40
  if (repoConfig) {
33
41
  return repoConfig;
34
42
  }
35
- return await loadPackageConfig(env);
43
+ if (options.allowPackageFallback === false) {
44
+ return null;
45
+ }
46
+ return await loadPackageConfig(env, options);
36
47
  }
37
48
  export function findPipeline(config, id) {
38
49
  if (!config?.pipelines) {
@@ -4,9 +4,10 @@ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { resolveCodexCliBin } from './utils/codexCli.js';
6
6
  import { resolveCodexHome } from './utils/codexPaths.js';
7
+ import { buildCommandPreview } from './utils/commandPreview.js';
7
8
  export async function runDelegationSetup(options = {}) {
8
9
  const env = options.env ?? process.env;
9
- const repoRoot = options.repoRoot ?? process.cwd();
10
+ const repoRoot = resolve(options.repoRoot ?? process.cwd());
10
11
  const codexBin = resolveCodexCliBin(env);
11
12
  const codexHome = resolveCodexHome(env);
12
13
  const configPath = join(codexHome, 'config.toml');
@@ -14,7 +15,16 @@ export async function runDelegationSetup(options = {}) {
14
15
  codexBin,
15
16
  codexHome,
16
17
  repoRoot,
17
- commandLine: `"${codexBin}" mcp add delegation -- codex-orchestrator delegate-server`
18
+ commandLine: buildCommandPreview(codexBin, [
19
+ 'mcp',
20
+ 'add',
21
+ 'delegation',
22
+ '--',
23
+ 'codex-orchestrator',
24
+ 'delegate-server',
25
+ '--repo',
26
+ repoRoot
27
+ ])
18
28
  };
19
29
  const probe = inspectDelegationReadiness({ codexBin, configPath, repoRoot, env });
20
30
  const readiness = { configured: probe.configured, configPath };
@@ -24,7 +34,7 @@ export async function runDelegationSetup(options = {}) {
24
34
  if (probe.configured) {
25
35
  return { status: 'skipped', reason: probe.reason ?? 'Delegation MCP is already configured.', plan, readiness };
26
36
  }
27
- await applyDelegationSetup({ codexBin, removeExisting: probe.removeExisting, envVars: probe.envVars }, env);
37
+ await applyDelegationSetup({ codexBin, repoRoot, removeExisting: probe.removeExisting, envVars: probe.envVars }, env);
28
38
  const configuredAfter = inspectDelegationReadiness({ codexBin, configPath, repoRoot, env }).configured;
29
39
  return {
30
40
  status: 'applied',
@@ -79,15 +89,14 @@ function inspectDelegationReadiness(options) {
79
89
  };
80
90
  }
81
91
  return {
82
- configured: true,
83
- removeExisting: false,
92
+ configured: false,
93
+ removeExisting: true,
84
94
  envVars,
85
- reason: 'Delegation MCP is already configured.'
95
+ reason: `Existing delegation MCP entry is not pinned; reconfiguring to ${requestedRepo}.`
86
96
  };
87
97
  }
88
98
  // Fall back to directly scanning config.toml when the Codex CLI probe is unavailable.
89
- const configured = isDelegationConfiguredFallback(options.configPath);
90
- return { configured, removeExisting: false, envVars: {} };
99
+ return inspectDelegationReadinessFallback(options.configPath, requestedRepo);
91
100
  }
92
101
  function applyDelegationSetup(plan, env) {
93
102
  const envFlags = [];
@@ -96,7 +105,17 @@ function applyDelegationSetup(plan, env) {
96
105
  }
97
106
  return new Promise((resolve, reject) => {
98
107
  const runAdd = () => {
99
- const child = spawn(plan.codexBin, ['mcp', 'add', 'delegation', ...envFlags, '--', 'codex-orchestrator', 'delegate-server'], { stdio: 'inherit', env });
108
+ const child = spawn(plan.codexBin, [
109
+ 'mcp',
110
+ 'add',
111
+ 'delegation',
112
+ ...envFlags,
113
+ '--',
114
+ 'codex-orchestrator',
115
+ 'delegate-server',
116
+ '--repo',
117
+ plan.repoRoot
118
+ ], { stdio: 'inherit', env });
100
119
  child.once('error', (error) => reject(error instanceof Error ? error : new Error(String(error))));
101
120
  child.once('exit', (code) => {
102
121
  if (code === 0) {
@@ -167,18 +186,96 @@ function readPinnedRepo(args) {
167
186
  const candidate = args[index + 1];
168
187
  return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate.trim() : null;
169
188
  }
170
- function isDelegationConfiguredFallback(configPath) {
189
+ function inspectDelegationReadinessFallback(configPath, requestedRepo) {
190
+ const config = readDelegationFallbackConfig(configPath);
191
+ if (!config) {
192
+ return { configured: false, removeExisting: false, envVars: {} };
193
+ }
194
+ const pinnedRepo = readPinnedRepo(config.args);
195
+ if (!pinnedRepo) {
196
+ return {
197
+ configured: false,
198
+ removeExisting: true,
199
+ envVars: config.envVars,
200
+ reason: `Existing delegation MCP entry is not pinned; reconfiguring to ${requestedRepo}.`
201
+ };
202
+ }
203
+ const normalizedPinned = resolve(pinnedRepo);
204
+ if (normalizedPinned !== requestedRepo) {
205
+ return {
206
+ configured: false,
207
+ removeExisting: true,
208
+ envVars: config.envVars,
209
+ reason: `Existing delegation MCP entry is pinned to ${pinnedRepo}; reconfiguring.`
210
+ };
211
+ }
212
+ return {
213
+ configured: true,
214
+ removeExisting: false,
215
+ envVars: config.envVars,
216
+ reason: `Delegation MCP is already configured (pinned to ${pinnedRepo}).`
217
+ };
218
+ }
219
+ function readDelegationFallbackConfig(configPath) {
171
220
  if (!existsSync(configPath)) {
172
- return false;
221
+ return null;
173
222
  }
174
223
  try {
175
- // Keep parsing loose; we only need to know whether a delegation entry exists.
176
224
  const raw = readFileSync(configPath, 'utf8');
177
- return hasMcpServerEntry(raw, 'delegation');
225
+ if (!hasMcpServerEntry(raw, 'delegation')) {
226
+ return null;
227
+ }
228
+ return {
229
+ args: readDelegationArgsFromConfig(raw),
230
+ envVars: readDelegationEnvVarsFromConfig(raw)
231
+ };
178
232
  }
179
233
  catch {
180
- return false;
234
+ return null;
235
+ }
236
+ }
237
+ function readDelegationArgsFromConfig(raw) {
238
+ const sectionMatch = raw.match(/\[mcp_servers(?:\.delegation|\."delegation"|.'delegation')\]([\s\S]*?)(?=\n\[|$)/u);
239
+ if (!sectionMatch) {
240
+ return [];
241
+ }
242
+ const section = sectionMatch[1] ?? '';
243
+ const argsMatch = section.match(/^\s*args\s*=\s*\[([\s\S]*?)\]/mu);
244
+ if (!argsMatch) {
245
+ return [];
246
+ }
247
+ const argsRaw = argsMatch[1] ?? '';
248
+ const args = [];
249
+ const tokenPattern = /"((?:\\"|[^"])*)"|'((?:\\'|[^'])*)'/gu;
250
+ let token = tokenPattern.exec(argsRaw);
251
+ while (token) {
252
+ const quoted = token[1] ?? token[2] ?? '';
253
+ const decoded = quoted.replace(/\\"/gu, '"').replace(/\\'/gu, '\'');
254
+ args.push(decoded);
255
+ token = tokenPattern.exec(argsRaw);
256
+ }
257
+ return args;
258
+ }
259
+ function readDelegationEnvVarsFromConfig(raw) {
260
+ const envVars = {};
261
+ const sectionMatch = raw.match(/\[mcp_servers(?:\.delegation|\."delegation"|.'delegation')\.env\]([\s\S]*?)(?=\n\[|$)/u);
262
+ if (!sectionMatch) {
263
+ return envVars;
264
+ }
265
+ const section = sectionMatch[1] ?? '';
266
+ const linePattern = /^\s*([A-Za-z0-9_.-]+)\s*=\s*("(?:\\"|[^"])*"|'(?:\\'|[^'])*')\s*$/gmu;
267
+ let match = linePattern.exec(section);
268
+ while (match) {
269
+ const key = match[1];
270
+ const rawValue = match[2] ?? '';
271
+ if (key) {
272
+ const unquoted = rawValue.slice(1, -1);
273
+ const decoded = unquoted.replace(/\\"/gu, '"').replace(/\\'/gu, '\'');
274
+ envVars[key] = decoded;
275
+ }
276
+ match = linePattern.exec(section);
181
277
  }
278
+ return envVars;
182
279
  }
183
280
  function hasMcpServerEntry(raw, serverName) {
184
281
  const lines = raw.split('\n');
@@ -7,6 +7,9 @@ import { isManagedCodexCliEnabled, resolveCodexCliBin, resolveCodexCliReadiness
7
7
  import { resolveCodexHome } from './utils/codexPaths.js';
8
8
  import { resolveOptionalDependency } from './utils/optionalDeps.js';
9
9
  import { runCloudPreflight } from './utils/cloudPreflight.js';
10
+ import { CommandPlanner } from './adapters/CommandPlanner.js';
11
+ import { PipelineResolver } from './services/pipelineResolver.js';
12
+ import { isRepoConfigRequired } from './config/repoConfigPolicy.js';
10
13
  const OPTIONAL_DEPENDENCIES = [
11
14
  {
12
15
  name: 'playwright',
@@ -95,6 +98,7 @@ export function runDoctor(cwd = process.cwd()) {
95
98
  ? process.env.CODEX_CLOUD_BRANCH.trim().replace(/^refs\/heads\//u, '')
96
99
  : null;
97
100
  const cloudStatus = !cloudCmdAvailable ? 'unavailable' : cloudEnvIdConfigured ? 'ok' : 'not_configured';
101
+ const cloudFallbackPolicy = resolveCloudFallbackPolicy();
98
102
  const delegationConfig = inspectDelegationConfig();
99
103
  const delegationStatus = delegationConfig.status === 'ok' ? 'ok' : 'missing-config';
100
104
  return {
@@ -120,11 +124,13 @@ export function runDoctor(cwd = process.cwd()) {
120
124
  status: cloudStatus,
121
125
  env_id_configured: cloudEnvIdConfigured,
122
126
  branch: cloudBranch,
127
+ fallback_policy: cloudFallbackPolicy,
123
128
  enablement: [
124
129
  'Set CODEX_CLOUD_ENV_ID to a valid Codex Cloud environment id.',
125
130
  'Optional: set CODEX_CLOUD_BRANCH (must exist on origin).',
126
131
  'Then run a pipeline stage in cloud mode with: codex-orchestrator start <pipeline> --cloud --target <stage-id>',
127
- 'If cloud preflight fails, CO falls back to mcp and records the reason in manifest.summary (surfaced in start output).'
132
+ 'Cloud fallback is a compatibility safety net; prefer fail-fast lanes with CODEX_ORCHESTRATOR_CLOUD_FALLBACK=deny.',
133
+ 'If cloud preflight fails and fallback is allowed, CO falls back to mcp and records the reason in manifest.summary (surfaced in start output).'
128
134
  ]
129
135
  },
130
136
  delegation: {
@@ -151,7 +157,26 @@ export async function runDoctorCloudPreflight(options = {}) {
151
157
  ?? normalizeOptionalString(env.MCP_RUNNER_TASK_ID)
152
158
  ?? normalizeOptionalString(env.TASK)
153
159
  ?? normalizeOptionalString(env.CODEX_ORCHESTRATOR_TASK_ID);
154
- const environmentId = normalizeOptionalString(options.environmentId)
160
+ const explicitEnvironmentId = normalizeOptionalString(options.environmentId);
161
+ const strictRepoConfigRequired = isRepoConfigRequired(env);
162
+ let planMetadataEnvironmentId = null;
163
+ let planMetadataIssue = null;
164
+ if (!explicitEnvironmentId || strictRepoConfigRequired) {
165
+ try {
166
+ planMetadataEnvironmentId = await resolvePlanMetadataCloudEnvironmentId(repoRoot, taskId, env);
167
+ }
168
+ catch (error) {
169
+ if (strictRepoConfigRequired) {
170
+ const detail = error instanceof Error ? error.message : String(error);
171
+ planMetadataIssue = {
172
+ code: 'pipeline_resolution_failed',
173
+ message: `Pipeline resolution failed during doctor cloud preflight: ${detail}`
174
+ };
175
+ }
176
+ }
177
+ }
178
+ const environmentId = explicitEnvironmentId
179
+ ?? planMetadataEnvironmentId
155
180
  ?? normalizeOptionalString(env.CODEX_CLOUD_ENV_ID)
156
181
  ?? resolveTaskMetadataCloudEnvironmentId(repoRoot, taskId);
157
182
  const branch = normalizeOptionalBranch(options.branch) ?? normalizeOptionalBranch(env.CODEX_CLOUD_BRANCH);
@@ -162,15 +187,16 @@ export async function runDoctorCloudPreflight(options = {}) {
162
187
  branch,
163
188
  env
164
189
  });
165
- const guidance = buildCloudPreflightGuidance(preflight.issues);
190
+ const issues = planMetadataIssue ? [planMetadataIssue, ...preflight.issues] : preflight.issues;
191
+ const guidance = buildCloudPreflightGuidance(issues);
166
192
  return {
167
- ok: preflight.ok,
193
+ ok: preflight.ok && planMetadataIssue === null,
168
194
  details: {
169
195
  codex_bin: preflight.details.codexBin,
170
196
  environment_id: preflight.details.environmentId,
171
197
  branch: preflight.details.branch
172
198
  },
173
- issues: preflight.issues,
199
+ issues,
174
200
  guidance
175
201
  };
176
202
  }
@@ -283,6 +309,7 @@ export function formatDoctorSummary(result) {
283
309
  lines.push(`Cloud: ${result.cloud.status}`);
284
310
  lines.push(` - CODEX_CLOUD_ENV_ID: ${result.cloud.env_id_configured ? 'set' : 'missing'}`);
285
311
  lines.push(` - CODEX_CLOUD_BRANCH: ${result.cloud.branch ?? '<unset>'}`);
312
+ lines.push(` - fallback policy: ${result.cloud.fallback_policy}`);
286
313
  for (const line of result.cloud.enablement) {
287
314
  lines.push(` - ${line}`);
288
315
  }
@@ -310,6 +337,53 @@ function normalizeOptionalBranch(value) {
310
337
  const normalized = normalizeOptionalString(value);
311
338
  return normalized ? normalized.replace(/^refs\/heads\//u, '') : null;
312
339
  }
340
+ function resolveCloudFallbackPolicy(env = process.env) {
341
+ const raw = normalizeOptionalString(env.CODEX_ORCHESTRATOR_CLOUD_FALLBACK);
342
+ if (!raw) {
343
+ return 'allow';
344
+ }
345
+ const normalized = raw.toLowerCase();
346
+ if (['0', 'false', 'off', 'deny', 'disabled', 'never', 'strict'].includes(normalized)) {
347
+ return 'deny';
348
+ }
349
+ return 'allow';
350
+ }
351
+ async function resolvePlanMetadataCloudEnvironmentId(repoRoot, taskId, processEnv = process.env) {
352
+ const env = {
353
+ repoRoot,
354
+ runsRoot: join(repoRoot, '.runs'),
355
+ outRoot: join(repoRoot, 'out'),
356
+ taskId: taskId ?? '0101'
357
+ };
358
+ const resolver = new PipelineResolver();
359
+ const resolution = await resolver.resolve(env, { quiet: true, processEnv });
360
+ const planner = new CommandPlanner(resolution.pipeline);
361
+ const context = {
362
+ id: env.taskId,
363
+ title: env.taskId,
364
+ metadata: {}
365
+ };
366
+ const plan = await planner.plan(context);
367
+ const selected = plan.items.find((item) => item.id === plan.targetId)
368
+ ?? plan.items[0]
369
+ ?? null;
370
+ if (!selected || !selected.metadata || typeof selected.metadata !== 'object') {
371
+ return null;
372
+ }
373
+ return resolveCloudEnvironmentIdFromMetadata(selected.metadata);
374
+ }
375
+ function resolveCloudEnvironmentIdFromMetadata(metadata) {
376
+ const stagePlan = metadata.plan && typeof metadata.plan === 'object'
377
+ ? metadata.plan
378
+ : null;
379
+ const candidates = [
380
+ normalizeOptionalString(typeof stagePlan?.cloudEnvId === 'string' ? stagePlan.cloudEnvId : null),
381
+ normalizeOptionalString(typeof stagePlan?.cloud_env_id === 'string' ? stagePlan.cloud_env_id : null),
382
+ normalizeOptionalString(typeof metadata.cloudEnvId === 'string' ? metadata.cloudEnvId : null),
383
+ normalizeOptionalString(typeof metadata.cloud_env_id === 'string' ? metadata.cloud_env_id : null)
384
+ ];
385
+ return candidates.find((value) => Boolean(value)) ?? null;
386
+ }
313
387
  function resolveTaskMetadataCloudEnvironmentId(repoRoot, taskId) {
314
388
  if (!taskId) {
315
389
  return null;
@@ -382,6 +456,9 @@ function buildCloudPreflightGuidance(issues) {
382
456
  case 'git_unavailable':
383
457
  guidance.push('Install git or run with CODEX_CLOUD_BRANCH unset to skip remote branch verification.');
384
458
  break;
459
+ case 'pipeline_resolution_failed':
460
+ guidance.push('Fix pipeline/config resolution errors before cloud runs (run `codex-orchestrator init codex`).');
461
+ break;
385
462
  default:
386
463
  break;
387
464
  }