@gajae-code/coding-agent 0.16.3 → 0.16.4
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 +59 -0
- package/dist/types/cli/args.d.ts +2 -2
- package/dist/types/cli/root-flags.d.ts +4 -2
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/autorouting-tier-map.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/model-resolver.d.ts +9 -0
- package/dist/types/config/settings-schema.d.ts +29 -1
- package/dist/types/discovery/claude-plugins.d.ts +3 -1
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -2
- package/dist/types/modes/components/settings-selector.d.ts +6 -1
- package/dist/types/modes/components/status-line/command.d.ts +35 -0
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +5 -0
- package/dist/types/modes/components/tool-status-header.d.ts +14 -0
- package/dist/types/modes/controllers/event-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/client.d.ts +9 -1
- package/dist/types/runtime-mcp/manager.d.ts +7 -1
- package/dist/types/runtime-mcp/pool.d.ts +22 -3
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/runtime-mcp/types.d.ts +22 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
- package/dist/types/sdk/host/session-runtime.d.ts +23 -0
- package/dist/types/sdk/session-api-key.d.ts +26 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/setup/paseo/announce.d.ts +111 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/fetch.d.ts +7 -0
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/path-utils.d.ts +2 -0
- package/dist/types/tools/search.d.ts +6 -0
- package/package.json +7 -7
- package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
- package/scripts/generate-sdk-operation-inventory.ts +9 -0
- package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
- package/src/cli/args.ts +20 -9
- package/src/cli/root-flags.ts +5 -3
- package/src/config/autorouting-tier-map.ts +1 -0
- package/src/config/file-lock.ts +181 -17
- package/src/config/model-preset-registry.ts +5 -3
- package/src/config/model-profiles.ts +40 -0
- package/src/config/model-registry.ts +57 -11
- package/src/config/model-resolver.ts +48 -0
- package/src/config/settings-schema.ts +33 -1
- package/src/config/settings.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
- package/src/discovery/builtin.ts +13 -4
- package/src/discovery/claude-plugins.ts +12 -4
- package/src/discovery/helpers.ts +10 -2
- package/src/extensibility/gjc-plugins/compiler.ts +208 -58
- package/src/extensibility/gjc-plugins/installer.ts +13 -12
- package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
- package/src/extensibility/gjc-plugins/paths.ts +13 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
- package/src/extensibility/runtime-skill-discovery.ts +77 -151
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +44 -16
- package/src/gjc-runtime/repository-binding.ts +1 -1
- package/src/gjc-runtime/session-state-lock.ts +92 -2
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/main.ts +72 -34
- package/src/modes/DESIGN.md +54 -0
- package/src/modes/components/assistant-message.ts +22 -9
- package/src/modes/components/model-selector.ts +166 -33
- package/src/modes/components/settings-selector.ts +1075 -235
- package/src/modes/components/status-line/command.ts +190 -0
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/segments.ts +32 -5
- package/src/modes/components/status-line/types.ts +5 -0
- package/src/modes/components/tool-status-header.ts +393 -21
- package/src/modes/controllers/command-controller.ts +2 -0
- package/src/modes/controllers/event-controller.ts +148 -5
- package/src/modes/controllers/selector-controller.ts +21 -0
- package/src/modes/interactive-mode.ts +47 -1
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/system/system-prompt.md +8 -0
- package/src/prompts/tools/search.md +2 -0
- package/src/runtime-mcp/client.ts +60 -17
- package/src/runtime-mcp/manager.ts +171 -14
- package/src/runtime-mcp/pool.test.ts +209 -0
- package/src/runtime-mcp/pool.ts +224 -23
- package/src/runtime-mcp/transports/stdio.ts +158 -24
- package/src/runtime-mcp/types.ts +24 -0
- package/src/sdk/broker/broker.ts +5 -4
- package/src/sdk/bus/chat-daemon-control.ts +1 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +37 -1
- package/src/sdk/bus/slack-daemon.ts +92 -2
- package/src/sdk/bus/slack-live-provider.ts +12 -0
- package/src/sdk/bus/slack-provider.ts +5 -0
- package/src/sdk/cli/session-cli.ts +9 -6
- package/src/sdk/host/session-runtime.test.ts +2 -2
- package/src/sdk/host/session-runtime.ts +275 -43
- package/src/sdk/protocol/operation-inventory.generated.json +66 -0
- package/src/sdk/session-api-key.ts +94 -0
- package/src/sdk/session.ts +73 -29
- package/src/sdk/transport/serve-cli.ts +3 -3
- package/src/session/agent-session.ts +123 -38
- package/src/session/contribution-prep.ts +214 -21
- package/src/setup/paseo/announce.ts +450 -0
- package/src/setup/paseo/setup-deps.ts +15 -3
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/system-prompt.ts +4 -0
- package/src/task/executor.ts +2 -1
- package/src/task/index.ts +17 -4
- package/src/tools/bash.ts +35 -8
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +29 -1
- package/src/tools/index.ts +8 -0
- package/src/tools/job.ts +8 -2
- package/src/tools/monitor.ts +8 -2
- package/src/tools/path-utils.ts +7 -0
- package/src/tools/search.ts +116 -58
- package/src/tools/skill.ts +22 -2
- package/src/tools/subagent.ts +3 -1
- package/src/tools/tool-catalog.generated.ts +8 -1
- package/src/web/insane/bridge.ts +113 -23
- package/src/web/search/providers/perplexity.ts +102 -41
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
3
5
|
import * as path from "node:path";
|
|
4
6
|
import { bindPluginMcpToPublicNetwork } from "../../runtime-mcp/plugin-network-boundary";
|
|
7
|
+
import type { MCPStdioPreparedLaunch, MCPStdioSpawnLaunch } from "../../runtime-mcp/types";
|
|
5
8
|
import { loadCustomTools } from "../custom-tools/loader";
|
|
6
9
|
import type { CustomTool } from "../custom-tools/types";
|
|
10
|
+
import { compileGjcPluginBundle } from "./compiler";
|
|
7
11
|
import { bundleIdentity } from "./lifecycle-reconciliation";
|
|
8
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
assertDnsResolvesPublic,
|
|
14
|
+
assertMcpInstallPolicy,
|
|
15
|
+
assertUrlAllowed,
|
|
16
|
+
classifyStdioInvocation,
|
|
17
|
+
} from "./mcp-policy";
|
|
18
|
+
import { canonicalJson, verifyImplementationHash } from "./metadata";
|
|
9
19
|
import { isV2Tool } from "./migration";
|
|
10
|
-
import { resolveWithinRoot } from "./paths";
|
|
11
|
-
import { loadEffectiveGjcPluginRegistry, registryPathForScope } from "./registry";
|
|
20
|
+
import { gjcPluginInstallRoot, resolveWithinRoot } from "./paths";
|
|
21
|
+
import { loadEffectiveGjcPluginRegistry, registryPathForScope, registryRootForScope } from "./registry";
|
|
12
22
|
import { type SessionQuarantine, type SessionValidationResult, validateSessionBundles } from "./session-validation";
|
|
13
23
|
import type { GjcPluginRegistryEntry, GjcPluginScope, JsonSchema202012, NormalizedToolSurfaceV2 } from "./types";
|
|
14
24
|
|
|
@@ -84,7 +94,53 @@ const hashCache = new Map<string, string>();
|
|
|
84
94
|
// Bound the digest memo so long sessions with plugin churn cannot grow it
|
|
85
95
|
// unboundedly; entries are re-derivable from disk at the cost of one read.
|
|
86
96
|
const HASH_CACHE_MAX_ENTRIES = 512;
|
|
97
|
+
const MCP_SNAPSHOT_MAX_FILES = 8_192;
|
|
98
|
+
const MCP_SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024;
|
|
99
|
+
const MCP_LAUNCHER_MAX_BYTES = 512 * 1024 * 1024;
|
|
87
100
|
const registryScopes: GjcPluginScope[] = ["user", "project"];
|
|
101
|
+
const initialProcessEnvironment =
|
|
102
|
+
process.platform === "linux"
|
|
103
|
+
? fs.readFile("/proc/self/environ").then(
|
|
104
|
+
bytes =>
|
|
105
|
+
new Map(
|
|
106
|
+
bytes
|
|
107
|
+
.toString("utf8")
|
|
108
|
+
.split("\0")
|
|
109
|
+
.flatMap(entry => {
|
|
110
|
+
const separator = entry.indexOf("=");
|
|
111
|
+
return separator > 0 ? [[entry.slice(0, separator), entry.slice(separator + 1)] as const] : [];
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
() => new Map<string, string>(),
|
|
115
|
+
)
|
|
116
|
+
: Promise.resolve(new Map<string, string>());
|
|
117
|
+
const initialTemporaryRoots = initialProcessEnvironment.then(async environment => {
|
|
118
|
+
const candidates = [environment.get("TMPDIR"), environment.get("TEMP"), environment.get("TMP"), "/tmp", "/var/tmp"]
|
|
119
|
+
.filter((root): root is string => typeof root === "string" && path.isAbsolute(root))
|
|
120
|
+
.map(root => path.resolve(root));
|
|
121
|
+
const roots = new Set<string>();
|
|
122
|
+
for (const candidate of candidates) {
|
|
123
|
+
roots.add(candidate);
|
|
124
|
+
roots.add(await fs.realpath(candidate).catch(() => candidate));
|
|
125
|
+
}
|
|
126
|
+
return [...roots];
|
|
127
|
+
});
|
|
128
|
+
const initialNodeAuthorities = initialProcessEnvironment.then(async environment => {
|
|
129
|
+
const authorities = new Map<string, string>();
|
|
130
|
+
const temporaryRoots = await initialTemporaryRoots;
|
|
131
|
+
for (const pathEntry of (environment.get("PATH") ?? "").split(path.delimiter).filter(path.isAbsolute)) {
|
|
132
|
+
const lexical = path.join(pathEntry, process.platform === "win32" ? "node.exe" : "node");
|
|
133
|
+
try {
|
|
134
|
+
const real = await fs.realpath(lexical);
|
|
135
|
+
if (temporaryRoots.some(root => isWithin(root, real))) continue;
|
|
136
|
+
const bytes = await readStableFile(real, "Initial Node executable", MCP_LAUNCHER_MAX_BYTES, true);
|
|
137
|
+
authorities.set(real, sha256(bytes));
|
|
138
|
+
} catch {
|
|
139
|
+
// Missing or unstable startup candidates do not become authority.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return authorities;
|
|
143
|
+
});
|
|
88
144
|
|
|
89
145
|
async function snapshotExistingFile(filePath: string): Promise<FileSnapshot | null> {
|
|
90
146
|
try {
|
|
@@ -128,11 +184,504 @@ function sha256(buf: Buffer): string {
|
|
|
128
184
|
return createHash("sha256").update(buf).digest("hex");
|
|
129
185
|
}
|
|
130
186
|
|
|
131
|
-
|
|
187
|
+
function canonicalPersistedJson(value: unknown): string {
|
|
188
|
+
const serialized = JSON.stringify(value);
|
|
189
|
+
if (serialized === undefined) throw new Error("Cannot canonicalize an undefined persisted value");
|
|
190
|
+
return canonicalJson(JSON.parse(serialized) as unknown);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const VERIFIED_STDIO_MODULE_WRAPPER = `
|
|
194
|
+
import { createHash } from "node:crypto";
|
|
195
|
+
import { constants } from "node:fs";
|
|
196
|
+
import { open, realpath, readdir } from "node:fs/promises";
|
|
197
|
+
import { register } from "node:module";
|
|
198
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
199
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
200
|
+
|
|
201
|
+
const [entrypointPath, snapshotRoot, authorityPath, expectedAuthorityHash, ...serverArgs] = process.argv.slice(1);
|
|
202
|
+
if (!entrypointPath || !snapshotRoot || !authorityPath || !/^[a-f0-9]{64}$/u.test(expectedAuthorityHash ?? "")) {
|
|
203
|
+
throw new Error("invalid verified plugin MCP launch metadata");
|
|
204
|
+
}
|
|
205
|
+
if (process.versions.bun || process.release?.name !== "node") {
|
|
206
|
+
throw new Error("Authenticated plugin MCP wrapper requires Node");
|
|
207
|
+
}
|
|
208
|
+
const flags = constants.O_RDONLY | (typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0);
|
|
209
|
+
const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
210
|
+
const isWithin = (root, target) => {
|
|
211
|
+
const rel = relative(root, target);
|
|
212
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
213
|
+
};
|
|
214
|
+
const resolveInside = (root, relativePath, label) => {
|
|
215
|
+
if (typeof relativePath !== "string" || relativePath.length === 0 || isAbsolute(relativePath)) {
|
|
216
|
+
throw new Error(label + " is not a relative file path");
|
|
217
|
+
}
|
|
218
|
+
const target = resolve(root, relativePath);
|
|
219
|
+
if (!isWithin(root, target) || target === root) throw new Error(label + " escapes its authenticated root");
|
|
220
|
+
return target;
|
|
221
|
+
};
|
|
222
|
+
async function readStableFile(target, label, maxBytes) {
|
|
223
|
+
const handle = await open(target, flags);
|
|
224
|
+
try {
|
|
225
|
+
const before = await handle.stat();
|
|
226
|
+
if (!before.isFile()) throw new Error(label + " is not a regular file");
|
|
227
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || before.size > maxBytes) throw new Error(label + " exceeds its byte authority");
|
|
228
|
+
const chunks = [];
|
|
229
|
+
let offset = 0;
|
|
230
|
+
for (;;) {
|
|
231
|
+
const remaining = maxBytes + 1 - offset;
|
|
232
|
+
if (remaining <= 0) throw new Error(label + " exceeds its byte authority");
|
|
233
|
+
const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, remaining));
|
|
234
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, offset);
|
|
235
|
+
if (bytesRead === 0) break;
|
|
236
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
237
|
+
offset += bytesRead;
|
|
238
|
+
if (offset > before.size) throw new Error(label + " grew while reading");
|
|
239
|
+
}
|
|
240
|
+
const bytes = Buffer.concat(chunks, offset);
|
|
241
|
+
const after = await handle.stat();
|
|
242
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || bytes.byteLength !== after.size) {
|
|
243
|
+
throw new Error(label + " changed while reading");
|
|
244
|
+
}
|
|
245
|
+
return bytes;
|
|
246
|
+
} finally {
|
|
247
|
+
await handle.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const snapshotReal = await realpath(snapshotRoot);
|
|
252
|
+
if (snapshotReal !== snapshotRoot || !isWithin(snapshotRoot, entrypointPath) || !isWithin(snapshotRoot, process.cwd())) {
|
|
253
|
+
throw new Error("verified plugin MCP snapshot authority drifted before execution");
|
|
254
|
+
}
|
|
255
|
+
const authorityBytes = await readStableFile(authorityPath, "plugin MCP snapshot authority", 2 * 1024 * 1024);
|
|
256
|
+
if (sha256(authorityBytes) !== expectedAuthorityHash) throw new Error("plugin MCP snapshot authority hash mismatch");
|
|
257
|
+
const authority = JSON.parse(authorityBytes.toString("utf8"));
|
|
258
|
+
if (!Array.isArray(authority.files) || authority.files.length === 0) throw new Error("plugin MCP snapshot files are missing");
|
|
259
|
+
const expectedPaths = new Set();
|
|
260
|
+
const authenticatedSources = new Map();
|
|
261
|
+
for (const file of authority.files) {
|
|
262
|
+
if (!file || typeof file.relativePath !== "string" || !/^[a-f0-9]{64}$/u.test(file.sha256)) throw new Error("plugin MCP snapshot record is malformed");
|
|
263
|
+
const target = resolveInside(snapshotRoot, file.relativePath, "plugin MCP snapshot file");
|
|
264
|
+
if (expectedPaths.has(target)) throw new Error("plugin MCP snapshot path is duplicated");
|
|
265
|
+
expectedPaths.add(target);
|
|
266
|
+
const bytes = await readStableFile(target, "plugin MCP snapshot file", file.bytes);
|
|
267
|
+
if (bytes.byteLength !== file.bytes || sha256(bytes) !== file.sha256) throw new Error("plugin MCP snapshot file hash mismatch");
|
|
268
|
+
authenticatedSources.set(pathToFileURL(target).href, bytes);
|
|
269
|
+
}
|
|
270
|
+
async function verifyTree(directory) {
|
|
271
|
+
for (const child of await readdir(directory, { withFileTypes: true })) {
|
|
272
|
+
const target = resolve(directory, child.name);
|
|
273
|
+
if (child.isDirectory()) await verifyTree(target);
|
|
274
|
+
else if (!child.isFile() || !expectedPaths.delete(target)) throw new Error("plugin MCP snapshot contains unauthenticated content");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await verifyTree(snapshotRoot);
|
|
278
|
+
if (expectedPaths.size !== 0) throw new Error("plugin MCP snapshot is incomplete");
|
|
279
|
+
const entrypointUrl = pathToFileURL(entrypointPath).href;
|
|
280
|
+
if (!authenticatedSources.has(entrypointUrl)) throw new Error("plugin MCP entrypoint is not authenticated");
|
|
281
|
+
|
|
282
|
+
if (typeof process.getBuiltinModule === "function") {
|
|
283
|
+
const getBuiltinModule = process.getBuiltinModule.bind(process);
|
|
284
|
+
process.getBuiltinModule = name => {
|
|
285
|
+
if (name === "module" || name === "node:module") throw new Error("plugin MCP module loader access is not allowed");
|
|
286
|
+
return getBuiltinModule(name);
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const loader = [
|
|
290
|
+
'import { isAbsolute, relative } from "node:path";',
|
|
291
|
+
'import { fileURLToPath } from "node:url";',
|
|
292
|
+
'let root;',
|
|
293
|
+
'let sources;',
|
|
294
|
+
'export function initialize(data) {',
|
|
295
|
+
' root = data.snapshotRoot;',
|
|
296
|
+
' sources = new Map(data.authenticatedSources);',
|
|
297
|
+
' if (sources.size === 0) throw new Error("plugin MCP authenticated module bytes are missing");',
|
|
298
|
+
'}',
|
|
299
|
+
'const within = target => { const rel = relative(root, target); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); };',
|
|
300
|
+
'const directFileUrl = (specifier, parentURL) => {',
|
|
301
|
+
' if (specifier.startsWith("file:")) return new URL(specifier).href;',
|
|
302
|
+
' if (!specifier.startsWith("./") && !specifier.startsWith("../") && !specifier.startsWith("/")) return undefined;',
|
|
303
|
+
' if (!parentURL?.startsWith("file:")) throw new Error("plugin MCP file import has no authenticated parent: " + specifier);',
|
|
304
|
+
' return new URL(specifier, parentURL).href;',
|
|
305
|
+
'};',
|
|
306
|
+
'const authenticatedFormat = format => {',
|
|
307
|
+
' if (format === "commonjs" || format === "commonjs-typescript" || format === "addon") throw new Error("plugin MCP CommonJS/native modules are not supported");',
|
|
308
|
+
' if (format !== "module" && format !== "module-typescript" && format !== "json" && format !== "wasm") throw new Error("plugin MCP module format is not supported: " + String(format));',
|
|
309
|
+
' return format;',
|
|
310
|
+
'};',
|
|
311
|
+
'export async function resolve(specifier, context, nextResolve) {',
|
|
312
|
+
' if (specifier === "module" || specifier === "node:module") throw new Error("plugin MCP module loader access is not allowed");',
|
|
313
|
+
' const expectedUrl = directFileUrl(specifier, context.parentURL);',
|
|
314
|
+
' const result = await nextResolve(specifier, context);',
|
|
315
|
+
' if (result.url === "node:module") throw new Error("plugin MCP module loader access is not allowed");',
|
|
316
|
+
' if (result.url.startsWith("node:")) return result;',
|
|
317
|
+
' if (!result.url.startsWith("file:") || !within(fileURLToPath(result.url))) throw new Error("plugin MCP import escapes its authenticated snapshot: " + specifier);',
|
|
318
|
+
' if (expectedUrl !== undefined && result.url !== expectedUrl) throw new Error("plugin MCP module identity drifted during resolution: " + specifier);',
|
|
319
|
+
' if (!sources.has(result.url)) throw new Error("plugin MCP import is not an authenticated snapshot module: " + specifier);',
|
|
320
|
+
' authenticatedFormat(result.format);',
|
|
321
|
+
' return result;',
|
|
322
|
+
'}',
|
|
323
|
+
'export async function load(url, context, nextLoad) {',
|
|
324
|
+
' if (url === "node:module") throw new Error("plugin MCP module loader access is not allowed");',
|
|
325
|
+
' if (url.startsWith("node:")) return nextLoad(url, context);',
|
|
326
|
+
' if (!url.startsWith("file:") || !within(fileURLToPath(url))) throw new Error("plugin MCP module load escapes its authenticated snapshot: " + url);',
|
|
327
|
+
' const source = sources.get(url);',
|
|
328
|
+
' if (source === undefined) throw new Error("plugin MCP module load is not authenticated: " + url);',
|
|
329
|
+
' return { format: authenticatedFormat(context.format), source: Uint8Array.from(source).buffer, shortCircuit: true };',
|
|
330
|
+
'}',
|
|
331
|
+
].join("\\n");
|
|
332
|
+
register("data:text/javascript," + encodeURIComponent(loader), {
|
|
333
|
+
parentURL: import.meta.url,
|
|
334
|
+
data: { snapshotRoot, authenticatedSources: [...authenticatedSources] },
|
|
335
|
+
});
|
|
336
|
+
authenticatedSources.clear();
|
|
337
|
+
|
|
338
|
+
process.argv = [process.execPath, entrypointPath, ...serverArgs];
|
|
339
|
+
await import(entrypointUrl);
|
|
340
|
+
`;
|
|
341
|
+
|
|
342
|
+
async function readStableFile(
|
|
343
|
+
filePath: string,
|
|
344
|
+
label: string,
|
|
345
|
+
maxBytes: number,
|
|
346
|
+
followSymlink = false,
|
|
347
|
+
): Promise<Buffer> {
|
|
348
|
+
const noFollow =
|
|
349
|
+
followSymlink || filePath === "/proc/self/exe" || typeof fs.constants.O_NOFOLLOW !== "number"
|
|
350
|
+
? 0
|
|
351
|
+
: fs.constants.O_NOFOLLOW;
|
|
352
|
+
const flags = fs.constants.O_RDONLY | noFollow;
|
|
353
|
+
const handle = await fs.open(filePath, flags);
|
|
354
|
+
try {
|
|
355
|
+
const before = await handle.stat();
|
|
356
|
+
if (!before.isFile()) throw new Error(`${label} is not a regular file`);
|
|
357
|
+
if (before.size > maxBytes) throw new Error(`${label} exceeds its byte limit`);
|
|
358
|
+
const chunks: Buffer[] = [];
|
|
359
|
+
let offset = 0;
|
|
360
|
+
for (;;) {
|
|
361
|
+
const remaining = maxBytes + 1 - offset;
|
|
362
|
+
if (remaining <= 0) throw new Error(`${label} exceeds its byte limit`);
|
|
363
|
+
const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, remaining));
|
|
364
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, offset);
|
|
365
|
+
if (bytesRead === 0) break;
|
|
366
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
367
|
+
offset += bytesRead;
|
|
368
|
+
}
|
|
369
|
+
const bytes = Buffer.concat(chunks, offset);
|
|
370
|
+
const after = await handle.stat();
|
|
371
|
+
if (
|
|
372
|
+
before.dev !== after.dev ||
|
|
373
|
+
before.ino !== after.ino ||
|
|
374
|
+
before.size !== after.size ||
|
|
375
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
376
|
+
before.ctimeMs !== after.ctimeMs ||
|
|
377
|
+
bytes.byteLength !== after.size
|
|
378
|
+
) {
|
|
379
|
+
throw new Error(`${label} changed while reading`);
|
|
380
|
+
}
|
|
381
|
+
return bytes;
|
|
382
|
+
} finally {
|
|
383
|
+
await handle.close();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Resolve host launchers through absolute PATH entries outside the workspace
|
|
389
|
+
* and installed plugin. Relative entries such as `.` and absolute workspace
|
|
390
|
+
* entries can otherwise select repository-controlled executables before the
|
|
391
|
+
* authenticated plugin wrapper.
|
|
392
|
+
*/
|
|
393
|
+
async function resolveTrustedStdioLauncher(
|
|
394
|
+
launcher: "node" | "bun",
|
|
395
|
+
workspaceRoot: string,
|
|
396
|
+
pluginRoot: string,
|
|
397
|
+
): Promise<string> {
|
|
398
|
+
const lexicalRoots = [workspaceRoot, pluginRoot].map(root => path.resolve(root));
|
|
399
|
+
const realRoots = await Promise.all(lexicalRoots.map(root => fs.realpath(root)));
|
|
400
|
+
const untrustedRoots = [...new Set([...lexicalRoots, ...realRoots])];
|
|
401
|
+
if (launcher === "bun") {
|
|
402
|
+
const runningExecutable = process.platform === "linux" ? "/proc/self/exe" : process.execPath;
|
|
403
|
+
const real = await fs.realpath(runningExecutable);
|
|
404
|
+
if (untrustedRoots.some(root => isWithin(root, real))) {
|
|
405
|
+
throw new Error("The running Bun executable overlaps an untrusted plugin/workspace root");
|
|
406
|
+
}
|
|
407
|
+
if (!(await fs.stat(real)).isFile()) throw new Error("The running Bun executable is not a regular file");
|
|
408
|
+
return runningExecutable;
|
|
409
|
+
}
|
|
410
|
+
const pathEntries = (Bun.env.PATH ?? "")
|
|
411
|
+
.split(path.delimiter)
|
|
412
|
+
.map(entry => {
|
|
413
|
+
const trimmed = entry.trim();
|
|
414
|
+
return trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
|
|
415
|
+
})
|
|
416
|
+
.filter(entry => entry.length > 0 && path.isAbsolute(entry));
|
|
417
|
+
|
|
418
|
+
for (const pathEntry of pathEntries) {
|
|
419
|
+
const candidate = Bun.which(launcher, { PATH: pathEntry });
|
|
420
|
+
if (!candidate || !path.isAbsolute(candidate)) continue;
|
|
421
|
+
try {
|
|
422
|
+
const lexicalCandidate = path.join(pathEntry, path.basename(candidate));
|
|
423
|
+
if (!untrustedRoots.some(root => isWithin(root, lexicalCandidate))) {
|
|
424
|
+
const realCandidate = await fs.realpath(lexicalCandidate);
|
|
425
|
+
if (
|
|
426
|
+
!untrustedRoots.some(root => isWithin(root, realCandidate)) &&
|
|
427
|
+
(await isInitialManagedNodeLauncherPath(lexicalCandidate, untrustedRoots))
|
|
428
|
+
) {
|
|
429
|
+
return realCandidate;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const lexical = path.resolve(candidate);
|
|
433
|
+
if (untrustedRoots.some(root => isWithin(root, lexical))) continue;
|
|
434
|
+
const real = await fs.realpath(lexical);
|
|
435
|
+
if (untrustedRoots.some(root => isWithin(root, real))) continue;
|
|
436
|
+
if (await isInitialManagedNodeLauncherPath(lexical, untrustedRoots)) return real;
|
|
437
|
+
} catch {
|
|
438
|
+
// A stale PATH entry is not launcher authority; try the next one.
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
throw new Error(`Trusted stdio launcher is unavailable from absolute host PATH entries: ${launcher}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function isInitialManagedNodeLauncherPath(
|
|
445
|
+
executablePath: string,
|
|
446
|
+
untrustedRoots: readonly string[],
|
|
447
|
+
): Promise<boolean> {
|
|
448
|
+
if (process.platform !== "linux") return false;
|
|
449
|
+
const real = await fs.realpath(executablePath);
|
|
450
|
+
if (untrustedRoots.some(root => isWithin(root, real))) return false;
|
|
451
|
+
if ((await initialTemporaryRoots).some(root => isWithin(root, real))) return false;
|
|
452
|
+
const expected = (await initialNodeAuthorities).get(real);
|
|
453
|
+
if (!expected) return false;
|
|
454
|
+
const bytes = await readStableFile(real, "Initial Node executable", MCP_LAUNCHER_MAX_BYTES, true);
|
|
455
|
+
return sha256(bytes) === expected;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function resolveTrustedSnapshotBase(
|
|
459
|
+
workspaceRoot: string,
|
|
460
|
+
pluginRoot: string,
|
|
461
|
+
): Promise<{
|
|
462
|
+
baseReal: string;
|
|
463
|
+
workspaceRootReal: string;
|
|
464
|
+
}> {
|
|
465
|
+
const lexicalRoots = [workspaceRoot, pluginRoot].map(root => path.resolve(root));
|
|
466
|
+
const realRoots = await Promise.all(lexicalRoots.map(root => fs.realpath(root)));
|
|
467
|
+
const untrustedRoots = [...new Set([...lexicalRoots, ...realRoots])];
|
|
468
|
+
const candidates = [
|
|
469
|
+
process.platform === "win32"
|
|
470
|
+
? path.join(os.tmpdir(), "gjc-plugin-mcp-private")
|
|
471
|
+
: path.join("/tmp", `gjc-plugin-mcp-${process.getuid?.() ?? process.pid}`),
|
|
472
|
+
];
|
|
473
|
+
for (const candidate of [...new Set(candidates.map(value => path.resolve(value)))]) {
|
|
474
|
+
if (untrustedRoots.some(root => isWithin(root, candidate))) continue;
|
|
475
|
+
try {
|
|
476
|
+
await fs.mkdir(candidate, { recursive: true, mode: 0o700 });
|
|
477
|
+
let lexicalStat = await fs.lstat(candidate);
|
|
478
|
+
if (!lexicalStat.isDirectory() || lexicalStat.isSymbolicLink()) continue;
|
|
479
|
+
if (process.platform !== "win32") {
|
|
480
|
+
const uid = process.getuid?.();
|
|
481
|
+
if (uid === undefined || lexicalStat.uid !== uid) continue;
|
|
482
|
+
if ((lexicalStat.mode & 0o077) !== 0) {
|
|
483
|
+
await fs.chmod(candidate, 0o700);
|
|
484
|
+
lexicalStat = await fs.lstat(candidate);
|
|
485
|
+
if ((lexicalStat.mode & 0o077) !== 0) continue;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const real = await fs.realpath(candidate);
|
|
489
|
+
if (untrustedRoots.some(root => isWithin(root, real))) continue;
|
|
490
|
+
await reapStalePluginMcpCapsules(real);
|
|
491
|
+
return { baseReal: real, workspaceRootReal: realRoots[0] };
|
|
492
|
+
} catch {
|
|
493
|
+
// An unavailable or unwritable base is not snapshot authority.
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
throw new Error("Trusted plugin MCP snapshot base is unavailable outside untrusted roots");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function reapStalePluginMcpCapsules(baseReal: string): Promise<void> {
|
|
500
|
+
let entries: Dirent[];
|
|
501
|
+
try {
|
|
502
|
+
entries = await fs.readdir(baseReal, { withFileTypes: true });
|
|
503
|
+
} catch {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
for (const entry of entries) {
|
|
507
|
+
if (!entry.isDirectory()) continue;
|
|
508
|
+
const match = /^gjc-plugin-mcp-(\d+)-[a-f0-9]{32}$/u.exec(entry.name);
|
|
509
|
+
if (!match) continue;
|
|
510
|
+
const ownerPid = Number(match[1]);
|
|
511
|
+
if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0 || ownerPid === process.pid) continue;
|
|
512
|
+
try {
|
|
513
|
+
process.kill(ownerPid, 0);
|
|
514
|
+
continue;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
|
|
517
|
+
}
|
|
518
|
+
await fs.rm(path.join(baseReal, entry.name), { recursive: true, force: true }).catch(() => {});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function verifiedStdioArgs(input: {
|
|
523
|
+
launcher: "node" | "bun";
|
|
524
|
+
entrypointPath: string;
|
|
525
|
+
snapshotRoot: string;
|
|
526
|
+
authorityPath: string;
|
|
527
|
+
authorityHash: string;
|
|
528
|
+
serverArgs: readonly string[];
|
|
529
|
+
}): string[] {
|
|
530
|
+
return [
|
|
531
|
+
...(input.launcher === "bun"
|
|
532
|
+
? [`--config=${os.devNull}`, "--no-env-file", "--no-install"]
|
|
533
|
+
: ["--input-type=module"]),
|
|
534
|
+
"--eval",
|
|
535
|
+
VERIFIED_STDIO_MODULE_WRAPPER,
|
|
536
|
+
"--",
|
|
537
|
+
input.entrypointPath,
|
|
538
|
+
input.snapshotRoot,
|
|
539
|
+
input.authorityPath,
|
|
540
|
+
input.authorityHash,
|
|
541
|
+
...input.serverArgs,
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function prepareVerifiedStdioLaunch(input: {
|
|
546
|
+
launcher: "node" | "bun";
|
|
547
|
+
launcherPath: string;
|
|
548
|
+
pluginRoot: string;
|
|
549
|
+
cwdRelative: string;
|
|
550
|
+
entrypointRelative: string;
|
|
551
|
+
snapshotBaseReal: string;
|
|
552
|
+
files: readonly GjcPluginRegistryEntry["copiedFiles"][number][];
|
|
553
|
+
serverArgs: readonly string[];
|
|
554
|
+
registerCleanup?: (cleanup: () => Promise<void>) => void;
|
|
555
|
+
}): Promise<MCPStdioPreparedLaunch> {
|
|
556
|
+
if (process.platform !== "linux") {
|
|
557
|
+
throw new Error("Authenticated plugin MCP stdio launch capsules are available only on Linux");
|
|
558
|
+
}
|
|
559
|
+
if (input.launcher === "bun") {
|
|
560
|
+
throw new Error("Authenticated plugin MCP Bun launch capsules are unavailable");
|
|
561
|
+
}
|
|
562
|
+
const registerCleanup = input.registerCleanup;
|
|
563
|
+
if (!registerCleanup) {
|
|
564
|
+
throw new Error("Authenticated plugin MCP capsule cleanup ownership is unavailable");
|
|
565
|
+
}
|
|
566
|
+
if (input.files.length === 0 || input.files.length > MCP_SNAPSHOT_MAX_FILES) {
|
|
567
|
+
throw new Error(`Plugin MCP snapshot file count exceeds ${MCP_SNAPSHOT_MAX_FILES}`);
|
|
568
|
+
}
|
|
569
|
+
let totalBytes = 0;
|
|
570
|
+
for (const file of input.files) {
|
|
571
|
+
if (!Number.isSafeInteger(file.bytes) || file.bytes < 0)
|
|
572
|
+
throw new Error("Plugin MCP snapshot byte authority is invalid");
|
|
573
|
+
totalBytes += file.bytes;
|
|
574
|
+
if (totalBytes > MCP_SNAPSHOT_MAX_BYTES) {
|
|
575
|
+
throw new Error(`Plugin MCP snapshot bytes exceed ${MCP_SNAPSHOT_MAX_BYTES}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const capsuleRoot = path.join(
|
|
580
|
+
input.snapshotBaseReal,
|
|
581
|
+
`gjc-plugin-mcp-${process.pid}-${randomBytes(16).toString("hex")}`,
|
|
582
|
+
);
|
|
583
|
+
let cleanupComplete = false;
|
|
584
|
+
let capsuleCreated = false;
|
|
585
|
+
const cleanup = async () => {
|
|
586
|
+
if (cleanupComplete || !capsuleCreated) return;
|
|
587
|
+
await fs.rm(capsuleRoot, { recursive: true, force: true });
|
|
588
|
+
cleanupComplete = true;
|
|
589
|
+
};
|
|
590
|
+
registerCleanup(cleanup);
|
|
591
|
+
|
|
592
|
+
const snapshotRoot = path.join(capsuleRoot, "bundle");
|
|
593
|
+
const authorityPath = path.join(capsuleRoot, "authority.json");
|
|
594
|
+
const launcherPath = path.join(capsuleRoot, input.launcher);
|
|
595
|
+
await fs.mkdir(capsuleRoot, { mode: 0o700 });
|
|
596
|
+
capsuleCreated = true;
|
|
597
|
+
await fs.mkdir(snapshotRoot, { mode: 0o700 });
|
|
598
|
+
if ((await fs.realpath(capsuleRoot)) !== capsuleRoot) throw new Error("Plugin MCP launch capsule path drifted");
|
|
599
|
+
|
|
600
|
+
const launcherBytes = await readStableFile(
|
|
601
|
+
input.launcherPath,
|
|
602
|
+
"Plugin MCP interpreter",
|
|
603
|
+
MCP_LAUNCHER_MAX_BYTES,
|
|
604
|
+
true,
|
|
605
|
+
);
|
|
606
|
+
const launcherReal = await fs.realpath(input.launcherPath);
|
|
607
|
+
const expectedLauncherHash = (await initialNodeAuthorities).get(launcherReal);
|
|
608
|
+
if (!expectedLauncherHash || sha256(launcherBytes) !== expectedLauncherHash) {
|
|
609
|
+
throw new Error("Plugin MCP Node interpreter drifted from startup authority");
|
|
610
|
+
}
|
|
611
|
+
await fs.writeFile(launcherPath, launcherBytes, { flag: "wx", mode: 0o500 });
|
|
612
|
+
await assertCapturedNodeRuntime(launcherPath);
|
|
613
|
+
|
|
614
|
+
for (const file of input.files) {
|
|
615
|
+
const sourcePath = resolveWithinRoot(input.pluginRoot, file.relativePath);
|
|
616
|
+
const sourceReal = await fs.realpath(sourcePath);
|
|
617
|
+
const pluginRootReal = await fs.realpath(input.pluginRoot);
|
|
618
|
+
if (!isWithin(pluginRootReal, sourceReal))
|
|
619
|
+
throw new Error(`Plugin MCP source escapes installed root: ${file.relativePath}`);
|
|
620
|
+
const bytes = await readStableFile(sourceReal, `Plugin MCP source ${file.relativePath}`, MCP_SNAPSHOT_MAX_BYTES);
|
|
621
|
+
if (bytes.byteLength !== file.bytes || sha256(bytes) !== file.sha256) {
|
|
622
|
+
throw new Error(`Plugin MCP source hash drift: ${file.relativePath}`);
|
|
623
|
+
}
|
|
624
|
+
const destination = resolveWithinRoot(snapshotRoot, file.relativePath);
|
|
625
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
626
|
+
await fs.writeFile(destination, bytes, { flag: "wx", mode: 0o400 });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const authorityBytes = Buffer.from(canonicalPersistedJson({ files: input.files }));
|
|
630
|
+
await fs.writeFile(authorityPath, authorityBytes, { flag: "wx", mode: 0o400 });
|
|
631
|
+
const cwd = input.cwdRelative === "" ? snapshotRoot : resolveWithinRoot(snapshotRoot, input.cwdRelative);
|
|
632
|
+
await fs.mkdir(cwd, { recursive: true, mode: 0o500 });
|
|
633
|
+
const entrypointPath = resolveWithinRoot(snapshotRoot, input.entrypointRelative);
|
|
634
|
+
return {
|
|
635
|
+
command: launcherPath,
|
|
636
|
+
args: verifiedStdioArgs({
|
|
637
|
+
launcher: input.launcher,
|
|
638
|
+
entrypointPath,
|
|
639
|
+
snapshotRoot,
|
|
640
|
+
authorityPath,
|
|
641
|
+
authorityHash: sha256(authorityBytes),
|
|
642
|
+
serverArgs: input.serverArgs,
|
|
643
|
+
}),
|
|
644
|
+
cwd,
|
|
645
|
+
afterProcessExit: cleanup,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function assertCapturedNodeRuntime(launcherPath: string): Promise<void> {
|
|
650
|
+
const child = Bun.spawn(
|
|
651
|
+
[
|
|
652
|
+
launcherPath,
|
|
653
|
+
"--input-type=module",
|
|
654
|
+
"--eval",
|
|
655
|
+
'if (process.versions.bun || process.release?.name !== "node") process.exit(42); process.stdout.write("gjc-node-authority\\n");',
|
|
656
|
+
],
|
|
657
|
+
{
|
|
658
|
+
stdin: "ignore",
|
|
659
|
+
stdout: "pipe",
|
|
660
|
+
stderr: "pipe",
|
|
661
|
+
env: { PATH: "", HOME: "", TMPDIR: path.dirname(launcherPath) },
|
|
662
|
+
},
|
|
663
|
+
);
|
|
664
|
+
const outcome = await Promise.race([
|
|
665
|
+
Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]),
|
|
666
|
+
Bun.sleep(2_000).then(() => null),
|
|
667
|
+
]);
|
|
668
|
+
if (!outcome) {
|
|
669
|
+
child.kill();
|
|
670
|
+
await child.exited.catch(() => {});
|
|
671
|
+
throw new Error("Plugin MCP Node interpreter identity check timed out");
|
|
672
|
+
}
|
|
673
|
+
const [exitCode, stdout] = outcome;
|
|
674
|
+
if (exitCode !== 0 || stdout !== "gjc-node-authority\n") {
|
|
675
|
+
throw new Error("Plugin MCP interpreter is not an authenticated Node runtime");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function hashFile(snapshot: FileSnapshot, expectedBytes: number): Promise<string> {
|
|
132
680
|
const key = `${snapshot.path}:${snapshot.mtimeMs}:${snapshot.ctimeMs}:${snapshot.size}:${snapshot.ino}`;
|
|
133
681
|
const cached = hashCache.get(key);
|
|
134
682
|
if (cached) return cached;
|
|
135
|
-
|
|
683
|
+
if (snapshot.size !== expectedBytes) throw new Error(`Installed file byte drift: ${snapshot.path}`);
|
|
684
|
+
const digest = sha256(await readStableFile(snapshot.path, "Installed plugin file", expectedBytes));
|
|
136
685
|
if (hashCache.size >= HASH_CACHE_MAX_ENTRIES) {
|
|
137
686
|
// FIFO eviction is sufficient: the memo only avoids re-reads within a
|
|
138
687
|
// session; correctness never depends on a hit.
|
|
@@ -167,7 +716,19 @@ async function verifyEntryHashesCached(entry: GjcPluginRegistryEntry): Promise<S
|
|
|
167
716
|
message: `Installed file missing: ${file.relativePath}`,
|
|
168
717
|
};
|
|
169
718
|
}
|
|
170
|
-
|
|
719
|
+
let digest: string;
|
|
720
|
+
try {
|
|
721
|
+
digest = await hashFile(snapshot, file.bytes);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
return {
|
|
724
|
+
identity: bundleIdentity(entry.scope, entry.name),
|
|
725
|
+
plugin: entry.name,
|
|
726
|
+
surfaceId: `plugin:${entry.name}`,
|
|
727
|
+
code: "runtime_mismatch",
|
|
728
|
+
message: error instanceof Error ? error.message : String(error),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
if (digest !== file.sha256) {
|
|
171
732
|
return {
|
|
172
733
|
identity: bundleIdentity(entry.scope, entry.name),
|
|
173
734
|
plugin: entry.name,
|
|
@@ -180,17 +741,75 @@ async function verifyEntryHashesCached(entry: GjcPluginRegistryEntry): Promise<S
|
|
|
180
741
|
return null;
|
|
181
742
|
}
|
|
182
743
|
|
|
183
|
-
async function
|
|
744
|
+
async function assertMcpPluginRootOwnedByScope(entry: GjcPluginRegistryEntry, cwd: string): Promise<void> {
|
|
745
|
+
const scopeRoot = path.resolve(registryRootForScope(entry.scope, cwd));
|
|
746
|
+
const pluginRoot = path.resolve(entry.pluginRoot);
|
|
747
|
+
const expectedPluginRoot = path.resolve(gjcPluginInstallRoot(entry.scope, cwd, entry.name));
|
|
748
|
+
if (pluginRoot !== expectedPluginRoot) {
|
|
749
|
+
throw new Error(
|
|
750
|
+
`Installed plugin root does not match its canonical ${entry.scope} identity: ${entry.pluginRoot}`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
if (!isWithin(scopeRoot, pluginRoot)) {
|
|
754
|
+
throw new Error(`Installed plugin root escapes its ${entry.scope} registry scope: ${entry.pluginRoot}`);
|
|
755
|
+
}
|
|
756
|
+
const [scopeRootReal, pluginRootReal, expectedPluginRootReal] = await Promise.all([
|
|
757
|
+
fs.realpath(scopeRoot),
|
|
758
|
+
fs.realpath(pluginRoot),
|
|
759
|
+
fs.realpath(expectedPluginRoot),
|
|
760
|
+
]);
|
|
761
|
+
if (pluginRootReal !== expectedPluginRootReal) {
|
|
762
|
+
throw new Error(
|
|
763
|
+
`Installed plugin real root does not match its canonical ${entry.scope} identity: ${entry.pluginRoot}`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (!isWithin(scopeRootReal, pluginRootReal)) {
|
|
767
|
+
throw new Error(`Installed plugin root escapes its ${entry.scope} registry scope: ${entry.pluginRoot}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function assertInstalledTreeAuthenticated(entry: GjcPluginRegistryEntry): Promise<void> {
|
|
772
|
+
const expected = new Set(entry.copiedFiles.map(file => path.normalize(file.relativePath)));
|
|
773
|
+
const visit = async (directory: string): Promise<void> => {
|
|
774
|
+
let children: Dirent[];
|
|
775
|
+
try {
|
|
776
|
+
children = await fs.readdir(directory, { withFileTypes: true });
|
|
777
|
+
} catch (error) {
|
|
778
|
+
throw new Error(
|
|
779
|
+
`Installed plugin tree is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
783
|
+
const absolutePath = path.join(directory, child.name);
|
|
784
|
+
const relativePath = path.normalize(path.relative(entry.pluginRoot, absolutePath));
|
|
785
|
+
if (child.isSymbolicLink()) throw new Error(`Installed plugin tree contains a symlink: ${relativePath}`);
|
|
786
|
+
if (child.isDirectory()) {
|
|
787
|
+
await visit(absolutePath);
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (!child.isFile()) throw new Error(`Installed plugin tree contains an unsupported entry: ${relativePath}`);
|
|
791
|
+
if (!expected.delete(relativePath)) {
|
|
792
|
+
throw new Error(`Installed plugin tree contains an unauthenticated file: ${relativePath}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
await visit(entry.pluginRoot);
|
|
797
|
+
if (expected.size > 0) {
|
|
798
|
+
throw new Error(`Installed plugin tree is missing authenticated files: ${[...expected].sort().join(", ")}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function loadValidatedPluginRegistry(cwd: string, forceRefresh = false): Promise<ValidatedPluginRegistry> {
|
|
184
803
|
const registryFiles = await snapshotRegistryFiles(cwd);
|
|
185
804
|
const registryKey = snapshotsKey(registryFiles);
|
|
186
805
|
const cached = validatedRegistryCache.get(cwd);
|
|
187
|
-
if (cached && cached.registryKey === registryKey) {
|
|
806
|
+
if (!forceRefresh && cached && cached.registryKey === registryKey) {
|
|
188
807
|
const pluginFiles = await snapshotPluginFiles(cached.effective);
|
|
189
808
|
const pluginKey = snapshotsKey(pluginFiles);
|
|
190
809
|
if (cached.pluginKey === pluginKey) return cached;
|
|
191
810
|
}
|
|
192
811
|
|
|
193
|
-
const effective = await loadEffectiveGjcPluginRegistry(cwd);
|
|
812
|
+
const effective = await loadEffectiveGjcPluginRegistry(cwd, forceRefresh ? { migrate: false } : undefined);
|
|
194
813
|
const currentRegistryFiles = await snapshotRegistryFiles(cwd);
|
|
195
814
|
const preQuarantine: SessionQuarantine[] = [];
|
|
196
815
|
for (const entry of effective) {
|
|
@@ -432,31 +1051,154 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
|
|
|
432
1051
|
configs: Record<string, any>;
|
|
433
1052
|
quarantine: SessionQuarantine[];
|
|
434
1053
|
}> {
|
|
435
|
-
const { effective, active, quarantine } = await loadValidatedPluginRegistry(input.cwd);
|
|
1054
|
+
const { effective, active, quarantine } = await loadValidatedPluginRegistry(input.cwd, true);
|
|
436
1055
|
if (effective.length === 0) return { configs: {}, quarantine: [] };
|
|
437
|
-
const { assertMcpInstallPolicy, assertDnsResolvesPublic, assertUrlAllowed } = await import("./mcp-policy");
|
|
438
|
-
const nodePath = await import("node:path");
|
|
439
1056
|
|
|
440
1057
|
// A manifest-controlled MCP name such as "constructor" or "toString" must
|
|
441
1058
|
// remain an ordinary own key rather than interacting with Object.prototype.
|
|
442
1059
|
const configs: Record<string, any> = Object.create(null) as Record<string, any>;
|
|
443
1060
|
for (const entry of active) {
|
|
444
1061
|
const disabled = new Set(entry.disabledSurfaceIds);
|
|
1062
|
+
let compiledMcps: Map<string, (typeof entry.surfaces.mcps)[number]> | undefined;
|
|
1063
|
+
let compileError: unknown;
|
|
1064
|
+
try {
|
|
1065
|
+
await assertMcpPluginRootOwnedByScope(entry, input.cwd);
|
|
1066
|
+
await assertInstalledTreeAuthenticated(entry);
|
|
1067
|
+
const compiled = await compileGjcPluginBundle(entry.pluginRoot);
|
|
1068
|
+
if (
|
|
1069
|
+
compiled.name !== entry.name ||
|
|
1070
|
+
compiled.version !== entry.version ||
|
|
1071
|
+
compiled.manifestHash !== entry.manifestHash ||
|
|
1072
|
+
canonicalPersistedJson(compiled.files) !== canonicalPersistedJson(entry.copiedFiles)
|
|
1073
|
+
) {
|
|
1074
|
+
throw new Error(`Installed plugin bundle identity does not match registry entry: ${entry.name}`);
|
|
1075
|
+
}
|
|
1076
|
+
compiledMcps = new Map(compiled.surfaces.mcps.map(surface => [surface.extensionId, surface]));
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
compileError = error;
|
|
1079
|
+
}
|
|
445
1080
|
for (const m of entry.surfaces.mcps) {
|
|
446
1081
|
if (disabled.has(m.extensionId)) continue;
|
|
447
1082
|
const cfg = m.config;
|
|
448
1083
|
try {
|
|
1084
|
+
if (compileError) throw compileError;
|
|
1085
|
+
const compiled = compiledMcps?.get(m.extensionId);
|
|
1086
|
+
if (
|
|
1087
|
+
!compiled ||
|
|
1088
|
+
compiled.name !== m.name ||
|
|
1089
|
+
compiled.extensionId !== m.extensionId ||
|
|
1090
|
+
m.configHash !== compiled.configHash ||
|
|
1091
|
+
canonicalPersistedJson(cfg) !== canonicalPersistedJson(compiled.config)
|
|
1092
|
+
) {
|
|
1093
|
+
throw new Error(`MCP "${m.name}": persisted config no longer matches its compiled manifest`);
|
|
1094
|
+
}
|
|
449
1095
|
assertMcpInstallPolicy(cfg, { pluginRoot: entry.pluginRoot });
|
|
450
1096
|
if (cfg.transport === "stdio") {
|
|
1097
|
+
if (process.platform !== "linux") {
|
|
1098
|
+
throw new Error("Authenticated plugin MCP stdio launch capsules are available only on Linux");
|
|
1099
|
+
}
|
|
1100
|
+
const invocation = classifyStdioInvocation(cfg, { pluginRoot: entry.pluginRoot });
|
|
1101
|
+
for (const relativePath of invocation.ownedRelativePaths) {
|
|
1102
|
+
const ownedFile = entry.copiedFiles.find(
|
|
1103
|
+
file => path.normalize(file.relativePath) === path.normalize(relativePath),
|
|
1104
|
+
);
|
|
1105
|
+
if (!ownedFile) {
|
|
1106
|
+
throw new Error(
|
|
1107
|
+
`MCP "${m.name}": selected file is not in the authenticated copied-file set: ${relativePath}`,
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const ownedFile = entry.copiedFiles.find(
|
|
1112
|
+
file => path.normalize(file.relativePath) === path.normalize(invocation.ownedRelativePath),
|
|
1113
|
+
);
|
|
1114
|
+
if (!ownedFile) throw new Error(`MCP "${m.name}": authenticated entrypoint record is missing`);
|
|
1115
|
+
const command = await resolveTrustedStdioLauncher(invocation.launcher, input.cwd, entry.pluginRoot);
|
|
1116
|
+
const initialArgs = [...(cfg.args ?? [])];
|
|
451
1117
|
configs[m.name] = {
|
|
452
1118
|
type: "stdio",
|
|
453
|
-
command
|
|
454
|
-
args:
|
|
455
|
-
cwd:
|
|
1119
|
+
command,
|
|
1120
|
+
args: initialArgs,
|
|
1121
|
+
cwd: invocation.cwd,
|
|
456
1122
|
timeout: 5_000,
|
|
457
1123
|
// Third-party plugin MCP processes must not inherit host secrets;
|
|
458
1124
|
// only a minimal OS allowlist (PATH/HOME/temp/locale) is provided.
|
|
459
1125
|
noInheritEnv: true,
|
|
1126
|
+
prepareSpawn: async (launch: MCPStdioSpawnLaunch) => {
|
|
1127
|
+
const [launchCwdReal, expectedInitialCwdReal] = await Promise.all([
|
|
1128
|
+
fs.realpath(launch.cwd),
|
|
1129
|
+
fs.realpath(invocation.cwd),
|
|
1130
|
+
]);
|
|
1131
|
+
if (
|
|
1132
|
+
launch.command !== command ||
|
|
1133
|
+
launchCwdReal !== expectedInitialCwdReal ||
|
|
1134
|
+
canonicalPersistedJson(launch.args) !== canonicalPersistedJson(initialArgs)
|
|
1135
|
+
) {
|
|
1136
|
+
throw new Error(`MCP "${m.name}": launch plan drifted before preparation`);
|
|
1137
|
+
}
|
|
1138
|
+
await assertMcpPluginRootOwnedByScope(entry, input.cwd);
|
|
1139
|
+
await assertInstalledTreeAuthenticated(entry);
|
|
1140
|
+
const freshBundle = await compileGjcPluginBundle(entry.pluginRoot);
|
|
1141
|
+
if (
|
|
1142
|
+
freshBundle.name !== entry.name ||
|
|
1143
|
+
freshBundle.version !== entry.version ||
|
|
1144
|
+
freshBundle.manifestHash !== entry.manifestHash ||
|
|
1145
|
+
canonicalPersistedJson(freshBundle.files) !== canonicalPersistedJson(entry.copiedFiles)
|
|
1146
|
+
) {
|
|
1147
|
+
throw new Error(`MCP "${m.name}": installed bundle identity/files drifted before spawn`);
|
|
1148
|
+
}
|
|
1149
|
+
const freshSurface = freshBundle.surfaces.mcps.find(
|
|
1150
|
+
surface => surface.extensionId === m.extensionId,
|
|
1151
|
+
);
|
|
1152
|
+
if (
|
|
1153
|
+
!freshSurface ||
|
|
1154
|
+
freshSurface.name !== m.name ||
|
|
1155
|
+
freshSurface.extensionId !== m.extensionId ||
|
|
1156
|
+
freshSurface.configHash !== m.configHash ||
|
|
1157
|
+
canonicalPersistedJson(freshSurface.config) !== canonicalPersistedJson(cfg)
|
|
1158
|
+
) {
|
|
1159
|
+
throw new Error(`MCP "${m.name}": installed manifest/config drifted before spawn`);
|
|
1160
|
+
}
|
|
1161
|
+
const freshInvocation = classifyStdioInvocation(freshSurface.config, {
|
|
1162
|
+
pluginRoot: entry.pluginRoot,
|
|
1163
|
+
});
|
|
1164
|
+
for (const relativePath of freshInvocation.ownedRelativePaths) {
|
|
1165
|
+
const freshOwnedFile = entry.copiedFiles.find(
|
|
1166
|
+
file => path.normalize(file.relativePath) === path.normalize(relativePath),
|
|
1167
|
+
);
|
|
1168
|
+
if (!freshOwnedFile) {
|
|
1169
|
+
throw new Error(
|
|
1170
|
+
`MCP "${m.name}": unauthenticated installed file selected before spawn: ${relativePath}`,
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
const freshPath = await resolveRuntimeFile(entry.pluginRoot, freshOwnedFile.relativePath);
|
|
1174
|
+
await verifyImplementationHash(freshPath, freshOwnedFile.sha256);
|
|
1175
|
+
}
|
|
1176
|
+
const freshOwnedFile = entry.copiedFiles.find(
|
|
1177
|
+
file => path.normalize(file.relativePath) === path.normalize(freshInvocation.ownedRelativePath),
|
|
1178
|
+
);
|
|
1179
|
+
if (!freshOwnedFile)
|
|
1180
|
+
throw new Error(`MCP "${m.name}": authenticated entrypoint drifted before spawn`);
|
|
1181
|
+
const freshCommand = await resolveTrustedStdioLauncher(
|
|
1182
|
+
freshInvocation.launcher,
|
|
1183
|
+
input.cwd,
|
|
1184
|
+
entry.pluginRoot,
|
|
1185
|
+
);
|
|
1186
|
+
const { baseReal: snapshotBaseReal } = await resolveTrustedSnapshotBase(
|
|
1187
|
+
input.cwd,
|
|
1188
|
+
entry.pluginRoot,
|
|
1189
|
+
);
|
|
1190
|
+
return prepareVerifiedStdioLaunch({
|
|
1191
|
+
launcher: freshInvocation.launcher,
|
|
1192
|
+
launcherPath: freshCommand,
|
|
1193
|
+
pluginRoot: entry.pluginRoot,
|
|
1194
|
+
cwdRelative: path.relative(entry.pluginRoot, freshInvocation.cwd),
|
|
1195
|
+
entrypointRelative: freshOwnedFile.relativePath,
|
|
1196
|
+
snapshotBaseReal,
|
|
1197
|
+
files: entry.copiedFiles,
|
|
1198
|
+
serverArgs: (freshSurface.config.args ?? []).slice(1),
|
|
1199
|
+
registerCleanup: launch.registerCleanup,
|
|
1200
|
+
});
|
|
1201
|
+
},
|
|
460
1202
|
};
|
|
461
1203
|
} else {
|
|
462
1204
|
const url = assertUrlAllowed(cfg.url ?? "", `MCP "${m.name}" url`);
|