@piagent/platform 1.1.9 → 1.2.1
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/CHANGELOG.md +175 -1
- package/README.md +2 -2
- package/docs/command-reference-vietnamese.md +19 -5
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +154 -2
- package/docs/model-options.md +10 -8
- package/docs/operator-manual-vietnamese.md +25 -13
- package/docs/publishing-for-teams.md +2 -0
- package/docs/quickstart-vietnamese.md +2 -2
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +4 -3
- package/docs/team-onboarding.md +4 -4
- package/docs/usage-observability.md +29 -0
- package/docs/vercel-docs-site.md +34 -4
- package/docs-site/architecture.html +152 -0
- package/docs-site/assets/docs.css +1001 -0
- package/docs-site/assets/docs.js +84 -0
- package/docs-site/commands.html +203 -0
- package/docs-site/content/architecture.html +29 -0
- package/docs-site/content/commands.html +80 -0
- package/docs-site/content/index.html +80 -0
- package/docs-site/content/mcp-approval.html +133 -0
- package/docs-site/content/mcp-auth.html +76 -0
- package/docs-site/content/mcp-servers.html +111 -0
- package/docs-site/content/mcp.html +120 -0
- package/docs-site/content/permissions.html +54 -0
- package/docs-site/content/profiles.html +68 -0
- package/docs-site/content/quickstart.html +144 -0
- package/docs-site/content/runtime.html +45 -0
- package/docs-site/content/scope.html +71 -0
- package/docs-site/content/security.html +51 -0
- package/docs-site/content/team.html +23 -0
- package/docs-site/content/workflows.html +47 -0
- package/docs-site/index.html +131 -1534
- package/docs-site/mcp-approval.html +256 -0
- package/docs-site/mcp-auth.html +199 -0
- package/docs-site/mcp-servers.html +234 -0
- package/docs-site/mcp.html +243 -0
- package/docs-site/permissions.html +177 -0
- package/docs-site/profiles.html +191 -0
- package/docs-site/quickstart.html +267 -0
- package/docs-site/runtime.html +168 -0
- package/docs-site/scope.html +194 -0
- package/docs-site/security.html +174 -0
- package/docs-site/team.html +142 -0
- package/docs-site/workflows.html +170 -0
- package/package.json +4 -2
- package/packages/piagent-core/capabilities/capability-core.js +58 -5
- package/packages/piagent-core/capabilities/capability-sources.js +73 -12
- package/packages/piagent-core/extensions/document-intake.ts +5 -5
- package/packages/piagent-core/extensions/piagent-guard.ts +432 -2
- package/packages/piagent-core/extensions/policy-core.js +1586 -98
- package/packages/piagent-core/mcp/mcp-approval-store.js +216 -0
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +225 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +400 -0
- package/packages/piagent-core/mcp/mcp-config-layers.js +466 -0
- package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
- package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +293 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +236 -0
- package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
- package/packages/piagent-core/package.json +7 -3
- package/packages/piagent-core/prompts/model-options.md +8 -8
- package/scripts/build-docs-site.mjs +421 -0
- package/scripts/configure-model-scope.sh +21 -5
- package/scripts/configure-subagents.sh +18 -5
- package/scripts/init-project.sh +15 -8
- package/scripts/install-global.sh +5 -5
- package/scripts/mcp-manage.mjs +656 -0
- package/scripts/pi-model-catalog.sh +11 -1
- package/scripts/pi-session-stats.sh +27 -1
- package/scripts/pi-usage-history.mjs +567 -0
- package/scripts/piagent-cli.mjs +1 -1
- package/scripts/preview-docs-site.mjs +88 -0
- package/scripts/quality-benchmark.sh +37 -9
- package/scripts/setup.sh +23 -15
- package/scripts/team-doctor.sh +18 -2
- package/scripts/uninstall-global.sh +10 -4
- package/scripts/verify-local.sh +65 -19
- package/scripts/verify-release-identity.mjs +37 -2
- package/scripts/configure-mcp.sh +0 -349
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
REPOSITORY_RELATIVE_KINDS,
|
|
5
|
+
canEnumerateImportKind,
|
|
6
|
+
declaredImports,
|
|
7
|
+
importKindFiles,
|
|
8
|
+
importedServers,
|
|
9
|
+
unreadableImportTargets
|
|
10
|
+
} from "./mcp-import-kinds.js";
|
|
11
|
+
import { erasesToolOrigin } from "./mcp-tool-naming.js";
|
|
12
|
+
|
|
13
|
+
// Four files can define MCP servers, and which one a change belongs in is the
|
|
14
|
+
// first thing anyone gets wrong. The scope names are the vocabulary the CLI
|
|
15
|
+
// exposes, so they are resolved in exactly one place.
|
|
16
|
+
//
|
|
17
|
+
// `global` and `pi-project` are the two that matter for safety. `global` is
|
|
18
|
+
// outside every repository, so nothing a repository contains can reach it.
|
|
19
|
+
// `project` is the file a repository can carry, which is why servers found there
|
|
20
|
+
// are the ones the approval gate covers.
|
|
21
|
+
|
|
22
|
+
const BASELINE_SETTINGS = {
|
|
23
|
+
toolPrefix: "server",
|
|
24
|
+
directTools: false,
|
|
25
|
+
idleTimeout: 10,
|
|
26
|
+
outputGuard: true
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const SCOPES = ["global", "pi-global", "project", "pi-project"];
|
|
30
|
+
|
|
31
|
+
/** Scopes whose file a repository can carry, and which therefore need approval. */
|
|
32
|
+
export const REPOSITORY_SCOPES = new Set(["project", "pi-project"]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} scope
|
|
36
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function configPathForScope(scope, options = {}) {
|
|
40
|
+
const env = options.env ?? process.env;
|
|
41
|
+
const home = options.home ?? env.HOME ?? "";
|
|
42
|
+
const projectPath = options.projectPath ?? process.cwd();
|
|
43
|
+
switch (scope) {
|
|
44
|
+
case "global":
|
|
45
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "mcp", "mcp.json");
|
|
46
|
+
case "pi-global":
|
|
47
|
+
return path.join(env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent"), "mcp.json");
|
|
48
|
+
case "project":
|
|
49
|
+
return path.join(projectPath, ".mcp.json");
|
|
50
|
+
case "pi-project":
|
|
51
|
+
return path.join(projectPath, ".pi", "mcp.json");
|
|
52
|
+
default:
|
|
53
|
+
throw new McpConfigError(`unsupported scope: ${scope}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class McpConfigError extends Error {}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read a config file into the shape the rest of this module assumes. A file that
|
|
61
|
+
* is missing reads as empty; a file that is present but malformed throws, because
|
|
62
|
+
* silently treating it as empty would let a write drop servers somebody
|
|
63
|
+
* configured by hand.
|
|
64
|
+
* @param {string} file
|
|
65
|
+
* @returns {{settings: Record<string, unknown>, mcpServers: Record<string, unknown>, rest: Record<string, unknown>}}
|
|
66
|
+
*/
|
|
67
|
+
export function readMcpConfig(file) {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = fs.readFileSync(file, "utf8");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
73
|
+
return { settings: {}, mcpServers: {}, imports: [], rawImports: [], rest: {} };
|
|
74
|
+
}
|
|
75
|
+
throw new McpConfigError(`cannot read MCP config: ${file}`);
|
|
76
|
+
}
|
|
77
|
+
let document;
|
|
78
|
+
try {
|
|
79
|
+
document = JSON.parse(raw);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new McpConfigError(`cannot parse MCP config: ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
82
|
+
}
|
|
83
|
+
// A JSON array, string or number parses fine and is not a config. Reading it
|
|
84
|
+
// as empty meant a later write replaced somebody's file with `{}` plus whatever
|
|
85
|
+
// the write added, so the document that could not be understood was destroyed
|
|
86
|
+
// rather than reported.
|
|
87
|
+
if (!isRecord(document)) {
|
|
88
|
+
throw new McpConfigError(`MCP config is not a JSON object: ${file}`);
|
|
89
|
+
}
|
|
90
|
+
const { settings, mcpServers, imports, ...rest } = document;
|
|
91
|
+
return {
|
|
92
|
+
settings: isRecord(settings) ? settings : {},
|
|
93
|
+
mcpServers: isRecord(mcpServers) ? mcpServers : {},
|
|
94
|
+
// Pulled out of `rest` deliberately. While it rode along in the spread it
|
|
95
|
+
// was preserved by every write and read by nothing here, which is the exact
|
|
96
|
+
// shape of a key that decides what loads without anything checking it.
|
|
97
|
+
imports: declaredImports(imports),
|
|
98
|
+
// The list exactly as written, so a write can put back kinds this version
|
|
99
|
+
// does not recognise. Nothing reads this to decide behaviour.
|
|
100
|
+
rawImports: Array.isArray(imports) ? imports.filter((kind) => typeof kind === "string") : [],
|
|
101
|
+
rest
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} file
|
|
107
|
+
* @param {{settings: Record<string, unknown>, mcpServers: Record<string, unknown>, rest?: Record<string, unknown>}} config
|
|
108
|
+
* @returns {void}
|
|
109
|
+
*/
|
|
110
|
+
export function writeMcpConfig(file, config) {
|
|
111
|
+
// `declaredImports` keeps only the kinds this version knows. Writing that back
|
|
112
|
+
// deleted any kind a newer adapter understands and this one does not, turning
|
|
113
|
+
// an unrelated `add` into a silent downgrade of somebody's config. The raw list
|
|
114
|
+
// is preserved and the known kinds are what the rest of this module reads.
|
|
115
|
+
const raw = Array.isArray(config.rawImports) ? config.rawImports : declaredImports(config.imports);
|
|
116
|
+
const document = {
|
|
117
|
+
...(config.rest ?? {}),
|
|
118
|
+
// Written back rather than dropped. This key is refused at the gate when a
|
|
119
|
+
// repository carries it, and deleting a line out of somebody's own config
|
|
120
|
+
// as a side effect of an unrelated `add` is not a fix.
|
|
121
|
+
...(raw.length > 0 ? { imports: raw } : {}),
|
|
122
|
+
settings: { ...BASELINE_SETTINGS, ...config.settings },
|
|
123
|
+
mcpServers: config.mcpServers
|
|
124
|
+
};
|
|
125
|
+
const output = `${JSON.stringify(document, null, 2)}\n`;
|
|
126
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
127
|
+
// A symlink here points the write somewhere the caller did not name. Renaming
|
|
128
|
+
// over it replaces the link itself, so the file the operator has been editing
|
|
129
|
+
// stops being the file this reads -- and following it would write through a
|
|
130
|
+
// link a repository could have planted.
|
|
131
|
+
let existing;
|
|
132
|
+
try {
|
|
133
|
+
existing = fs.lstatSync(file);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error;
|
|
136
|
+
}
|
|
137
|
+
if (existing?.isSymbolicLink()) {
|
|
138
|
+
throw new McpConfigError(`refusing to write through a symlink: ${file}`);
|
|
139
|
+
}
|
|
140
|
+
// Written beside the target and renamed, so a crash mid-write leaves the old
|
|
141
|
+
// config rather than a truncated one that would read as "no servers".
|
|
142
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
143
|
+
fs.writeFileSync(temporary, output);
|
|
144
|
+
fs.renameSync(temporary, file);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every server visible from a project, with the scope each came from. Later
|
|
149
|
+
* scopes do not merge into earlier ones: a name defined twice is reported twice,
|
|
150
|
+
* because which definition a client picks is the client's rule, not ours, and
|
|
151
|
+
* hiding the duplicate is how a project-scoped override goes unnoticed.
|
|
152
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string, scopes?: string[]}} [options]
|
|
153
|
+
* `origin` is the scope name for a server the layer declares directly, and
|
|
154
|
+
* `import:<kind>` for one reached through an `imports` key, so a caller can say
|
|
155
|
+
* where a server came from rather than only which layer named it.
|
|
156
|
+
*
|
|
157
|
+
* `requiresApproval` says whether this server is one a repository put into the
|
|
158
|
+
* session. Scope alone cannot answer that: a `global` config importing a
|
|
159
|
+
* repository-relative kind carries a scope of `global` and a definition that
|
|
160
|
+
* came out of the clone. Deciding it here keeps the gate and the listing on one
|
|
161
|
+
* rule — the listing reporting `ready` for a server the gate is blocking is
|
|
162
|
+
* worse than either answer alone, because it sends the operator looking
|
|
163
|
+
* anywhere but at the approval.
|
|
164
|
+
*
|
|
165
|
+
* @returns {{name: string, scope: string, file: string, entry: Record<string, unknown>, origin: string, requiresApproval: boolean}[]}
|
|
166
|
+
*/
|
|
167
|
+
export function collectServers(options = {}) {
|
|
168
|
+
const found = [];
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
const push = (server) => {
|
|
171
|
+
const key = `${server.name}\u0000${server.file}`;
|
|
172
|
+
if (seen.has(key)) return;
|
|
173
|
+
seen.add(key);
|
|
174
|
+
found.push(server);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
for (const scope of options.scopes ?? SCOPES) {
|
|
178
|
+
const file = configPathForScope(scope, options);
|
|
179
|
+
let config;
|
|
180
|
+
try {
|
|
181
|
+
config = readMcpConfig(file);
|
|
182
|
+
} catch {
|
|
183
|
+
// A layer this process cannot parse is reported as empty rather than
|
|
184
|
+
// failing the whole listing; `piagent-mcp get` on that scope still throws
|
|
185
|
+
// and `piagent-mcp doctor` reports it as unreadable.
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
for (const [name, entry] of Object.entries(config.mcpServers)) {
|
|
189
|
+
if (isRecord(entry)) push({ name, scope, file, entry, origin: scope, requiresApproval: REPOSITORY_SCOPES.has(scope) });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Imports of a repository-relative kind are scanned across every layer, not
|
|
194
|
+
// just the ones the caller asked for. Which file declares the import decides
|
|
195
|
+
// nothing: what makes the servers repository-scoped is that the file they are
|
|
196
|
+
// read from travels with the clone. A `global` config importing `vscode`
|
|
197
|
+
// therefore puts a repository's servers into every session on this machine,
|
|
198
|
+
// and that has to reach the gate the same way `.mcp.json` does.
|
|
199
|
+
//
|
|
200
|
+
// The other direction matters too: whichever kind a repository-scoped config
|
|
201
|
+
// names, the repository is choosing which servers a session runs. Those are
|
|
202
|
+
// the operator's own definitions, so the question is not whether to trust the
|
|
203
|
+
// command but whether this project was meant to start it — which is the same
|
|
204
|
+
// question the gate already asks.
|
|
205
|
+
if (options.includeRepositoryImports !== false) {
|
|
206
|
+
for (const scope of SCOPES) {
|
|
207
|
+
let config;
|
|
208
|
+
try {
|
|
209
|
+
config = readMcpConfig(configPathForScope(scope, options));
|
|
210
|
+
} catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const repositoryScoped = REPOSITORY_SCOPES.has(scope);
|
|
214
|
+
for (const kind of config.imports) {
|
|
215
|
+
if (!repositoryScoped && !REPOSITORY_RELATIVE_KINDS.has(kind)) continue;
|
|
216
|
+
for (const server of importedServers(kind, options)) {
|
|
217
|
+
// Both branches that reach here are the repository choosing a
|
|
218
|
+
// server: it either declared the import itself, or the file being
|
|
219
|
+
// read travels with the clone.
|
|
220
|
+
push({ name: server.name, scope, file: server.file, entry: server.entry, origin: `import:${kind}`, requiresApproval: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The definition a same-named server actually runs under.
|
|
231
|
+
*
|
|
232
|
+
* The adapter merges server maps key by key in scope order, so a later layer
|
|
233
|
+
* that names an existing server overrides only the keys it sets. A repository
|
|
234
|
+
* declaring `{"args": ["--evil"]}` therefore inherits `command` from the
|
|
235
|
+
* operator's own global config, and reading that layer alone shows an argument
|
|
236
|
+
* list with no command attached — which is not what will run, and not what an
|
|
237
|
+
* operator should be asked to consent to.
|
|
238
|
+
*
|
|
239
|
+
* The digest deliberately stays over the repository's own fragment: that is the
|
|
240
|
+
* part the repository controls, and hashing the merge would return a server to
|
|
241
|
+
* pending every time the operator edited their own global config.
|
|
242
|
+
*
|
|
243
|
+
* @param {{projectPath?: string, name: string, entry?: Record<string, unknown>, env?: Record<string, string|undefined>, home?: string}} options
|
|
244
|
+
* @returns {Record<string, unknown>}
|
|
245
|
+
*/
|
|
246
|
+
export function mergedServerEntry(options) {
|
|
247
|
+
let merged = {};
|
|
248
|
+
let seen = false;
|
|
249
|
+
for (const scope of SCOPES) {
|
|
250
|
+
let config;
|
|
251
|
+
try {
|
|
252
|
+
config = readMcpConfig(configPathForScope(scope, options));
|
|
253
|
+
} catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const entry = config.mcpServers[options.name];
|
|
257
|
+
if (!isRecord(entry)) continue;
|
|
258
|
+
merged = { ...merged, ...entry };
|
|
259
|
+
seen = true;
|
|
260
|
+
}
|
|
261
|
+
// A server reached through `imports` is in no layer's `mcpServers`, so the
|
|
262
|
+
// entry the caller already has is the whole definition.
|
|
263
|
+
return seen ? merged : { ...(options.entry ?? {}) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Layers that exist but cannot be read. `collectServers` skips these so one bad
|
|
268
|
+
* file does not take out the whole listing, which is the right call for a
|
|
269
|
+
* listing and the wrong one for a diagnosis: a config nothing can parse
|
|
270
|
+
* contributes no servers and is indistinguishable from a config that defines
|
|
271
|
+
* none. Somewhere has to say which of the two it was.
|
|
272
|
+
*
|
|
273
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string, scopes?: string[]}} [options]
|
|
274
|
+
* @returns {{scope: string, file: string, detail: string}[]}
|
|
275
|
+
*/
|
|
276
|
+
export function unreadableLayers(options = {}) {
|
|
277
|
+
const problems = [];
|
|
278
|
+
for (const scope of options.scopes ?? SCOPES) {
|
|
279
|
+
const file = configPathForScope(scope, options);
|
|
280
|
+
let config;
|
|
281
|
+
try {
|
|
282
|
+
config = readMcpConfig(file);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
problems.push({ scope, file, detail: error instanceof Error ? error.message : String(error) });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// A layer can also be unreadable one step removed: it parses, declares an
|
|
288
|
+
// import, and the file that import names is the one nothing can read. That
|
|
289
|
+
// layer contributes no servers for a reason the layer itself does not show,
|
|
290
|
+
// so the diagnosis has to follow the declaration to where it points.
|
|
291
|
+
for (const kind of config.imports) {
|
|
292
|
+
for (const target of unreadableImportTargets(kind, options)) {
|
|
293
|
+
problems.push({
|
|
294
|
+
scope,
|
|
295
|
+
file: target.file,
|
|
296
|
+
detail: `${file} imports ${kind}, and that config is present but unreadable: ${target.detail}`
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return problems;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Repository-scoped layers that declare `imports`. A repository reaching into
|
|
306
|
+
* another tool's config on this machine is refused on the declaration rather
|
|
307
|
+
* than on its contents: one of the six kinds is TOML that nothing here parses,
|
|
308
|
+
* so an enumeration would be incomplete, and a repository has no business
|
|
309
|
+
* deciding which of the operator's personal servers a session loads regardless.
|
|
310
|
+
*
|
|
311
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
|
|
312
|
+
* @returns {{scope: string, file: string, kinds: string[]}[]}
|
|
313
|
+
*/
|
|
314
|
+
export function repositoryImportDeclarations(options = {}) {
|
|
315
|
+
const declarations = [];
|
|
316
|
+
for (const scope of REPOSITORY_SCOPES) {
|
|
317
|
+
const file = configPathForScope(scope, options);
|
|
318
|
+
let config;
|
|
319
|
+
try {
|
|
320
|
+
config = readMcpConfig(file);
|
|
321
|
+
} catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const kinds = declaredImports(config.imports);
|
|
325
|
+
if (kinds.length > 0) declarations.push({ scope, file, kinds });
|
|
326
|
+
}
|
|
327
|
+
return declarations;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The `settings` block a session actually runs under.
|
|
332
|
+
*
|
|
333
|
+
* The adapter merges `settings` across all four layers in scope order, later
|
|
334
|
+
* layers winning key by key — it is one session-wide block, not a per-server
|
|
335
|
+
* one. Reading a single layer therefore answers a different question than the
|
|
336
|
+
* one that matters: `directTools` set in one file and `toolPrefix: "none"` set
|
|
337
|
+
* in another produce a session with neither property, and a check that looked at
|
|
338
|
+
* either file alone saw nothing wrong with it.
|
|
339
|
+
*
|
|
340
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
|
|
341
|
+
* @returns {{settings: Record<string, unknown>, contributors: Record<string, string>}}
|
|
342
|
+
*/
|
|
343
|
+
export function mergedSettings(options = {}) {
|
|
344
|
+
const settings = { ...BASELINE_SETTINGS };
|
|
345
|
+
/** @type {Record<string, string>} */
|
|
346
|
+
const contributors = {};
|
|
347
|
+
for (const scope of SCOPES) {
|
|
348
|
+
let config;
|
|
349
|
+
try {
|
|
350
|
+
config = readMcpConfig(configPathForScope(scope, options));
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
for (const [key, item] of Object.entries(config.settings)) {
|
|
355
|
+
settings[key] = item;
|
|
356
|
+
contributors[key] = scope;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { settings, contributors };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Every file any decision in this module can depend on, including the ones that
|
|
364
|
+
* matter by being absent.
|
|
365
|
+
*
|
|
366
|
+
* The guard caches its gate and recomputes when a signature over these changes.
|
|
367
|
+
* That signature used to be built by hand from the repository layers, and when
|
|
368
|
+
* the scan below grew to read merged settings from all four scopes and to stat
|
|
369
|
+
* import targets outside the repository, the signature did not grow with it —
|
|
370
|
+
* so adding `directTools` to a personal global config left an already-loaded
|
|
371
|
+
* guard permitting calls that `piagent-mcp doctor` was reporting as blocked.
|
|
372
|
+
*
|
|
373
|
+
* Derived from the same traversal the readers use, so a reader that starts
|
|
374
|
+
* consulting a new file is watched for that file without anything else being
|
|
375
|
+
* updated to match. An absent file is listed too: `imports: ["codex"]` becomes
|
|
376
|
+
* a block the moment `~/.codex/config.toml` appears.
|
|
377
|
+
*
|
|
378
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
|
|
379
|
+
* @returns {string[]}
|
|
380
|
+
*/
|
|
381
|
+
export function mcpDecisionInputs(options = {}) {
|
|
382
|
+
const inputs = [];
|
|
383
|
+
for (const scope of SCOPES) {
|
|
384
|
+
const file = configPathForScope(scope, options);
|
|
385
|
+
inputs.push(file);
|
|
386
|
+
let config;
|
|
387
|
+
try {
|
|
388
|
+
config = readMcpConfig(file);
|
|
389
|
+
} catch {
|
|
390
|
+
// Unreadable still counts: the file is watched, and becoming readable is
|
|
391
|
+
// a change the gate has to see.
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
// Every location the kind can be read from, whether or not it has one now.
|
|
395
|
+
// A kind that resolves to nothing today is exactly the kind whose file
|
|
396
|
+
// appearing tomorrow changes the answer.
|
|
397
|
+
for (const kind of config.imports) inputs.push(...importKindFiles(kind, options));
|
|
398
|
+
}
|
|
399
|
+
return [...new Set(inputs)];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Config that leaves the gate with nothing to check, so the only sound answer is
|
|
404
|
+
* to refuse every tool call.
|
|
405
|
+
*
|
|
406
|
+
* Kept here rather than in the guard because three surfaces have to agree on it.
|
|
407
|
+
* The guard enforces it; `doctor` and `list` are where an operator goes to find
|
|
408
|
+
* out why a session stopped working. When only the guard knew, `doctor` reported
|
|
409
|
+
* "No MCP servers configured" and exited 0 while nothing could run — an answer
|
|
410
|
+
* that sends the operator looking anywhere but at the config that caused it.
|
|
411
|
+
*
|
|
412
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
|
|
413
|
+
* @returns {{scope: string, file: string, detail: string}[]}
|
|
414
|
+
*/
|
|
415
|
+
export function unverifiableMcpConfig(options = {}) {
|
|
416
|
+
const problems = [];
|
|
417
|
+
const merged = mergedSettings(options);
|
|
418
|
+
if (erasesToolOrigin(merged.settings)) {
|
|
419
|
+
// Which files to edit, not just that something is wrong: the two keys can be
|
|
420
|
+
// set in different layers, and naming only one of them sends the operator to
|
|
421
|
+
// a file where the other half is not.
|
|
422
|
+
const scopes = [...new Set(["directTools", "toolPrefix"].map((key) => merged.contributors[key]).filter(Boolean))];
|
|
423
|
+
const files = scopes.map((scope) => configPathForScope(scope, options));
|
|
424
|
+
problems.push({
|
|
425
|
+
scope: scopes.join(" + ") || "global",
|
|
426
|
+
file: files[0] ?? configPathForScope("global", options),
|
|
427
|
+
detail: `${files.join(" and ")} together turn on directTools with toolPrefix "none", which strips the ` +
|
|
428
|
+
"server name off every tool and leaves nothing to check an approval against. " +
|
|
429
|
+
"Remove that setting, then approve servers individually."
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const scope of REPOSITORY_SCOPES) {
|
|
434
|
+
const file = configPathForScope(scope, options);
|
|
435
|
+
let config;
|
|
436
|
+
try {
|
|
437
|
+
config = readMcpConfig(file);
|
|
438
|
+
} catch {
|
|
439
|
+
// Unreadable is a different diagnosis with its own report, and nothing is
|
|
440
|
+
// assumed about the contents of a file nobody could read.
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
for (const kind of config.imports) {
|
|
444
|
+
if (canEnumerateImportKind(kind)) continue;
|
|
445
|
+
// An import of a kind nothing here can parse is only a hole when the file
|
|
446
|
+
// it names exists. Refusing on the declaration alone blocked every session
|
|
447
|
+
// whose repository listed a tool the operator does not even have installed,
|
|
448
|
+
// which is a config to clean up rather than a session to stop.
|
|
449
|
+
const present = importKindFiles(kind, options).filter((candidate) => fs.existsSync(candidate));
|
|
450
|
+
if (present.length === 0) continue;
|
|
451
|
+
problems.push({
|
|
452
|
+
scope,
|
|
453
|
+
file,
|
|
454
|
+
detail: `${file} imports servers from ${kind} config, which this platform cannot enumerate ` +
|
|
455
|
+
`(${present.join(", ")}), so which servers reach this session is unknown. ` +
|
|
456
|
+
"Remove the import and declare the servers you want directly."
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return problems;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
464
|
+
export function isRecord(value) {
|
|
465
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
466
|
+
}
|