@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
@@ -0,0 +1,484 @@
1
+ /**
2
+ * MCP credential rendering and policy.
3
+ *
4
+ * A registry entry carries credentials in two ways:
5
+ * - literal values in `env` / `headers` (and OMP `auth` / `oauth` secrets)
6
+ * - references: `headerEnv` (header -> variable) and `envFrom`
7
+ * (server variable -> variable), which name a variable the harness
8
+ * resolves when it starts the server. Only the variable name is written.
9
+ *
10
+ * Each harness spells a reference differently; ENV_REFERENCE_SYNTAX holds the
11
+ * documented form. `null` means the harness documents no interpolation in its
12
+ * MCP config, so a reference cannot be rendered and injection refuses.
13
+ */
14
+
15
+ const ENV_REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+
17
+ /**
18
+ * Keyed by normalized MCP provider id.
19
+ * Sources (checked 2026-10-03):
20
+ * claude-code https://code.claude.com/docs/en/mcp (command, args, env, url, headers)
21
+ * cursor https://cursor.com/docs/context/mcp (command, args, env, url, headers)
22
+ * windsurf https://docs.devin.ai/desktop/cascade/mcp (command, args, env, url, headers)
23
+ * factory https://docs.factory.com/cli/configuration/mcp (env and headers values only)
24
+ * opencode https://opencode.ai/docs/config (any string value)
25
+ * antigravity https://antigravity.google/docs/mcp (no interpolation documented)
26
+ * warp https://docs.warp.dev/agent-platform/capabilities/mcp/ (no interpolation documented)
27
+ * codex named keys instead of interpolation (env_vars, env_http_headers)
28
+ */
29
+ export const ENV_REFERENCE_SYNTAX = Object.freeze({
30
+ 'claude-code': '${NAME}',
31
+ claude: '${NAME}',
32
+ cursor: '${env:NAME}',
33
+ windsurf: '${env:NAME}',
34
+ factory: '${NAME}',
35
+ opencode: '{env:NAME}',
36
+ omp: '${NAME}',
37
+ 'grok-build': '${NAME}',
38
+ antigravity: null,
39
+ agy: null,
40
+ warp: null,
41
+ });
42
+
43
+ export const CREDENTIAL_POLICIES = Object.freeze(['literal', 'references', 'none']);
44
+
45
+ export function validateEnvReferenceName(name) {
46
+ if (!ENV_REFERENCE_NAME.test(name)) {
47
+ throw new Error(`Invalid MCP environment variable reference "${name}"`);
48
+ }
49
+ }
50
+
51
+ function hasEntries(value) {
52
+ return value !== null && typeof value === 'object' && Object.keys(value).length > 0;
53
+ }
54
+
55
+ function hasReferences(server) {
56
+ return hasEntries(server.headerEnv) || hasEntries(server.envFrom);
57
+ }
58
+
59
+ /**
60
+ * Render the env and headers maps a JSON harness receives, with references in
61
+ * the harness's own syntax. Throws when the server carries references and the
62
+ * harness has no documented syntax for them.
63
+ */
64
+ export function renderCredentialMaps(server, provider) {
65
+ const env = server.env ? { ...server.env } : undefined;
66
+ const headers = server.headers ? { ...server.headers } : undefined;
67
+ if (!hasReferences(server)) return { env, headers };
68
+
69
+ const syntax = ENV_REFERENCE_SYNTAX[provider];
70
+ if (!syntax) {
71
+ throw new Error(
72
+ `MCP server "${server.name}" uses environment references (header-env/env-from), ` +
73
+ `but ${provider} documents no environment interpolation in its MCP config. ` +
74
+ 'Refusing to render it rather than writing the secret value or dropping the reference.',
75
+ );
76
+ }
77
+ const reference = name => {
78
+ validateEnvReferenceName(name);
79
+ return syntax.replace('NAME', name);
80
+ };
81
+ const renderedEnv = { ...(env || {}) };
82
+ for (const [key, name] of Object.entries(server.envFrom || {})) renderedEnv[key] = reference(name);
83
+ const renderedHeaders = { ...(headers || {}) };
84
+ for (const [header, name] of Object.entries(server.headerEnv || {})) renderedHeaders[header] = reference(name);
85
+ return {
86
+ env: Object.keys(renderedEnv).length > 0 ? renderedEnv : undefined,
87
+ headers: Object.keys(renderedHeaders).length > 0 ? renderedHeaders : undefined,
88
+ };
89
+ }
90
+
91
+ // A supported environment-reference value in any documented harness syntax:
92
+ // ${NAME} (claude/factory/omp/grok), ${env:NAME} (cursor/windsurf),
93
+ // {env:NAME} (opencode). Such a value names a variable; it is not a secret.
94
+ const CREDENTIAL_REFERENCE_VALUE = /^(?:\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$\{env:[A-Za-z_][A-Za-z0-9_]*\}|\{env:[A-Za-z_][A-Za-z0-9_]*\})$/;
95
+
96
+ /** True when the value is a supported reference form rather than a literal secret. */
97
+ export function isCredentialReferenceValue(value) {
98
+ return typeof value === 'string' && CREDENTIAL_REFERENCE_VALUE.test(value);
99
+ }
100
+
101
+ // One scanner supplies both policy findings and display replacements. Spans
102
+ // retain offsets into the original text so malformed URLs and encoded keys
103
+ // cannot disappear during URL normalization.
104
+ const CREDENTIAL_ARG_NAME = /^(api[-_]?key|access[-_]?key|access[-_]?token|auth[-_]?token|client[-_]?secret|secret|password|passwd|token)$/i;
105
+ const CREDENTIAL_QUERY_NAME = /(?:^|[-_.])(?:api[-_]?key|access[-_]?key|private[-_]?key|signing[-_]?key|encryption[-_]?key|secret[-_]?key|api[-_]?token|access[-_]?token|auth[-_]?token|session[-_]?id|client[-_]?secret|token|secret|password|passwd|pwd|passcode|auth|authorization|signature|jwt|bearer|credential|credentials)(?:$|[-_.])/i;
106
+
107
+ function credentialName(key) {
108
+ const normalized = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
109
+ const parts = normalized.split(/[.\[\]]/).filter(Boolean);
110
+ // Dot and bracket nesting are structural; underscores/hyphens are word
111
+ // separators, so sort_key/cache-key stay ordinary parameter names.
112
+ return parts.some((part, index) =>
113
+ // Location and mode suffixes exclude only this part, not credential ancestors.
114
+ !/(?:^|[-_])(?:file|path|dir|directory|name|mode|type|location|url|uri)$/.test(part)
115
+ && ((/^(?:key|sig)$/.test(part) && !parts.slice(0, index).some(parent => /^(?:sort|order|cache)$/.test(parent)))
116
+ || CREDENTIAL_QUERY_NAME.test(part.replace(/[_\s]+/g, '-'))));
117
+ }
118
+
119
+ function literalValue(value, includeReferences) {
120
+ return value !== '' && (includeReferences || !isCredentialReferenceValue(value));
121
+ }
122
+
123
+ function scanCredentialText(value, { includeReferences = false } = {}) {
124
+ if (typeof value !== 'string') return [];
125
+ const hits = [];
126
+ // Authority-only userinfo, including URLs embedded in --url=... argv.
127
+ for (const match of value.matchAll(/[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/g)) {
128
+ const at = match[1].lastIndexOf('@');
129
+ if (at >= 0) {
130
+ const start = match.index + match[0].length - match[1].length;
131
+ hits.push({ start, end: start + at, kind: 'userinfo', credential: true });
132
+ }
133
+ }
134
+ // WHATWG URL parsing accepts special-scheme authorities with one/no slash
135
+ // or backslashes. Use its userinfo decision, retaining original-text offsets
136
+ // for redaction. The raw scan above still covers malformed hosts.
137
+ const offsets = [];
138
+ let normalized = '';
139
+ for (let index = 0; index < value.length; index++) {
140
+ if (/[\t\r\n]/.test(value[index])) continue;
141
+ offsets.push(index);
142
+ normalized += value[index];
143
+ }
144
+ for (const match of normalized.matchAll(/[A-Za-z][A-Za-z0-9+.-]*:/g)) {
145
+ try {
146
+ const candidate = normalized.slice(match.index);
147
+ const parsed = new URL(candidate);
148
+ if (!parsed.username && !parsed.password) continue;
149
+ const authority = /^[A-Za-z][A-Za-z0-9+.-]*:[/\\]*([^/\\?#]*)/.exec(candidate);
150
+ const at = authority?.[1].lastIndexOf('@') ?? -1;
151
+ if (at < 0) continue;
152
+ const start = match.index + authority[0].length - authority[1].length;
153
+ hits.push({ start: offsets[start], end: offsets[start + at], kind: 'userinfo', credential: true });
154
+ } catch { /* Malformed URLs are handled by the raw scanner. */ }
155
+ }
156
+ const hash = value.indexOf('#');
157
+ const query = value.indexOf('?');
158
+ const regions = [];
159
+ if (query >= 0 && (hash < 0 || query < hash)) regions.push({ start: query + 1, end: hash < 0 ? value.length : hash, kind: 'query' });
160
+ if (hash >= 0) regions.push({ start: hash + 1, end: value.length, kind: 'fragment' });
161
+ for (const region of regions) {
162
+ // Scan both sides of '?' in a fragment: #token=secret?view=full and
163
+ // #/callback?token=secret must both retain the secret assignment.
164
+ const text = value.slice(region.start, region.end);
165
+ for (const match of text.matchAll(region.kind === 'fragment' ? /[^&?]+/g : /[^&]+/g)) {
166
+ const equals = match[0].indexOf('=');
167
+ if (equals < 0) continue;
168
+ const start = region.start + match.index + equals + 1;
169
+ // '?' can start fragment routing, but it is also part of an assignment
170
+ // value. Classify the entire value before deciding whether to redact it.
171
+ const nextPair = value.indexOf('&', start);
172
+ const valueEnd = region.kind === 'fragment'
173
+ ? (nextPair < 0 ? region.end : nextPair)
174
+ : region.start + match.index + match[0].length;
175
+ const assignment = match[0].slice(0, equals + 1) + value.slice(start, valueEnd);
176
+ const [[key, decoded]] = new URLSearchParams(assignment.replace(/[\t\r\n]/g, ''));
177
+ const credential = credentialName(key) && literalValue(decoded, includeReferences);
178
+ // A credential value may itself contain '?'. Redact the whole value
179
+ // through the next '&', including any fragment routing suffix.
180
+ const end = region.kind === 'fragment' && credential
181
+ ? valueEnd
182
+ : region.start + match.index + match[0].length;
183
+ hits.push({ start, end, kind: region.kind, key, credential });
184
+ }
185
+ }
186
+ const header = /^\s*([^:]+):\s*(.*)$/.exec(value);
187
+ if (header && credentialName(header[1])) {
188
+ const content = header[2];
189
+ const credential = content.replace(/^(?:Bearer|Basic)\s+/i, '');
190
+ if (literalValue(credential, includeReferences)) {
191
+ hits.push({ start: value.length - content.length, end: value.length, kind: 'header', credential: true });
192
+ }
193
+ }
194
+ const assignment = /^(?:--header=|-H=?)?([A-Za-z_][A-Za-z0-9_.-]*)=(.*)$/s.exec(value);
195
+ if (assignment && credentialName(assignment[1]) && literalValue(assignment[2], includeReferences)) {
196
+ hits.push({ start: value.length - assignment[2].length, end: value.length, kind: 'assignment', credential: true });
197
+ }
198
+ // curl accepts both --header=Authorization: ... and -HAuthorization: ... .
199
+ const inlineHeader = /^(?:--header=|-H=?)(.+:.+)$/s.exec(value);
200
+ if (inlineHeader) {
201
+ const offset = value.length - inlineHeader[1].length;
202
+ for (const hit of scanCredentialText(inlineHeader[1], { includeReferences })) {
203
+ hits.push({ ...hit, start: offset + hit.start, end: offset + hit.end });
204
+ }
205
+ }
206
+ return hits;
207
+ }
208
+
209
+ function redactSpans(value, hits) {
210
+ if (typeof value !== 'string') return value;
211
+ const sorted = hits.filter(hit => hit.end > hit.start).sort((a, b) => a.start - b.start);
212
+ const spans = [];
213
+ for (const hit of sorted) {
214
+ const previous = spans.at(-1);
215
+ if (previous && hit.start <= previous.end) previous.end = Math.max(previous.end, hit.end);
216
+ else spans.push({ start: hit.start, end: hit.end });
217
+ }
218
+ let result = '';
219
+ let end = 0;
220
+ for (const span of spans) {
221
+ result += value.slice(end, span.start) + '***';
222
+ end = span.end;
223
+ }
224
+ return result + value.slice(end);
225
+ }
226
+
227
+ /** Redact URL, fragment, header, and assignment credentials in display text. */
228
+ export function redactCredentialText(value) {
229
+ if (typeof value !== 'string') return value;
230
+ const hits = scanCredentialText(value);
231
+ return redactSpans(value, hits.filter(hit => hit.credential));
232
+ }
233
+
234
+ function credentialArgHits(args, options = {}) {
235
+ const hits = [];
236
+ for (let index = 0; index < args.length; index++) {
237
+ const arg = args[index];
238
+ if (typeof arg !== 'string') continue;
239
+ for (const hit of scanCredentialText(arg, options)) {
240
+ if (hit.credential) hits.push({ ...hit, index, length: 1 });
241
+ }
242
+ const flag = /^--?([^=]+)(?:=(.*))?$/s.exec(arg);
243
+ if (!flag || !CREDENTIAL_ARG_NAME.test(flag[1])) continue;
244
+ if (flag[2] !== undefined) {
245
+ if (literalValue(flag[2], options.includeReferences)) {
246
+ hits.push({ index, length: 1, start: arg.indexOf('=') + 1, end: arg.length });
247
+ }
248
+ } else {
249
+ const next = args[index + 1];
250
+ if (typeof next === 'string' && !next.startsWith('--') && literalValue(next, options.includeReferences)) {
251
+ hits.push({ index, length: 2, start: 0, end: next.length });
252
+ }
253
+ }
254
+ }
255
+ return hits;
256
+ }
257
+
258
+ export function credentialLikeArgs(args, options = {}) {
259
+ if (!Array.isArray(args)) return [];
260
+ return [...new Set(credentialArgHits(args, options).map(({ index, length }) =>
261
+ length === 1 ? args[index] : `${args[index]} ${args[index + 1]}`))];
262
+ }
263
+
264
+ export function redactCredentialArgs(args) {
265
+ if (!Array.isArray(args)) return args;
266
+ const byIndex = new Map();
267
+ for (const hit of credentialArgHits(args)) {
268
+ const index = hit.index + hit.length - 1;
269
+ if (!byIndex.has(index)) byIndex.set(index, []);
270
+ byIndex.get(index).push(hit);
271
+ }
272
+ return args.map((arg, index) => redactSpans(arg, byIndex.get(index) || []));
273
+ }
274
+
275
+ export function urlCarriesUserinfo(url) {
276
+ return scanCredentialText(url).some(hit => hit.kind === 'userinfo' && hit.credential);
277
+ }
278
+
279
+ export function credentialLikeQueryParams(url, options = {}) {
280
+ return [...new Set(scanCredentialText(url, options).filter(hit => hit.kind === 'query' && hit.credential).map(hit => hit.key))];
281
+ }
282
+
283
+ export function credentialLikeFragmentParams(url, options = {}) {
284
+ return [...new Set(scanCredentialText(url, options).filter(hit => hit.kind === 'fragment' && hit.credential).map(hit => hit.key))];
285
+ }
286
+
287
+ /** Recursively redact display records, including JSON output and literal maps. */
288
+ export function redactCredentialRecord(value, field) {
289
+ if (field === 'args') return redactCredentialArgs(value);
290
+ if (Array.isArray(value)) return value.map(item => redactCredentialRecord(item));
291
+ if (value && typeof value === 'object') {
292
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key,
293
+ ['env', 'headers'].includes(field) || key === 'clientSecret'
294
+ ? (item === '' || isCredentialReferenceValue(item) ? item : '***')
295
+ : redactCredentialRecord(item, key)]));
296
+ }
297
+ return redactCredentialText(value);
298
+ }
299
+
300
+ /**
301
+ * List the fields of a server that the policy forbids.
302
+ *
303
+ * literal nothing is forbidden
304
+ * references literal env/headers values, literal auth argument values, URL
305
+ * userinfo/query/fragment credentials, and literal auth/oauth secrets are forbidden;
306
+ * header-env/env-from are allowed
307
+ * none every credential-bearing field is forbidden, references included
308
+ */
309
+ export function credentialPolicyViolations(server, policy) {
310
+ if (policy === 'literal' || policy === undefined) return [];
311
+ const violations = [];
312
+ if (hasEntries(server.env)) violations.push('env');
313
+ if (hasEntries(server.headers)) violations.push('headers');
314
+ if (credentialLikeArgs(server.args, { includeReferences: policy === 'none' }).length > 0) violations.push('args');
315
+ if (urlCarriesUserinfo(server.url)) violations.push('url userinfo');
316
+ if (credentialLikeQueryParams(server.url, { includeReferences: policy === 'none' }).length) violations.push('url query credentials');
317
+ if (credentialLikeFragmentParams(server.url, { includeReferences: policy === 'none' }).length) violations.push('url fragment credentials');
318
+ for (const field of ['auth', 'oauth']) {
319
+ for (const key of ['tokenUrl', 'redirectUri', 'resource']) {
320
+ if (urlCarriesUserinfo(server[field]?.[key])) violations.push(`${field}.${key} userinfo`);
321
+ if (credentialLikeQueryParams(server[field]?.[key], { includeReferences: policy === 'none' }).length) {
322
+ violations.push(`${field}.${key} query credentials`);
323
+ }
324
+ if (credentialLikeFragmentParams(server[field]?.[key], { includeReferences: policy === 'none' }).length) {
325
+ violations.push(`${field}.${key} fragment credentials`);
326
+ }
327
+ }
328
+ }
329
+ if (server.auth?.clientSecret) violations.push('auth.clientSecret');
330
+ if (server.oauth?.clientSecret) violations.push('oauth.clientSecret');
331
+ if (policy === 'none') {
332
+ if (hasEntries(server.headerEnv)) violations.push('headerEnv');
333
+ if (hasEntries(server.envFrom)) violations.push('envFrom');
334
+ if (server.auth) violations.push('auth');
335
+ if (server.oauth) violations.push('oauth');
336
+ }
337
+ return [...new Set(violations)];
338
+ }
339
+
340
+ /** Throw if any server violates the policy; names every offending server and field. */
341
+ export function assertCredentialPolicy(servers, policy) {
342
+ if (!CREDENTIAL_POLICIES.includes(policy ?? 'literal')) {
343
+ throw new Error(`Unknown MCP credential policy "${policy}". Use one of: ${CREDENTIAL_POLICIES.join(', ')}`);
344
+ }
345
+ const offenders = servers
346
+ .map(server => ({ name: server.name, fields: credentialPolicyViolations(server, policy) }))
347
+ .filter(entry => entry.fields.length > 0);
348
+ if (offenders.length === 0) return;
349
+ const detail = offenders.map(entry => `${entry.name} (${entry.fields.join(', ')})`).join('; ');
350
+ const remedy = policy === 'none'
351
+ ? 'Credential policy "none" renders no credential-bearing field at all.'
352
+ : 'Credential policy "references" renders only header-env/env-from references; move literal values into environment variables.';
353
+ throw new Error(`Refusing to render MCP servers with credentials: ${detail}. ${remedy}`);
354
+ }
355
+
356
+ // Relaxations can be encountered repeatedly while loading layers and rendering.
357
+ const warnedRelaxations = new Set();
358
+
359
+ /**
360
+ * `source`, when given, is the enforcing layer's config file path (never a
361
+ * credential value - just a filesystem path) and is named in the message so
362
+ * the operator knows which layer to change. Deduplicates once per process
363
+ * per attempted `candidate` value, independent of `source`.
364
+ */
365
+ export function warnCredentialPolicyRelaxation(candidate, floor, source) {
366
+ if (warnedRelaxations.has(candidate)) return;
367
+ warnedRelaxations.add(candidate);
368
+ const location = source ? ` (set in ${source})` : '';
369
+ console.warn(`Ignoring MCP credential policy "${candidate}": cannot relax credential policy floor "${floor}"${location}.`);
370
+ }
371
+
372
+ // Tracked separately from warnedRelaxations: this is a distinct message
373
+ // (the overlay is being dropped from the write layer on save), not the
374
+ // resolver's generic relaxation message, but follows the same once-per-value rule.
375
+ const warnedDroppedOverlays = new Set();
376
+
377
+ export function warnDroppedOverlayPolicy(candidate, floor, source) {
378
+ if (warnedDroppedOverlays.has(candidate)) return;
379
+ warnedDroppedOverlays.add(candidate);
380
+ const location = source ? ` (set in ${source})` : '';
381
+ console.warn(`Dropping ignored MCP overlay credential policy "${candidate}": cannot relax credential policy floor "${floor}"${location}.`);
382
+ }
383
+
384
+ /**
385
+ * Test-only: clears the per-process relaxation-warning dedup state. Each of
386
+ * these warnings is designed to fire once per process per attempted value,
387
+ * which is exactly what a long-running CLI/session process needs - but a
388
+ * test file that exercises the same candidate value more than once (for
389
+ * example running the same scenario against both the `.ts` and `.mjs`
390
+ * registry implementations) needs to reset it between cases to observe the
391
+ * warning each time, since both implementations share this module's state.
392
+ */
393
+ export function resetCredentialPolicyWarnings() {
394
+ warnedRelaxations.clear();
395
+ warnedDroppedOverlays.clear();
396
+ }
397
+
398
+ function validatePolicyValue(candidate) {
399
+ if (!CREDENTIAL_POLICIES.includes(candidate)) {
400
+ throw new Error(`Unknown MCP credential policy "${candidate}". Use one of: ${CREDENTIAL_POLICIES.join(', ')}`);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Resolve a credential policy for one invocation.
406
+ *
407
+ * `registryPolicy` (the personal/stored policy), `env`
408
+ * (AIWG_MCP_CREDENTIAL_POLICY) and `flag` compose as a default: the most
409
+ * specific source wins outright, including a relaxation. `flag` overrides
410
+ * `env`, which overrides `registryPolicy`, which overrides the built-in
411
+ * `literal` default - a flag or the environment may freely relax or tighten
412
+ * a personal stored policy.
413
+ *
414
+ * `floorPolicy`, when given, is an enforced policy from a lower (shared or
415
+ * organisation) configuration layer - the strictest value declared in any
416
+ * such layer. It cannot be relaxed by `registryPolicy`, `env` or `flag`,
417
+ * only tightened further. An attempt to relax below it is ignored, with a
418
+ * warning once per process per attempted value, and the floor is used
419
+ * instead. `floorSource` (the enforcing layer's config file path) is named
420
+ * in that warning when given.
421
+ */
422
+ export function resolveCredentialPolicy({ flag, registryPolicy, floorPolicy, floorSource, env = process.env } = {}) {
423
+ let policy = 'literal';
424
+ // Whether any of registryPolicy/env/flag actually supplied a value, as
425
+ // opposed to `policy` merely sitting at the built-in default. The default
426
+ // landing below an enforced floor is ordinary inheritance, not a
427
+ // relaxation attempt, and must not warn.
428
+ let explicit = false;
429
+ // An empty environment string is unset; empty layer/flag values are errors.
430
+ const envPolicy = env.AIWG_MCP_CREDENTIAL_POLICY === '' ? undefined : env.AIWG_MCP_CREDENTIAL_POLICY;
431
+ for (const candidate of [registryPolicy, envPolicy, flag]) {
432
+ if (candidate === undefined) continue;
433
+ validatePolicyValue(candidate);
434
+ policy = candidate;
435
+ explicit = true;
436
+ }
437
+ if (floorPolicy !== undefined) {
438
+ validatePolicyValue(floorPolicy);
439
+ if (CREDENTIAL_POLICIES.indexOf(policy) < CREDENTIAL_POLICIES.indexOf(floorPolicy)) {
440
+ if (explicit) warnCredentialPolicyRelaxation(policy, floorPolicy, floorSource);
441
+ policy = floorPolicy;
442
+ }
443
+ }
444
+ return policy;
445
+ }
446
+
447
+ /**
448
+ * The stricter of two credential policies; `undefined` means no preference
449
+ * and defers to the other side. Folds an organisation floor across several
450
+ * lower configuration layers (each layer's own declared value is validated).
451
+ */
452
+ export function stricterCredentialPolicy(a, b) {
453
+ if (a !== undefined) validatePolicyValue(a);
454
+ if (b !== undefined) validatePolicyValue(b);
455
+ if (a === undefined) return b;
456
+ if (b === undefined) return a;
457
+ return CREDENTIAL_POLICIES.indexOf(a) >= CREDENTIAL_POLICIES.indexOf(b) ? a : b;
458
+ }
459
+
460
+ /**
461
+ * Env keys install-generated server definitions may carry as literal values
462
+ * without tripping credential-policy checks, keyed by the server name that
463
+ * may carry them. `AIWG_ROOT` is an install-generated, non-secret filesystem
464
+ * path (the AIWG installation directory) embedded in the built-in "aiwg"
465
+ * server's own `env`, not an authorization credential - unlike a registry
466
+ * server's `env`, which always comes from the operator and is never exempt.
467
+ * The exemption is scoped to this exact server name and key; it does not
468
+ * weaken the check for any other env/header field or any other server.
469
+ */
470
+ export const INSTALL_POLICY_EXEMPT_ENV = Object.freeze({ aiwg: Object.freeze(['AIWG_ROOT']) });
471
+
472
+ /**
473
+ * View an install-generated server definition for a credential-policy
474
+ * check, with `INSTALL_POLICY_EXEMPT_ENV[name]` removed from `env` first.
475
+ * Use only for install-generated definitions (`aiwg mcp install`); registry
476
+ * servers rendered by `aiwg mcp inject` must never pass through this - their
477
+ * `env` is operator-supplied and always subject to the full check.
478
+ */
479
+ export function installPolicyCheckedServer(name, server) {
480
+ const exempt = INSTALL_POLICY_EXEMPT_ENV[name];
481
+ if (!exempt || !hasEntries(server.env)) return { name, ...server };
482
+ const env = Object.fromEntries(Object.entries(server.env).filter(([key]) => !exempt.includes(key)));
483
+ return { name, ...server, env: Object.keys(env).length > 0 ? env : undefined };
484
+ }
@@ -1,18 +1,15 @@
1
+ import { writeConfigAtomic } from './config-file.mjs';
1
2
  import { constants } from 'node:fs';
2
- import { access, lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { access, lstat, readFile } from 'node:fs/promises';
3
4
  import { dirname, relative, resolve } from 'node:path';
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import { replaceServer } from './toml-editor.mjs';
7
+ import { tomlString as string } from './toml-string.mjs';
6
8
 
7
9
  const START = name => `# >>> AIWG-managed Grok MCP: ${name}`;
8
10
  const END = name => `# <<< AIWG-managed Grok MCP: ${name}`;
9
11
  const ENV_REF = /^(?:[^$]|\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\})*$/;
10
12
 
11
- function string(value) {
12
- if (typeof value !== 'string') throw new Error('Grok MCP string fields must be strings');
13
- return JSON.stringify(value).replace(/\u007f/g, '\\u007f');
14
- }
15
-
16
13
  function key(value) {
17
14
  return /^[A-Za-z0-9_-]+$/.test(value) ? value : string(value);
18
15
  }
@@ -28,7 +25,11 @@ export function renderGrokMcpServer(server) {
28
25
  if (!server.command) throw new Error(`Grok MCP stdio server "${server.name}" requires command`);
29
26
  lines.push(`command = ${string(server.command)}`);
30
27
  if (server.args?.length) lines.push(`args = [${server.args.map(string).join(', ')}]`);
31
- if (server.env && Object.keys(server.env).length) lines.push(`env = ${inline(server.env)}`);
28
+ const env = { ...(server.env || {}) };
29
+ for (const [key, envName] of Object.entries(server.envFrom || {})) {
30
+ env[key] = `\${${envName}}`;
31
+ }
32
+ if (Object.keys(env).length) lines.push(`env = ${inline(env)}`);
32
33
  } else {
33
34
  if (!server.url) throw new Error(`Grok MCP HTTP server "${server.name}" requires url`);
34
35
  lines.push(`url = ${string(server.url)}`);
@@ -94,14 +95,14 @@ export function removeGrokMcpServers(text, names) {
94
95
  return { text: output, removed };
95
96
  }
96
97
 
97
- async function assertSafeTarget(configPath, root) {
98
+ async function assertSafeTarget(configPath, root, userScope) {
98
99
  const absolute = resolve(configPath);
99
100
  const base = resolve(root);
100
101
  const rel = relative(base, absolute);
101
102
  if (!rel || rel.startsWith('..') || rel.startsWith('/') || rel.includes('\0')) {
102
103
  throw new Error('Grok MCP config path must be a file beneath the selected configuration root');
103
104
  }
104
- for (let current = dirname(absolute); current !== base; current = dirname(current)) {
105
+ for (let current = dirname(absolute); !userScope && current !== base; current = dirname(current)) {
105
106
  try {
106
107
  if ((await lstat(current)).isSymbolicLink()) throw new Error('Refusing Grok MCP path through a symbolic link');
107
108
  } catch (error) {
@@ -116,40 +117,29 @@ async function assertSafeTarget(configPath, root) {
116
117
  }
117
118
  }
118
119
 
119
- async function atomicWrite(file, content) {
120
- await mkdir(dirname(file), { recursive: true });
121
- const temp = `${file}.aiwg-${process.pid}-${Date.now()}.tmp`;
122
- try {
123
- const handle = await open(temp, 'wx', 0o600);
124
- await handle.writeFile(content, 'utf8');
125
- await handle.sync();
126
- await handle.close();
127
- await rename(temp, file);
128
- } catch (error) {
129
- await rm(temp, { force: true });
130
- throw error;
131
- }
120
+ async function atomicWrite(file, content, options) {
121
+ await writeConfigAtomic(file, content, options);
132
122
  }
133
123
 
134
124
  export async function manageGrokBuildMcp(configPath, servers, options = {}) {
135
125
  const root = options.root || dirname(dirname(configPath));
136
126
  if (options.blockedByPolicy) return { configPath, state: 'blocked-by-policy', serversInjected: [], alreadyPresent: [], error: options.blockedByPolicy };
137
- await assertSafeTarget(configPath, root);
127
+ await assertSafeTarget(configPath, root, options.userScope);
138
128
  let before = '';
139
129
  try { before = await readFile(configPath, 'utf8'); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
140
130
  const alreadyPresent = servers.filter(server => managedRange(before, server.name)).map(server => server.name);
141
131
  const after = mergeGrokMcpServers(before, servers);
142
- if (!options.dryRun && after !== before) await atomicWrite(configPath, after);
132
+ if (!options.dryRun && after !== before) await atomicWrite(configPath, after, { userScope: options.userScope ?? true, projectRoot: options.userScope ? undefined : root });
143
133
  return { configPath, state: options.disabled ? 'disabled' : 'configured', serversInjected: servers.map(server => server.name), alreadyPresent };
144
134
  }
145
135
 
146
136
  export async function unmanageGrokBuildMcp(configPath, names, options = {}) {
147
137
  const root = options.root || dirname(dirname(configPath));
148
- await assertSafeTarget(configPath, root);
138
+ await assertSafeTarget(configPath, root, options.userScope);
149
139
  let before;
150
140
  try { before = await readFile(configPath, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return { configPath, state: 'absent', removed: [] }; throw error; }
151
141
  const result = removeGrokMcpServers(before, names);
152
- if (!options.dryRun && result.text !== before) await atomicWrite(configPath, result.text);
142
+ if (!options.dryRun && result.text !== before) await atomicWrite(configPath, result.text, { userScope: options.userScope ?? true, projectRoot: options.userScope ? undefined : root });
153
143
  return { configPath, state: result.removed.length ? 'configured' : 'absent', removed: result.removed };
154
144
  }
155
145