@aiwg/cli 2026.10.0 → 2026.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agentic/code/providers/capability-matrix.yaml +270 -0
- package/agentic/code/providers/model-capabilities.v1.json +3091 -24
- package/bin/aiwg.mjs +86 -21
- package/dist/src/a2a/codecs.js +9 -0
- package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
- package/dist/src/artifacts/repair.js +9 -4
- package/dist/src/channel/manager.mjs +59 -10
- package/dist/src/cli/child-process-result.js +40 -0
- package/dist/src/cli/env.js +38 -16
- package/dist/src/cli/flag-parsing.js +236 -0
- package/dist/src/cli/handlers/decision.js +297 -2
- package/dist/src/cli/handlers/diagnose.js +9 -2
- package/dist/src/cli/handlers/feedback.js +14 -5
- package/dist/src/cli/handlers/init.js +245 -30
- package/dist/src/cli/handlers/mc.js +23 -0
- package/dist/src/cli/handlers/models.js +69 -6
- package/dist/src/cli/handlers/ralph.js +3 -0
- package/dist/src/cli/handlers/refresh.js +16 -0
- package/dist/src/cli/handlers/regenerate.js +14 -0
- package/dist/src/cli/handlers/runtime-info.js +24 -9
- package/dist/src/cli/handlers/script-runner.js +16 -6
- package/dist/src/cli/handlers/session.js +396 -62
- package/dist/src/cli/handlers/sessions.js +146 -40
- package/dist/src/cli/handlers/setup-manifest.js +439 -39
- package/dist/src/cli/handlers/setup.js +139 -39
- package/dist/src/cli/handlers/subcommands.js +3 -1
- package/dist/src/cli/handlers/team.js +18 -8
- package/dist/src/cli/handlers/use.js +13 -0
- package/dist/src/cli/handlers/utilities.js +106 -23
- package/dist/src/cli/prompt-utils.js +72 -0
- package/dist/src/cli/provider-resolution.js +11 -0
- package/dist/src/config/aiwg-config.js +268 -4
- package/dist/src/config/cli.js +146 -19
- package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
- package/dist/src/config/project-artifacts-health.mjs +14 -3
- package/dist/src/config/user-config.js +36 -10
- package/dist/src/decision/adapters/jev.js +80 -28
- package/dist/src/decision/ask.js +352 -0
- package/dist/src/decision/driver.js +4 -0
- package/dist/src/decision/entry.js +38 -6
- package/dist/src/decision/heldout/collector.js +10 -1
- package/dist/src/decision/index.js +3 -0
- package/dist/src/decision/interop.js +60 -8
- package/dist/src/decision/jev-credentials.js +121 -0
- package/dist/src/decision/ledger.js +839 -0
- package/dist/src/decision/validate.js +6 -3
- package/dist/src/effects/dispatch-verifier.js +252 -0
- package/dist/src/effects/index.js +1 -0
- package/dist/src/features/installer.js +2 -2
- package/dist/src/flow/graph-metadata.js +5 -0
- package/dist/src/gates/providers/loader.js +3 -1
- package/dist/src/installation/manager-command.mjs +1 -1
- package/dist/src/installation/manager.mjs +17 -2
- package/dist/src/lint/runner.js +2 -2
- package/dist/src/mcp/adapters/codex-runtime.js +360 -56
- package/dist/src/mcp/cli.mjs +587 -77
- package/dist/src/mcp/config-file.mjs +279 -0
- package/dist/src/mcp/config-layers.mjs +348 -0
- package/dist/src/mcp/credentials.mjs +484 -0
- package/dist/src/mcp/grok-build-config.mjs +16 -26
- package/dist/src/mcp/helpers.mjs +106 -2
- package/dist/src/mcp/omp-config.mjs +50 -29
- package/dist/src/mcp/profiles.js +97 -23
- package/dist/src/mcp/profiles.mjs +99 -27
- package/dist/src/mcp/registry.js +264 -57
- package/dist/src/mcp/registry.mjs +198 -45
- package/dist/src/mcp/server.mjs +403 -20
- package/dist/src/mcp/toml-string.mjs +9 -0
- package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
- package/dist/src/mcp/tool-filters.mjs +357 -0
- package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
- package/dist/src/mcp/tools/decision.mjs +58 -0
- package/dist/src/mcp/tools/orchestration.mjs +38 -0
- package/dist/src/mcp/tools/subsystems.mjs +39 -8
- package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
- package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
- package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
- package/dist/src/memory/project-registry.js +2 -2
- package/dist/src/mission-protocol/codecs.js +639 -1
- package/dist/src/mission-protocol/types.js +110 -0
- package/dist/src/mission-protocol/worker-status.js +2 -0
- package/dist/src/models/config-loader.js +70 -13
- package/dist/src/models/grok-build-models.js +93 -41
- package/dist/src/models/model-capabilities.v1.json +3091 -24
- package/dist/src/models/model-discovery.js +862 -63
- package/dist/src/models/provider-policy.js +625 -11
- package/dist/src/models/qualification-digest.mjs +17 -0
- package/dist/src/models/resolver.js +259 -0
- package/dist/src/models/worker-direct-readiness.js +529 -0
- package/dist/src/models/worker-dispatch-offer.js +169 -0
- package/dist/src/models/worker-health-readiness.js +131 -0
- package/dist/src/models/worker-installation.mjs +879 -0
- package/dist/src/models/wrapper-route.js +18 -0
- package/dist/src/ops/registry.js +9 -14
- package/dist/src/plugins/standalone-packager.js +4 -3
- package/dist/src/process/bounded-child-process.mjs +387 -0
- package/dist/src/process/cli-command.mjs +75 -0
- package/dist/src/providers/capability-matrix.js +8 -0
- package/dist/src/providers/capability-matrix.yaml +270 -0
- package/dist/src/providers/grok-build-headless.js +43 -21
- package/dist/src/providers/provider-definitions.js +9 -1
- package/dist/src/providers/provider-definitions.mjs +9 -2
- package/dist/src/serve/executor-registry.js +16 -24
- package/dist/src/serve/fleet-mission-conductor.js +9 -0
- package/dist/src/serve/mission-conductor.js +9 -0
- package/dist/src/serve/shared-host-scheduler.js +2 -0
- package/dist/src/serve/worker-invocation.js +538 -0
- package/dist/src/serve/worker-stdin-frame.mjs +107 -0
- package/dist/src/sessions/adapter-registry.js +45 -0
- package/dist/src/sessions/adapters/generic.js +37 -3
- package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
- package/dist/src/sessions/adapters/grokbot.js +423 -24
- package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
- package/dist/src/sessions/adapters/muse.js +68 -29
- package/dist/src/sessions/adapters/pi.js +1 -0
- package/dist/src/sessions/batch-contracts.js +13 -1
- package/dist/src/sessions/batch-import.js +23 -42
- package/dist/src/sessions/candidates.js +47 -11
- package/dist/src/sessions/contracts.js +35 -0
- package/dist/src/sessions/coverage-preflight.js +134 -0
- package/dist/src/sessions/discovery.js +3 -0
- package/dist/src/sessions/grok-build-cli.js +67 -5
- package/dist/src/sessions/importer.js +24 -8
- package/dist/src/sessions/index.js +2 -0
- package/dist/src/sessions/repository.js +54 -13
- package/dist/src/sessions/workspace-discovery.js +99 -37
- package/dist/src/skills/adapters/clawhub.js +10 -8
- package/dist/src/skills/adapters/openclaw.js +10 -7
- package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
- package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
- package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
- package/dist/src/smiths/mcpsmith/example.js +1 -1
- package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
- package/dist/src/storage/backends/logseq.js +68 -7
- package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
- package/dist/src/testing/generators/test-code-generator.js +57 -30
- package/dist/src/uhp/client.js +11 -1
- package/dist/src/uhp/mission.js +5 -0
- package/dist/src/update/checker.mjs +5 -5
- package/dist/src/update/notifier.mjs +54 -2
- package/dist/src/writing/fidelity.js +18 -1
- package/package.json +18 -1
- package/schemas/mission-v1.schema.json +2429 -0
- package/schemas/models/model-catalog.v1.schema.json +301 -0
- package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
- package/schemas/models/model-policy.v1.schema.json +14 -0
- package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
- package/tools/_resolve-impl.mjs +39 -0
- package/tools/agents/deploy-agents.mjs +15 -2
- package/tools/agents/providers/base.mjs +63 -43
- package/tools/agents/providers/grokbot.mjs +11 -12
- package/tools/agents/providers/hermes.mjs +6 -3
- package/tools/agents/providers/omp.mjs +5 -2
- package/tools/agents/providers/openclaw-translator.mjs +19 -5
- package/tools/providers/deepseek-harness-transport.mjs +358 -0
- package/tools/providers/omp-transport.mjs +192 -0
- package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
- package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
- package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
- package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
- package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
- package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
- package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
- package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
- package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
- package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
- package/tools/ralph-external/lib/worker-health.mjs +439 -0
- 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
|
+
}
|