@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,279 @@
1
+ import { urlCarriesUserinfo, credentialLikeArgs, credentialLikeQueryParams, credentialLikeFragmentParams, isCredentialReferenceValue } from './credentials.mjs';
2
+ import { access, lstat, mkdir, open, readFile, realpath, rename, unlink } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { constants } from 'node:fs';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { getMcpInjectionDefinition, resolveMcpConfigPath } from '../providers/provider-definitions.mjs';
7
+
8
+ export async function assertNoSymlinkParents(file, configRoot = dirname(resolve(file))) {
9
+ const root = resolve(configRoot);
10
+ const rel = relative(root, resolve(file));
11
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
12
+ throw new Error(`MCP config ${file} must be beneath the config directory ${root}`);
13
+ }
14
+ let parent = dirname(resolve(file));
15
+ while (parent !== root) {
16
+ try {
17
+ if ((await lstat(parent)).isSymbolicLink()) {
18
+ throw new Error(`Refusing to write MCP config ${file}: parent directory is a symlink`);
19
+ }
20
+ } catch (error) {
21
+ if (error.code !== 'ENOENT') throw error;
22
+ }
23
+ parent = dirname(parent);
24
+ }
25
+ }
26
+
27
+ export async function assertConfigDestination(file, projectRoot) {
28
+ if (projectRoot) {
29
+ const root = resolve(projectRoot);
30
+ const rel = relative(root, resolve(file));
31
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
32
+ throw new Error(`MCP config ${file} must be beneath the project directory`);
33
+ }
34
+ let current = root;
35
+ for (const component of rel.split(sep).slice(0, -1)) {
36
+ current = resolve(current, component);
37
+ try {
38
+ if ((await lstat(current)).isSymbolicLink()) {
39
+ throw new Error(`Refusing to write MCP config ${file}: parent directory is a symlink`);
40
+ }
41
+ } catch (error) {
42
+ if (error.code !== 'ENOENT') throw error;
43
+ }
44
+ }
45
+ }
46
+ try {
47
+ const info = await lstat(file);
48
+ if (info.isSymbolicLink()) throw new Error(`Refusing to write MCP config ${file}: destination is a symlink`);
49
+ return info;
50
+ } catch (error) {
51
+ if (error.code === 'ENOENT') return null;
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ export function isUserMcpScope(provider, scope) {
57
+ return scope === 'user' || getMcpInjectionDefinition(provider)?.configPath.scope === 'home';
58
+ }
59
+
60
+ /** True when the resolved destination lives inside the project directory. */
61
+ export function isPathInsideDir(file, dir) {
62
+ const root = resolve(dir);
63
+ const rel = relative(root, resolve(file));
64
+ return Boolean(rel) && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
65
+ }
66
+
67
+ /**
68
+ * Refuse a `--scope user` request the provider cannot honour: when the
69
+ * resolved destination is still the project file, the request would silently
70
+ * write secrets to a shareable location. Providers whose destination is
71
+ * genuinely outside the project (home configs, explicit user paths) pass.
72
+ */
73
+ export function assertSupportedScope(provider, scope, configPath, projectDir = '.') {
74
+ if (scope !== 'user') return;
75
+ if (!isPathInsideDir(configPath, projectDir)) return;
76
+ throw new Error(`Provider "${provider}" has no user-scope destination: --scope user would still write the project file ${configPath}. Use --scope project with --header-env/--env-from references instead of literal credentials.`);
77
+ }
78
+
79
+ /**
80
+ * The single place every MCP config writer - `inject`, `install`, and OMP's
81
+ * `install`/`uninject` - decides whether a destination is a project write.
82
+ * The decision follows the RESOLVED destination, never the requested scope
83
+ * string and never a provider's home-directory override (e.g.
84
+ * `PI_CODING_AGENT_DIR`, `CODEX_HOME`, `GROK_HOME`): a destination that still
85
+ * resolves inside the project directory gets project containment rules
86
+ * (ancestor-symlink checks, literal-credential refusal) regardless of what
87
+ * scope was requested or which environment variable produced the path.
88
+ * `userScope: true` from this function means the destination is genuinely
89
+ * outside the project; callers must never override it with a flag of their
90
+ * own, and must never derive it from `scope` again downstream.
91
+ *
92
+ * Throws via `assertSupportedScope` when `--scope user` was requested but the
93
+ * destination is still inside the project (no private destination exists).
94
+ */
95
+ export function classifyMcpDestination(provider, scope, configPath, projectDir = '.') {
96
+ assertSupportedScope(provider, scope, configPath, projectDir);
97
+ const inProject = isPathInsideDir(configPath, projectDir);
98
+ return { userScope: !inProject, projectRoot: inProject ? projectDir : undefined };
99
+ }
100
+
101
+ /**
102
+ * Remediation for a project-scope literal refusal that names the provider's
103
+ * real private destination when it has one, instead of always naming
104
+ * ~/.claude.json. Providers without a user-scope destination get reference
105
+ * guidance instead.
106
+ */
107
+ export function projectCredentialRemedy(provider, projectDir = '.') {
108
+ try {
109
+ const userPath = resolveMcpConfigPath(provider, projectDir, { scope: 'user' });
110
+ const projectPath = resolveMcpConfigPath(provider, projectDir, { scope: 'project' });
111
+ if (userPath && userPath !== projectPath && !isPathInsideDir(userPath, projectDir)) {
112
+ return `Use --scope user to write private ${userPath} instead, or move secrets into environment variables with --header-env/--env-from references.`;
113
+ }
114
+ } catch {
115
+ // An unresolvable user destination means references are the only remedy.
116
+ }
117
+ return `Move secrets into environment variables with --header-env/--env-from references instead; --scope user has no private destination for provider "${provider}".`;
118
+ }
119
+
120
+ /** Reject non-string env/headers values with a schema-style error. */
121
+ function assertStringMap(server, field) {
122
+ const entries = server[field];
123
+ if (entries === undefined || entries === null) return {};
124
+ if (typeof entries !== 'object' || Array.isArray(entries)) {
125
+ throw new Error(`Invalid MCP server "${server.name}": ${field} must be an object`);
126
+ }
127
+ for (const [key, value] of Object.entries(entries)) {
128
+ if (typeof value !== 'string') {
129
+ throw new Error(`Invalid MCP server "${server.name}": ${field}["${key}"] must be a string`);
130
+ }
131
+ }
132
+ return entries;
133
+ }
134
+
135
+ export function assertProjectCredentials(servers, configPath, remedy) {
136
+ const literals = servers.flatMap(server => {
137
+ const reasons = [];
138
+ // A value is shareable only when it is empty or a supported reference
139
+ // form (${VAR}, ${env:VAR}, {env:VAR}); any other string is a literal.
140
+ for (const field of ['env', 'headers']) {
141
+ const entries = assertStringMap(server, field);
142
+ const literalKeys = Object.entries(entries)
143
+ .filter(([, value]) => value !== '' && !isCredentialReferenceValue(value))
144
+ .map(([key]) => key);
145
+ if (literalKeys.length) reasons.push(`${server.name} has literal ${field} values (${literalKeys.join(', ')})`);
146
+ }
147
+ // OMP persists auth/oauth beside env/headers, so the same refusal covers
148
+ // any secret-bearing value there; `type` alone selects a flow, not a secret.
149
+ // This deliberately includes all URL fields (tokenUrl/redirectUri/resource),
150
+ // so OAuth query and fragment credentials cannot bypass project protection.
151
+ for (const field of ['auth', 'oauth']) {
152
+ const section = server[field];
153
+ if (section !== undefined && section !== null) {
154
+ if (typeof section !== 'object' || Array.isArray(section)) {
155
+ throw new Error(`Invalid MCP server "${server.name}": ${field} must be an object`);
156
+ }
157
+ const literalKeys = Object.keys(section).filter(key =>
158
+ key !== 'type' && section[key] !== '' && section[key] !== undefined && section[key] !== null);
159
+ if (literalKeys.length) reasons.push(`${server.name} has literal ${field} values (${literalKeys.join(', ')})`);
160
+ }
161
+ }
162
+ if (urlCarriesUserinfo(server.url)) reasons.push(`${server.name} has URL userinfo`);
163
+ if (credentialLikeQueryParams(server.url).length) reasons.push(`${server.name} has URL query credentials`);
164
+ if (credentialLikeFragmentParams(server.url).length) reasons.push(`${server.name} has URL fragment credentials`);
165
+ // A flag like --token=... or --password <value> almost always carries the secret
166
+ // in argv, visible to every process listing on the host; the same literal-credential
167
+ // refusal that applies to env/headers applies here for a shareable project file.
168
+ // Name only the flag, never the "--flag=value" form's value half.
169
+ const argFlags = credentialLikeArgs(server.args).map(arg =>
170
+ /^--?[A-Za-z][A-Za-z0-9_-]*(?=[ =]|$)/.exec(arg)?.[0] || '[redacted]');
171
+ if (argFlags.length) reasons.push(`${server.name} has credential-like args (${argFlags.join(', ')})`);
172
+ return reasons;
173
+ });
174
+ if (literals.length) {
175
+ throw new Error(`Refusing to write project MCP config ${configPath}: ${literals.join('; ')}. ${remedy || 'Use --scope user to write to a private destination instead.'}`);
176
+ }
177
+ }
178
+
179
+ export function assertConfigObject(config, serversKey) {
180
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
181
+ if (!isObject(config) || (Object.hasOwn(config, serversKey) && !isObject(config[serversKey]))) {
182
+ throw new Error('MCP configuration must contain an object root and an object server map');
183
+ }
184
+ }
185
+
186
+ export async function writeConfigAtomic(file, content, { userScope = false, projectRoot, newFileMode, mode: restoreMode, rejectSymlinkParents = false, symlinkRoot } = {}) {
187
+ if (rejectSymlinkParents) await assertNoSymlinkParents(file, symlinkRoot);
188
+ const info = await assertConfigDestination(file, projectRoot);
189
+ const mode = restoreMode ?? (userScope ? 0o600 : info ? info.mode & 0o7777 : newFileMode ?? 0o666);
190
+ await mkdir(dirname(file), { recursive: true });
191
+ const temporary = `${file}.${randomUUID()}.tmp`;
192
+ let handle;
193
+ try {
194
+ handle = await open(temporary, 'wx', mode);
195
+ await handle.writeFile(content, 'utf-8');
196
+ if (userScope || info || restoreMode !== undefined || newFileMode !== undefined) await handle.chmod(mode);
197
+ await handle.sync();
198
+ await handle.close();
199
+ handle = undefined;
200
+ if (rejectSymlinkParents) await assertNoSymlinkParents(file, symlinkRoot);
201
+ await assertConfigDestination(file, projectRoot);
202
+ await rename(temporary, file);
203
+ } finally {
204
+ try {
205
+ if (handle) await handle.close();
206
+ } finally {
207
+ await unlink(temporary).catch(() => {});
208
+ }
209
+ }
210
+ }
211
+
212
+ /** Resolve existing parent aliases without creating directories during a dry run. */
213
+ export async function canonicalConfigPath(file) {
214
+ let parent = dirname(resolve(file));
215
+ const missing = [];
216
+ while (true) {
217
+ try {
218
+ return resolve(await realpath(parent), ...missing, basename(file));
219
+ } catch (error) {
220
+ if (error.code !== 'ENOENT' || dirname(parent) === parent) throw error;
221
+ missing.unshift(basename(parent));
222
+ parent = dirname(parent);
223
+ }
224
+ }
225
+ }
226
+
227
+ /** Check the destination and the nearest existing parent before a multi-file write. */
228
+ export async function prepareConfigWrite(file, options = {}) {
229
+ await assertConfigDestination(file, options.projectRoot);
230
+ let parent = dirname(resolve(file));
231
+ while (true) {
232
+ try {
233
+ // Follow a root/home alias, just as assertConfigDestination does.
234
+ const actual = await realpath(parent);
235
+ if (!(await lstat(actual)).isDirectory()) throw new Error(`Config parent ${parent} must be a directory`);
236
+ await access(actual, constants.W_OK | constants.X_OK);
237
+ return;
238
+ } catch (error) {
239
+ if (error.code !== 'ENOENT' || dirname(parent) === parent) {
240
+ throw new Error(`Cannot prepare config destination ${file}: ${error.message}`);
241
+ }
242
+ parent = dirname(parent);
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Write MCP, settings and ownership in the supplied order, restoring earlier files on failure. */
248
+ export async function writeConfigTransaction(writes) {
249
+ const originals = [];
250
+ for (const { file, options = {} } of writes) {
251
+ await prepareConfigWrite(file, options);
252
+ const info = await assertConfigDestination(file, options.projectRoot);
253
+ originals.push(info ? { content: await readFile(file), mode: info.mode & 0o7777 } : null);
254
+ }
255
+ let completed = 0;
256
+ try {
257
+ for (const { file, content, options } of writes) {
258
+ await writeConfigAtomic(file, content, options);
259
+ completed++;
260
+ }
261
+ } catch (error) {
262
+ const failures = [];
263
+ for (let index = completed - 1; index >= 0; index--) {
264
+ const { file, options = {} } = writes[index];
265
+ try {
266
+ if (originals[index]) {
267
+ await writeConfigAtomic(file, originals[index].content, { ...options, mode: originals[index].mode });
268
+ } else {
269
+ await assertConfigDestination(file, options.projectRoot);
270
+ await unlink(file);
271
+ }
272
+ } catch (rollbackError) {
273
+ failures.push(`${file}: ${rollbackError.message}`);
274
+ }
275
+ }
276
+ if (failures.length) throw new Error(`${error.message}; config rollback failed: ${failures.join('; ')}`);
277
+ throw error;
278
+ }
279
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Layered MCP configuration roots.
3
+ *
4
+ * AIWG_CONFIG_LAYERS lists configuration directories separated by the
5
+ * platform path delimiter (`:` on POSIX, `;` on Windows), lowest precedence
6
+ * first. Each directory may hold mcp-servers.json and mcp-profiles.json. An
7
+ * entry (server or profile) in a later directory replaces the entry of the
8
+ * same name in an earlier one; entries are not merged field by field.
9
+ *
10
+ * The last directory is the write layer: add, update and recordInjection
11
+ * write there, and nothing from an earlier layer is copied into it unless
12
+ * that entry is updated.
13
+ *
14
+ * When AIWG_CONFIG_LAYERS is unset, or a registry is constructed with an
15
+ * explicit directory, the single-directory behaviour applies unchanged.
16
+ */
17
+
18
+ import { lstat, mkdtemp, readFile, readlink, realpath, rmdir } from 'fs/promises';
19
+ import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'path';
20
+ import { assertConfigDestination, assertNoSymlinkParents, writeConfigAtomic } from './config-file.mjs';
21
+ import { resolveCredentialPolicy, stricterCredentialPolicy, warnCredentialPolicyRelaxation, warnDroppedOverlayPolicy } from './credentials.mjs';
22
+
23
+ /**
24
+ * `platform` lets tests validate Windows path semantics (`C:\org`, UNC
25
+ * `\\server`, root-relative `\org`/`/org`) deterministically on any host; it
26
+ * defaults to the real host platform, so production calls behave exactly as
27
+ * the native `path` module already would.
28
+ */
29
+ export function resolveConfigLayers(configDirOverride, env = process.env, { platform = process.platform } = {}) {
30
+ if (configDirOverride) return null;
31
+ const raw = env.AIWG_CONFIG_LAYERS;
32
+ if (!raw || !raw.trim()) return null;
33
+ const pathForPlatform = platform === 'win32' ? win32 : posix;
34
+ // A Windows-formatted value uses `;`; splitting a drive-qualified entry
35
+ // (`C:\org`) on the real host's POSIX `:` would wrongly cut it at the drive
36
+ // letter, so the simulated platform's own delimiter is used, not the real
37
+ // host's - matching the native `path` module's behaviour when the real host
38
+ // actually is that platform.
39
+ const entries = raw.split(pathForPlatform.delimiter).map(entry => entry.trim()).filter(Boolean);
40
+ // A bare leading `\` or `/` with no drive letter is current-drive-relative
41
+ // under Windows path semantics: resolving it from C:\work versus D:\work
42
+ // selects different drives, so a layer list could change meaning across
43
+ // hosts. The backslash form is refused on every platform, since it is never
44
+ // a valid POSIX absolute path either. The forward-slash form is refused
45
+ // only when interpreting entries under actual Windows path semantics; on
46
+ // POSIX, a leading `/` is an unambiguous, fully-qualified absolute path and
47
+ // must keep working. Drive-qualified (`C:\`, `C:/`) and UNC (`\\server`,
48
+ // `//server`) forms remain allowed on every platform.
49
+ const driveRelativePattern = platform === 'win32' ? /^[\\/](?![\\/])/ : /^\\(?!\\)/;
50
+ const driveRelativeEntries = entries.filter(entry => driveRelativePattern.test(entry));
51
+ if (driveRelativeEntries.length) {
52
+ throw new Error(`AIWG_CONFIG_LAYERS entries must be absolute paths; refusing Windows current-drive-relative entries: ${driveRelativeEntries.join(', ')}`);
53
+ }
54
+ // A relative entry would resolve against the current working directory, so the
55
+ // same AIWG_CONFIG_LAYERS value would name different layers from different
56
+ // invocation directories. Refuse instead of silently picking a directory.
57
+ const relativeEntries = entries.filter(entry => !pathForPlatform.isAbsolute(entry));
58
+ if (relativeEntries.length) {
59
+ throw new Error(`AIWG_CONFIG_LAYERS entries must be absolute paths; refusing relative entries: ${relativeEntries.join(', ')}`);
60
+ }
61
+ const layers = entries.map(entry => pathForPlatform.resolve(entry));
62
+ return layers.length > 0 ? layers : null;
63
+ }
64
+
65
+ async function readLayer(path) {
66
+ try {
67
+ return JSON.parse(await readFile(path, 'utf-8'));
68
+ } catch (error) {
69
+ if (error?.code === 'ENOENT') return null;
70
+ if (error instanceof SyntaxError) throw new Error(`Invalid JSON in MCP configuration layer ${path}`);
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ /** File-format fields every layer file keeps, whichever layer set them. */
76
+ const FORMAT_FIELDS = ['apiVersion', 'kind'];
77
+
78
+ /** Bookkeeping fields that do not make an entry "changed" for layering purposes. */
79
+ const BOOKKEEPING_FIELDS = ['injectedProviders', 'addedAt', 'createdAt', 'updatedAt'];
80
+
81
+ function sortKeys(value) {
82
+ if (Array.isArray(value)) return value.map(sortKeys);
83
+ if (value === null || typeof value !== 'object') return value;
84
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, sortKeys(value[key])]));
85
+ }
86
+
87
+ function comparable(entry) {
88
+ const copy = { ...entry };
89
+ for (const field of BOOKKEEPING_FIELDS) delete copy[field];
90
+ return JSON.stringify(sortKeys(copy));
91
+ }
92
+
93
+ /**
94
+ * Read `filename` from every layer and merge the `collection` map by entry
95
+ * name. Top-level fields come from the highest layer that sets them, except
96
+ * credentialPolicy: lower (non-write) layers fold together with the
97
+ * strictest value winning - an enforced organisation floor - and the write
98
+ * layer's own declared value (a personal default) is then bounded below by
99
+ * that floor, never copying the floor itself into the write layer's record.
100
+ * `lowerPolicySource` names the config file of the lower layer that set the
101
+ * folded floor's value (the first layer to set it; a later layer repeating
102
+ * the same strictness does not move attribution), for diagnostics.
103
+ *
104
+ * Returns the merged data and a layering record: the names defined in the
105
+ * write layer, and comparable forms of entries and top-level fields in lower layers.
106
+ */
107
+ export async function loadLayered(layers, filename, collection, defaults) {
108
+ const merged = { ...defaults, [collection]: {} };
109
+ const lower = new Map();
110
+ const own = new Set();
111
+ const lowerFields = new Map();
112
+ const ownFields = new Set();
113
+ let ownPolicy;
114
+ let lowerPolicy;
115
+ let lowerPolicySource;
116
+ const writeLayer = layers.length - 1;
117
+ for (const [index, dir] of layers.entries()) {
118
+ const parsed = await readLayer(resolve(dir, filename));
119
+ if (!parsed) continue;
120
+ const { [collection]: entries, ...rest } = parsed;
121
+ if (index === writeLayer) {
122
+ ownPolicy = rest.credentialPolicy;
123
+ } else if (Object.hasOwn(rest, 'credentialPolicy')) {
124
+ try {
125
+ const folded = stricterCredentialPolicy(lowerPolicy, rest.credentialPolicy);
126
+ if (folded !== lowerPolicy) lowerPolicySource = resolve(dir, filename);
127
+ lowerPolicy = folded;
128
+ } catch (error) {
129
+ throw new Error(`Invalid MCP credential policy in layer ${resolve(dir, filename)}: ${error.message}`);
130
+ }
131
+ }
132
+ Object.assign(merged, rest);
133
+ // Across several lower layers, credentialPolicy folds by strictness, not
134
+ // by "last layer wins" like every other field: keep the running snapshot
135
+ // in sync with the folded floor so a later lowerFields comparison (used
136
+ // to decide whether to copy an unchanged inherited value) reflects it,
137
+ // not one lower layer's own raw declared value.
138
+ if (index < writeLayer) merged.credentialPolicy = lowerPolicy;
139
+ for (const field of Object.keys(rest)) {
140
+ if (index === writeLayer) ownFields.add(field);
141
+ else lowerFields.set(field, comparable({ value: merged[field] }));
142
+ }
143
+ for (const [name, entry] of Object.entries(entries || {})) {
144
+ merged[collection][name] = entry;
145
+ if (index === writeLayer) own.add(name);
146
+ else lower.set(name, comparable(entry));
147
+ }
148
+ }
149
+ // No layer declared a policy anywhere: leave the field absent, exactly as
150
+ // when `defaults` carries no credentialPolicy, rather than materializing
151
+ // the built-in `literal` default as a stored value.
152
+ let effectivePolicy;
153
+ if (ownPolicy !== undefined || lowerPolicy !== undefined) {
154
+ try {
155
+ effectivePolicy = resolveCredentialPolicy({
156
+ registryPolicy: ownPolicy, floorPolicy: lowerPolicy, floorSource: lowerPolicySource, env: {},
157
+ });
158
+ } catch (error) {
159
+ throw new Error(`Invalid MCP credential policy in layer ${resolve(layers[writeLayer], filename)}: ${error.message}`);
160
+ }
161
+ merged.credentialPolicy = effectivePolicy;
162
+ } else {
163
+ delete merged.credentialPolicy;
164
+ }
165
+ return { data: merged, layering: { lower, own, lowerFields, ownFields, ownPolicy, lowerPolicy, lowerPolicySource, effectivePolicy } };
166
+ }
167
+
168
+ /** True when the entry is defined only below the write layer. */
169
+ export function isLowerLayerEntry(layering, name) {
170
+ return layering.lower.has(name) && !layering.own.has(name);
171
+ }
172
+
173
+ /**
174
+ * True when the write layer overrides a lower-layer entry that still carries
175
+ * worker qualification. Removing that override would silently re-expose the
176
+ * lower entry's qualification, so removal must be refused instead.
177
+ */
178
+ export function lowerEntryQualified(layering, name) {
179
+ if (!layering?.lower?.has(name) || !layering?.own?.has(name)) return false;
180
+ try {
181
+ return JSON.parse(layering.lower.get(name))?.workerQualification !== undefined;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The data to persist in the write layer: entries defined there, new entries,
189
+ * and lower-layer entries whose content (bookkeeping aside) was changed.
190
+ * Top-level fields follow the same rule, without entry bookkeeping exclusions;
191
+ * apiVersion and kind are always kept so the file stays self-describing.
192
+ */
193
+ export function writeLayerData(data, collection, layering, explicitPolicy = false) {
194
+ const entries = Object.entries(data[collection] || {}).filter(([name, entry]) =>
195
+ layering.own.has(name) || !layering.lower.has(name) || comparable(entry) !== layering.lower.get(name));
196
+ const fields = Object.entries(data).filter(([field, value]) => field !== collection && (
197
+ FORMAT_FIELDS.includes(field) || layering.ownFields.has(field) || !layering.lowerFields.has(field) ||
198
+ comparable({ value }) !== layering.lowerFields.get(field)));
199
+ const persisted = { ...Object.fromEntries(fields), [collection]: Object.fromEntries(entries) };
200
+ // A setter explicitly writes even when its value equals the merged floor.
201
+ // Other saves retain policy ownership, without copying an inherited floor.
202
+ if (explicitPolicy || data.credentialPolicy !== layering.effectivePolicy) {
203
+ const effective = resolveCredentialPolicy({
204
+ flag: data.credentialPolicy, floorPolicy: layering.lowerPolicy, floorSource: layering.lowerPolicySource, env: {},
205
+ });
206
+ if (effective !== data.credentialPolicy) {
207
+ const location = layering.lowerPolicySource ? ` (set in ${layering.lowerPolicySource})` : '';
208
+ throw new Error(`Cannot relax MCP credential policy floor "${effective}"${location} to "${data.credentialPolicy}".`);
209
+ }
210
+ persisted.credentialPolicy = data.credentialPolicy;
211
+ } else if (layering.ownPolicy !== undefined &&
212
+ resolveCredentialPolicy({ flag: layering.ownPolicy, floorPolicy: layering.lowerPolicy, floorSource: layering.lowerPolicySource, env: {} }) !== layering.ownPolicy) {
213
+ delete persisted.credentialPolicy;
214
+ warnDroppedOverlayPolicy(layering.ownPolicy, layering.lowerPolicy, layering.lowerPolicySource);
215
+ }
216
+ return persisted;
217
+ }
218
+
219
+ /** Resolve a target, including a missing suffix, without creating it. */
220
+ async function targetRealpath(path, aliases = []) {
221
+ const absolute = resolve(path);
222
+ if (aliases.includes(absolute)) throw new Error(`MCP configuration target symlink cycle: ${[...aliases, absolute].join(' -> ')}`);
223
+ try {
224
+ return await realpath(path);
225
+ } catch (error) {
226
+ if (error.code !== 'ENOENT') throw error;
227
+ // Resolve dangling aliases too: creating their missing referent would change
228
+ // a lower layer even though realpath currently reports ENOENT.
229
+ if (dirname(absolute) === absolute) throw error;
230
+ const candidate = resolve(await targetRealpath(dirname(absolute), aliases), basename(absolute));
231
+ try {
232
+ if ((await lstat(candidate)).isSymbolicLink()) {
233
+ return targetRealpath(resolve(dirname(candidate), await readlink(candidate)), [...aliases, absolute]);
234
+ }
235
+ } catch (statError) {
236
+ if (statError.code !== 'ENOENT') throw statError;
237
+ }
238
+ return candidate;
239
+ }
240
+ }
241
+
242
+ /** Probe the filesystem containing the nearest existing ancestor of a target. */
243
+ export async function isCaseInsensitivePath(path) {
244
+ let ancestor = resolve(path);
245
+ while (true) {
246
+ try {
247
+ await realpath(ancestor);
248
+ break;
249
+ } catch (error) {
250
+ if (error.code !== 'ENOENT' || dirname(ancestor) === ancestor) throw error;
251
+ ancestor = dirname(ancestor);
252
+ }
253
+ }
254
+ let probe;
255
+ try {
256
+ probe = await mkdtemp(resolve(ancestor, '.aiwg-case-probe-a-'));
257
+ const alternate = resolve(dirname(probe), basename(probe).toUpperCase());
258
+ try {
259
+ const [original, folded] = await Promise.all([lstat(probe), lstat(alternate)]);
260
+ return original.dev === folded.dev && original.ino === folded.ino;
261
+ } catch (error) {
262
+ if (error.code === 'ENOENT') return false;
263
+ throw error;
264
+ }
265
+ } catch (error) {
266
+ // Read-only ancestors cannot be probed: compare conservatively rather than allow overlap.
267
+ if (error.code === 'EACCES' || error.code === 'EPERM' || error.code === 'EROFS') return true;
268
+ throw error;
269
+ } finally {
270
+ if (probe) await rmdir(probe);
271
+ }
272
+ }
273
+
274
+ function inside(target, parent, caseInsensitive) {
275
+ const rel = relative(caseInsensitive ? parent.toLowerCase() : parent, caseInsensitive ? target.toLowerCase() : target);
276
+ return !rel || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
277
+ }
278
+
279
+ /** Refuse a write that could modify another configuration layer. */
280
+ export async function assertLayerWriteDestination(layers, filename) {
281
+ const targets = await Promise.all(layers.map(async dir => ({
282
+ dir: await targetRealpath(dir),
283
+ files: await Promise.all(['mcp-servers.json', 'mcp-profiles.json'].map(name => targetRealpath(resolve(dir, name)))),
284
+ })));
285
+ const write = targets[targets.length - 1];
286
+ const caseInsensitive = await isCaseInsensitivePath(write.dir);
287
+ const writeFile = await targetRealpath(resolve(layers[layers.length - 1], filename));
288
+ for (const lower of targets.slice(0, -1)) {
289
+ if (inside(write.dir, lower.dir, caseInsensitive) || inside(writeFile, lower.dir, caseInsensitive) || lower.files.some(file => inside(writeFile, file, caseInsensitive))) {
290
+ throw new Error(`Refusing overlapping MCP configuration layer targets: ${writeFile} overlaps ${lower.dir}`);
291
+ }
292
+ }
293
+ const file = resolve(layers[layers.length - 1], filename);
294
+ await assertNoSymlinkParents(file, layers[layers.length - 1]);
295
+ await assertConfigDestination(file);
296
+ }
297
+
298
+ /** Persist atomically, then refresh ownership only after a successful write. */
299
+ export async function saveLayerData(file, data, collection, layers, layering, explicitPolicy = false) {
300
+ if (layers) await assertLayerWriteDestination(layers, basename(file));
301
+ const persisted = layering ? writeLayerData(data, collection, layering, explicitPolicy) : data;
302
+ // The active identity layer file is always 0600: it is the personal write
303
+ // layer of an active AIWG_CONFIG_LAYERS configuration and may carry secrets.
304
+ // Without layering, `file` is whatever single directory the caller
305
+ // configured (which may be a shared, group-readable organisation checkout
306
+ // maintained directly, not a private identity directory): an existing file
307
+ // keeps its mode, while a newly created file starts private at 0600.
308
+ await writeConfigAtomic(file, JSON.stringify(persisted, null, 2) + '\n', layers
309
+ ? { userScope: true, rejectSymlinkParents: true }
310
+ : { newFileMode: 0o600, rejectSymlinkParents: true });
311
+ if (layering) {
312
+ layering.own = new Set(Object.keys(persisted[collection] || {}));
313
+ layering.ownFields = new Set(Object.keys(persisted).filter(field => field !== collection));
314
+ layering.ownPolicy = persisted.credentialPolicy;
315
+ layering.effectivePolicy = data.credentialPolicy;
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Resolve a profile's `extends` chain. Servers are concatenated base-first
321
+ * without duplicates. Per provider key, toolDeny accumulates across the chain
322
+ * and toolAllow is taken from the most-derived profile that sets it.
323
+ */
324
+ export function resolveProfileExtends(name, profiles, seen = []) {
325
+ const profile = profiles[name];
326
+ if (!profile) {
327
+ throw new Error(seen.length > 0
328
+ ? `Profile "${seen[seen.length - 1]}" extends "${name}", which is not defined in any configuration layer.`
329
+ : `Profile "${name}" not found.`);
330
+ }
331
+ if (seen.includes(name)) throw new Error(`Profile extends cycle: ${[...seen, name].join(' -> ')}`);
332
+ const bases = (profile.extends || []).map(base => resolveProfileExtends(base, profiles, [...seen, name]));
333
+
334
+ const servers = [];
335
+ const providerOverrides = {};
336
+ for (const source of [...bases, profile]) {
337
+ for (const server of source.servers || []) if (!servers.includes(server)) servers.push(server);
338
+ for (const [provider, override] of Object.entries(source.providerOverrides || {})) {
339
+ const previous = providerOverrides[provider] || {};
340
+ providerOverrides[provider] = {
341
+ ...previous,
342
+ ...(override.toolDeny ? { toolDeny: [...new Set([...(previous.toolDeny || []), ...override.toolDeny])] } : {}),
343
+ ...(override.toolAllow ? { toolAllow: [...override.toolAllow] } : {}),
344
+ };
345
+ }
346
+ }
347
+ return { ...profile, servers, providerOverrides };
348
+ }