@aiwg/cli 2026.10.0 → 2026.10.3

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 (168) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +270 -0
  2. package/agentic/code/providers/model-capabilities.v1.json +3091 -24
  3. package/bin/aiwg.mjs +86 -21
  4. package/dist/src/a2a/codecs.js +9 -0
  5. package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
  6. package/dist/src/artifacts/repair.js +9 -4
  7. package/dist/src/channel/manager.mjs +59 -10
  8. package/dist/src/cli/child-process-result.js +40 -0
  9. package/dist/src/cli/env.js +38 -16
  10. package/dist/src/cli/flag-parsing.js +236 -0
  11. package/dist/src/cli/handlers/decision.js +297 -2
  12. package/dist/src/cli/handlers/diagnose.js +9 -2
  13. package/dist/src/cli/handlers/feedback.js +14 -5
  14. package/dist/src/cli/handlers/init.js +245 -30
  15. package/dist/src/cli/handlers/mc.js +23 -0
  16. package/dist/src/cli/handlers/models.js +69 -6
  17. package/dist/src/cli/handlers/ralph.js +3 -0
  18. package/dist/src/cli/handlers/refresh.js +16 -0
  19. package/dist/src/cli/handlers/regenerate.js +14 -0
  20. package/dist/src/cli/handlers/runtime-info.js +24 -9
  21. package/dist/src/cli/handlers/script-runner.js +16 -6
  22. package/dist/src/cli/handlers/session.js +396 -62
  23. package/dist/src/cli/handlers/sessions.js +146 -40
  24. package/dist/src/cli/handlers/setup-manifest.js +439 -39
  25. package/dist/src/cli/handlers/setup.js +139 -39
  26. package/dist/src/cli/handlers/subcommands.js +3 -1
  27. package/dist/src/cli/handlers/team.js +18 -8
  28. package/dist/src/cli/handlers/use.js +13 -0
  29. package/dist/src/cli/handlers/utilities.js +106 -23
  30. package/dist/src/cli/prompt-utils.js +72 -0
  31. package/dist/src/cli/provider-resolution.js +11 -0
  32. package/dist/src/config/aiwg-config.js +268 -4
  33. package/dist/src/config/cli.js +146 -19
  34. package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
  35. package/dist/src/config/project-artifacts-health.mjs +14 -3
  36. package/dist/src/config/user-config.js +36 -10
  37. package/dist/src/decision/adapters/jev.js +80 -28
  38. package/dist/src/decision/ask.js +352 -0
  39. package/dist/src/decision/driver.js +4 -0
  40. package/dist/src/decision/entry.js +38 -6
  41. package/dist/src/decision/heldout/collector.js +10 -1
  42. package/dist/src/decision/index.js +3 -0
  43. package/dist/src/decision/interop.js +60 -8
  44. package/dist/src/decision/jev-credentials.js +121 -0
  45. package/dist/src/decision/ledger.js +839 -0
  46. package/dist/src/decision/validate.js +6 -3
  47. package/dist/src/effects/dispatch-verifier.js +252 -0
  48. package/dist/src/effects/index.js +1 -0
  49. package/dist/src/features/installer.js +2 -2
  50. package/dist/src/flow/graph-metadata.js +5 -0
  51. package/dist/src/gates/providers/loader.js +3 -1
  52. package/dist/src/installation/manager-command.mjs +1 -1
  53. package/dist/src/installation/manager.mjs +17 -2
  54. package/dist/src/lint/runner.js +2 -2
  55. package/dist/src/mcp/adapters/codex-runtime.js +360 -56
  56. package/dist/src/mcp/cli.mjs +587 -77
  57. package/dist/src/mcp/config-file.mjs +279 -0
  58. package/dist/src/mcp/config-layers.mjs +348 -0
  59. package/dist/src/mcp/credentials.mjs +484 -0
  60. package/dist/src/mcp/grok-build-config.mjs +16 -26
  61. package/dist/src/mcp/helpers.mjs +106 -2
  62. package/dist/src/mcp/omp-config.mjs +50 -29
  63. package/dist/src/mcp/profiles.js +97 -23
  64. package/dist/src/mcp/profiles.mjs +99 -27
  65. package/dist/src/mcp/registry.js +264 -57
  66. package/dist/src/mcp/registry.mjs +198 -45
  67. package/dist/src/mcp/server.mjs +403 -20
  68. package/dist/src/mcp/toml-string.mjs +9 -0
  69. package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
  70. package/dist/src/mcp/tool-filters.mjs +357 -0
  71. package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
  72. package/dist/src/mcp/tools/decision.mjs +58 -0
  73. package/dist/src/mcp/tools/orchestration.mjs +38 -0
  74. package/dist/src/mcp/tools/subsystems.mjs +39 -8
  75. package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
  76. package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
  77. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
  78. package/dist/src/memory/project-registry.js +2 -2
  79. package/dist/src/mission-protocol/codecs.js +639 -1
  80. package/dist/src/mission-protocol/types.js +110 -0
  81. package/dist/src/mission-protocol/worker-status.js +2 -0
  82. package/dist/src/models/config-loader.js +70 -13
  83. package/dist/src/models/grok-build-models.js +93 -41
  84. package/dist/src/models/model-capabilities.v1.json +3091 -24
  85. package/dist/src/models/model-discovery.js +862 -63
  86. package/dist/src/models/provider-policy.js +625 -11
  87. package/dist/src/models/qualification-digest.mjs +17 -0
  88. package/dist/src/models/resolver.js +259 -0
  89. package/dist/src/models/worker-direct-readiness.js +529 -0
  90. package/dist/src/models/worker-dispatch-offer.js +169 -0
  91. package/dist/src/models/worker-health-readiness.js +131 -0
  92. package/dist/src/models/worker-installation.mjs +879 -0
  93. package/dist/src/models/wrapper-route.js +18 -0
  94. package/dist/src/ops/registry.js +9 -14
  95. package/dist/src/plugins/standalone-packager.js +4 -3
  96. package/dist/src/process/bounded-child-process.mjs +387 -0
  97. package/dist/src/process/cli-command.mjs +75 -0
  98. package/dist/src/providers/capability-matrix.js +8 -0
  99. package/dist/src/providers/capability-matrix.yaml +270 -0
  100. package/dist/src/providers/grok-build-headless.js +43 -21
  101. package/dist/src/providers/provider-definitions.js +9 -1
  102. package/dist/src/providers/provider-definitions.mjs +9 -2
  103. package/dist/src/serve/executor-registry.js +16 -24
  104. package/dist/src/serve/fleet-mission-conductor.js +9 -0
  105. package/dist/src/serve/mission-conductor.js +9 -0
  106. package/dist/src/serve/shared-host-scheduler.js +2 -0
  107. package/dist/src/serve/worker-invocation.js +538 -0
  108. package/dist/src/serve/worker-stdin-frame.mjs +107 -0
  109. package/dist/src/sessions/adapter-registry.js +45 -0
  110. package/dist/src/sessions/adapters/generic.js +37 -3
  111. package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
  112. package/dist/src/sessions/adapters/grokbot.js +423 -24
  113. package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
  114. package/dist/src/sessions/adapters/muse.js +68 -29
  115. package/dist/src/sessions/adapters/pi.js +1 -0
  116. package/dist/src/sessions/batch-contracts.js +13 -1
  117. package/dist/src/sessions/batch-import.js +23 -42
  118. package/dist/src/sessions/candidates.js +47 -11
  119. package/dist/src/sessions/contracts.js +35 -0
  120. package/dist/src/sessions/coverage-preflight.js +134 -0
  121. package/dist/src/sessions/discovery.js +3 -0
  122. package/dist/src/sessions/grok-build-cli.js +67 -5
  123. package/dist/src/sessions/importer.js +24 -8
  124. package/dist/src/sessions/index.js +2 -0
  125. package/dist/src/sessions/repository.js +54 -13
  126. package/dist/src/sessions/workspace-discovery.js +99 -37
  127. package/dist/src/skills/adapters/clawhub.js +10 -8
  128. package/dist/src/skills/adapters/openclaw.js +10 -7
  129. package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
  130. package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
  131. package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
  132. package/dist/src/smiths/mcpsmith/example.js +1 -1
  133. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
  134. package/dist/src/storage/backends/logseq.js +68 -7
  135. package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
  136. package/dist/src/testing/generators/test-code-generator.js +57 -30
  137. package/dist/src/uhp/client.js +11 -1
  138. package/dist/src/uhp/mission.js +5 -0
  139. package/dist/src/update/checker.mjs +5 -5
  140. package/dist/src/update/notifier.mjs +54 -2
  141. package/dist/src/writing/fidelity.js +18 -1
  142. package/package.json +18 -1
  143. package/schemas/mission-v1.schema.json +2429 -0
  144. package/schemas/models/model-catalog.v1.schema.json +301 -0
  145. package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
  146. package/schemas/models/model-policy.v1.schema.json +14 -0
  147. package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
  148. package/tools/_resolve-impl.mjs +39 -0
  149. package/tools/agents/deploy-agents.mjs +15 -2
  150. package/tools/agents/providers/base.mjs +63 -43
  151. package/tools/agents/providers/grokbot.mjs +11 -12
  152. package/tools/agents/providers/hermes.mjs +6 -3
  153. package/tools/agents/providers/omp.mjs +5 -2
  154. package/tools/agents/providers/openclaw-translator.mjs +19 -5
  155. package/tools/providers/deepseek-harness-transport.mjs +358 -0
  156. package/tools/providers/omp-transport.mjs +192 -0
  157. package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
  158. package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
  159. package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
  160. package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
  161. package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
  162. package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
  163. package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
  164. package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
  165. package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
  166. package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
  167. package/tools/ralph-external/lib/worker-health.mjs +439 -0
  168. package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
package/bin/aiwg.mjs CHANGED
@@ -244,10 +244,81 @@ if (process.argv.slice(2).includes('--dry-run')) {
244
244
  process.env['AIWG_CLI_DRY_RUN'] = '1';
245
245
  }
246
246
 
247
+ // Startup tracing — set AIWG_TRACE_STARTUP=1 to print per-phase timings to
248
+ // stderr. Useful for diagnosing cold-start regressions, and for a test to
249
+ // observe whether the credential-handling skip below actually fired,
250
+ // without needing the update notifier to otherwise be eligible to run (it
251
+ // also needs a real terminal — see `isCredentialHandlingCommand()`'s own
252
+ // tests). No-op by default so it doesn't cost anything on the hot path.
253
+ // Defined ahead of its first use below (moved up from just before
254
+ // `resolveRouterPath()`, where it used to sit) so the notifier gate can use
255
+ // it too.
256
+ const traceStartup = process.env['AIWG_TRACE_STARTUP'] === '1' ||
257
+ process.env['AIWG_TRACE_STARTUP']?.toLowerCase() === 'true';
258
+ const startHr = process.hrtime.bigint();
259
+ function trace(phase) {
260
+ if (!traceStartup) return;
261
+ const ms = Number(process.hrtime.bigint() - startHr) / 1_000_000;
262
+ process.stderr.write(`[trace] +${ms.toFixed(1)}ms ${phase}\n`);
263
+ }
264
+ trace('bin:entry');
265
+
266
+ /**
267
+ * True iff this invocation is a command that reads a secret directly from
268
+ * the environment or from an argument as part of its own documented
269
+ * contract (go/no-go review, blocker 1A). Checked from argv alone — no
270
+ * dist/ import, no router resolution — so it costs nothing on the
271
+ * `--version`/`--help` fast paths and can run before any bootstrap
272
+ * subprocess starts.
273
+ *
274
+ * No bootstrap subprocess may start before dispatch for one of these,
275
+ * because a bootstrap child that copies the parent's environment (or is
276
+ * merely spawned while `NODE_DEBUG=child_process` is set) would otherwise
277
+ * see whatever the command's own secret-bearing variable holds, before the
278
+ * command itself ever ran. Scrubbing a specific variable out of every
279
+ * possible bootstrap child is not attempted here — not starting the
280
+ * bootstrap at all for these commands is the smaller, certain fix. (The
281
+ * background update-check child also gets its own environment allow-list,
282
+ * independent of this list — see `notifierChildEnv()` in
283
+ * `src/update/notifier.mjs` — so a secret in the parent's environment that
284
+ * this list does not yet name is still not copied into that one child.)
285
+ *
286
+ * Qualifying commands, and why:
287
+ * - `mcp profile login` — reads an API key via `--api-key-env`,
288
+ * `--api-key-stdin`, or `--api-key-file`.
289
+ * - `decision setup jev` — without `--token-stdin`, reads the token
290
+ * directly from `JEV_API_KEY`/`AIWG_DECISION_JEV_API_KEY`.
291
+ *
292
+ * Checked and left out, with reasons (see the go/no-go review response):
293
+ * - `auth login` — an OAuth device/browser flow; it does not read a
294
+ * pre-existing secret from the environment or argv at invocation, it
295
+ * obtains one over the network after the user authorizes it.
296
+ * - `mcp inject` / `mcp add` / `mcp update` (including `--header-env`) —
297
+ * `headerEnv`/`envFrom` references only ever carry a variable *name*
298
+ * into a provider's config (resolved by that provider later, not by
299
+ * this process); a literal `--env NAME=value` on `mcp add` is stored
300
+ * to the registry file and never touches `process.env` here.
301
+ */
302
+ function isCredentialHandlingCommand(args) {
303
+ const positionals = [];
304
+ for (const arg of args) {
305
+ if (arg === '--') break;
306
+ if (!arg.startsWith('-')) {
307
+ positionals.push(arg);
308
+ if (positionals.length >= 3) break;
309
+ }
310
+ }
311
+ if (positionals[0] === 'mcp' && positionals[1] === 'profile' && positionals[2] === 'login') return true;
312
+ if (positionals[0] === 'decision' && positionals[1] === 'setup' && positionals[2] === 'jev') return true;
313
+ return false;
314
+ }
315
+
247
316
  // Display a cached notice and schedule its refresh before every eligible CLI
248
317
  // path, including fast help/version, channel recovery, and later preflight
249
- // failures. This local-only bootstrap never waits on the registry and failures
250
- // are deliberately ignored so update advice cannot change command behavior.
318
+ // failures — except a credential-handling command (above), which never gets
319
+ // this or any other bootstrap subprocess before dispatch. This local-only
320
+ // bootstrap never waits on the registry and failures are deliberately
321
+ // ignored so update advice cannot change command behavior.
251
322
  async function runUpdateNotifierBootstrap() {
252
323
  try {
253
324
  const notifierPath = path.join(packageRoot, 'dist', 'src', 'update', 'notifier.mjs');
@@ -260,12 +331,18 @@ async function runUpdateNotifierBootstrap() {
260
331
  }
261
332
  }
262
333
 
263
- await runUpdateNotifierBootstrap();
334
+ const cliArgs = process.argv.slice(2);
335
+ if (isCredentialHandlingCommand(cliArgs)) {
336
+ trace('notifier:skip (credential-handling command)');
337
+ } else {
338
+ await runUpdateNotifierBootstrap();
339
+ trace('notifier:bootstrap-complete');
340
+ }
264
341
 
265
- if (maybeHandleFastVersion(process.argv.slice(2))) {
342
+ if (maybeHandleFastVersion(cliArgs)) {
266
343
  process.exit(0);
267
344
  }
268
- if (maybeHandleFastHelp(process.argv.slice(2))) {
345
+ if (maybeHandleFastHelp(cliArgs)) {
269
346
  process.exit(0);
270
347
  }
271
348
 
@@ -286,19 +363,6 @@ function ensureInvocationId() {
286
363
  }
287
364
  const invocationId = ensureInvocationId();
288
365
 
289
- // Startup tracing — set AIWG_TRACE_STARTUP=1 to print per-phase timings to
290
- // stderr. Useful for diagnosing cold-start regressions. No-op by default so
291
- // it doesn't cost anything on the hot path.
292
- const traceStartup = process.env['AIWG_TRACE_STARTUP'] === '1' ||
293
- process.env['AIWG_TRACE_STARTUP']?.toLowerCase() === 'true';
294
- const startHr = process.hrtime.bigint();
295
- function trace(phase) {
296
- if (!traceStartup) return;
297
- const ms = Number(process.hrtime.bigint() - startHr) / 1_000_000;
298
- process.stderr.write(`[trace] +${ms.toFixed(1)}ms ${phase}\n`);
299
- }
300
- trace('bin:entry');
301
-
302
366
  /**
303
367
  * Resolve the path to the compiled router.
304
368
  *
@@ -309,10 +373,11 @@ trace('bin:entry');
309
373
  * emit a clear error and exit rather than falling back to a tsx fork.
310
374
  */
311
375
  async function resolveRouterPath() {
312
- const { loadConfig } = await import('../dist/src/channel/manager.mjs');
376
+ const { loadConfig, resolveEffectiveInstallationRoot } = await import('../dist/src/channel/manager.mjs');
313
377
  const config = await loadConfig({ createIfMissing: !process.argv.slice(2).includes('--dry-run') });
314
- if (config.devMode && config.edgePath && config.edgePath !== packageRoot) {
315
- const devRouter = path.join(config.edgePath, 'dist', 'src', 'cli', 'router.js');
378
+ const effectiveRoot = resolveEffectiveInstallationRoot(packageRoot, config);
379
+ if (effectiveRoot !== packageRoot) {
380
+ const devRouter = path.join(effectiveRoot, 'dist', 'src', 'cli', 'router.js');
316
381
  if (!existsSync(devRouter)) {
317
382
  console.error(`Dev mode: compiled router not found at ${devRouter}`);
318
383
  console.error(` Run: (cd ${config.edgePath} && npm run build)`);
@@ -1,3 +1,4 @@
1
+ import { assertMissionWorkerHandoff } from '../mission-protocol/codecs.js';
1
2
  export function encodePushNotificationConfig(version, config) {
2
3
  assertNonEmptyString(version, '$.url', config.url);
3
4
  if (version === '0.3') {
@@ -81,6 +82,7 @@ const V1_TO_STATE = {
81
82
  };
82
83
  const STATE_TO_V1 = Object.fromEntries(Object.entries(V1_TO_STATE).map(([wire, normalized]) => [normalized, wire]));
83
84
  export function encodeMessage(version, message) {
85
+ assertMissionWorkerHandoff(message, 'a2a');
84
86
  assertNonEmptyString(version, '$.messageId', message.messageId);
85
87
  if (message.role !== 'user' && message.role !== 'agent') {
86
88
  fail(version, '$.role', 'must be user or agent');
@@ -101,6 +103,7 @@ export function encodeMessage(version, message) {
101
103
  return out;
102
104
  }
103
105
  export function decodeMessage(version, input, path = '$') {
106
+ assertMissionWorkerHandoff(input, 'a2a');
104
107
  const obj = objectAt(version, path, input);
105
108
  const messageId = stringAt(version, `${path}.messageId`, obj.messageId);
106
109
  const role = decodeRole(version, obj.role, `${path}.role`);
@@ -120,6 +123,7 @@ export function decodeMessage(version, input, path = '$') {
120
123
  return message;
121
124
  }
122
125
  export function encodeTask(version, task) {
126
+ assertMissionWorkerHandoff(task, 'a2a');
123
127
  assertNonEmptyString(version, '$.id', task.id);
124
128
  const out = {
125
129
  id: task.id,
@@ -137,6 +141,7 @@ export function encodeTask(version, task) {
137
141
  return out;
138
142
  }
139
143
  export function decodeTask(version, input, path = '$') {
144
+ assertMissionWorkerHandoff(input, 'a2a');
140
145
  const obj = objectAt(version, path, input);
141
146
  if (version === '1.0' && typeof obj.kind === 'string') {
142
147
  fail(version, `${path}.kind`, 'legacy kind discriminator is not valid in 1.0');
@@ -219,6 +224,7 @@ export function decodeStatus(version, input, path = '$') {
219
224
  return status;
220
225
  }
221
226
  export function encodeArtifact(version, artifact, path = '$') {
227
+ assertMissionWorkerHandoff(artifact, 'a2a');
222
228
  assertNonEmptyString(version, `${path}.artifactId`, artifact.artifactId);
223
229
  if (!Array.isArray(artifact.parts) || artifact.parts.length === 0) {
224
230
  fail(version, `${path}.parts`, 'must contain at least one part');
@@ -234,6 +240,7 @@ export function encodeArtifact(version, artifact, path = '$') {
234
240
  return out;
235
241
  }
236
242
  export function decodeArtifact(version, input, path = '$') {
243
+ assertMissionWorkerHandoff(input, 'a2a');
237
244
  const obj = objectAt(version, path, input);
238
245
  const partsRaw = arrayAt(version, `${path}.parts`, obj.parts);
239
246
  if (partsRaw.length === 0)
@@ -260,6 +267,7 @@ function encodeMessageAt(version, message, path) {
260
267
  }
261
268
  }
262
269
  function encodePart(version, part, path) {
270
+ assertMissionWorkerHandoff(part, 'a2a');
263
271
  const common = {};
264
272
  copyJsonObject(version, common, 'metadata', part.metadata, `${path}.metadata`);
265
273
  copyOptional(common, version === '0.3' ? 'mimeType' : 'mediaType', part.mediaType);
@@ -288,6 +296,7 @@ function encodePart(version, part, path) {
288
296
  };
289
297
  }
290
298
  function decodePart(version, input, path) {
299
+ assertMissionWorkerHandoff(input, 'a2a');
291
300
  const obj = objectAt(version, path, input);
292
301
  if (version === '0.3')
293
302
  return decodeLegacyPart(obj, path);
@@ -21,6 +21,7 @@
21
21
  import * as fs from 'fs';
22
22
  import * as path from 'path';
23
23
  import { spawnSync } from 'child_process';
24
+ import { boundedSpawnSync, positiveTimeoutMs } from '../../process/bounded-child-process.mjs';
24
25
  /** Strict-transcription prompt. `title` parameterizes the corpus-specific intro. */
25
26
  export function buildPrompt(page, title) {
26
27
  const subject = title ? `from ${title}` : 'from a scanned document';
@@ -53,7 +54,11 @@ const codexAdapter = {
53
54
  try {
54
55
  const r = spawnSync(process.env.AIWG_CODEX_BIN || 'codex', args, {
55
56
  input: prompt,
56
- timeout: ctx.timeoutSec * 1000,
57
+ // ctx.timeoutSec is caller-controlled (a CLI flag); clamp it so a
58
+ // configured 0 (or anything else non-positive) can't disable the
59
+ // bound entirely (#3062 B/C finding 8). 240s matches this
60
+ // function's own documented default (see extractPages() below).
61
+ timeout: positiveTimeoutMs(ctx.timeoutSec * 1000, 240_000),
57
62
  encoding: 'utf8',
58
63
  });
59
64
  if (r.status === 0 && fs.existsSync(tmp)) {
@@ -70,15 +75,93 @@ const codexAdapter = {
70
75
  }
71
76
  },
72
77
  };
78
+ /** Paths enter the shell as positional arguments, never as shell source. */
79
+ const MAX_VISION_TEMPLATE_LENGTH = 4096;
80
+ function visionCommand(template) {
81
+ const guidance = 'Use "$1"/"$2"/"$3" as data operands or move complex logic to a wrapper script';
82
+ const reject = (construct) => {
83
+ throw new Error(`Vision command rejects ${construct}. ${guidance}`);
84
+ };
85
+ // Bound the checks below; a vision command template is a short command line.
86
+ if (template.length > MAX_VISION_TEMPLATE_LENGTH)
87
+ reject(`templates longer than ${MAX_VISION_TEMPLATE_LENGTH} characters`);
88
+ // A vision template is one command line; a backslash-newline continuation can
89
+ // join an allowed name with following text into a different variable at runtime.
90
+ if (/\\\r?\n|\\\r/.test(template))
91
+ reject('line continuations (backslash-newline)');
92
+ // Recognize literal command names even when quoted or backslash-escaped.
93
+ const syntax = template.replace(/\\([^\n])/g, '$1').replace(/["']/g, '');
94
+ if (syntax.includes('$('))
95
+ reject('command substitution ($(...))');
96
+ if (template.includes('`'))
97
+ reject('backticks');
98
+ if (/\beval\b/.test(syntax))
99
+ reject('eval');
100
+ if (syntax.includes('<<'))
101
+ reject('here-documents and here-strings (<<)');
102
+ // Conservatively reject a shell word together with any short -c option
103
+ // cluster, regardless of order, wrappers, redirections or command boundaries.
104
+ const words = syntax.split(/[\s;&|(){}<>]+/);
105
+ const shellWords = words.map(word => word.slice(word.lastIndexOf('/') + 1)
106
+ .match(/^(?:sh|bash|zsh|dash|ksh|ash|mksh|yash|fish|csh|tcsh|rbash|busybox|toybox)(?:$|(-[A-Za-z]*c))/));
107
+ const hasShell = shellWords.some(Boolean);
108
+ if (shellWords.some(word => word?.[1]) || (hasShell && words.some(word => /^-[A-Za-z]*c/.test(word))))
109
+ reject('nested shell invocation (-c)');
110
+ // Only contract-provided parameters may expand; match at each dollar without
111
+ // rescanning the remaining template. Braces must contain only the exact name.
112
+ const parameter = /\$(?:[123](?![0-9])|AIWG_VISION_(?:IMAGE|PROMPT_FILE|OUT)(?![A-Za-z0-9_])|\{(?:[123]|AIWG_VISION_(?:IMAGE|PROMPT_FILE|OUT))\})/y;
113
+ // Track quotes to reject quoted/escaped placeholders, expansions and comments.
114
+ let quote = '';
115
+ return template.replace(/\\?\{(image|prompt_file|out)\}|\\[\s\S]|\$'|[$"'#]/g, (token, name, offset) => {
116
+ if ((token === '#' || token === '\\#') && quote !== "'")
117
+ reject('comments (# outside single quotes)');
118
+ if (name) {
119
+ if (quote)
120
+ throw new Error(`Vision command placeholders must be outside shell quotes. ${guidance}`);
121
+ if (token.startsWith('\\'))
122
+ throw new Error(`Vision command placeholders must not be escaped. ${guidance}`);
123
+ return `"$${name === 'image' ? 1 : name === 'prompt_file' ? 2 : 3}"`;
124
+ }
125
+ if (token === '#')
126
+ return token;
127
+ // Backslashes are literal inside POSIX single quotes, but escape the next
128
+ // character in double quotes and ANSI-C ($'...') quotes.
129
+ if (token.startsWith('\\')) {
130
+ if (quote === "'" && token[1] === "'")
131
+ quote = '';
132
+ return token;
133
+ }
134
+ if (token.startsWith('$')) {
135
+ if (quote !== "'") {
136
+ parameter.lastIndex = offset;
137
+ if (!parameter.test(template))
138
+ reject('parameter expansions other than the provided vision variables');
139
+ }
140
+ if (token === '$')
141
+ return token;
142
+ }
143
+ if (!quote)
144
+ quote = token;
145
+ else if (quote === token || (quote === "$'" && token === "'") || (quote === "'" && token === "$'"))
146
+ quote = '';
147
+ return token;
148
+ });
149
+ }
73
150
  /**
74
151
  * Generic command-template adapter (provider-neutral). Runs
75
152
  * `AIWG_VISION_COMMAND` (or the `--command` flag) with placeholders:
76
- * {image} absolute image path
77
- * {prompt_file} path to a file containing the prompt
78
- * {out} path the command must write the transcription to
79
- * e.g. claude -p "$(cat {prompt_file})" --image {image} > {out}
153
+ * {image} "$1": absolute image path
154
+ * {prompt_file} "$2": path to a file containing the prompt
155
+ * {out} "$3": path the command must write the transcription to
156
+ * Placeholders must be unquoted and unescaped. AIWG_VISION_IMAGE /
157
+ * AIWG_VISION_PROMPT_FILE / AIWG_VISION_OUT are also exported.
158
+ * Only $1/$2/$3 and the exported vision variables (also braced) may expand.
159
+ * Other $ expansions, command substitution, backticks, eval, nested shells with -c, <<, and #
160
+ * outside single quotes are rejected; use a wrapper script for complex logic.
161
+ * e.g. vision-cli --prompt-file {prompt_file} --image {image} > {out}
80
162
  */
81
163
  function commandAdapter(template) {
164
+ const cmd = visionCommand(template);
82
165
  return {
83
166
  name: 'command',
84
167
  transcribe(imagePath, prompt, ctx) {
@@ -86,12 +169,12 @@ function commandAdapter(template) {
86
169
  const promptFile = path.join(dir, `.vision-prompt-${ctx.page}.txt`);
87
170
  const out = path.join(dir, `.vision-out-${ctx.page}.txt`);
88
171
  fs.writeFileSync(promptFile, prompt);
89
- const cmd = template
90
- .replaceAll('{image}', JSON.stringify(imagePath))
91
- .replaceAll('{prompt_file}', JSON.stringify(promptFile))
92
- .replaceAll('{out}', JSON.stringify(out));
93
172
  try {
94
- const r = spawnSync('sh', ['-c', cmd], { timeout: ctx.timeoutSec * 1000, encoding: 'utf8' });
173
+ // Same caller-controlled-timeout clamp as the codex adapter above.
174
+ const r = spawnSync('sh', ['-c', cmd, 'aiwg-vision', imagePath, promptFile, out], {
175
+ timeout: positiveTimeoutMs(ctx.timeoutSec * 1000, 240_000), encoding: 'utf8',
176
+ env: { ...process.env, AIWG_VISION_IMAGE: imagePath, AIWG_VISION_PROMPT_FILE: promptFile, AIWG_VISION_OUT: out },
177
+ });
95
178
  let text;
96
179
  if (fs.existsSync(out))
97
180
  text = fs.readFileSync(out, 'utf8');
@@ -115,7 +198,7 @@ export function resolveAdapter(opts = {}) {
115
198
  const provider = opts.provider ?? process.env.AIWG_VISION_PROVIDER ?? (command ? 'command' : 'codex');
116
199
  if (provider === 'command') {
117
200
  if (!command)
118
- throw new Error("vision provider 'command' requires --command or AIWG_VISION_COMMAND (template with {image}/{prompt_file}/{out})");
201
+ throw new Error("vision provider 'command' requires --command or AIWG_VISION_COMMAND (template with unquoted, unescaped {image}/{prompt_file}/{out} placeholders)");
119
202
  return commandAdapter(command);
120
203
  }
121
204
  if (provider === 'codex')
@@ -125,7 +208,8 @@ export function resolveAdapter(opts = {}) {
125
208
  // ── PDF → PNG rasterization (poppler pdftoppm) ───────────────────────────────
126
209
  export function pdftoppmAvailable() {
127
210
  try {
128
- return spawnSync('pdftoppm', ['-v'], { encoding: 'utf8' }).status === 0 || spawnSync('pdftoppm', ['-h'], { encoding: 'utf8' }).status !== null;
211
+ return boundedSpawnSync('pdftoppm', ['-v'], 'quick', { encoding: 'utf8' }).status === 0
212
+ || boundedSpawnSync('pdftoppm', ['-h'], 'quick', { encoding: 'utf8' }).status !== null;
129
213
  }
130
214
  catch {
131
215
  return false;
@@ -144,9 +228,15 @@ export function rasterizePdf(pdfPath, outDir, dpi = 200) {
144
228
  fs.mkdirSync(outDir, { recursive: true });
145
229
  // pdftoppm writes <prefix>-<n>.png with its own zero-padding based on page count.
146
230
  const prefix = path.join(outDir, 'page');
147
- const r = spawnSync('pdftoppm', ['-png', '-r', String(dpi), pdfPath, prefix], { encoding: 'utf8' });
148
- if (r.status !== 0)
149
- throw new Error(`pdftoppm failed: ${r.stderr || r.status}`);
231
+ // Rasterizing a multi-page PDF can take a while; long tier (#3062).
232
+ const r = boundedSpawnSync('pdftoppm', ['-png', '-r', String(dpi), pdfPath, prefix], 'long', { encoding: 'utf8' });
233
+ if (r.status !== 0) {
234
+ // #3062 finding 14: r.error.message (when present) carries the
235
+ // enriched timeout explanation (tier/bound/override) that stderr/
236
+ // status alone do not — put it first, captured stderr after.
237
+ const detail = r.error ? r.error.message : `pdftoppm failed: ${r.stderr || r.status}`;
238
+ throw new Error(r.error && r.stderr ? `${detail} (stderr: ${r.stderr})` : detail);
239
+ }
150
240
  // Normalize names to page-NNN.png (3-digit zero-pad).
151
241
  let count = 0;
152
242
  for (const f of fs.readdirSync(outDir)) {
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { PROJECT_CONTROL_PLANE_FILES, auditProjectArtifactHealth, } from '../config/project-artifacts.js';
4
+ import { PROJECT_CONTROL_PLANE_FILES, auditProjectArtifactHealth, isProjectArtifactRootExternal, } from '../config/project-artifacts.js';
5
5
  async function optionalBytes(filePath) {
6
6
  try {
7
7
  return await readFile(filePath);
@@ -21,14 +21,19 @@ async function writeAndVerify(destination, bytes) {
21
21
  }
22
22
  export async function repairProjectArtifacts(options) {
23
23
  const projectDir = path.resolve(options.projectDir);
24
+ // Refuse local mode before auditing — the audit's own payload scan is a
25
+ // no-op in local mode now (#274), but the refusal should not depend on
26
+ // running the audit first either (repair.ts:45-51 previously audited
27
+ // before this check existed, so the refusal reason always paid for the
28
+ // scan it was about to reject).
29
+ if (!isProjectArtifactRootExternal(projectDir)) {
30
+ throw new Error('No external AIWG artifact corpus is configured for this project.');
31
+ }
24
32
  const before = auditProjectArtifactHealth(projectDir);
25
33
  const copied = [];
26
34
  const migrated = [];
27
35
  const archivedConflicts = [];
28
36
  const removed = [];
29
- if (!before.external_configured) {
30
- throw new Error('No external AIWG artifact corpus is configured for this project.');
31
- }
32
37
  if (!before.external_reachable) {
33
38
  throw new Error(`External AIWG artifact corpus is unavailable: ${before.artifact_root}`);
34
39
  }
@@ -13,9 +13,10 @@ import fs from 'fs/promises';
13
13
  import { readFileSync, existsSync } from 'fs';
14
14
  import path from 'path';
15
15
  import { fileURLToPath } from 'url';
16
- import { execFileSync, execSync, spawn } from 'child_process';
16
+ import { spawn } from 'child_process';
17
17
  import os from 'os';
18
18
  import { resolveUserConfigDir } from '../config/user-config-dir.mjs';
19
+ import { boundedExecFileSync } from '../process/bounded-child-process.mjs';
19
20
  import {
20
21
  assertCanonicalInstallation,
21
22
  createInstallationIdentity,
@@ -135,6 +136,50 @@ export function getPackageRoot() {
135
136
  return path.resolve(__dirname, '..', '..');
136
137
  }
137
138
 
139
+ /**
140
+ * Resolve the installation root bin/aiwg.mjs actually dispatches to.
141
+ *
142
+ * This is the exact decision `resolveRouterPath()` in bin/aiwg.mjs makes
143
+ * when picking which `dist/` to load the compiled router (and therefore
144
+ * every compiled-dist module it imports, including the MCP server) from:
145
+ * the dev-mode edge checkout when configured and present and distinct from
146
+ * the package root, otherwise the package root itself. Shared so health
147
+ * checks (doctor) validate the layout the launcher will actually execute
148
+ * instead of re-deriving — and risking drifting from — the same decision
149
+ * (#276).
150
+ *
151
+ * @param {string} packageRoot - This install's own package root (`getPackageRoot()`).
152
+ * @param {{devMode?: boolean, edgePath?: string}} config - Channel config (`loadConfig()`).
153
+ * @returns {string} The effective root whose `dist/src/...` the launcher loads from.
154
+ */
155
+ export function resolveEffectiveInstallationRoot(packageRoot, config) {
156
+ if (config?.devMode && config.edgePath && config.edgePath !== packageRoot) {
157
+ return config.edgePath;
158
+ }
159
+ return packageRoot;
160
+ }
161
+
162
+ /**
163
+ * Resolve the absolute path to the MCP server entrypoint the production
164
+ * launcher actually loads: `bin/aiwg.mjs` always dispatches to the
165
+ * *compiled* router (`resolveRouterPath()`, never an uncompiled `src/`
166
+ * tree), whose MCP handler imports `../../mcp/cli.mjs` relative to its own
167
+ * compiled location, which in turn imports `./server.mjs` relative to
168
+ * itself — i.e. always `<effective root>/dist/src/mcp/server.mjs`, in every
169
+ * layout (npm install, source checkout, dev-mode edge checkout). There is
170
+ * no runtime path that falls back to `src/mcp/server.mjs` directly, so a
171
+ * health check that also accepts that source path can report OK for a
172
+ * layout where `aiwg mcp serve` will actually fail (#276).
173
+ *
174
+ * @param {string} packageRoot - This install's own package root (`getPackageRoot()`).
175
+ * @param {{devMode?: boolean, edgePath?: string}} config - Channel config (`loadConfig()`).
176
+ * @returns {string} Absolute path to the compiled MCP server entrypoint.
177
+ */
178
+ export function resolveMcpServerEntrypointPath(packageRoot, config) {
179
+ const root = resolveEffectiveInstallationRoot(packageRoot, config);
180
+ return path.join(root, 'dist', 'src', 'mcp', 'server.mjs');
181
+ }
182
+
138
183
  /**
139
184
  * Load channel configuration
140
185
  * @returns {Promise<object>} Channel configuration
@@ -237,6 +282,9 @@ export async function getFrameworkRoot(options = {}) {
237
282
  */
238
283
  export async function switchToEdge() {
239
284
  const config = await loadConfig();
285
+ if (typeof config.edgePath !== 'string' || !path.isAbsolute(config.edgePath)) {
286
+ throw new Error('Edge installation path must be absolute');
287
+ }
240
288
 
241
289
  console.log('Switching to edge channel (bleeding edge from main branch)...');
242
290
  console.log('');
@@ -256,8 +304,8 @@ export async function switchToEdge() {
256
304
  const sparseConfig = path.join(config.edgePath, '.git', 'info', 'sparse-checkout');
257
305
  const hasSparse = await fs.access(sparseConfig).then(() => true).catch(() => false);
258
306
  if (!hasSparse) {
259
- execSync('git sparse-checkout init --cone', { cwd: config.edgePath, stdio: 'pipe' });
260
- execSync('git sparse-checkout set --no-cone "/*" "!/.aiwg"', { cwd: config.edgePath, stdio: 'pipe' });
307
+ boundedExecFileSync('git', ['sparse-checkout', 'init', '--cone'], 'quick', { cwd: config.edgePath, stdio: 'pipe' });
308
+ boundedExecFileSync('git', ['sparse-checkout', 'set', '--no-cone', '/*', '!/.aiwg'], 'quick', { cwd: config.edgePath, stdio: 'pipe' });
261
309
  }
262
310
  } catch {
263
311
  // Sparse checkout is optional — older git may not support it
@@ -275,11 +323,12 @@ export async function switchToEdge() {
275
323
  console.log(`Cloning repository to ${config.edgePath}...`);
276
324
  await fs.mkdir(path.dirname(config.edgePath), { recursive: true });
277
325
  try {
278
- execSync(`git clone --branch main ${REPO_URL} "${config.edgePath}"`, { stdio: 'inherit' });
326
+ // Cloning the full repository; long tier (#3062).
327
+ boundedExecFileSync('git', ['clone', '--branch', 'main', '--', REPO_URL, config.edgePath], 'long', { stdio: 'inherit' });
279
328
  // Exclude dogfooding artifacts from edge clones — users want framework source only
280
329
  try {
281
- execSync('git sparse-checkout init --cone', { cwd: config.edgePath, stdio: 'pipe' });
282
- execSync('git sparse-checkout set --no-cone "/*" "!/.aiwg"', { cwd: config.edgePath, stdio: 'pipe' });
330
+ boundedExecFileSync('git', ['sparse-checkout', 'init', '--cone'], 'quick', { cwd: config.edgePath, stdio: 'pipe' });
331
+ boundedExecFileSync('git', ['sparse-checkout', 'set', '--no-cone', '/*', '!/.aiwg'], 'quick', { cwd: config.edgePath, stdio: 'pipe' });
283
332
  } catch {
284
333
  // Sparse checkout is optional — older git versions may not support it
285
334
  console.log('Note: sparse checkout not available, .aiwg/ will be present in edge install.');
@@ -375,7 +424,7 @@ export async function switchToNext() {
375
424
  console.log('');
376
425
 
377
426
  try {
378
- execFileSync(status.identity.managerExecutable, ['install', '--global', 'aiwg@next'], { stdio: 'inherit' });
427
+ boundedExecFileSync(status.identity.managerExecutable, ['install', '--global', 'aiwg@next'], 'long', { stdio: 'inherit' });
379
428
  } catch (error) {
380
429
  console.error('Failed to install aiwg@next:', error.message);
381
430
  console.error('Check that npm is available and you have write access to the global prefix.');
@@ -409,7 +458,7 @@ export async function switchToNightly() {
409
458
  console.log('');
410
459
 
411
460
  try {
412
- execFileSync(status.identity.managerExecutable, ['install', '--global', 'aiwg@nightly'], { stdio: 'inherit' });
461
+ boundedExecFileSync(status.identity.managerExecutable, ['install', '--global', 'aiwg@nightly'], 'long', { stdio: 'inherit' });
413
462
  } catch (error) {
414
463
  console.error('Failed to install aiwg@nightly:', error.message);
415
464
  console.error('Check that npm is available and you have write access to the global prefix.');
@@ -526,11 +575,11 @@ export async function getVersionInfo() {
526
575
  if (config.channel === 'edge') {
527
576
  // Get git info for edge channel
528
577
  try {
529
- const gitHash = execSync('git rev-parse --short HEAD', {
578
+ const gitHash = boundedExecFileSync('git', ['rev-parse', '--short', 'HEAD'], 'quick', {
530
579
  cwd: config.edgePath,
531
580
  encoding: 'utf8',
532
581
  }).trim();
533
- const gitBranch = execSync('git branch --show-current', {
582
+ const gitBranch = boundedExecFileSync('git', ['branch', '--show-current'], 'quick', {
534
583
  cwd: config.edgePath,
535
584
  encoding: 'utf8',
536
585
  }).trim();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared `spawnSync`/`boundedSpawnSync` result-to-exit-code mapping (#3062
3
+ * review item 7).
4
+ *
5
+ * `SpawnSyncReturns.status` is `null` on spawn failure, timeout, or signal
6
+ * termination — not `0`. Several call sites derived a CLI exit code with
7
+ * `result.status ?? 0`, which reports *success* for exactly those failure
8
+ * cases. This helper never does that: a `status` of `null` defaults to a
9
+ * non-zero exit, and when the bounded helper's `error`/`signal` fields are
10
+ * present, their message (already enriched with the tier/bound/override
11
+ * guidance for a timeout) is surfaced instead of being silently dropped.
12
+ *
13
+ * Deliberately lives under `src/cli/` rather than `src/process/` so it
14
+ * composes with, but does not modify, the bounded helper itself.
15
+ *
16
+ * @module src/cli/child-process-result
17
+ */
18
+ /**
19
+ * Map one spawn result to `{ exitCode, message? }`.
20
+ *
21
+ * - `result.error` set (spawn failure, or a bounded-helper timeout with its
22
+ * enriched message) → non-zero, `message` is `error.message` (optionally
23
+ * prefixed with `context`).
24
+ * - No `error`, but `result.signal` set (child killed by a signal, with no
25
+ * separate error object) → non-zero, `message` names the signal.
26
+ * - Otherwise → `result.status ?? 1` (a `null`/`undefined` status with
27
+ * neither `error` nor `signal` set is still treated as a failure, never
28
+ * as success).
29
+ */
30
+ export function exitCodeForSpawnResult(result, context) {
31
+ const prefix = context ? `${context}: ` : '';
32
+ if (result.error) {
33
+ return { exitCode: 1, message: `${prefix}${result.error.message}` };
34
+ }
35
+ if (result.signal) {
36
+ return { exitCode: 1, message: `${prefix}terminated by signal ${result.signal}` };
37
+ }
38
+ return { exitCode: result.status ?? 1 };
39
+ }
40
+ //# sourceMappingURL=child-process-result.js.map