@aiwg/cli 2026.10.1 → 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 (166) 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 +210 -4
  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 +75 -27
  38. package/dist/src/decision/driver.js +3 -1
  39. package/dist/src/decision/entry.js +38 -6
  40. package/dist/src/decision/heldout/collector.js +10 -1
  41. package/dist/src/decision/index.js +1 -0
  42. package/dist/src/decision/interop.js +60 -8
  43. package/dist/src/decision/ledger.js +839 -0
  44. package/dist/src/decision/validate.js +6 -3
  45. package/dist/src/effects/dispatch-verifier.js +252 -0
  46. package/dist/src/effects/index.js +1 -0
  47. package/dist/src/features/installer.js +2 -2
  48. package/dist/src/flow/graph-metadata.js +5 -0
  49. package/dist/src/gates/providers/loader.js +3 -1
  50. package/dist/src/installation/manager-command.mjs +1 -1
  51. package/dist/src/installation/manager.mjs +17 -2
  52. package/dist/src/lint/runner.js +2 -2
  53. package/dist/src/mcp/adapters/codex-runtime.js +360 -56
  54. package/dist/src/mcp/cli.mjs +587 -77
  55. package/dist/src/mcp/config-file.mjs +279 -0
  56. package/dist/src/mcp/config-layers.mjs +348 -0
  57. package/dist/src/mcp/credentials.mjs +484 -0
  58. package/dist/src/mcp/grok-build-config.mjs +16 -26
  59. package/dist/src/mcp/helpers.mjs +106 -2
  60. package/dist/src/mcp/omp-config.mjs +50 -29
  61. package/dist/src/mcp/profiles.js +97 -23
  62. package/dist/src/mcp/profiles.mjs +99 -27
  63. package/dist/src/mcp/registry.js +264 -57
  64. package/dist/src/mcp/registry.mjs +198 -45
  65. package/dist/src/mcp/server.mjs +403 -20
  66. package/dist/src/mcp/toml-string.mjs +9 -0
  67. package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
  68. package/dist/src/mcp/tool-filters.mjs +357 -0
  69. package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
  70. package/dist/src/mcp/tools/decision.mjs +21 -3
  71. package/dist/src/mcp/tools/orchestration.mjs +38 -0
  72. package/dist/src/mcp/tools/subsystems.mjs +39 -8
  73. package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
  74. package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
  75. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
  76. package/dist/src/memory/project-registry.js +2 -2
  77. package/dist/src/mission-protocol/codecs.js +639 -1
  78. package/dist/src/mission-protocol/types.js +110 -0
  79. package/dist/src/mission-protocol/worker-status.js +2 -0
  80. package/dist/src/models/config-loader.js +70 -13
  81. package/dist/src/models/grok-build-models.js +93 -41
  82. package/dist/src/models/model-capabilities.v1.json +3091 -24
  83. package/dist/src/models/model-discovery.js +862 -63
  84. package/dist/src/models/provider-policy.js +625 -11
  85. package/dist/src/models/qualification-digest.mjs +17 -0
  86. package/dist/src/models/resolver.js +259 -0
  87. package/dist/src/models/worker-direct-readiness.js +529 -0
  88. package/dist/src/models/worker-dispatch-offer.js +169 -0
  89. package/dist/src/models/worker-health-readiness.js +131 -0
  90. package/dist/src/models/worker-installation.mjs +879 -0
  91. package/dist/src/models/wrapper-route.js +18 -0
  92. package/dist/src/ops/registry.js +9 -14
  93. package/dist/src/plugins/standalone-packager.js +4 -3
  94. package/dist/src/process/bounded-child-process.mjs +387 -0
  95. package/dist/src/process/cli-command.mjs +75 -0
  96. package/dist/src/providers/capability-matrix.js +8 -0
  97. package/dist/src/providers/capability-matrix.yaml +270 -0
  98. package/dist/src/providers/grok-build-headless.js +43 -21
  99. package/dist/src/providers/provider-definitions.js +9 -1
  100. package/dist/src/providers/provider-definitions.mjs +9 -2
  101. package/dist/src/serve/executor-registry.js +16 -24
  102. package/dist/src/serve/fleet-mission-conductor.js +9 -0
  103. package/dist/src/serve/mission-conductor.js +9 -0
  104. package/dist/src/serve/shared-host-scheduler.js +2 -0
  105. package/dist/src/serve/worker-invocation.js +538 -0
  106. package/dist/src/serve/worker-stdin-frame.mjs +107 -0
  107. package/dist/src/sessions/adapter-registry.js +45 -0
  108. package/dist/src/sessions/adapters/generic.js +37 -3
  109. package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
  110. package/dist/src/sessions/adapters/grokbot.js +423 -24
  111. package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
  112. package/dist/src/sessions/adapters/muse.js +68 -29
  113. package/dist/src/sessions/adapters/pi.js +1 -0
  114. package/dist/src/sessions/batch-contracts.js +13 -1
  115. package/dist/src/sessions/batch-import.js +23 -42
  116. package/dist/src/sessions/candidates.js +47 -11
  117. package/dist/src/sessions/contracts.js +35 -0
  118. package/dist/src/sessions/coverage-preflight.js +134 -0
  119. package/dist/src/sessions/discovery.js +3 -0
  120. package/dist/src/sessions/grok-build-cli.js +67 -5
  121. package/dist/src/sessions/importer.js +24 -8
  122. package/dist/src/sessions/index.js +2 -0
  123. package/dist/src/sessions/repository.js +54 -13
  124. package/dist/src/sessions/workspace-discovery.js +99 -37
  125. package/dist/src/skills/adapters/clawhub.js +10 -8
  126. package/dist/src/skills/adapters/openclaw.js +10 -7
  127. package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
  128. package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
  129. package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
  130. package/dist/src/smiths/mcpsmith/example.js +1 -1
  131. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
  132. package/dist/src/storage/backends/logseq.js +68 -7
  133. package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
  134. package/dist/src/testing/generators/test-code-generator.js +57 -30
  135. package/dist/src/uhp/client.js +11 -1
  136. package/dist/src/uhp/mission.js +5 -0
  137. package/dist/src/update/checker.mjs +5 -5
  138. package/dist/src/update/notifier.mjs +54 -2
  139. package/dist/src/writing/fidelity.js +18 -1
  140. package/package.json +18 -1
  141. package/schemas/mission-v1.schema.json +2429 -0
  142. package/schemas/models/model-catalog.v1.schema.json +301 -0
  143. package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
  144. package/schemas/models/model-policy.v1.schema.json +14 -0
  145. package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
  146. package/tools/_resolve-impl.mjs +39 -0
  147. package/tools/agents/deploy-agents.mjs +15 -2
  148. package/tools/agents/providers/base.mjs +49 -32
  149. package/tools/agents/providers/grokbot.mjs +11 -12
  150. package/tools/agents/providers/hermes.mjs +6 -3
  151. package/tools/agents/providers/omp.mjs +5 -2
  152. package/tools/agents/providers/openclaw-translator.mjs +19 -5
  153. package/tools/providers/deepseek-harness-transport.mjs +358 -0
  154. package/tools/providers/omp-transport.mjs +192 -0
  155. package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
  156. package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
  157. package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
  158. package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
  159. package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
  160. package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
  161. package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
  162. package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
  163. package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
  164. package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
  165. package/tools/ralph-external/lib/worker-health.mjs +439 -0
  166. package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
@@ -4,21 +4,29 @@
4
4
  * Codex has no native per-session config flag (unlike `claude --mcp-config`).
5
5
  * This adapter implements the sysops `codex-role.sh` pattern:
6
6
  *
7
- * 1. Create ~/.codex/roles-runtime/<profile>/ per profile
7
+ * 1. Create <CODEX_HOME or ~/.codex>/roles-runtime/<profile>/ per profile
8
8
  * 2. Symlink shared state (history, sessions) into the runtime home
9
9
  * 3. Write a profile-scoped config.toml (stripped global MCP, only profile servers)
10
- * 4. Launch with HOME=<runtime-home> codex
10
+ * 4. Launch with CODEX_HOME=<runtime-home> codex
11
11
  * 5. Auth flows execute against the runtime home — OAuth tokens are isolated per profile
12
12
  *
13
13
  * Reference: roctinam/sysops:scripts/mcp-roles/codex-role.sh
14
14
  *
15
15
  * @implements #892
16
16
  */
17
- import { readFile, writeFile, mkdir, symlink, access, readdir } from 'fs/promises';
17
+ import { readFile, mkdir, symlink, access, readdir, chmod, lstat, open } from 'fs/promises';
18
18
  import { join } from 'path';
19
19
  import { homedir } from 'os';
20
- import { existsSync } from 'fs';
21
- import { spawnSync } from 'child_process';
20
+ import { existsSync, constants } from 'fs';
21
+ import { spawn, spawnSync } from 'child_process';
22
+ import { boundedSpawnSync, resolveSyncProcessTimeoutMs } from '../../process/bounded-child-process.mjs';
23
+ import { isInteractive } from '../../cli/env.js';
24
+ import { exitCodeForSpawnResult } from '../../cli/child-process-result.js';
25
+ import { buildServerToml } from '../registry.js';
26
+ import { assertCredentialPolicy, resolveCredentialPolicy } from '../credentials.mjs';
27
+ import { planToolFilters } from '../tool-filters.mjs';
28
+ import { assertConfigDestination, writeConfigAtomic } from '../config-file.mjs';
29
+ import { stripMcpServers } from '../toml-strip-mcp.mjs';
22
30
  const DEFAULT_SHARED_STATE = {
23
31
  // Shared: operator history and session index are cross-profile
24
32
  shared: ['history.jsonl', 'sessions'],
@@ -29,12 +37,15 @@ const DEFAULT_SHARED_STATE = {
29
37
  // Paths
30
38
  // ─────────────────────────────────────────────
31
39
  function codexHome() {
32
- return join(homedir(), '.codex');
40
+ return process.env.CODEX_HOME || join(homedir(), '.codex');
33
41
  }
34
42
  function runtimeHomesDir() {
35
43
  return join(codexHome(), 'roles-runtime');
36
44
  }
37
45
  function runtimeHomePath(profile) {
46
+ if (!/^[a-z0-9-]+$/.test(profile)) {
47
+ throw new Error('Invalid profile name. Names must match [a-z0-9-]+.');
48
+ }
38
49
  return join(runtimeHomesDir(), profile);
39
50
  }
40
51
  function runtimeConfigPath(profile) {
@@ -43,14 +54,22 @@ function runtimeConfigPath(profile) {
43
54
  // ─────────────────────────────────────────────
44
55
  // Runtime home management
45
56
  // ─────────────────────────────────────────────
57
+ async function ensurePrivateRuntimeHome(profile) {
58
+ const rtHome = runtimeHomePath(profile);
59
+ // The global .codex home may be managed by a dotfile symlink. Refuse
60
+ // symlinks below it, including roles-runtime, the profile, and config.
61
+ await assertConfigDestination(runtimeConfigPath(profile), codexHome());
62
+ await mkdir(rtHome, { recursive: true, mode: 0o700 });
63
+ await chmod(rtHome, 0o700);
64
+ return rtHome;
65
+ }
46
66
  /**
47
67
  * Ensure the runtime home directory exists for a profile.
48
68
  * Creates the directory and sets up shared-state symlinks.
49
69
  * Returns the runtime home path.
50
70
  */
51
71
  export async function ensureRuntimeHome(profile, policy = DEFAULT_SHARED_STATE) {
52
- const rtHome = runtimeHomePath(profile);
53
- await mkdir(rtHome, { recursive: true });
72
+ const rtHome = await ensurePrivateRuntimeHome(profile);
54
73
  const globalHome = codexHome();
55
74
  // Create symlinks for shared state (only if source exists in global home)
56
75
  for (const name of policy.shared) {
@@ -77,64 +96,75 @@ export async function ensureRuntimeHome(profile, policy = DEFAULT_SHARED_STATE)
77
96
  }
78
97
  /**
79
98
  * Write a profile-scoped config.toml into the runtime home.
80
- * Strips all [mcp_servers.*] blocks from any existing global config.toml
99
+ * Strips the entire mcp_servers subtree from any existing global config.toml
81
100
  * and writes only the profile's servers.
101
+ *
102
+ * `options.floorPolicy`, when given, is an enforced organisation floor
103
+ * (see `resolveCredentialPolicy`): this function resolves and enforces it
104
+ * itself, so a direct caller that omits `credentialPolicy` or passes a
105
+ * weaker value cannot write literal credentials below the floor. Pass
106
+ * `floorPolicy` from the same registry whose servers are being written
107
+ * (`McpServerRegistry#getCredentialPolicyFloor`); omitting it disables floor
108
+ * enforcement for this call, matching a single registry with no layering.
82
109
  */
83
- export async function writeProfileConfig(profile, servers) {
84
- const rtHome = runtimeHomePath(profile);
85
- await mkdir(rtHome, { recursive: true });
110
+ export async function writeProfileConfig(profile, servers, options = {}) {
111
+ // env is deliberately excluded: the caller (CLI/session) already resolved
112
+ // any environment/flag override before calling here, and `credentialPolicy`
113
+ // carries that result as `flag`; this call only adds the floor enforcement.
114
+ const resolvedPolicy = resolveCredentialPolicy({
115
+ flag: options.credentialPolicy, floorPolicy: options.floorPolicy, env: {},
116
+ });
117
+ assertCredentialPolicy(servers, resolvedPolicy);
118
+ const toolPlan = options.toolFilters
119
+ ? planToolFilters('codex', servers.map((server) => server.name), options.toolFilters)
120
+ : null;
121
+ await ensurePrivateRuntimeHome(profile);
86
122
  // Load global config.toml as base (strip existing mcp_servers blocks)
87
123
  let baseConfig = '';
88
124
  const globalConfigPath = join(codexHome(), 'config.toml');
89
125
  try {
90
126
  const raw = await readFile(globalConfigPath, 'utf-8');
91
- // Remove all [mcp_servers.*] sections and their content
92
- baseConfig = raw
93
- .replace(/\[mcp_servers\.[^\]]+\][\s\S]*?(?=\n\[|\s*$)/g, '')
94
- .trimEnd();
127
+ baseConfig = stripMcpServers(raw).trimEnd();
95
128
  }
96
- catch {
97
- // No global config — start empty
129
+ catch (error) {
130
+ // Only a missing global config permits an empty base. Classification and
131
+ // read errors must never silently copy or replace a previous config.
132
+ if (error.code !== 'ENOENT')
133
+ throw error;
98
134
  }
99
135
  // Build profile-specific [mcp_servers.*] TOML sections
100
- const mcpSections = [];
101
- for (const server of servers) {
102
- const lines = [`[mcp_servers.${server.name}]`];
103
- if (server.type === 'stdio') {
104
- lines.push(`command = "${server.command}"`);
105
- if (server.args && server.args.length > 0) {
106
- const argsStr = server.args.map((a) => `"${a}"`).join(', ');
107
- lines.push(`args = [${argsStr}]`);
108
- }
109
- if (server.env) {
110
- for (const [k, v] of Object.entries(server.env)) {
111
- lines.push(`env.${k} = "${v}"`);
112
- }
113
- }
114
- }
115
- else {
116
- lines.push(`url = "${server.url}"`);
117
- if (server.headers) {
118
- for (const [k, v] of Object.entries(server.headers)) {
119
- lines.push(`headers.${k} = "${v}"`);
120
- }
121
- }
122
- }
123
- lines.push(`startup_timeout_sec = 10.0`);
124
- lines.push(`tool_timeout_sec = 60.0`);
125
- mcpSections.push(lines.join('\n'));
126
- }
136
+ const mcpSections = servers.map((server) => [buildServerToml(server), ...(toolPlan?.tomlLines[server.name] ?? [])].join('\n'));
127
137
  const configContent = (baseConfig ? baseConfig + '\n\n' : '') +
128
138
  mcpSections.join('\n\n') +
129
139
  '\n';
130
- await writeFile(runtimeConfigPath(profile), configContent, 'utf-8');
140
+ const configPath = runtimeConfigPath(profile);
141
+ await writeConfigAtomic(configPath, configContent, { userScope: true, projectRoot: codexHome() });
142
+ return toolPlan?.warnings ?? [];
143
+ }
144
+ /**
145
+ * True iff `extraArgs` already selects one of codex's documented
146
+ * non-interactive forms, so launching with them is safe to bound (#3062).
147
+ * `exec` is the only evidenced one today (`codex exec <prompt>`; see
148
+ * tools/ralph-external/lib/codex-adapter.mjs). Anything else — including no
149
+ * args at all — hands the terminal to the interactive TUI.
150
+ */
151
+ function isNonInteractiveCodexInvocation(extraArgs) {
152
+ return extraArgs[0] === 'exec';
131
153
  }
132
154
  /**
133
155
  * Launch Codex with a profile's runtime home.
134
- * Sets HOME=<runtime-home> so Codex reads its profile-scoped config and
156
+ * Sets CODEX_HOME and HOME to the runtime home so Codex reads its profile-scoped config and
135
157
  * OAuth tokens are written to the runtime home (isolated from other profiles).
158
+ *
159
+ * Headless (#3062): when `extraArgs` is already a non-interactive form (see
160
+ * {@link isNonInteractiveCodexInvocation}), the call never hands over the
161
+ * terminal, so it is bounded like any other child process and never inherits
162
+ * stdin — unless `options.input` is given, in which case it carries the
163
+ * prompt on stdin instead (review item 8). Otherwise this launches the
164
+ * interactive TUI, which requires a terminal — without one, this refuses
165
+ * instead of spawning something that would hang.
136
166
  */
137
- export function launchWithProfile(profile, extraArgs = []) {
167
+ export function launchWithProfile(profile, extraArgs = [], options = {}) {
138
168
  const rtHome = runtimeHomePath(profile);
139
169
  if (!existsSync(rtHome)) {
140
170
  throw new Error(`Runtime home for profile "${profile}" does not exist.\n` +
@@ -143,29 +173,302 @@ export function launchWithProfile(profile, extraArgs = []) {
143
173
  const env = {
144
174
  ...process.env,
145
175
  HOME: rtHome,
176
+ CODEX_HOME: rtHome,
146
177
  };
178
+ if (isNonInteractiveCodexInvocation(extraArgs)) {
179
+ // Bounded (#3062): `codex exec -` never hands over the terminal; the
180
+ // prompt (if any) rides on stdin via `options.input`, never on argv.
181
+ return boundedSpawnSync('codex', extraArgs, 'long', {
182
+ stdio: options.input !== undefined ? ['pipe', 'inherit', 'inherit'] : ['ignore', 'inherit', 'inherit'],
183
+ env,
184
+ ...(options.input !== undefined ? { input: options.input } : {}),
185
+ });
186
+ }
187
+ if (!(options.interactive ?? isInteractive())) {
188
+ throw new Error(`Launching codex for profile "${profile}" with no arguments hands over the terminal and requires one to be attached.\n` +
189
+ 'Pass a non-interactive form instead — e.g. run `aiwg session --provider codex --profile ' +
190
+ `${profile} --prompt "<text>"\` (maps to \`codex exec -\`, with the prompt on stdin).`);
191
+ }
192
+ // sync-spawn: interactive codex-exec — replaces the terminal with the codex
193
+ // binary the user is meant to talk to, when extraArgs is not already a
194
+ // non-interactive form (see tools/testing/sync-spawn-allowlist.json).
195
+ // Reached only when `options.interactive ?? isInteractive()` is true
196
+ // (#3062) — the non-interactive and no-terminal paths above are handled
197
+ // before this point. Not a candidate for a bounded timeout.
147
198
  return spawnSync('codex', extraArgs, {
148
199
  stdio: 'inherit',
149
200
  env,
150
201
  });
151
202
  }
203
+ /**
204
+ * Build a child environment that excludes `envVarName` (when given) and
205
+ * any other variable holding the identical value (#3062 second review,
206
+ * finding 1): the API key must never be visible in the child's own
207
+ * environment, only on its stdin, including as a coincidental duplicate
208
+ * under some other name. `envVarName` is `undefined` when the key came
209
+ * from somewhere other than a named environment variable (`--api-key-stdin`
210
+ * / `--api-key-file`, go/no-go review blocker 1C) — the by-value scrub
211
+ * still applies in that case.
212
+ */
213
+ function envWithoutSecretValue(base, envVarName, secretValue) {
214
+ const result = {};
215
+ for (const [key, value] of Object.entries(base)) {
216
+ if (value === undefined)
217
+ continue;
218
+ if (envVarName !== undefined && key === envVarName)
219
+ continue;
220
+ if (value === secretValue)
221
+ continue;
222
+ result[key] = value;
223
+ }
224
+ return result;
225
+ }
226
+ /**
227
+ * Read an API key from a private file (#3062 go/no-go review, blocker 1C):
228
+ * `--api-key-file <path>` reads the key from disk instead of an
229
+ * environment variable, so a file that does not meet the same bar the
230
+ * decision ledger's own file writer enforces
231
+ * (`src/decision/ledger.ts`'s `openLedgerFileForAppend`) is refused rather
232
+ * than silently trusted. This mirrors that check for a read instead of a
233
+ * write — there is no already-exported helper at this layer to reuse (the
234
+ * ledger's checks are private to that module and tied to its own
235
+ * create-if-missing write path), so this re-implements the same checks for
236
+ * a read: never follow a symlink; the open file descriptor must resolve to
237
+ * a regular file with exactly one hard link; and, on POSIX, must be owned
238
+ * by the current user with no group/other permission bits. The file's
239
+ * contents are returned trimmed; no error message here ever includes them.
240
+ */
241
+ async function readApiKeyFromFile(filePath) {
242
+ try {
243
+ const pre = await lstat(filePath);
244
+ if (pre.isSymbolicLink()) {
245
+ throw new Error(`--api-key-file: "${filePath}" is a symlink; refusing to follow it.`);
246
+ }
247
+ }
248
+ catch (error) {
249
+ if (error.code === 'ENOENT') {
250
+ throw new Error(`--api-key-file: "${filePath}" does not exist.`);
251
+ }
252
+ if (error instanceof Error && error.message.startsWith('--api-key-file:'))
253
+ throw error;
254
+ throw new Error(`--api-key-file: cannot stat "${filePath}": ${error.message}`);
255
+ }
256
+ const hasNoFollow = typeof constants.O_NOFOLLOW === 'number';
257
+ const flags = constants.O_RDONLY | (hasNoFollow ? constants.O_NOFOLLOW : 0);
258
+ let handle;
259
+ try {
260
+ handle = await open(filePath, flags);
261
+ }
262
+ catch (error) {
263
+ const code = error.code;
264
+ if (code === 'ELOOP')
265
+ throw new Error(`--api-key-file: "${filePath}" is a symlink; refusing to follow it.`);
266
+ if (code === 'ENOENT')
267
+ throw new Error(`--api-key-file: "${filePath}" does not exist.`);
268
+ throw new Error(`--api-key-file: cannot open "${filePath}": ${error.message}`);
269
+ }
270
+ try {
271
+ const stat = await handle.stat();
272
+ if (!stat.isFile()) {
273
+ throw new Error(`--api-key-file: "${filePath}" is not a regular file.`);
274
+ }
275
+ if (stat.nlink !== 1) {
276
+ throw new Error(`--api-key-file: "${filePath}" has more than one hard link; refusing to read it.`);
277
+ }
278
+ if (process.platform !== 'win32') {
279
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
280
+ if (uid !== null && stat.uid !== uid) {
281
+ throw new Error(`--api-key-file: "${filePath}" is not owned by the current user.`);
282
+ }
283
+ if ((stat.mode & 0o077) !== 0) {
284
+ throw new Error(`--api-key-file: "${filePath}" must not be readable or writable by group or other ` +
285
+ '(chmod 600 it).');
286
+ }
287
+ }
288
+ return (await handle.readFile({ encoding: 'utf-8' })).trim();
289
+ }
290
+ finally {
291
+ await handle.close();
292
+ }
293
+ }
294
+ /**
295
+ * Async, credential-safe launch of `codex login --with-api-key` (#3062
296
+ * second review, finding 1). This is an *interactive-site exception* to
297
+ * the synchronous bounded-child-process helper — not a sync call at all,
298
+ * so the sync-child-process gate does not (and should not) apply to it.
299
+ *
300
+ * `boundedSpawnSync`'s `input` option places the secret inside the
301
+ * options object `child_process` hands to its native binding, which
302
+ * `NODE_DEBUG=child_process` (and any other dump of those options)
303
+ * prints verbatim — confirmed by the review with a live probe. An async
304
+ * `spawn()` has no `input` option at all: the key is written directly to
305
+ * the already-created child's own `stdin` stream, after the options
306
+ * object was already built and handed off, so it is never part of any
307
+ * options object, logged value, or thrown Error. The 'network'-tier bound
308
+ * (same as the previous synchronous call) is enforced here by hand, with
309
+ * the same SIGKILL-and-enriched-message contract the bounded helper
310
+ * itself uses, since this call can no longer go through that helper.
311
+ */
312
+ function spawnCodexLoginWithApiKey(apiKey, env) {
313
+ const tier = 'network';
314
+ const boundMs = resolveSyncProcessTimeoutMs(tier);
315
+ return new Promise((resolve) => {
316
+ const child = spawn('codex', ['login', '--with-api-key'], {
317
+ env,
318
+ stdio: ['pipe', 'inherit', 'inherit'],
319
+ });
320
+ let settled = false;
321
+ let timer;
322
+ // #3062 go/no-go review, blocker 2: stdin error, write completion,
323
+ // child 'error', child 'exit', and the deadline all funnel through this
324
+ // one function, which settles exactly once, always clears the timer and
325
+ // removes every listener this call added, and kills the child if it is
326
+ // still alive on a failing settlement (a running process on the
327
+ // timeout path; a process that may still be running on an unexpected
328
+ // stdin-write error). The 'exit' path never reaches the kill — by the
329
+ // time that event fires the child has already exited.
330
+ const settle = (result) => {
331
+ if (settled)
332
+ return;
333
+ settled = true;
334
+ if (timer)
335
+ clearTimeout(timer);
336
+ child.stdin?.removeListener('error', onStdinError);
337
+ child.removeListener('error', onChildError);
338
+ child.removeListener('exit', onExit);
339
+ if (result.error && child.exitCode === null && child.signalCode === null) {
340
+ try {
341
+ child.kill('SIGKILL');
342
+ }
343
+ catch { /* already gone */ }
344
+ }
345
+ resolve(result);
346
+ };
347
+ timer = setTimeout(() => {
348
+ settle({
349
+ status: null,
350
+ signal: 'SIGKILL',
351
+ error: Object.assign(new Error(`\`codex login --with-api-key\` exceeded the '${tier}' tier bound of ${boundMs}ms and was killed (SIGKILL).`), { code: 'ETIMEDOUT' }),
352
+ });
353
+ }, boundMs);
354
+ const onChildError = (err) => settle({ status: null, signal: null, error: err });
355
+ child.on('error', onChildError);
356
+ const onExit = (code, signal) => settle({ status: code, signal });
357
+ child.on('exit', onExit);
358
+ // A child that exits (or closes its read end) before reading stdin
359
+ // raises EPIPE/ECONNRESET on the *stdin stream*, not on the child
360
+ // object — `child.on('error', ...)` above never sees it, so without
361
+ // this listener it is an unhandled 'error' event, which Node turns into
362
+ // an uncaught exception. Installed before the write/end below, per the
363
+ // review. EPIPE/ECONNRESET specifically mean "the child already went
364
+ // away"; the already-firing (or about-to-fire) 'exit' event carries the
365
+ // real, non-secret result, so this swallows exactly those two codes and
366
+ // lets settle() happen there instead. Anything else is a genuine,
367
+ // unexpected stdin failure and is itself the settling error.
368
+ const onStdinError = (err) => {
369
+ if (err.code === 'EPIPE' || err.code === 'ECONNRESET')
370
+ return;
371
+ settle({ status: null, signal: null, error: err });
372
+ };
373
+ child.stdin.on('error', onStdinError);
374
+ // Written directly to the live stream — never part of the spawn
375
+ // options object above, so NODE_DEBUG=child_process (and any dump of
376
+ // those options) cannot print it. `end(chunk, callback)` reports a
377
+ // failed write through its callback; `onStdinError` above is the
378
+ // primary path (Node always emits 'error' on the stream too), so this
379
+ // callback only needs to catch the case where, for some Node version or
380
+ // stream state, the callback fires but the 'error' event does not.
381
+ child.stdin.end(apiKey, (err) => {
382
+ const code = err?.code;
383
+ if (err && code !== 'EPIPE' && code !== 'ECONNRESET') {
384
+ settle({ status: null, signal: null, error: err });
385
+ }
386
+ });
387
+ });
388
+ }
152
389
  /**
153
390
  * Run `codex login` within a profile's runtime home to isolate OAuth tokens.
391
+ *
392
+ * Headless (#3062): pass `apiKeyEnv` to log in non-interactively. codex-cli
393
+ * documents `--with-api-key` for exactly this (verified: `codex login
394
+ * --help`, codex-cli 0.162.1 — "Read the API key from stdin (e.g. `printenv
395
+ * OPENAI_API_KEY | codex login --with-api-key`)"); this function reads
396
+ * `process.env[apiKeyEnv]` and feeds it to codex over stdin, so the key
397
+ * itself never appears on argv or in a process listing — only the
398
+ * environment-variable *name* is ever passed around. Without `apiKeyEnv`
399
+ * and without a terminal, this refuses instead of spawning the interactive
400
+ * OAuth flow, which would hang waiting for input nobody can supply.
401
+ *
402
+ * Wired to `aiwg mcp profile login <name> [--api-key-env <NAME>]`
403
+ * (`src/mcp/cli.mjs`, #3069). The `apiKeyEnv` branch never places the key
404
+ * in a spawn options object or the child's own environment — see
405
+ * `spawnCodexLoginWithApiKey()`'s doc comment.
154
406
  */
155
- export async function loginInProfile(profile) {
407
+ export async function loginInProfile(profile, options = {}) {
156
408
  await ensureRuntimeHome(profile);
157
409
  const rtHome = runtimeHomePath(profile);
410
+ const env = {
411
+ ...process.env,
412
+ HOME: rtHome,
413
+ CODEX_HOME: rtHome,
414
+ };
415
+ // `options.apiKey` (go/no-go review, blocker 1C) is the already-resolved
416
+ // key from `--api-key-stdin`/`--api-key-file` — the caller (`src/mcp/
417
+ // cli.mjs`) read and validated it before calling here, so there is no
418
+ // single named source environment variable to exclude below, only the
419
+ // by-value scrub `envWithoutSecretValue()` always does. `options.apiKeyEnv`
420
+ // is unchanged: still read from `process.env` here, so its *name* is the
421
+ // one thing ever passed around. Exactly one of the two is ever set — the
422
+ // CLI layer enforces that mutual exclusivity before calling this function.
423
+ let apiKey;
424
+ let sourceDescription;
425
+ if (options.apiKey !== undefined) {
426
+ apiKey = options.apiKey;
427
+ sourceDescription = 'the supplied key';
428
+ }
429
+ else if (options.apiKeyEnv) {
430
+ apiKey = process.env[options.apiKeyEnv];
431
+ if (!apiKey) {
432
+ throw new Error(`Environment variable "${options.apiKeyEnv}" is not set; cannot log in to profile "${profile}" non-interactively.`);
433
+ }
434
+ sourceDescription = `an API key from $${options.apiKeyEnv}`;
435
+ }
436
+ if (apiKey !== undefined) {
437
+ console.log(`\n Logging in to profile "${profile}" with ${sourceDescription}...`);
438
+ // Credential-safe async launch (#3062 second review, finding 1): the
439
+ // key never enters a spawn options object, env dump, log line, or
440
+ // thrown value — see spawnCodexLoginWithApiKey()'s own doc comment for
441
+ // why this can no longer be the synchronous bounded helper.
442
+ const safeEnv = envWithoutSecretValue(env, options.apiKeyEnv, apiKey);
443
+ const result = await spawnCodexLoginWithApiKey(apiKey, safeEnv);
444
+ if (result.status !== 0) {
445
+ // #3062 review item 7: surface the bounded helper's `error`/`signal`
446
+ // detail (e.g. the enriched timeout message naming the bound and
447
+ // override variable) instead of only the exit code.
448
+ const { message } = exitCodeForSpawnResult(result, 'codex login --with-api-key');
449
+ throw new Error(message ?? `codex login --with-api-key failed with exit code ${result.status ?? 'unknown'}`);
450
+ }
451
+ return;
452
+ }
453
+ if (!isInteractive()) {
454
+ throw new Error(`Logging in to profile "${profile}" requires an interactive terminal, or a key supplied via ` +
455
+ '--api-key-env, --api-key-stdin, or --api-key-file (never pass the key itself on the command ' +
456
+ `line), or run \`codex login\` yourself with CODEX_HOME=${rtHome}.`);
457
+ }
158
458
  console.log(`\n Running codex login in profile "${profile}" runtime home...`);
159
459
  console.log(` Auth tokens will be isolated to: ${rtHome}\n`);
460
+ // sync-spawn: interactive codex-login — runs the codex OAuth login flow,
461
+ // which prompts the user directly (see tools/testing/
462
+ // sync-spawn-allowlist.json). Reached only when isInteractive() is true
463
+ // and no apiKeyEnv was given (#3062). Not a candidate for a bounded
464
+ // timeout.
160
465
  const result = spawnSync('codex', ['login'], {
161
466
  stdio: 'inherit',
162
- env: {
163
- ...process.env,
164
- HOME: rtHome,
165
- },
467
+ env,
166
468
  });
167
469
  if (result.status !== 0) {
168
- throw new Error(`codex login failed with exit code ${result.status ?? 'unknown'}`);
470
+ const { message } = exitCodeForSpawnResult(result, 'codex login');
471
+ throw new Error(message ?? `codex login failed with exit code ${result.status ?? 'unknown'}`);
169
472
  }
170
473
  }
171
474
  // ─────────────────────────────────────────────
@@ -206,7 +509,8 @@ export async function removeRuntimeHome(profile) {
206
509
  const rtHome = runtimeHomePath(profile);
207
510
  if (!existsSync(rtHome))
208
511
  return false;
209
- const result = spawnSync('rm', ['-rf', rtHome], { stdio: 'inherit' });
512
+ // Local filesystem delete; bounded as a quick-tier call (#3062).
513
+ const result = boundedSpawnSync('rm', ['-rf', rtHome], 'quick', { stdio: 'inherit' });
210
514
  return result.status === 0;
211
515
  }
212
516
  /**
@@ -220,5 +524,5 @@ export async function detectOrphanedRuntimes(knownProfiles) {
220
524
  // ─────────────────────────────────────────────
221
525
  // Re-exports (convenience)
222
526
  // ─────────────────────────────────────────────
223
- export { DEFAULT_SHARED_STATE };
527
+ export { DEFAULT_SHARED_STATE, readApiKeyFromFile };
224
528
  //# sourceMappingURL=codex-runtime.js.map