@zhivex-ai/harness 1.2.0 → 1.3.0-rc.7
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 +64 -0
- package/README.md +9 -10
- package/contracts/engine-api.json +2921 -0
- package/contracts/public-api.json +267 -1
- package/contracts/stable-api-signatures.json +43 -43
- package/dist/acp-cli.js +137 -15002
- package/dist/approvals/approval-history.d.ts +22 -0
- package/dist/approvals/explicit-review.d.ts +24 -0
- package/dist/approvals/host-policy-identity.d.ts +3 -0
- package/dist/cli/arguments.d.ts +6 -2
- package/dist/cli/checkpoints.d.ts +230 -0
- package/dist/cli/cli-options.d.ts +2 -41
- package/dist/cli/cli-profiles.d.ts +4 -2
- package/dist/cli/cli-stream.d.ts +2 -167
- package/dist/cli/governance-report.d.ts +5 -0
- package/dist/cli/policy-decisions.d.ts +9 -0
- package/dist/cli/policy.d.ts +5 -0
- package/dist/cli/presentation.d.ts +3 -1
- package/dist/cli/run-document.d.ts +2 -91
- package/dist/cli/terminal/terminal-continuation.d.ts +2 -3
- package/dist/cli/terminal/terminal-ui.d.ts +4 -0
- package/dist/{cli-entry-c2f58zj2.js → cli-entry-ae7x44d4.js} +14969 -10731
- package/dist/{cli-entry-427869g9.js → cli-entry-d5788aay.js} +388 -47
- package/dist/cli-entry-ev76ezcd.js +656 -0
- package/dist/{cli-3jmy4h5h.js → cli-t7hrhayq.js} +918 -2223
- package/dist/cli.js +3 -5
- package/dist/client/acp-mcp-admission.d.ts +32 -0
- package/dist/client/acp-mcp-host.d.ts +19 -0
- package/dist/client/acp-mcp-session.d.ts +14 -0
- package/dist/client/acp-stdio.d.ts +2 -0
- package/dist/client/acp.d.ts +4 -1
- package/dist/client/adapter.d.ts +13 -1
- package/dist/client/continuation.d.ts +3 -0
- package/dist/client/json-contracts.d.ts +107 -4
- package/dist/client/local-service.d.ts +2 -0
- package/dist/client/protocol.d.ts +127 -1
- package/dist/client/run-document.d.ts +91 -0
- package/dist/client/service-events.d.ts +4 -0
- package/dist/client/stream.d.ts +167 -0
- package/dist/compat/cli-options.d.ts +53 -0
- package/dist/compat/code-support.d.ts +22 -0
- package/dist/compat/code-support.js +74 -0
- package/dist/{config-1k9t4jhh.js → config-c002f06f.js} +1 -1
- package/dist/config-j3jga5wh.js +61 -0
- package/dist/context/adaptive-compaction.d.ts +1 -0
- package/dist/context/compaction.d.ts +1 -1
- package/dist/context/request-measurements.d.ts +17 -0
- package/dist/context/request-projection.d.ts +10 -0
- package/dist/context/task-memory.d.ts +1 -0
- package/dist/engine/acp.d.ts +6 -0
- package/dist/engine/acp.js +205 -0
- package/dist/engine/client.d.ts +6 -0
- package/dist/engine/client.js +28 -0
- package/dist/engine/desktop-providers-v1.d.ts +4 -0
- package/dist/engine/desktop-providers-v1.js +18 -0
- package/dist/engine/desktop-state-v1.d.ts +10 -0
- package/dist/engine/desktop-state-v1.js +168 -0
- package/dist/engine/index.d.ts +73 -0
- package/dist/engine/index.js +977 -0
- package/dist/engine/mcp-stdio-v1.d.ts +11 -0
- package/dist/engine/mcp-stdio-v1.js +108 -0
- package/dist/engine/models.d.ts +3 -0
- package/dist/engine/models.js +894 -0
- package/dist/engine/protocol.d.ts +7 -0
- package/dist/engine/protocol.js +182 -0
- package/dist/engine/service.d.ts +5 -0
- package/dist/engine/service.js +28 -0
- package/dist/execution/acceptance-snapshot.d.ts +2 -0
- package/dist/execution/execution-environment.d.ts +10 -0
- package/dist/execution/mcp-oci-channel.d.ts +11 -0
- package/dist/execution/mcp-oci-server.d.ts +38 -0
- package/dist/execution/mcp-oci-workspace.d.ts +23 -0
- package/dist/execution/mcp-resource-recovery.d.ts +19 -0
- package/dist/execution/mcp-workspace-snapshot.d.ts +20 -0
- package/dist/hostile-repository-demo.js +3368 -1304
- package/dist/index-1bx83sw4.js +4 -0
- package/dist/index-2gz7jv7g.js +549 -0
- package/dist/{cli-entry-b1mfvmxe.js → index-2jrdcj6b.js} +72 -115
- package/dist/index-3ah1f369.js +356 -0
- package/dist/index-42gp2hy8.js +1555 -0
- package/dist/index-4jw3tvy8.js +301 -0
- package/dist/index-8705zxpr.js +145 -0
- package/dist/index-8vm2m34s.js +2080 -0
- package/dist/index-8zzhggxv.js +477 -0
- package/dist/index-a97bezbz.js +1073 -0
- package/dist/index-gq9xagx2.js +1343 -0
- package/dist/index-h0mqx2ed.js +1373 -0
- package/dist/index-jgrx804e.js +5589 -0
- package/dist/index-qfpz5y2v.js +174 -0
- package/dist/index-t60rva31.js +1004 -0
- package/dist/index-wg1sd3at.js +80 -0
- package/dist/index-xqxeypt5.js +888 -0
- package/dist/index-yb239ghk.js +961 -0
- package/dist/index-z0qxmkm2.js +3389 -0
- package/dist/index-z4e8d0ph.js +1768 -0
- package/dist/index.js +251 -18649
- package/dist/integrations/mcp-host-session.d.ts +26 -0
- package/dist/integrations/mcp-stdio-admission.d.ts +54 -0
- package/dist/integrations/mcp-stdio-client.d.ts +44 -0
- package/dist/integrations/mcp-stdio-framing.d.ts +18 -0
- package/dist/integrations/mcp-stdio-launch.d.ts +20 -0
- package/dist/integrations/mcp-stdio-session.d.ts +7 -0
- package/dist/integrations/mcp-stdio-tools.d.ts +12 -0
- package/dist/internal/desktop/catalog.d.ts +2 -0
- package/dist/internal/desktop/persistence.d.ts +2 -3
- package/dist/internal/desktop/providers.d.ts +1 -0
- package/dist/models/catalog.d.ts +2 -0
- package/dist/persistence/governance-context.d.ts +126 -0
- package/dist/persistence/governance-report.d.ts +299 -0
- package/dist/persistence/host-database-backup.d.ts +28 -0
- package/dist/persistence/mcp-execution-journal.d.ts +19 -0
- package/dist/persistence/mcp-reconciliation.d.ts +33 -0
- package/dist/persistence/mcp-resource-journal.d.ts +54 -0
- package/dist/persistence/workspace-checkpoints.d.ts +138 -0
- package/dist/providers/anthropic-continuation.d.ts +5 -0
- package/dist/providers/providers.d.ts +1 -1
- package/dist/providers/vertex-auth.d.ts +27 -0
- package/dist/runtime/delegation-contracts.d.ts +9 -0
- package/dist/runtime/delegation-correction.d.ts +9 -0
- package/dist/runtime/delegation-result.d.ts +73 -0
- package/dist/runtime/error-diagnostics.d.ts +86 -0
- package/dist/runtime/harness.d.ts +11 -1
- package/dist/runtime/policy-decisions.d.ts +66 -0
- package/dist/runtime/policy-inspection.d.ts +52 -0
- package/dist/runtime/provider-diagnostics.d.ts +1 -0
- package/dist/runtime/runtime-instructions.d.ts +8 -0
- package/dist/runtime/task-acceptance-checks.d.ts +103 -0
- package/dist/runtime/task-acceptance-delivery.d.ts +107 -0
- package/dist/runtime/task-acceptance-host.d.ts +20 -0
- package/dist/runtime/task-acceptance-record.d.ts +107 -0
- package/dist/runtime/task-acceptance-validation.d.ts +25 -0
- package/dist/runtime/task-acceptance.d.ts +47 -0
- package/dist/runtime/time-to-safe-fix.d.ts +83 -0
- package/dist/runtime/tool-policy-file.d.ts +23 -0
- package/dist/runtime/tool-policy-paths.d.ts +5 -0
- package/dist/runtime/tool-policy.d.ts +6 -0
- package/dist/service-cli.js +19 -14504
- package/dist/{service-client-cli-yx56n0hc.js → service-client-cli-kns4mf57.js} +307 -248
- package/dist/verify-historical-migrations.js +3314 -1251
- package/dist/workspace/path-policy.d.ts +4 -0
- package/dist/zhx.js +3 -5
- package/docs/ACP.md +14 -2
- package/docs/ANTHROPIC.md +33 -0
- package/docs/CLI.md +132 -0
- package/docs/CLIENT_PROTOCOL.md +45 -0
- package/docs/CODE_RELEASE.md +93 -0
- package/docs/ENGINE_API.md +319 -0
- package/docs/EXTENSIBILITY.md +46 -3
- package/docs/FIRST_USE.md +6 -6
- package/docs/LOCAL_SERVICE.md +13 -0
- package/docs/MCP_STDIO.md +20 -0
- package/docs/README.md +6 -0
- package/docs/STABILITY.md +9 -1
- package/docs/SUPPORT_MATRIX.md +1 -1
- package/docs/TOOL_POLICY.md +81 -0
- package/docs/VERTEX.md +50 -0
- package/docs/adr/0002-isolated-mcp-stdio.md +135 -0
- package/docs/fixtures/mcp-stdio-contract-v1.json +189 -0
- package/docs/support-matrix.json +2 -0
- package/evaluations/representative-assembly-matrix.json +64 -1
- package/evaluations/task-acceptance-routes.json +7 -0
- package/examples/README.md +1 -1
- package/package.json +74 -12
- package/dist/cli-entry-64pdp3k5.js +0 -17
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeMcpHostSession,
|
|
3
|
+
combineMcpHostSessions,
|
|
4
|
+
createHarnessMcpTools,
|
|
5
|
+
issueMcpHostSession
|
|
6
|
+
} from "./index-t60rva31.js";
|
|
7
|
+
import {
|
|
8
|
+
collectSnapshotInventory,
|
|
9
|
+
readSnapshotFile
|
|
10
|
+
} from "./index-z0qxmkm2.js";
|
|
11
|
+
import {
|
|
12
|
+
readRegularFileNoFollow,
|
|
13
|
+
runPortableProcess
|
|
14
|
+
} from "./index-yb239ghk.js";
|
|
15
|
+
|
|
16
|
+
// src/execution/mcp-workspace-snapshot.ts
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
var snapshots = new WeakMap;
|
|
22
|
+
var hash = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
23
|
+
var safePath = (value) => value.length > 0 && !value.startsWith("/") && !value.includes("\\") && !/[\x00-\x1f\x7f]/.test(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
24
|
+
async function prepareMcpWorkspaceSnapshot(workspace, maxWorkspaceBytes) {
|
|
25
|
+
if (!Number.isSafeInteger(maxWorkspaceBytes) || maxWorkspaceBytes < 1024 || maxWorkspaceBytes > 512 * 1024 * 1024) {
|
|
26
|
+
throw new Error("Invalid MCP snapshot byte limit.");
|
|
27
|
+
}
|
|
28
|
+
const root = await mkdtemp(path.join(tmpdir(), "zhx-mcp-snapshot-"));
|
|
29
|
+
await chmod(root, 448);
|
|
30
|
+
const directory = path.join(root, "workspace");
|
|
31
|
+
try {
|
|
32
|
+
const inventory = await collectSnapshotInventory(workspace, maxWorkspaceBytes, undefined, 20000);
|
|
33
|
+
const entries = [...inventory.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
34
|
+
await mkdir(directory, { mode: 448 });
|
|
35
|
+
for (const expected of entries) {
|
|
36
|
+
if (!safePath(expected.path))
|
|
37
|
+
throw new Error("Invalid MCP snapshot path.");
|
|
38
|
+
const file = await readSnapshotFile(workspace, expected, undefined, true);
|
|
39
|
+
const target = path.join(directory, ...file.path.split("/"));
|
|
40
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 448 });
|
|
41
|
+
await writeFile(target, file.contents, { flag: "wx", mode: 256 });
|
|
42
|
+
}
|
|
43
|
+
const value = Object.freeze({
|
|
44
|
+
digest: hash(JSON.stringify({ version: 1, entries })),
|
|
45
|
+
workspace: hash(workspace.root),
|
|
46
|
+
files: entries.length,
|
|
47
|
+
bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0)
|
|
48
|
+
});
|
|
49
|
+
snapshots.set(value, { root, directory, entries, used: false });
|
|
50
|
+
return value;
|
|
51
|
+
} catch {
|
|
52
|
+
await rm(root, { recursive: true, force: true });
|
|
53
|
+
throw new Error("MCP workspace snapshot preparation failed.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function consumeMcpWorkspaceSnapshot(snapshot, expected, seed) {
|
|
57
|
+
const record = snapshots.get(snapshot);
|
|
58
|
+
if (!record || record.used)
|
|
59
|
+
throw new Error("Unknown or consumed MCP workspace snapshot.");
|
|
60
|
+
record.used = true;
|
|
61
|
+
try {
|
|
62
|
+
if (snapshot.digest !== expected.snapshotDigest || snapshot.workspace !== expected.workspace || snapshot.bytes > expected.maxWorkspaceBytes) {
|
|
63
|
+
throw new Error("MCP workspace snapshot identity changed.");
|
|
64
|
+
}
|
|
65
|
+
let delivered = 0;
|
|
66
|
+
async function* files() {
|
|
67
|
+
for (const entry of record.entries) {
|
|
68
|
+
const file = await readRegularFileNoFollow(path.join(record.directory, ...entry.path.split("/")), {
|
|
69
|
+
label: "MCP snapshot file",
|
|
70
|
+
maxBytes: entry.bytes,
|
|
71
|
+
requireSingleLink: true
|
|
72
|
+
});
|
|
73
|
+
if (file.contents.byteLength !== entry.bytes || hash(file.contents) !== entry.digest)
|
|
74
|
+
throw new Error("MCP private snapshot changed.");
|
|
75
|
+
delivered++;
|
|
76
|
+
yield { path: entry.path, contents: new Uint8Array(file.contents), executable: (entry.mode & 73) !== 0 };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await seed(files());
|
|
80
|
+
if (delivered !== record.entries.length)
|
|
81
|
+
throw new Error("MCP snapshot was not fully seeded.");
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error("MCP workspace snapshot handoff failed.");
|
|
84
|
+
} finally {
|
|
85
|
+
await rm(record.root, { recursive: true, force: true });
|
|
86
|
+
snapshots.delete(snapshot);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function discardMcpWorkspaceSnapshot(snapshot) {
|
|
90
|
+
const record = snapshots.get(snapshot);
|
|
91
|
+
if (!record)
|
|
92
|
+
return;
|
|
93
|
+
if (record.used)
|
|
94
|
+
throw new Error("MCP workspace snapshot handoff is active.");
|
|
95
|
+
record.used = true;
|
|
96
|
+
try {
|
|
97
|
+
await rm(record.root, { recursive: true, force: true });
|
|
98
|
+
} finally {
|
|
99
|
+
snapshots.delete(snapshot);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/persistence/mcp-resource-journal.ts
|
|
104
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
105
|
+
import { z } from "zod";
|
|
106
|
+
var ownerInstance = randomUUID();
|
|
107
|
+
var digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
108
|
+
var image = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
109
|
+
var uuid = z.string().uuid();
|
|
110
|
+
var phases = ["reserved", "provisioning", "ready", "closing", "closed", "cleanup_required"];
|
|
111
|
+
var mcpResourcePlanSchema = z.object({
|
|
112
|
+
schemaVersion: z.literal(1),
|
|
113
|
+
kind: z.literal("isolated-mcp-resources"),
|
|
114
|
+
leaseId: uuid,
|
|
115
|
+
scopeHash: digest,
|
|
116
|
+
hostHash: digest,
|
|
117
|
+
proposalHash: digest,
|
|
118
|
+
daemonId: z.string().min(1).max(128).regex(/^[A-Za-z0-9:_-]+$/),
|
|
119
|
+
ownerPid: z.number().int().positive(),
|
|
120
|
+
ownerInstance: uuid,
|
|
121
|
+
serverImageId: image,
|
|
122
|
+
provisionerImageId: image,
|
|
123
|
+
serverName: z.string(),
|
|
124
|
+
volumeName: z.string(),
|
|
125
|
+
seederName: z.string()
|
|
126
|
+
}).strict().refine((value) => value.serverName === `zhx-mcp-server-${value.leaseId}` && value.volumeName === `zhx-mcp-${value.leaseId}` && value.seederName === `zhx-mcp-${value.leaseId}-seed`);
|
|
127
|
+
var mcpResourceOutputSchema = z.object({ schemaVersion: z.literal(1), phase: z.enum(phases) }).strict();
|
|
128
|
+
var hash2 = (value) => createHash2("sha256").update(JSON.stringify(value)).digest("hex");
|
|
129
|
+
var transitions = {
|
|
130
|
+
reserved: ["provisioning", "closing", "cleanup_required"],
|
|
131
|
+
provisioning: ["ready", "closing", "cleanup_required"],
|
|
132
|
+
ready: ["closing", "cleanup_required"],
|
|
133
|
+
closing: ["closed", "cleanup_required"],
|
|
134
|
+
cleanup_required: ["closing"],
|
|
135
|
+
closed: []
|
|
136
|
+
};
|
|
137
|
+
async function reserveMcpResources(options) {
|
|
138
|
+
if (!options.store.claimToolExecution || !options.store.loadToolCall || !options.store.saveToolCall || !options.hostId || options.hostId.length > 256 || options.runId !== options.proposal.scope.session || options.scope.tenantId !== options.proposal.scope.tenant || options.scope.userId !== options.proposal.scope.principal) {
|
|
139
|
+
throw new Error("MCP resource journal binding is invalid.");
|
|
140
|
+
}
|
|
141
|
+
const scope = structuredClone(options.scope), runId = options.runId;
|
|
142
|
+
const leaseId = randomUUID();
|
|
143
|
+
const plan = Object.freeze(mcpResourcePlanSchema.parse({
|
|
144
|
+
schemaVersion: 1,
|
|
145
|
+
kind: "isolated-mcp-resources",
|
|
146
|
+
leaseId,
|
|
147
|
+
scopeHash: hash2({ runId, scope }),
|
|
148
|
+
hostHash: hash2(options.hostId),
|
|
149
|
+
proposalHash: hash2(options.proposal),
|
|
150
|
+
daemonId: options.daemonId,
|
|
151
|
+
ownerPid: process.pid,
|
|
152
|
+
ownerInstance,
|
|
153
|
+
serverImageId: options.serverImageId,
|
|
154
|
+
provisionerImageId: options.provisionerImageId,
|
|
155
|
+
serverName: `zhx-mcp-server-${leaseId}`,
|
|
156
|
+
volumeName: `zhx-mcp-${leaseId}`,
|
|
157
|
+
seederName: `zhx-mcp-${leaseId}-seed`
|
|
158
|
+
}));
|
|
159
|
+
const id = `mcp_resources_${leaseId}`;
|
|
160
|
+
const claim = await options.store.claimToolExecution({
|
|
161
|
+
runId,
|
|
162
|
+
scope,
|
|
163
|
+
toolCallId: id,
|
|
164
|
+
toolName: "isolated_mcp_resources",
|
|
165
|
+
idempotencyKey: `${runId}:${id}`,
|
|
166
|
+
status: "pending",
|
|
167
|
+
revision: 0,
|
|
168
|
+
input: plan,
|
|
169
|
+
output: { schemaVersion: 1, phase: "reserved" },
|
|
170
|
+
updatedAt: Date.now()
|
|
171
|
+
});
|
|
172
|
+
if (!claim.claimed)
|
|
173
|
+
throw new Error("MCP resource reservation already exists.");
|
|
174
|
+
const load = options.store.loadToolCall.bind(options.store), save = options.store.saveToolCall.bind(options.store);
|
|
175
|
+
let queue = Promise.resolve();
|
|
176
|
+
const advance = async (next) => {
|
|
177
|
+
const current = await load(runId, id, scope);
|
|
178
|
+
if (!current || current.toolName !== "isolated_mcp_resources" || JSON.stringify(mcpResourcePlanSchema.parse(current.input)) !== JSON.stringify(plan)) {
|
|
179
|
+
throw new Error("MCP resource journal identity changed.");
|
|
180
|
+
}
|
|
181
|
+
const phase = mcpResourceOutputSchema.parse(current.output).phase;
|
|
182
|
+
if (phase === next)
|
|
183
|
+
return;
|
|
184
|
+
if (!transitions[phase].includes(next))
|
|
185
|
+
throw new Error("Invalid MCP resource lifecycle transition.");
|
|
186
|
+
const { error: _previousError, ...base } = current;
|
|
187
|
+
const entry = {
|
|
188
|
+
...base,
|
|
189
|
+
status: next === "closed" ? "completed" : next === "cleanup_required" ? "failed" : "running",
|
|
190
|
+
output: { schemaVersion: 1, phase: next },
|
|
191
|
+
updatedAt: Date.now(),
|
|
192
|
+
...next === "closed" ? { completedAt: Date.now() } : {},
|
|
193
|
+
...next === "cleanup_required" ? { error: { message: "MCP_RESOURCE_CLEANUP_REQUIRED" } } : {}
|
|
194
|
+
};
|
|
195
|
+
await save(entry, { expectedRevision: current.revision });
|
|
196
|
+
};
|
|
197
|
+
return Object.freeze({
|
|
198
|
+
plan,
|
|
199
|
+
labels: Object.freeze({
|
|
200
|
+
"com.zhivex.harness.mcp": "v1",
|
|
201
|
+
"com.zhivex.harness.owner-pid": String(plan.ownerPid),
|
|
202
|
+
"com.zhivex.harness.mcp-lease": leaseId,
|
|
203
|
+
"com.zhivex.harness.mcp-scope": plan.scopeHash,
|
|
204
|
+
"com.zhivex.harness.mcp-host": plan.hostHash,
|
|
205
|
+
"com.zhivex.harness.mcp-owner": ownerInstance
|
|
206
|
+
}),
|
|
207
|
+
advance(phase) {
|
|
208
|
+
const result = queue.then(() => advance(phase));
|
|
209
|
+
queue = result.catch(() => {});
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function mcpResourceLabels(plan) {
|
|
215
|
+
return Object.freeze({
|
|
216
|
+
"com.zhivex.harness.mcp": "v1",
|
|
217
|
+
"com.zhivex.harness.owner-pid": String(plan.ownerPid),
|
|
218
|
+
"com.zhivex.harness.mcp-lease": plan.leaseId,
|
|
219
|
+
"com.zhivex.harness.mcp-scope": plan.scopeHash,
|
|
220
|
+
"com.zhivex.harness.mcp-host": plan.hostHash,
|
|
221
|
+
"com.zhivex.harness.mcp-owner": plan.ownerInstance
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function matchesMcpResourceScope(plan, runId, scope, hostId) {
|
|
225
|
+
return !!hostId && plan.hostHash === hash2(hostId) && plan.scopeHash === hash2({ runId, scope });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/execution/mcp-resource-recovery.ts
|
|
229
|
+
function ownerIsDead(pid) {
|
|
230
|
+
try {
|
|
231
|
+
process.kill(pid, 0);
|
|
232
|
+
return false;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return error.code === "ESRCH";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function labelsMatch(actual, plan) {
|
|
238
|
+
if (!actual || typeof actual !== "object")
|
|
239
|
+
return false;
|
|
240
|
+
return Object.entries(mcpResourceLabels(plan)).every(([key, value]) => actual[key] === value);
|
|
241
|
+
}
|
|
242
|
+
async function recoverMcpResources(options) {
|
|
243
|
+
const { store, cli, runId, hostId } = options;
|
|
244
|
+
const scope = structuredClone(options.scope);
|
|
245
|
+
if (!store.listToolCalls || !store.saveToolCall || !hostId)
|
|
246
|
+
throw new Error("MCP recovery requires a durable host scope.");
|
|
247
|
+
const daemon = await cli(["info", "--format", "{{.ID}}"]);
|
|
248
|
+
const rows = await store.listToolCalls(runId, scope);
|
|
249
|
+
let closed = 0, deferred = 0;
|
|
250
|
+
for (const row of rows.filter((entry) => entry.toolName === "isolated_mcp_resources")) {
|
|
251
|
+
const plan = mcpResourcePlanSchema.parse(row.input);
|
|
252
|
+
if (mcpResourceOutputSchema.parse(row.output).phase === "closed")
|
|
253
|
+
continue;
|
|
254
|
+
if (row.toolCallId !== `mcp_resources_${plan.leaseId}` || !matchesMcpResourceScope(plan, runId, scope, hostId) || plan.daemonId !== daemon || !ownerIsDead(plan.ownerPid)) {
|
|
255
|
+
deferred++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
let current = row;
|
|
259
|
+
const record = async (phase) => {
|
|
260
|
+
const { error: _oldError, ...base } = current;
|
|
261
|
+
const entry = {
|
|
262
|
+
...base,
|
|
263
|
+
output: { schemaVersion: 1, phase },
|
|
264
|
+
updatedAt: Date.now(),
|
|
265
|
+
status: phase === "closed" ? "completed" : phase === "cleanup_required" ? "failed" : "running",
|
|
266
|
+
...phase === "closed" ? { completedAt: Date.now() } : {},
|
|
267
|
+
...phase === "cleanup_required" ? { error: { message: "MCP_RESOURCE_CLEANUP_REQUIRED" } } : {}
|
|
268
|
+
};
|
|
269
|
+
current = await store.saveToolCall(entry, { expectedRevision: current.revision });
|
|
270
|
+
};
|
|
271
|
+
const inspectContainer = async (name, image2) => {
|
|
272
|
+
const ids = await cli(["ps", "--all", "--quiet", "--no-trunc", "--filter", `name=^/${name}$`]);
|
|
273
|
+
if (!ids)
|
|
274
|
+
return;
|
|
275
|
+
if (!/^[a-f0-9]{64}$/.test(ids))
|
|
276
|
+
throw new Error("MCP recovery container identity mismatch.");
|
|
277
|
+
const values = JSON.parse(await cli(["inspect", ids]));
|
|
278
|
+
const value = values[0];
|
|
279
|
+
if (values.length !== 1 || value?.Id !== ids || value?.Name !== `/${name}` || value?.Image !== image2 || !labelsMatch(value?.Config?.Labels, plan)) {
|
|
280
|
+
throw new Error("MCP recovery container ownership mismatch.");
|
|
281
|
+
}
|
|
282
|
+
return ids;
|
|
283
|
+
};
|
|
284
|
+
const inspectVolume = async () => {
|
|
285
|
+
const names = (await cli(["volume", "ls", "--quiet", "--filter", `name=${plan.volumeName}`])).split(`
|
|
286
|
+
`);
|
|
287
|
+
if (!names.includes(plan.volumeName))
|
|
288
|
+
return false;
|
|
289
|
+
const values = JSON.parse(await cli(["volume", "inspect", plan.volumeName]));
|
|
290
|
+
if (values.length !== 1 || values[0]?.Name !== plan.volumeName || !labelsMatch(values[0]?.Labels, plan))
|
|
291
|
+
throw new Error("MCP recovery volume ownership mismatch.");
|
|
292
|
+
return true;
|
|
293
|
+
};
|
|
294
|
+
await record("closing");
|
|
295
|
+
try {
|
|
296
|
+
const server = await inspectContainer(plan.serverName, plan.serverImageId);
|
|
297
|
+
const seeder = await inspectContainer(plan.seederName, plan.provisionerImageId);
|
|
298
|
+
const volume = await inspectVolume();
|
|
299
|
+
if (await cli(["info", "--format", "{{.ID}}"]) !== daemon || !ownerIsDead(plan.ownerPid))
|
|
300
|
+
throw new Error("MCP recovery owner changed.");
|
|
301
|
+
for (const id of [server, seeder])
|
|
302
|
+
if (id)
|
|
303
|
+
await cli(["rm", "--force", id]);
|
|
304
|
+
if (volume) {
|
|
305
|
+
await inspectVolume();
|
|
306
|
+
await cli(["volume", "rm", plan.volumeName]);
|
|
307
|
+
}
|
|
308
|
+
if (await inspectContainer(plan.serverName, plan.serverImageId) || await inspectContainer(plan.seederName, plan.provisionerImageId) || await inspectVolume()) {
|
|
309
|
+
throw new Error("MCP recovery absence not confirmed.");
|
|
310
|
+
}
|
|
311
|
+
await record("closed");
|
|
312
|
+
closed++;
|
|
313
|
+
} catch {
|
|
314
|
+
await record("cleanup_required");
|
|
315
|
+
throw new Error("MCP resource recovery could not confirm cleanup.");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { closed, deferred };
|
|
319
|
+
}
|
|
320
|
+
async function recoverDockerMcpResources(options) {
|
|
321
|
+
const env = Object.fromEntries(["HOME", "PATH", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "XDG_RUNTIME_DIR"].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
322
|
+
return recoverMcpResources({ ...options, cli: async (args) => {
|
|
323
|
+
const result = await runPortableProcess(["docker", ...args], { env, timeoutMs: 1e4, maxOutputCharacters: 1024 * 1024 });
|
|
324
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
325
|
+
throw new Error("MCP recovery Docker operation failed.");
|
|
326
|
+
return result.stdout.trim();
|
|
327
|
+
} });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/integrations/mcp-stdio-admission.ts
|
|
331
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
332
|
+
import { z as z2 } from "zod";
|
|
333
|
+
var name = z2.string().min(1).max(128).regex(/^[A-Za-z][A-Za-z0-9_-]*$/);
|
|
334
|
+
var digest2 = z2.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
335
|
+
var safeString = z2.string().max(4096).refine((value) => !/[\x00-\x1f\x7f]/.test(value));
|
|
336
|
+
var relativeDirectory = z2.string().max(1024).refine((value) => value === "." || value.length > 0 && !value.startsWith("/") && !value.includes("\\") && value.split("/").every((part) => part !== "" && part !== "." && part !== "..") && !/[\x00-\x1f\x7f]/.test(value));
|
|
337
|
+
var proposalSchema = z2.object({
|
|
338
|
+
schemaVersion: z2.literal(1),
|
|
339
|
+
serverId: name.max(64),
|
|
340
|
+
boundary: z2.literal("oci"),
|
|
341
|
+
image: z2.string().max(512).regex(/^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$/),
|
|
342
|
+
executable: safeString.refine((value) => value.startsWith("/") && !value.includes("\\") && value.split("/").slice(1).every((part) => part !== "" && part !== "." && part !== "..")),
|
|
343
|
+
args: z2.array(safeString).max(128),
|
|
344
|
+
protocolVersion: z2.literal("2025-11-25"),
|
|
345
|
+
workingDirectory: relativeDirectory,
|
|
346
|
+
snapshotDigest: digest2,
|
|
347
|
+
scope: z2.object({ principal: name, tenant: name, session: name, workspace: digest2 }).strict(),
|
|
348
|
+
includeTools: z2.array(z2.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_.:/-]*$/)).min(1).max(200),
|
|
349
|
+
permissions: z2.array(z2.enum(["read", "write"])).min(1).max(2),
|
|
350
|
+
environment: z2.record(z2.string().regex(/^ZHIVEX_MCP_[A-Z0-9_]{1,96}$/), safeString).refine((value) => Object.keys(value).length <= 64 && new TextEncoder().encode(JSON.stringify(value)).byteLength <= 32 * 1024).default({}),
|
|
351
|
+
secretReferences: z2.record(z2.string().regex(/^ZHIVEX_MCP_[A-Z0-9_]{1,96}$/), name).refine((value) => Object.keys(value).length <= 32).default({}),
|
|
352
|
+
limits: z2.object({
|
|
353
|
+
sessionMs: z2.number().int().min(100).max(600000),
|
|
354
|
+
callMs: z2.number().int().min(100).max(60000),
|
|
355
|
+
memoryMb: z2.number().int().min(16).max(2048),
|
|
356
|
+
maxCpus: z2.number().min(0.1).max(8).multipleOf(0.1),
|
|
357
|
+
maxPids: z2.number().int().min(1).max(128),
|
|
358
|
+
maxWorkspaceBytes: z2.number().int().min(1024).max(512 * 1024 * 1024),
|
|
359
|
+
maxFileWriteBytes: z2.number().int().min(1).max(64 * 1024 * 1024),
|
|
360
|
+
tmpfsMb: z2.number().int().min(1).max(256),
|
|
361
|
+
maxOutputBytes: z2.number().int().min(1024).max(4 * 1024 * 1024)
|
|
362
|
+
}).strict()
|
|
363
|
+
}).strict().superRefine((value, context) => {
|
|
364
|
+
if (Object.keys(value.environment).some((key) => (key in value.secretReferences))) {
|
|
365
|
+
context.addIssue({ code: "custom", message: "Environment and secret names overlap" });
|
|
366
|
+
}
|
|
367
|
+
if (value.limits.callMs > value.limits.sessionMs)
|
|
368
|
+
context.addIssue({ code: "custom", message: "Call limit exceeds session limit" });
|
|
369
|
+
if (value.limits.maxFileWriteBytes > value.limits.maxWorkspaceBytes)
|
|
370
|
+
context.addIssue({ code: "custom", message: "Write limit exceeds workspace limit" });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
class McpStdioAdmissionError extends Error {
|
|
374
|
+
constructor(message) {
|
|
375
|
+
super(message);
|
|
376
|
+
this.name = "McpStdioAdmissionError";
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
var canonical = (value) => {
|
|
380
|
+
if (Array.isArray(value))
|
|
381
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
382
|
+
if (value !== null && typeof value === "object")
|
|
383
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
|
384
|
+
return JSON.stringify(value);
|
|
385
|
+
};
|
|
386
|
+
var fingerprint = (proposal) => createHash3("sha256").update(canonical(proposal)).digest("hex");
|
|
387
|
+
function normalize(input) {
|
|
388
|
+
const parsed = proposalSchema.safeParse(input);
|
|
389
|
+
if (!parsed.success)
|
|
390
|
+
throw new McpStdioAdmissionError("Invalid isolated MCP launch proposal.");
|
|
391
|
+
return parsed.data;
|
|
392
|
+
}
|
|
393
|
+
function freeze(value) {
|
|
394
|
+
if (value !== null && typeof value === "object") {
|
|
395
|
+
for (const child of Object.values(value))
|
|
396
|
+
freeze(child);
|
|
397
|
+
Object.freeze(value);
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
function createMcpStdioAdmissionAuthority(options) {
|
|
402
|
+
const limits = proposalSchema.shape.limits.parse(options.maximumLimits);
|
|
403
|
+
if (!options.policyVersion || typeof options.authorize !== "function")
|
|
404
|
+
throw new McpStdioAdmissionError("Host admission policy is required.");
|
|
405
|
+
const policyVersion = options.policyVersion;
|
|
406
|
+
const authorize = options.authorize;
|
|
407
|
+
const ttl = options.receiptTtlMs ?? 30000;
|
|
408
|
+
const timeoutMs = options.reviewTimeoutMs ?? 30000;
|
|
409
|
+
if (![ttl, timeoutMs].every((value) => Number.isSafeInteger(value) && value >= 1 && value <= 60000)) {
|
|
410
|
+
throw new McpStdioAdmissionError("Invalid admission deadline.");
|
|
411
|
+
}
|
|
412
|
+
const now = options.now ?? (() => performance.now());
|
|
413
|
+
const receipts = new WeakMap;
|
|
414
|
+
const checked = (input) => {
|
|
415
|
+
const proposal = normalize(input);
|
|
416
|
+
for (const key of Object.keys(limits)) {
|
|
417
|
+
if (proposal.limits[key] > limits[key])
|
|
418
|
+
throw new McpStdioAdmissionError("MCP launch exceeds host policy limits.");
|
|
419
|
+
}
|
|
420
|
+
return proposal;
|
|
421
|
+
};
|
|
422
|
+
return {
|
|
423
|
+
async admit(input) {
|
|
424
|
+
const proposal = freeze(checked(input));
|
|
425
|
+
let timer;
|
|
426
|
+
let approved;
|
|
427
|
+
try {
|
|
428
|
+
approved = await Promise.race([
|
|
429
|
+
Promise.resolve().then(() => authorize(proposal)),
|
|
430
|
+
new Promise((_, reject) => {
|
|
431
|
+
timer = setTimeout(() => reject(new McpStdioAdmissionError("MCP host admission timed out.")), timeoutMs);
|
|
432
|
+
})
|
|
433
|
+
]);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (error instanceof McpStdioAdmissionError)
|
|
436
|
+
throw error;
|
|
437
|
+
throw new McpStdioAdmissionError("MCP host admission failed.");
|
|
438
|
+
} finally {
|
|
439
|
+
if (timer !== undefined)
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
}
|
|
442
|
+
if (approved !== true)
|
|
443
|
+
throw new McpStdioAdmissionError("MCP launch was not admitted by the host.");
|
|
444
|
+
const receipt = Object.freeze(Object.create(null));
|
|
445
|
+
receipts.set(receipt, { fingerprint: fingerprint(proposal), expires: now() + ttl, policy: policyVersion });
|
|
446
|
+
return receipt;
|
|
447
|
+
},
|
|
448
|
+
consume(receipt, input) {
|
|
449
|
+
const stored = receipt && typeof receipt === "object" ? receipts.get(receipt) : undefined;
|
|
450
|
+
if (!stored)
|
|
451
|
+
throw new McpStdioAdmissionError("Unknown or already consumed MCP admission.");
|
|
452
|
+
receipts.delete(receipt);
|
|
453
|
+
const proposal = checked(input);
|
|
454
|
+
if (stored.expires <= now() || stored.policy !== policyVersion || stored.fingerprint !== fingerprint(proposal)) {
|
|
455
|
+
throw new McpStdioAdmissionError("MCP admission expired or launch identity changed.");
|
|
456
|
+
}
|
|
457
|
+
return freeze(proposal);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/execution/mcp-oci-server.ts
|
|
463
|
+
import { constants } from "node:fs";
|
|
464
|
+
import { access, mkdtemp as mkdtemp2, realpath, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
465
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
466
|
+
import path2 from "node:path";
|
|
467
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
468
|
+
|
|
469
|
+
// src/execution/mcp-oci-channel.ts
|
|
470
|
+
import { spawn } from "node:child_process";
|
|
471
|
+
async function removeDockerMcpBoundary(cli, id) {
|
|
472
|
+
await cli(["stop", "--time", "2", id]).catch(() => {});
|
|
473
|
+
await cli(["rm", "--force", id]).catch(() => {});
|
|
474
|
+
const remaining = (await cli(["ps", "--all", "--no-trunc", "--quiet", "--filter", `id=${id}`])).trim();
|
|
475
|
+
if (remaining)
|
|
476
|
+
throw new Error("Docker MCP boundary removal was not confirmed.");
|
|
477
|
+
}
|
|
478
|
+
async function openDockerMcpChannel(options) {
|
|
479
|
+
if (!/^([a-f0-9]{64})$/.test(options.containerId) || !/^sha256:[a-f0-9]{64}$/.test(options.imageId))
|
|
480
|
+
throw new Error("Invalid isolated MCP container identity.");
|
|
481
|
+
for (const value of [options.sessionMs, options.maxOutputBytes]) {
|
|
482
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
483
|
+
throw new Error("Invalid isolated MCP channel limit.");
|
|
484
|
+
}
|
|
485
|
+
const id = options.containerId;
|
|
486
|
+
const env = Object.fromEntries(["HOME", "PATH", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "XDG_RUNTIME_DIR"].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
487
|
+
const cli = async (args) => {
|
|
488
|
+
const result = await runPortableProcess(["docker", ...args], { env, timeoutMs: 1e4, maxOutputCharacters: 64 * 1024 });
|
|
489
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
490
|
+
throw new Error("Docker MCP boundary operation failed.");
|
|
491
|
+
return result.stdout;
|
|
492
|
+
};
|
|
493
|
+
const inspected = JSON.parse(await cli(["inspect", id]))[0];
|
|
494
|
+
const config = inspected?.HostConfig;
|
|
495
|
+
if (inspected?.Id !== id || inspected?.Image !== options.imageId || inspected?.State?.Running !== false || inspected?.Config?.Labels?.["com.zhivex.harness.mcp"] !== "v1" || inspected?.Config?.Labels?.["com.zhivex.harness.owner-pid"] !== String(process.pid) || inspected?.Config?.Tty !== false || inspected?.Config?.OpenStdin !== true || config?.NetworkMode !== "none" || config?.ReadonlyRootfs !== true || !config?.CapDrop?.includes("ALL") || !config?.SecurityOpt?.includes("no-new-privileges") || !(config?.Memory > 0) || !(config?.PidsLimit > 0) || !(config?.NanoCpus > 0) || !/^[1-9][0-9]*:[1-9][0-9]*$/.test(inspected?.Config?.User ?? "") || config?.Privileged || (config?.CapAdd?.length ?? 0) > 0 || (config?.Devices?.length ?? 0) > 0 || (config?.DeviceRequests?.length ?? 0) > 0 || (config?.VolumesFrom?.length ?? 0) > 0 || config?.PidMode === "host" || config?.IpcMode === "host" || (inspected?.Mounts ?? []).some((mount) => mount.Type === "bind")) {
|
|
496
|
+
throw new Error("Docker MCP boundary is not attested.");
|
|
497
|
+
}
|
|
498
|
+
options.attest?.(inspected);
|
|
499
|
+
const child = spawn("docker", ["start", "--attach", "--interactive", id], { stdio: ["pipe", "pipe", "pipe"], env });
|
|
500
|
+
let closed;
|
|
501
|
+
let total = 0;
|
|
502
|
+
let failure;
|
|
503
|
+
const exit = new Promise((resolve) => {
|
|
504
|
+
child.once("exit", () => resolve());
|
|
505
|
+
child.once("error", () => resolve());
|
|
506
|
+
});
|
|
507
|
+
const close = () => closed ??= (async () => {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
child.stdin.destroy();
|
|
510
|
+
try {
|
|
511
|
+
await removeDockerMcpBoundary(cli, id);
|
|
512
|
+
} finally {
|
|
513
|
+
child.kill("SIGTERM");
|
|
514
|
+
let deadline;
|
|
515
|
+
try {
|
|
516
|
+
await Promise.race([exit, new Promise((_, reject) => {
|
|
517
|
+
deadline = setTimeout(() => {
|
|
518
|
+
child.kill("SIGKILL");
|
|
519
|
+
reject(new Error("Docker MCP attachment did not exit."));
|
|
520
|
+
}, 1000);
|
|
521
|
+
})]);
|
|
522
|
+
} finally {
|
|
523
|
+
if (deadline)
|
|
524
|
+
clearTimeout(deadline);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
})();
|
|
528
|
+
const stop = (message) => {
|
|
529
|
+
failure ??= new Error(message);
|
|
530
|
+
close().catch(() => {});
|
|
531
|
+
};
|
|
532
|
+
const timer = setTimeout(() => stop("Docker MCP session deadline exceeded."), options.sessionMs);
|
|
533
|
+
child.stdin.on("error", () => stop("Docker MCP input failed."));
|
|
534
|
+
child.stderr.on("error", () => stop("Docker MCP diagnostic stream failed."));
|
|
535
|
+
child.on("error", () => stop("Docker MCP attachment failed."));
|
|
536
|
+
child.stderr.on("data", (chunk) => {
|
|
537
|
+
total += chunk.byteLength;
|
|
538
|
+
if (total > options.maxOutputBytes)
|
|
539
|
+
stop("Docker MCP output limit exceeded.");
|
|
540
|
+
});
|
|
541
|
+
return {
|
|
542
|
+
stdout: { async* [Symbol.asyncIterator]() {
|
|
543
|
+
for await (const chunk of child.stdout) {
|
|
544
|
+
total += chunk.byteLength;
|
|
545
|
+
if (total > options.maxOutputBytes) {
|
|
546
|
+
stop("Docker MCP output limit exceeded.");
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
yield new Uint8Array(chunk);
|
|
550
|
+
}
|
|
551
|
+
if (failure)
|
|
552
|
+
throw failure;
|
|
553
|
+
} },
|
|
554
|
+
write(frame) {
|
|
555
|
+
if (closed || failure || child.stdin.destroyed)
|
|
556
|
+
return Promise.reject(new Error("Docker MCP channel is closed."));
|
|
557
|
+
return new Promise((resolve, reject) => child.stdin.write(frame, (error) => error ? reject(new Error("Docker MCP write failed.")) : resolve()));
|
|
558
|
+
},
|
|
559
|
+
close
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/execution/mcp-oci-workspace.ts
|
|
564
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
565
|
+
|
|
566
|
+
// src/integrations/mcp-stdio-framing.ts
|
|
567
|
+
class McpStdioProtocolError extends Error {
|
|
568
|
+
constructor(message) {
|
|
569
|
+
super(message);
|
|
570
|
+
this.name = "McpStdioProtocolError";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
var validId = (id) => typeof id === "string" ? id.length <= 256 : typeof id === "number" && Number.isSafeInteger(id);
|
|
574
|
+
function parseMessage(bytes) {
|
|
575
|
+
let value;
|
|
576
|
+
try {
|
|
577
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
578
|
+
} catch {
|
|
579
|
+
throw new McpStdioProtocolError("Invalid MCP stdio UTF-8 or JSON frame.");
|
|
580
|
+
}
|
|
581
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
582
|
+
throw new McpStdioProtocolError("Invalid MCP stdio message envelope.");
|
|
583
|
+
}
|
|
584
|
+
const message = value;
|
|
585
|
+
const has = (key) => Object.hasOwn(message, key);
|
|
586
|
+
const request = typeof message.method === "string" && message.method.length > 0 && message.method.length <= 256;
|
|
587
|
+
const response = !has("method") && has("id") && has("result") !== has("error");
|
|
588
|
+
if (message.jsonrpc !== "2.0" || !request && !response || request && (has("result") || has("error")) || has("id") && !validId(message.id) || has("params") && (!message.params || typeof message.params !== "object") || response && has("params")) {
|
|
589
|
+
throw new McpStdioProtocolError("Invalid MCP stdio message envelope.");
|
|
590
|
+
}
|
|
591
|
+
if (has("error")) {
|
|
592
|
+
const error = message.error;
|
|
593
|
+
if (!error || typeof error !== "object" || Array.isArray(error) || !Number.isSafeInteger(error.code) || typeof error.message !== "string") {
|
|
594
|
+
throw new McpStdioProtocolError("Invalid MCP stdio error envelope.");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return message;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
class McpStdioFrameDecoder {
|
|
601
|
+
maxFrameBytes;
|
|
602
|
+
onMessage;
|
|
603
|
+
frame;
|
|
604
|
+
size = 0;
|
|
605
|
+
ended = false;
|
|
606
|
+
constructor(maxFrameBytes, onMessage) {
|
|
607
|
+
this.maxFrameBytes = maxFrameBytes;
|
|
608
|
+
this.onMessage = onMessage;
|
|
609
|
+
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes < 1 || maxFrameBytes > 1024 * 1024) {
|
|
610
|
+
throw new RangeError("MCP stdio frame limit must be between 1 and 1048576 bytes.");
|
|
611
|
+
}
|
|
612
|
+
this.frame = new Uint8Array(maxFrameBytes);
|
|
613
|
+
}
|
|
614
|
+
push(chunk) {
|
|
615
|
+
if (this.ended)
|
|
616
|
+
throw new McpStdioProtocolError("MCP stdio decoder is closed.");
|
|
617
|
+
try {
|
|
618
|
+
let offset = 0;
|
|
619
|
+
while (offset < chunk.length) {
|
|
620
|
+
const newline = chunk.indexOf(10, offset);
|
|
621
|
+
const end = newline === -1 ? chunk.length : newline;
|
|
622
|
+
const length = end - offset;
|
|
623
|
+
if (this.size + length > this.maxFrameBytes)
|
|
624
|
+
throw new McpStdioProtocolError("MCP stdio frame exceeds byte limit.");
|
|
625
|
+
if (length) {
|
|
626
|
+
this.frame.set(chunk.subarray(offset, end), this.size);
|
|
627
|
+
this.size += length;
|
|
628
|
+
}
|
|
629
|
+
if (newline === -1)
|
|
630
|
+
return;
|
|
631
|
+
const message = parseMessage(this.frame.subarray(0, this.size));
|
|
632
|
+
this.size = 0;
|
|
633
|
+
this.onMessage(message);
|
|
634
|
+
offset = newline + 1;
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
this.ended = true;
|
|
638
|
+
this.frame.fill(0);
|
|
639
|
+
this.size = 0;
|
|
640
|
+
throw error;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
finish() {
|
|
644
|
+
if (this.ended)
|
|
645
|
+
return;
|
|
646
|
+
this.ended = true;
|
|
647
|
+
const incomplete = this.size > 0;
|
|
648
|
+
this.frame.fill(0);
|
|
649
|
+
this.size = 0;
|
|
650
|
+
if (incomplete)
|
|
651
|
+
throw new McpStdioProtocolError("MCP stdio ended with an incomplete frame.");
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/integrations/mcp-stdio-client.ts
|
|
656
|
+
class McpStdioClientError extends Error {
|
|
657
|
+
code;
|
|
658
|
+
outcomeUnknown;
|
|
659
|
+
constructor(code, message, outcomeUnknown = false) {
|
|
660
|
+
super(message);
|
|
661
|
+
this.code = code;
|
|
662
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
663
|
+
this.name = "McpStdioClientError";
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
class IsolatedMcpStdioClient {
|
|
668
|
+
channel;
|
|
669
|
+
limits;
|
|
670
|
+
nextId = 1;
|
|
671
|
+
pending;
|
|
672
|
+
stopped = false;
|
|
673
|
+
ready = false;
|
|
674
|
+
initializing = false;
|
|
675
|
+
closing;
|
|
676
|
+
decoder;
|
|
677
|
+
reading;
|
|
678
|
+
constructor(channel, limits) {
|
|
679
|
+
this.channel = channel;
|
|
680
|
+
this.limits = limits;
|
|
681
|
+
for (const value of [limits.callMs, limits.initializeMs, limits.sessionMs, limits.closeMs ?? 2000]) {
|
|
682
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 600000)
|
|
683
|
+
throw new RangeError("Invalid MCP deadline.");
|
|
684
|
+
}
|
|
685
|
+
this.decoder = new McpStdioFrameDecoder(limits.maxFrameBytes, (message) => this.receive(message));
|
|
686
|
+
this.reading = this.read();
|
|
687
|
+
this.sessionTimer = setTimeout(() => this.fail("timeout", "MCP session deadline exceeded."), limits.sessionMs);
|
|
688
|
+
}
|
|
689
|
+
sessionTimer;
|
|
690
|
+
receive(message) {
|
|
691
|
+
if (this.stopped)
|
|
692
|
+
return;
|
|
693
|
+
if ("method" in message) {
|
|
694
|
+
throw new McpStdioProtocolError("Unsolicited MCP server message.");
|
|
695
|
+
}
|
|
696
|
+
if (!this.pending || message.id !== this.pending.id)
|
|
697
|
+
throw new McpStdioProtocolError("Unknown or duplicate MCP response ID.");
|
|
698
|
+
const pending = this.pending;
|
|
699
|
+
this.pending = undefined;
|
|
700
|
+
if ("error" in message)
|
|
701
|
+
pending.reject(new McpStdioClientError("remote", "MCP server rejected the request.", pending.effectful));
|
|
702
|
+
else
|
|
703
|
+
pending.resolve(message.result);
|
|
704
|
+
}
|
|
705
|
+
async read() {
|
|
706
|
+
try {
|
|
707
|
+
for await (const chunk of this.channel.stdout) {
|
|
708
|
+
if (this.stopped)
|
|
709
|
+
break;
|
|
710
|
+
try {
|
|
711
|
+
this.decoder.push(chunk);
|
|
712
|
+
} catch {
|
|
713
|
+
this.fail("protocol", "MCP server output is invalid.");
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (!this.stopped) {
|
|
718
|
+
this.decoder.finish();
|
|
719
|
+
this.fail("closed", "MCP server closed its output.");
|
|
720
|
+
}
|
|
721
|
+
} catch {
|
|
722
|
+
if (!this.stopped)
|
|
723
|
+
this.fail("protocol", "MCP server output is invalid.");
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
fail(code, message) {
|
|
727
|
+
if (this.stopped)
|
|
728
|
+
return;
|
|
729
|
+
this.stopped = true;
|
|
730
|
+
clearTimeout(this.sessionTimer);
|
|
731
|
+
const pending = this.pending;
|
|
732
|
+
this.pending = undefined;
|
|
733
|
+
pending?.reject(new McpStdioClientError(code, message, pending.effectful));
|
|
734
|
+
this.closing = this.boundedCleanup(Promise.resolve().then(() => this.channel.close())).catch(() => {
|
|
735
|
+
throw new McpStdioClientError("cleanup_required", "MCP isolated boundary cleanup was not confirmed.");
|
|
736
|
+
});
|
|
737
|
+
this.closing.catch(() => {});
|
|
738
|
+
}
|
|
739
|
+
async request(method, params, timeout, options, effectful = false) {
|
|
740
|
+
if (this.stopped)
|
|
741
|
+
throw new McpStdioClientError("closed", "MCP client is closed.");
|
|
742
|
+
if (this.pending)
|
|
743
|
+
throw new McpStdioClientError("busy", "MCP client already has an active request.");
|
|
744
|
+
if (options?.abortSignal?.aborted)
|
|
745
|
+
throw new McpStdioClientError("cancelled", "MCP request cancelled before dispatch.");
|
|
746
|
+
if (options?.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1))
|
|
747
|
+
throw new RangeError("Invalid MCP request deadline.");
|
|
748
|
+
const frame = new TextEncoder().encode(JSON.stringify({ jsonrpc: "2.0", id: this.nextId, method, params }) + `
|
|
749
|
+
`);
|
|
750
|
+
if (frame.length - 1 > this.limits.maxFrameBytes)
|
|
751
|
+
throw new McpStdioClientError("protocol", "MCP request exceeds frame limit.");
|
|
752
|
+
let timer;
|
|
753
|
+
const abort = () => this.fail("cancelled", "MCP request cancelled after dispatch.");
|
|
754
|
+
try {
|
|
755
|
+
const result = await new Promise((resolve, reject) => {
|
|
756
|
+
this.pending = { id: this.nextId++, effectful, resolve, reject };
|
|
757
|
+
timer = setTimeout(() => this.fail("timeout", "MCP request deadline exceeded."), Math.min(timeout, options?.timeoutMs ?? timeout));
|
|
758
|
+
options?.abortSignal?.addEventListener("abort", abort, { once: true });
|
|
759
|
+
Promise.resolve().then(() => {
|
|
760
|
+
if (!this.stopped)
|
|
761
|
+
return this.channel.write(frame);
|
|
762
|
+
}).catch(() => this.fail("closed", "MCP request transport failed."));
|
|
763
|
+
});
|
|
764
|
+
if (this.stopped)
|
|
765
|
+
throw new McpStdioClientError("protocol", "MCP client stopped before response acceptance.", effectful);
|
|
766
|
+
return result;
|
|
767
|
+
} finally {
|
|
768
|
+
if (timer !== undefined)
|
|
769
|
+
clearTimeout(timer);
|
|
770
|
+
options?.abortSignal?.removeEventListener("abort", abort);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async initialize() {
|
|
774
|
+
try {
|
|
775
|
+
await this.initializeInternal();
|
|
776
|
+
} catch (error) {
|
|
777
|
+
this.fail("protocol", "MCP initialization failed.");
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async initializeInternal() {
|
|
782
|
+
if (this.ready || this.initializing)
|
|
783
|
+
throw new McpStdioClientError("protocol", "MCP client was already initialized.");
|
|
784
|
+
this.initializing = true;
|
|
785
|
+
const deadline = performance.now() + this.limits.initializeMs;
|
|
786
|
+
const result = await this.request("initialize", {
|
|
787
|
+
protocolVersion: "2025-11-25",
|
|
788
|
+
capabilities: {},
|
|
789
|
+
clientInfo: { name: "zhivex-harness", version: "1" }
|
|
790
|
+
}, this.limits.initializeMs);
|
|
791
|
+
if (!result || typeof result !== "object" || result.protocolVersion !== "2025-11-25") {
|
|
792
|
+
this.fail("protocol", "Unsupported MCP initialization response.");
|
|
793
|
+
throw new McpStdioClientError("protocol", "Unsupported MCP initialization response.");
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
const remaining = deadline - performance.now();
|
|
797
|
+
if (remaining <= 0)
|
|
798
|
+
throw new Error("timeout");
|
|
799
|
+
let timer;
|
|
800
|
+
try {
|
|
801
|
+
await Promise.race([
|
|
802
|
+
this.channel.write(new TextEncoder().encode(`{"jsonrpc":"2.0","method":"notifications/initialized"}
|
|
803
|
+
`)),
|
|
804
|
+
new Promise((_, reject) => {
|
|
805
|
+
timer = setTimeout(() => reject(new Error("timeout")), remaining);
|
|
806
|
+
})
|
|
807
|
+
]);
|
|
808
|
+
} finally {
|
|
809
|
+
if (timer !== undefined)
|
|
810
|
+
clearTimeout(timer);
|
|
811
|
+
}
|
|
812
|
+
if (this.stopped)
|
|
813
|
+
throw new Error("closed");
|
|
814
|
+
this.ready = true;
|
|
815
|
+
} catch {
|
|
816
|
+
this.fail("protocol", "MCP initialization did not complete.");
|
|
817
|
+
throw new McpStdioClientError("protocol", "MCP initialization did not complete.");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
async listTools(input, options) {
|
|
821
|
+
if (!this.ready)
|
|
822
|
+
throw new McpStdioClientError("protocol", "MCP client is not initialized.");
|
|
823
|
+
const result = await this.request("tools/list", input ?? {}, this.limits.callMs, options);
|
|
824
|
+
if (!result || typeof result !== "object" || !Array.isArray(result.tools)) {
|
|
825
|
+
this.fail("protocol", "Invalid MCP tool listing.");
|
|
826
|
+
throw new McpStdioClientError("protocol", "Invalid MCP tool listing.");
|
|
827
|
+
}
|
|
828
|
+
return result;
|
|
829
|
+
}
|
|
830
|
+
async callTool(input, options) {
|
|
831
|
+
if (!this.ready)
|
|
832
|
+
throw new McpStdioClientError("protocol", "MCP client is not initialized.");
|
|
833
|
+
const result = await this.request("tools/call", input, this.limits.callMs, options, true);
|
|
834
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
835
|
+
this.fail("protocol", "Invalid MCP tool result.");
|
|
836
|
+
throw new McpStdioClientError("protocol", "Invalid MCP tool result.", true);
|
|
837
|
+
}
|
|
838
|
+
return result;
|
|
839
|
+
}
|
|
840
|
+
async boundedCleanup(operation) {
|
|
841
|
+
let timer;
|
|
842
|
+
try {
|
|
843
|
+
await Promise.race([operation, new Promise((_, reject) => {
|
|
844
|
+
timer = setTimeout(() => reject(new McpStdioClientError("cleanup_required", "MCP isolated boundary cleanup was not confirmed.")), this.limits.closeMs ?? 2000);
|
|
845
|
+
})]);
|
|
846
|
+
} finally {
|
|
847
|
+
if (timer !== undefined)
|
|
848
|
+
clearTimeout(timer);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
async close() {
|
|
852
|
+
this.fail("closed", "MCP client was closed.");
|
|
853
|
+
await this.closing;
|
|
854
|
+
await this.boundedCleanup(this.reading);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// src/execution/mcp-oci-workspace.ts
|
|
859
|
+
var SEEDER = String.raw`
|
|
860
|
+
const fs = require('node:fs/promises');
|
|
861
|
+
const path = require('node:path');
|
|
862
|
+
const readline = require('node:readline');
|
|
863
|
+
const maximum = Number(process.env.ZHIVEX_MCP_WORKSPACE_BYTES);
|
|
864
|
+
let bytes = 0, entries = 0, current, poisoned = false, serial = Promise.resolve();
|
|
865
|
+
const safe = p => typeof p === 'string' && p.length > 0 && p.length <= 4096 && !p.startsWith('/') && !p.includes('\\') && !/[\x00-\x1f\x7f]/.test(p) && p.split('/').every(x => x && x !== '.' && x !== '..');
|
|
866
|
+
async function receive(line) {
|
|
867
|
+
if (poisoned) return;
|
|
868
|
+
let request;
|
|
869
|
+
try {
|
|
870
|
+
if (Buffer.byteLength(line) > 128 * 1024) throw Error();
|
|
871
|
+
request = JSON.parse(line);
|
|
872
|
+
if (!('id' in request)) return;
|
|
873
|
+
let result;
|
|
874
|
+
if (request.method === 'initialize') result = { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'workspace-seeder', version: '1' } };
|
|
875
|
+
else if (request.method === 'tools/call') {
|
|
876
|
+
const { name, arguments: input } = request.params;
|
|
877
|
+
if (name === 'begin') {
|
|
878
|
+
if (current || !safe(input.path) || ++entries > 20000) throw Error();
|
|
879
|
+
const target = path.join('/workspace', input.path);
|
|
880
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
881
|
+
current = await fs.open(target, 'wx', input.writable ? (input.executable ? 0o700 : 0o600) : (input.executable ? 0o500 : 0o400));
|
|
882
|
+
} else if (name === 'chunk') {
|
|
883
|
+
if (!current || typeof input.data !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input.data)) throw Error();
|
|
884
|
+
const data = Buffer.from(input.data, 'base64');
|
|
885
|
+
if (data.length > 65536 || (bytes += data.length) > maximum) throw Error();
|
|
886
|
+
let offset = 0;
|
|
887
|
+
while (offset < data.length) {
|
|
888
|
+
const written = await current.write(data, offset, data.length - offset);
|
|
889
|
+
if (!written.bytesWritten) throw Error();
|
|
890
|
+
offset += written.bytesWritten;
|
|
891
|
+
}
|
|
892
|
+
} else if (name === 'end') {
|
|
893
|
+
if (!current) throw Error(); await current.close(); current = undefined;
|
|
894
|
+
} else if (name === 'complete') {
|
|
895
|
+
if (current || bytes !== input.bytes || entries !== input.entries) throw Error();
|
|
896
|
+
} else throw Error();
|
|
897
|
+
result = { content: [], structuredContent: { bytes, entries } };
|
|
898
|
+
} else throw Error();
|
|
899
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
|
|
900
|
+
} catch {
|
|
901
|
+
poisoned = true;
|
|
902
|
+
if (current) await current.close().catch(() => {});
|
|
903
|
+
process.exitCode = 1; process.stdin.destroy();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
readline.createInterface({ input: process.stdin }).on('line', line => { serial = serial.then(() => receive(line)); });
|
|
907
|
+
`;
|
|
908
|
+
async function createDockerMcpWorkspace(options) {
|
|
909
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(options.provisionerImageId))
|
|
910
|
+
throw new Error("MCP provisioner requires an immutable local image.");
|
|
911
|
+
const env = Object.fromEntries(["HOME", "PATH", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "XDG_RUNTIME_DIR"].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
912
|
+
const cli = async (args) => {
|
|
913
|
+
const result = await runPortableProcess(["docker", ...args], { env, timeoutMs: 1e4, maxOutputCharacters: 64 * 1024 });
|
|
914
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
915
|
+
throw new Error("MCP workspace Docker operation failed.");
|
|
916
|
+
return result.stdout.trim();
|
|
917
|
+
};
|
|
918
|
+
const name2 = options.resources?.plan.volumeName ?? `zhx-mcp-${randomUUID2()}`;
|
|
919
|
+
const containerName = `${name2}-seed`;
|
|
920
|
+
const ownership = options.resources?.labels ?? { "com.zhivex.harness.mcp": "v1", "com.zhivex.harness.owner-pid": String(process.pid) };
|
|
921
|
+
const labels = Object.entries(ownership).flatMap(([key, value]) => ["--label", `${key}=${value}`]);
|
|
922
|
+
const limits = options.proposal.limits;
|
|
923
|
+
const volumeOptions = `size=${limits.maxWorkspaceBytes},uid=65532,gid=65532,mode=0700,noexec,nosuid,nodev`;
|
|
924
|
+
let container;
|
|
925
|
+
let client;
|
|
926
|
+
let volumeAttempted = false;
|
|
927
|
+
let containerAttempted = false;
|
|
928
|
+
let closing;
|
|
929
|
+
const stopSeeder = async () => {
|
|
930
|
+
if (client)
|
|
931
|
+
await client.close();
|
|
932
|
+
else if (container)
|
|
933
|
+
await removeDockerMcpBoundary(cli, container);
|
|
934
|
+
else if (containerAttempted) {
|
|
935
|
+
await cli(["rm", "--force", containerName]).catch(() => {});
|
|
936
|
+
if (await cli(["ps", "--all", "--quiet", "--filter", `name=^/${containerName}$`]))
|
|
937
|
+
throw new Error("MCP seeder cleanup was not confirmed.");
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
const close = () => closing ??= (async () => {
|
|
941
|
+
let failed = false;
|
|
942
|
+
try {
|
|
943
|
+
await stopSeeder();
|
|
944
|
+
} catch {
|
|
945
|
+
failed = true;
|
|
946
|
+
}
|
|
947
|
+
if (volumeAttempted) {
|
|
948
|
+
await cli(["volume", "rm", "--force", name2]).catch(() => {});
|
|
949
|
+
try {
|
|
950
|
+
const volumes = (await cli(["volume", "ls", "--quiet", "--filter", `name=${name2}`])).split(`
|
|
951
|
+
`);
|
|
952
|
+
if (volumes.includes(name2))
|
|
953
|
+
failed = true;
|
|
954
|
+
} catch {
|
|
955
|
+
failed = true;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (failed)
|
|
959
|
+
throw new Error("MCP workspace cleanup was not confirmed.");
|
|
960
|
+
})();
|
|
961
|
+
try {
|
|
962
|
+
const image2 = JSON.parse(await cli(["image", "inspect", options.provisionerImageId]))[0];
|
|
963
|
+
if (image2?.Id !== options.provisionerImageId || Object.keys(image2?.Config?.Volumes ?? {}).length > 0)
|
|
964
|
+
throw new Error("Invalid MCP provisioner image.");
|
|
965
|
+
volumeAttempted = true;
|
|
966
|
+
await cli(["volume", "create", "--driver", "local", "--opt", "type=tmpfs", "--opt", "device=tmpfs", "--opt", `o=${volumeOptions}`, ...labels, name2]);
|
|
967
|
+
const volume = JSON.parse(await cli(["volume", "inspect", name2]))[0];
|
|
968
|
+
if (volume?.Name !== name2 || volume?.Driver !== "local" || volume?.Options?.type !== "tmpfs" || volume?.Options?.device !== "tmpfs" || volume?.Options?.o !== volumeOptions || !Object.entries(ownership).every(([key, value]) => volume?.Labels?.[key] === value))
|
|
969
|
+
throw new Error("MCP workspace volume was not attested.");
|
|
970
|
+
containerAttempted = true;
|
|
971
|
+
container = await cli([
|
|
972
|
+
"create",
|
|
973
|
+
"--name",
|
|
974
|
+
containerName,
|
|
975
|
+
"--pull",
|
|
976
|
+
"never",
|
|
977
|
+
"--interactive",
|
|
978
|
+
"--network",
|
|
979
|
+
"none",
|
|
980
|
+
"--read-only",
|
|
981
|
+
"--cap-drop",
|
|
982
|
+
"ALL",
|
|
983
|
+
"--security-opt",
|
|
984
|
+
"no-new-privileges",
|
|
985
|
+
"--memory",
|
|
986
|
+
`${limits.memoryMb}m`,
|
|
987
|
+
"--memory-swap",
|
|
988
|
+
`${limits.memoryMb}m`,
|
|
989
|
+
"--pids-limit",
|
|
990
|
+
String(limits.maxPids),
|
|
991
|
+
"--cpus",
|
|
992
|
+
String(limits.maxCpus),
|
|
993
|
+
"--user",
|
|
994
|
+
"65532:65532",
|
|
995
|
+
...labels,
|
|
996
|
+
"--mount",
|
|
997
|
+
`type=volume,src=${name2},dst=/workspace,volume-nocopy`,
|
|
998
|
+
"--env",
|
|
999
|
+
"NODE_OPTIONS=",
|
|
1000
|
+
"--env",
|
|
1001
|
+
"NODE_PATH=",
|
|
1002
|
+
"--env",
|
|
1003
|
+
"LD_PRELOAD=",
|
|
1004
|
+
"--env",
|
|
1005
|
+
"LD_LIBRARY_PATH=",
|
|
1006
|
+
"--env",
|
|
1007
|
+
`ZHIVEX_MCP_WORKSPACE_BYTES=${limits.maxWorkspaceBytes}`,
|
|
1008
|
+
"--entrypoint",
|
|
1009
|
+
"/usr/local/bin/node",
|
|
1010
|
+
options.provisionerImageId,
|
|
1011
|
+
"-e",
|
|
1012
|
+
SEEDER
|
|
1013
|
+
]);
|
|
1014
|
+
if (!/^[a-f0-9]{64}$/.test(container)) {
|
|
1015
|
+
container = undefined;
|
|
1016
|
+
throw new Error("Invalid MCP seeder identity.");
|
|
1017
|
+
}
|
|
1018
|
+
const actual = JSON.parse(await cli(["inspect", container]))[0];
|
|
1019
|
+
const mounts = actual?.Mounts;
|
|
1020
|
+
if (actual?.HostConfig?.Memory !== limits.memoryMb * 1024 * 1024 || actual?.HostConfig?.MemorySwap !== limits.memoryMb * 1024 * 1024 || actual?.HostConfig?.PidsLimit !== limits.maxPids || actual?.HostConfig?.NanoCpus !== Math.round(limits.maxCpus * 1e9) || !Array.isArray(mounts) || mounts.length !== 1 || mounts[0]?.Name !== name2 || mounts[0]?.Destination !== "/workspace" || mounts[0]?.RW !== true) {
|
|
1021
|
+
throw new Error("MCP provisioner limits were not attested.");
|
|
1022
|
+
}
|
|
1023
|
+
const channel = await openDockerMcpChannel({
|
|
1024
|
+
containerId: container,
|
|
1025
|
+
imageId: options.provisionerImageId,
|
|
1026
|
+
sessionMs: 60000,
|
|
1027
|
+
maxOutputBytes: 8 * 1024 * 1024
|
|
1028
|
+
});
|
|
1029
|
+
client = new IsolatedMcpStdioClient(channel, { callMs: 5000, initializeMs: 5000, sessionMs: 60000, maxFrameBytes: 128 * 1024, closeMs: 35000 });
|
|
1030
|
+
await client.initialize();
|
|
1031
|
+
let bytes = 0, entries = 0;
|
|
1032
|
+
for await (const file of options.files) {
|
|
1033
|
+
if (++entries > 20000 || (bytes += file.contents.byteLength) > limits.maxWorkspaceBytes)
|
|
1034
|
+
throw new Error("MCP seed exceeds admitted limits.");
|
|
1035
|
+
await client.callTool({ name: "begin", arguments: { path: file.path, executable: file.executable, writable: options.proposal.permissions.includes("write") } });
|
|
1036
|
+
for (let offset = 0;offset < file.contents.byteLength; offset += 65536) {
|
|
1037
|
+
await client.callTool({ name: "chunk", arguments: { data: Buffer.from(file.contents.subarray(offset, offset + 65536)).toString("base64") } });
|
|
1038
|
+
}
|
|
1039
|
+
await client.callTool({ name: "end", arguments: {} });
|
|
1040
|
+
}
|
|
1041
|
+
await client.callTool({ name: "complete", arguments: { bytes, entries } });
|
|
1042
|
+
return { volumeName: name2, stopSeeder, close };
|
|
1043
|
+
} catch {
|
|
1044
|
+
await close();
|
|
1045
|
+
throw new Error("MCP workspace provisioning failed.");
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/integrations/mcp-stdio-launch.ts
|
|
1050
|
+
async function withAdmittedMcpLaunch(options) {
|
|
1051
|
+
let result;
|
|
1052
|
+
try {
|
|
1053
|
+
const proposal = options.authority.consume(options.receipt, options.proposal);
|
|
1054
|
+
for (const key of ["principal", "tenant", "session", "workspace"]) {
|
|
1055
|
+
if (proposal.scope[key] !== options.scope[key])
|
|
1056
|
+
throw new McpStdioAdmissionError("MCP launch scope changed.");
|
|
1057
|
+
}
|
|
1058
|
+
await consumeMcpWorkspaceSnapshot(options.snapshot, {
|
|
1059
|
+
snapshotDigest: proposal.snapshotDigest,
|
|
1060
|
+
workspace: options.scope.workspace,
|
|
1061
|
+
maxWorkspaceBytes: proposal.limits.maxWorkspaceBytes
|
|
1062
|
+
}, async (files) => {
|
|
1063
|
+
const environment = { HOME: "/tmp", TMPDIR: "/tmp", LANG: "C.UTF-8", HOSTNAME: "mcp", ...proposal.environment };
|
|
1064
|
+
const controller = new AbortController;
|
|
1065
|
+
let timer;
|
|
1066
|
+
let secretBytes = 0;
|
|
1067
|
+
try {
|
|
1068
|
+
await Promise.race([
|
|
1069
|
+
(async () => {
|
|
1070
|
+
for (const [name2, reference] of Object.entries(proposal.secretReferences)) {
|
|
1071
|
+
const value = await options.resolveSecret(reference, controller.signal);
|
|
1072
|
+
if (controller.signal.aborted)
|
|
1073
|
+
throw new Error("expired");
|
|
1074
|
+
if (typeof value !== "string" || value.length === 0 || /[\x00\r\n]/.test(value))
|
|
1075
|
+
throw new Error("invalid");
|
|
1076
|
+
secretBytes += new TextEncoder().encode(value).byteLength;
|
|
1077
|
+
if (secretBytes > 32 * 1024)
|
|
1078
|
+
throw new Error("limit");
|
|
1079
|
+
environment[name2] = value;
|
|
1080
|
+
}
|
|
1081
|
+
})(),
|
|
1082
|
+
new Promise((_, reject) => {
|
|
1083
|
+
timer = setTimeout(() => {
|
|
1084
|
+
controller.abort();
|
|
1085
|
+
reject(new Error("expired"));
|
|
1086
|
+
}, 5000);
|
|
1087
|
+
})
|
|
1088
|
+
]);
|
|
1089
|
+
} finally {
|
|
1090
|
+
if (timer !== undefined)
|
|
1091
|
+
clearTimeout(timer);
|
|
1092
|
+
controller.abort();
|
|
1093
|
+
}
|
|
1094
|
+
result = await options.seed({ proposal, environment: Object.freeze(environment), files });
|
|
1095
|
+
});
|
|
1096
|
+
return result;
|
|
1097
|
+
} catch {
|
|
1098
|
+
throw new McpStdioAdmissionError("MCP admitted launch handoff failed.");
|
|
1099
|
+
} finally {
|
|
1100
|
+
await discardMcpWorkspaceSnapshot(options.snapshot);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/integrations/mcp-stdio-tools.ts
|
|
1105
|
+
import { createRedactionPolicy } from "@zhivex-ai/core";
|
|
1106
|
+
import { z as z3 } from "zod";
|
|
1107
|
+
|
|
1108
|
+
// src/persistence/mcp-execution-journal.ts
|
|
1109
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1110
|
+
class McpExecutionJournalError extends Error {
|
|
1111
|
+
code;
|
|
1112
|
+
outcomeUnknown;
|
|
1113
|
+
constructor(code, outcomeUnknown) {
|
|
1114
|
+
super(`MCP durable execution ${code}.`);
|
|
1115
|
+
this.code = code;
|
|
1116
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
1117
|
+
this.name = "McpExecutionJournalError";
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
function canonical2(value) {
|
|
1121
|
+
if (Array.isArray(value))
|
|
1122
|
+
return `[${value.map(canonical2).join(",")}]`;
|
|
1123
|
+
if (value && typeof value === "object")
|
|
1124
|
+
return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonical2(item)}`).join(",")}}`;
|
|
1125
|
+
return JSON.stringify(value);
|
|
1126
|
+
}
|
|
1127
|
+
var hash3 = (value) => createHash4("sha256").update(canonical2(value)).digest("hex");
|
|
1128
|
+
function createJournaledMcpClient(options) {
|
|
1129
|
+
if (!options.store.claimToolExecution || !options.store.completeToolExecution || !options.store.listToolCalls || options.runId !== options.proposal.scope.session || options.scope.tenantId !== options.proposal.scope.tenant || options.scope.userId !== options.proposal.scope.principal)
|
|
1130
|
+
throw new McpExecutionJournalError("identity_changed", false);
|
|
1131
|
+
const scope = structuredClone(options.scope);
|
|
1132
|
+
const runId = options.runId;
|
|
1133
|
+
const proposalHash = hash3(options.proposal);
|
|
1134
|
+
const serverId = options.proposal.serverId;
|
|
1135
|
+
const claim = options.store.claimToolExecution.bind(options.store);
|
|
1136
|
+
const complete = options.store.completeToolExecution.bind(options.store);
|
|
1137
|
+
const list = options.store.listToolCalls.bind(options.store);
|
|
1138
|
+
let stopped = false;
|
|
1139
|
+
const stop = async (code, uncertain) => {
|
|
1140
|
+
stopped = true;
|
|
1141
|
+
try {
|
|
1142
|
+
await options.client.close();
|
|
1143
|
+
} catch {
|
|
1144
|
+
throw new McpExecutionJournalError("cleanup_required", uncertain);
|
|
1145
|
+
}
|
|
1146
|
+
throw new McpExecutionJournalError(code, uncertain);
|
|
1147
|
+
};
|
|
1148
|
+
return {
|
|
1149
|
+
listTools: (input, callOptions) => options.client.listTools(input, callOptions),
|
|
1150
|
+
close: () => options.client.close(),
|
|
1151
|
+
async callTool(input, callOptions) {
|
|
1152
|
+
if (stopped)
|
|
1153
|
+
throw new McpExecutionJournalError("outcome_unknown", true);
|
|
1154
|
+
const idempotencyKey = callOptions?.idempotencyKey;
|
|
1155
|
+
if (!idempotencyKey || idempotencyKey.length > 1024)
|
|
1156
|
+
throw new McpExecutionJournalError("identity_changed", false);
|
|
1157
|
+
if (callOptions?.abortSignal?.aborted)
|
|
1158
|
+
throw new McpStdioClientError("cancelled", "MCP call cancelled before durable dispatch.");
|
|
1159
|
+
let prior;
|
|
1160
|
+
try {
|
|
1161
|
+
prior = await list(runId, scope);
|
|
1162
|
+
} catch {
|
|
1163
|
+
throw new McpExecutionJournalError("persistence_failed", false);
|
|
1164
|
+
}
|
|
1165
|
+
if (prior.some((entry2) => entry2.toolName === "isolated_mcp_call" && (entry2.status === "pending" || entry2.status === "running" || entry2.error?.message === "MCP_OUTCOME_UNKNOWN"))) {
|
|
1166
|
+
return stop("outcome_unknown", true);
|
|
1167
|
+
}
|
|
1168
|
+
const toolCallId = `mcp_execution_${hash3({ serverId, idempotencyKey })}`;
|
|
1169
|
+
const identity = { schemaVersion: 1, kind: "isolated-mcp-call", proposalHash, argumentsHash: hash3(input), toolNameHash: hash3(input.name) };
|
|
1170
|
+
let claimed;
|
|
1171
|
+
try {
|
|
1172
|
+
claimed = await claim({
|
|
1173
|
+
runId,
|
|
1174
|
+
scope,
|
|
1175
|
+
toolCallId,
|
|
1176
|
+
toolName: "isolated_mcp_call",
|
|
1177
|
+
status: "pending",
|
|
1178
|
+
idempotencyKey: `${runId}:${toolCallId}`,
|
|
1179
|
+
revision: 0,
|
|
1180
|
+
input: identity,
|
|
1181
|
+
updatedAt: Date.now()
|
|
1182
|
+
});
|
|
1183
|
+
} catch {
|
|
1184
|
+
throw new McpExecutionJournalError("persistence_failed", false);
|
|
1185
|
+
}
|
|
1186
|
+
if (!claimed.claimed) {
|
|
1187
|
+
if (canonical2(claimed.entry.input) !== canonical2(identity))
|
|
1188
|
+
throw new McpExecutionJournalError("identity_changed", false);
|
|
1189
|
+
const uncertain = claimed.entry.status === "pending" || claimed.entry.status === "running" || claimed.entry.error?.message === "MCP_OUTCOME_UNKNOWN";
|
|
1190
|
+
if (uncertain)
|
|
1191
|
+
return stop("outcome_unknown", true);
|
|
1192
|
+
throw new McpExecutionJournalError("replay_denied", false);
|
|
1193
|
+
}
|
|
1194
|
+
const entry = claimed.entry;
|
|
1195
|
+
let output;
|
|
1196
|
+
try {
|
|
1197
|
+
output = await options.client.callTool(input, callOptions);
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
const uncertain = !(error instanceof McpStdioClientError) || error.outcomeUnknown;
|
|
1200
|
+
try {
|
|
1201
|
+
await complete({
|
|
1202
|
+
...entry,
|
|
1203
|
+
status: "failed",
|
|
1204
|
+
error: { message: uncertain ? "MCP_OUTCOME_UNKNOWN" : "MCP_NOT_DISPATCHED" },
|
|
1205
|
+
completedAt: Date.now(),
|
|
1206
|
+
updatedAt: Date.now()
|
|
1207
|
+
}, { expectedRevision: entry.revision });
|
|
1208
|
+
} catch {
|
|
1209
|
+
return stop("persistence_failed", uncertain);
|
|
1210
|
+
}
|
|
1211
|
+
if (uncertain)
|
|
1212
|
+
return stop("outcome_unknown", true);
|
|
1213
|
+
throw new McpStdioClientError("closed", "MCP durable call failed before dispatch.");
|
|
1214
|
+
}
|
|
1215
|
+
try {
|
|
1216
|
+
await complete({
|
|
1217
|
+
...entry,
|
|
1218
|
+
status: "completed",
|
|
1219
|
+
output: { outcome: "completed", outputHash: hash3(output) },
|
|
1220
|
+
completedAt: Date.now(),
|
|
1221
|
+
updatedAt: Date.now()
|
|
1222
|
+
}, { expectedRevision: entry.revision });
|
|
1223
|
+
} catch {
|
|
1224
|
+
return stop("persistence_failed", true);
|
|
1225
|
+
}
|
|
1226
|
+
return output;
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// src/integrations/mcp-stdio-tools.ts
|
|
1232
|
+
var record = z3.record(z3.string(), z3.unknown());
|
|
1233
|
+
var listing = z3.object({
|
|
1234
|
+
tools: z3.array(z3.object({
|
|
1235
|
+
name: z3.string().min(1).max(128),
|
|
1236
|
+
description: z3.string().max(16000).optional(),
|
|
1237
|
+
title: z3.string().max(300).optional(),
|
|
1238
|
+
inputSchema: record.refine((value) => value.type === "object"),
|
|
1239
|
+
outputSchema: record.refine((value) => value.type === "object").optional(),
|
|
1240
|
+
annotations: record.optional()
|
|
1241
|
+
}).passthrough()).max(200),
|
|
1242
|
+
nextCursor: z3.string().min(1).max(2048).optional(),
|
|
1243
|
+
_meta: record.optional()
|
|
1244
|
+
}).strict();
|
|
1245
|
+
var resultSchema = z3.object({
|
|
1246
|
+
content: z3.array(z3.discriminatedUnion("type", [
|
|
1247
|
+
z3.object({ type: z3.literal("text"), text: z3.string() }).passthrough(),
|
|
1248
|
+
z3.object({ type: z3.literal("image"), data: z3.string(), mimeType: z3.string().min(1) }).passthrough(),
|
|
1249
|
+
z3.object({ type: z3.literal("audio"), data: z3.string(), mimeType: z3.string().min(1) }).passthrough(),
|
|
1250
|
+
z3.object({ type: z3.literal("resource_link"), uri: z3.string().min(1), name: z3.string().min(1) }).passthrough(),
|
|
1251
|
+
z3.object({ type: z3.literal("resource"), resource: z3.object({ uri: z3.string().min(1), text: z3.string().optional(), blob: z3.string().optional() }).passthrough().refine((value) => value.text !== undefined || value.blob !== undefined) }).passthrough()
|
|
1252
|
+
])).max(500).optional(),
|
|
1253
|
+
structuredContent: record.optional(),
|
|
1254
|
+
isError: z3.boolean().optional(),
|
|
1255
|
+
_meta: record.optional()
|
|
1256
|
+
}).strict().refine((value) => value.content !== undefined || value.structuredContent !== undefined);
|
|
1257
|
+
function compileSchema(schema) {
|
|
1258
|
+
const pending = [{ value: schema, depth: 0 }];
|
|
1259
|
+
let nodes = 0;
|
|
1260
|
+
while (pending.length) {
|
|
1261
|
+
const { value, depth } = pending.pop();
|
|
1262
|
+
if (++nodes > 2000 || depth > 16)
|
|
1263
|
+
throw new Error("MCP schema complexity limit exceeded.");
|
|
1264
|
+
if (value && typeof value === "object") {
|
|
1265
|
+
const item = value;
|
|
1266
|
+
if (typeof item.pattern === "string" || item.patternProperties !== undefined || typeof item.$ref === "string" || typeof item.$dynamicRef === "string")
|
|
1267
|
+
throw new Error("Unsupported isolated MCP schema feature.");
|
|
1268
|
+
for (const child of Object.values(value))
|
|
1269
|
+
pending.push({ value: child, depth: depth + 1 });
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return z3.fromJSONSchema(schema);
|
|
1273
|
+
}
|
|
1274
|
+
async function createIsolatedMcpTools(options) {
|
|
1275
|
+
const proposal = structuredClone(options.proposal);
|
|
1276
|
+
const journal = options.journal ? { ...options.journal, scope: structuredClone(options.journal.scope) } : undefined;
|
|
1277
|
+
const allow = new Set(proposal.includeTools);
|
|
1278
|
+
const validators = new Map;
|
|
1279
|
+
const outputValidators = new Map;
|
|
1280
|
+
const invalidResponse = async (message, effectful = false) => {
|
|
1281
|
+
try {
|
|
1282
|
+
await options.client.close();
|
|
1283
|
+
} catch {
|
|
1284
|
+
throw new McpStdioClientError("cleanup_required", "Isolated MCP cleanup was not confirmed.", effectful);
|
|
1285
|
+
}
|
|
1286
|
+
throw new McpStdioClientError("protocol", message, effectful);
|
|
1287
|
+
};
|
|
1288
|
+
const secrets = [...new Set(options.sensitiveValues ?? [])].filter(Boolean).sort((a, b) => b.length - a.length);
|
|
1289
|
+
const redaction = createRedactionPolicy({ includeEmails: true });
|
|
1290
|
+
const text = (value) => redaction.redactText(secrets.reduce((current, secret) => current.split(secret).join("[REDACTED]"), value));
|
|
1291
|
+
const scrub = (value, depth = 0, budget = { nodes: 0 }) => {
|
|
1292
|
+
if (depth > 32 || ++budget.nodes > 20000)
|
|
1293
|
+
throw new Error("MCP payload complexity limit exceeded.");
|
|
1294
|
+
if (typeof value === "string")
|
|
1295
|
+
return text(value);
|
|
1296
|
+
if (Array.isArray(value))
|
|
1297
|
+
return value.map((item) => scrub(item, depth + 1, budget));
|
|
1298
|
+
if (value && typeof value === "object")
|
|
1299
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [text(key), scrub(item, depth + 1, budget)]));
|
|
1300
|
+
return value;
|
|
1301
|
+
};
|
|
1302
|
+
const bounded = (value) => {
|
|
1303
|
+
const clean = scrub(value);
|
|
1304
|
+
if (Buffer.byteLength(JSON.stringify(value)) > proposal.limits.maxOutputBytes)
|
|
1305
|
+
throw new Error("MCP output limit exceeded.");
|
|
1306
|
+
return clean;
|
|
1307
|
+
};
|
|
1308
|
+
const invoke = async (input, callOptions) => {
|
|
1309
|
+
let response;
|
|
1310
|
+
try {
|
|
1311
|
+
response = await options.client.callTool(input, callOptions);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
if (error instanceof McpStdioClientError)
|
|
1314
|
+
throw new McpStdioClientError(error.code, "Isolated MCP call failed.", error.outcomeUnknown);
|
|
1315
|
+
throw new Error("Isolated MCP call failed.");
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
const parsed = resultSchema.parse(response);
|
|
1319
|
+
const output = outputValidators.get(input.name);
|
|
1320
|
+
if (output && !parsed.isError && !output.safeParse(parsed.structuredContent).success)
|
|
1321
|
+
throw new Error("Output schema");
|
|
1322
|
+
return bounded(parsed);
|
|
1323
|
+
} catch {
|
|
1324
|
+
return invalidResponse("Isolated MCP result validation failed.", true);
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
const validatedClient = { callTool: invoke, listTools: (input, callOptions) => options.client.listTools(input, callOptions), close: () => options.client.close() };
|
|
1328
|
+
const audited = journal ? createJournaledMcpClient({ ...journal, client: validatedClient, proposal }) : validatedClient;
|
|
1329
|
+
const client = {
|
|
1330
|
+
async listTools(input, callOptions) {
|
|
1331
|
+
try {
|
|
1332
|
+
const response = await options.client.listTools(input, callOptions);
|
|
1333
|
+
const clean = bounded(response);
|
|
1334
|
+
if (JSON.stringify(clean) !== JSON.stringify(response))
|
|
1335
|
+
throw new Error("Sensitive discovery");
|
|
1336
|
+
const parsed = listing.parse(response);
|
|
1337
|
+
for (const tool of parsed.tools) {
|
|
1338
|
+
if (!allow.has(tool.name))
|
|
1339
|
+
continue;
|
|
1340
|
+
if (validators.has(tool.name))
|
|
1341
|
+
throw new Error("Duplicate tool");
|
|
1342
|
+
validators.set(tool.name, compileSchema(tool.inputSchema));
|
|
1343
|
+
if (tool.outputSchema)
|
|
1344
|
+
outputValidators.set(tool.name, compileSchema(tool.outputSchema));
|
|
1345
|
+
}
|
|
1346
|
+
return parsed;
|
|
1347
|
+
} catch {
|
|
1348
|
+
return invalidResponse("Isolated MCP discovery validation failed.");
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
async callTool(input, callOptions) {
|
|
1352
|
+
const validator = validators.get(input.name);
|
|
1353
|
+
if (!allow.has(input.name) || !validator)
|
|
1354
|
+
throw new Error("MCP tool was not admitted.");
|
|
1355
|
+
const argumentsResult = validator.safeParse(input.arguments ?? {});
|
|
1356
|
+
if (!argumentsResult.success)
|
|
1357
|
+
throw new Error("MCP tool arguments failed validation.");
|
|
1358
|
+
return audited.callTool({ name: input.name, arguments: argumentsResult.data }, callOptions);
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
const configuration = { schemaVersion: 1, servers: [{
|
|
1362
|
+
name: proposal.serverId,
|
|
1363
|
+
transport: "custom",
|
|
1364
|
+
includeTools: [...allow],
|
|
1365
|
+
includeResources: [],
|
|
1366
|
+
excludeTools: [],
|
|
1367
|
+
toolNamePrefix: `mcp_${proposal.serverId.replace(/-/g, "_")}_`,
|
|
1368
|
+
permissions: [...proposal.permissions],
|
|
1369
|
+
headerEnv: {},
|
|
1370
|
+
trustServerToolAnnotations: false,
|
|
1371
|
+
maxListPages: 5,
|
|
1372
|
+
maxListedTools: 200,
|
|
1373
|
+
listToolsTimeoutMs: proposal.limits.callMs,
|
|
1374
|
+
callToolTimeoutMs: proposal.limits.callMs,
|
|
1375
|
+
maxOutputBytes: proposal.limits.maxOutputBytes
|
|
1376
|
+
}] };
|
|
1377
|
+
try {
|
|
1378
|
+
const tools = await createHarnessMcpTools(configuration, { clients: { [proposal.serverId]: client } });
|
|
1379
|
+
if (Object.keys(tools).length !== allow.size)
|
|
1380
|
+
throw new Error("Admitted tools are missing.");
|
|
1381
|
+
for (const name2 of allow) {
|
|
1382
|
+
const key = `${configuration.servers[0].toolNamePrefix}${name2}`;
|
|
1383
|
+
const definition = tools[key];
|
|
1384
|
+
if (!definition || !("execute" in definition))
|
|
1385
|
+
throw new Error("Missing callable tool.");
|
|
1386
|
+
tools[key] = { ...definition, schema: validators.get(name2), execute: async (input, context) => {
|
|
1387
|
+
if (journal && (context?.runId !== journal.runId || context.scope?.tenantId !== journal.scope.tenantId || context.scope?.userId !== journal.scope.userId || context.scope?.namespace !== journal.scope.namespace || context.toolCall?.name !== key)) {
|
|
1388
|
+
throw new Error("MCP execution context does not match its admitted scope.");
|
|
1389
|
+
}
|
|
1390
|
+
return definition.execute(input, context);
|
|
1391
|
+
} };
|
|
1392
|
+
}
|
|
1393
|
+
return tools;
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
if (error instanceof McpStdioClientError && error.code === "cleanup_required")
|
|
1396
|
+
throw error;
|
|
1397
|
+
return invalidResponse("Isolated MCP tool discovery failed.");
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// src/execution/mcp-oci-server.ts
|
|
1402
|
+
var endpointKeys = ["HOME", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "XDG_RUNTIME_DIR"];
|
|
1403
|
+
async function dockerExecutable() {
|
|
1404
|
+
for (const directory of (process.env.PATH ?? "").split(path2.delimiter).filter(Boolean)) {
|
|
1405
|
+
const candidate = path2.resolve(directory, "docker");
|
|
1406
|
+
try {
|
|
1407
|
+
await access(candidate, constants.X_OK);
|
|
1408
|
+
return await realpath(candidate);
|
|
1409
|
+
} catch {}
|
|
1410
|
+
}
|
|
1411
|
+
throw new Error("Docker is unavailable.");
|
|
1412
|
+
}
|
|
1413
|
+
function effectiveMcpContainerEnvironment(input) {
|
|
1414
|
+
if (!Array.isArray(input))
|
|
1415
|
+
throw new Error("Invalid MCP container environment.");
|
|
1416
|
+
const result = Object.create(null);
|
|
1417
|
+
const seen = new Set;
|
|
1418
|
+
for (const entry of input) {
|
|
1419
|
+
if (typeof entry !== "string")
|
|
1420
|
+
throw new Error("Invalid MCP container environment.");
|
|
1421
|
+
const index = entry.indexOf("=");
|
|
1422
|
+
const name2 = index < 0 ? entry : entry.slice(0, index);
|
|
1423
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name2) || seen.has(name2))
|
|
1424
|
+
throw new Error("Invalid MCP container environment.");
|
|
1425
|
+
seen.add(name2);
|
|
1426
|
+
if (index >= 0)
|
|
1427
|
+
result[name2] = entry.slice(index + 1);
|
|
1428
|
+
}
|
|
1429
|
+
return result;
|
|
1430
|
+
}
|
|
1431
|
+
var same = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
|
|
1432
|
+
function attestMcpServer(inspection, expected) {
|
|
1433
|
+
const value = inspection;
|
|
1434
|
+
const proposal = expected.proposal, limits = proposal.limits;
|
|
1435
|
+
const config = value?.Config, host = value?.HostConfig;
|
|
1436
|
+
const environment = effectiveMcpContainerEnvironment(config?.Env);
|
|
1437
|
+
const wanted = expected.environment;
|
|
1438
|
+
const mounts = value?.Mounts;
|
|
1439
|
+
const tmpfs = `/tmp:rw,noexec,nosuid,nodev,size=${limits.tmpfsMb}m,uid=65532,gid=65532,mode=0700`;
|
|
1440
|
+
const checks = {
|
|
1441
|
+
identity: value?.Image === expected.imageId && value?.State?.Status === "created",
|
|
1442
|
+
command: same(config?.Entrypoint, [proposal.executable]) && same(config?.Cmd ?? [], proposal.args),
|
|
1443
|
+
user: config?.User === "65532:65532" && config?.Hostname === "mcp",
|
|
1444
|
+
cwd: config?.WorkingDir === (proposal.workingDirectory === "." ? "/workspace" : `/workspace/${proposal.workingDirectory}`),
|
|
1445
|
+
auxiliary: config?.Healthcheck?.Test?.[0] === "NONE" && host?.RestartPolicy?.Name === "no" && host?.LogConfig?.Type === "none" && host?.IpcMode === "none",
|
|
1446
|
+
memory: host?.Memory === limits.memoryMb * 1024 * 1024 && host?.MemorySwap === limits.memoryMb * 1024 * 1024,
|
|
1447
|
+
processes: host?.PidsLimit === limits.maxPids && host?.NanoCpus === Math.round(limits.maxCpus * 1e9),
|
|
1448
|
+
tmpfs: same(host?.Tmpfs, { "/tmp": tmpfs.slice(5) }),
|
|
1449
|
+
writes: same(host?.Ulimits, [{ Name: "fsize", Hard: limits.maxFileWriteBytes, Soft: limits.maxFileWriteBytes }]),
|
|
1450
|
+
environment: Object.keys(environment).length === Object.keys(wanted).length && Object.keys(wanted).every((key) => environment[key] === wanted[key]),
|
|
1451
|
+
mount: Array.isArray(mounts) && mounts.length === 1 && mounts[0]?.Type === "volume" && mounts[0]?.Name === expected.volumeName && mounts[0]?.Destination === "/workspace" && mounts[0]?.RW === proposal.permissions.includes("write")
|
|
1452
|
+
};
|
|
1453
|
+
const failed = Object.keys(checks).filter((key) => !checks[key]);
|
|
1454
|
+
if (failed.length)
|
|
1455
|
+
throw new McpServerAttestationError(failed);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
class McpServerAttestationError extends Error {
|
|
1459
|
+
fields;
|
|
1460
|
+
constructor(fields) {
|
|
1461
|
+
super("MCP server does not match its admission.");
|
|
1462
|
+
this.fields = fields;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
async function launchDockerMcpServer(options) {
|
|
1466
|
+
let workspace;
|
|
1467
|
+
let channel;
|
|
1468
|
+
let client;
|
|
1469
|
+
let container;
|
|
1470
|
+
let attempted = false;
|
|
1471
|
+
let resources;
|
|
1472
|
+
let containerName = `zhx-mcp-server-${randomUUID3()}`;
|
|
1473
|
+
let cli;
|
|
1474
|
+
let privateRoot;
|
|
1475
|
+
let phase = "admission";
|
|
1476
|
+
const confirmResourceAbsence = async () => {
|
|
1477
|
+
if (!resources || !cli)
|
|
1478
|
+
return;
|
|
1479
|
+
for (const name2 of [resources.plan.serverName, resources.plan.seederName]) {
|
|
1480
|
+
if (await cli(["ps", "--all", "--quiet", "--filter", `name=^/${name2}$`]))
|
|
1481
|
+
throw new Error("MCP resource remains.");
|
|
1482
|
+
}
|
|
1483
|
+
if ((await cli(["volume", "ls", "--quiet", "--filter", `name=${resources.plan.volumeName}`])).split(`
|
|
1484
|
+
`).includes(resources.plan.volumeName)) {
|
|
1485
|
+
throw new Error("MCP resource remains.");
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
try {
|
|
1489
|
+
return await withAdmittedMcpLaunch({ ...options, seed: async ({ proposal, environment, files }) => {
|
|
1490
|
+
phase = "image-inspection";
|
|
1491
|
+
const executable = await dockerExecutable();
|
|
1492
|
+
const endpoint = Object.fromEntries(endpointKeys.flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
1493
|
+
cli = async (args, additions) => {
|
|
1494
|
+
const result = await runPortableProcess([executable, ...args], { env: { ...endpoint, ...additions }, timeoutMs: 1e4, maxOutputCharacters: 1024 * 1024 });
|
|
1495
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
1496
|
+
throw new Error("MCP server Docker operation failed.");
|
|
1497
|
+
return result.stdout.trim();
|
|
1498
|
+
};
|
|
1499
|
+
const image2 = JSON.parse(await cli(["image", "inspect", proposal.image]))[0];
|
|
1500
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(image2?.Id ?? "") || Object.keys(image2?.Config?.Volumes ?? {}).length > 0)
|
|
1501
|
+
throw new Error("Invalid MCP server image.");
|
|
1502
|
+
const inherited = effectiveMcpContainerEnvironment(image2?.Config?.Env ?? []);
|
|
1503
|
+
const removed = Object.keys(inherited).filter((key) => !(key in environment));
|
|
1504
|
+
if (removed.some((key) => endpointKeys.includes(key)))
|
|
1505
|
+
throw new Error("MCP image environment conflicts with Docker control.");
|
|
1506
|
+
const createEnvironment = Object.fromEntries(removed.map((key) => [key, undefined]));
|
|
1507
|
+
privateRoot = await mkdtemp2(path2.join(tmpdir2(), "zhx-mcp-env-"));
|
|
1508
|
+
const envFile = path2.join(privateRoot, "environment");
|
|
1509
|
+
await writeFile2(envFile, Object.entries(environment).map(([key, value]) => `${key}=${value}`).join(`
|
|
1510
|
+
`) + `
|
|
1511
|
+
`, { mode: 384, flag: "wx" });
|
|
1512
|
+
if (options.resourceJournal) {
|
|
1513
|
+
phase = "resource-reservation";
|
|
1514
|
+
resources = await reserveMcpResources({
|
|
1515
|
+
...options.resourceJournal,
|
|
1516
|
+
proposal,
|
|
1517
|
+
serverImageId: image2.Id,
|
|
1518
|
+
provisionerImageId: options.provisionerImageId,
|
|
1519
|
+
daemonId: await cli(["info", "--format", "{{.ID}}"])
|
|
1520
|
+
});
|
|
1521
|
+
containerName = resources.plan.serverName;
|
|
1522
|
+
await resources.advance("provisioning");
|
|
1523
|
+
}
|
|
1524
|
+
phase = "workspace";
|
|
1525
|
+
workspace = await createDockerMcpWorkspace({ provisionerImageId: options.provisionerImageId, proposal, files, ...resources ? { resources } : {} });
|
|
1526
|
+
const limits = proposal.limits;
|
|
1527
|
+
attempted = true;
|
|
1528
|
+
phase = "container-create";
|
|
1529
|
+
container = await cli([
|
|
1530
|
+
"create",
|
|
1531
|
+
"--name",
|
|
1532
|
+
containerName,
|
|
1533
|
+
"--hostname",
|
|
1534
|
+
"mcp",
|
|
1535
|
+
"--pull",
|
|
1536
|
+
"never",
|
|
1537
|
+
"--interactive",
|
|
1538
|
+
"--network",
|
|
1539
|
+
"none",
|
|
1540
|
+
"--read-only",
|
|
1541
|
+
"--cap-drop",
|
|
1542
|
+
"ALL",
|
|
1543
|
+
"--security-opt",
|
|
1544
|
+
"no-new-privileges",
|
|
1545
|
+
"--user",
|
|
1546
|
+
"65532:65532",
|
|
1547
|
+
"--ipc",
|
|
1548
|
+
"none",
|
|
1549
|
+
"--no-healthcheck",
|
|
1550
|
+
"--restart",
|
|
1551
|
+
"no",
|
|
1552
|
+
"--log-driver",
|
|
1553
|
+
"none",
|
|
1554
|
+
"--memory",
|
|
1555
|
+
`${limits.memoryMb}m`,
|
|
1556
|
+
"--memory-swap",
|
|
1557
|
+
`${limits.memoryMb}m`,
|
|
1558
|
+
"--pids-limit",
|
|
1559
|
+
String(limits.maxPids),
|
|
1560
|
+
"--cpus",
|
|
1561
|
+
String(limits.maxCpus),
|
|
1562
|
+
"--ulimit",
|
|
1563
|
+
`fsize=${limits.maxFileWriteBytes}:${limits.maxFileWriteBytes}`,
|
|
1564
|
+
"--tmpfs",
|
|
1565
|
+
`/tmp:rw,noexec,nosuid,nodev,size=${limits.tmpfsMb}m,uid=65532,gid=65532,mode=0700`,
|
|
1566
|
+
...Object.entries(resources?.labels ?? { "com.zhivex.harness.mcp": "v1", "com.zhivex.harness.owner-pid": String(process.pid) }).flatMap(([key, value]) => ["--label", `${key}=${value}`]),
|
|
1567
|
+
"--mount",
|
|
1568
|
+
`type=volume,src=${workspace.volumeName},dst=/workspace,volume-nocopy${proposal.permissions.includes("write") ? "" : ",readonly"}`,
|
|
1569
|
+
...removed.flatMap((key) => ["--env", key]),
|
|
1570
|
+
"--env-file",
|
|
1571
|
+
envFile,
|
|
1572
|
+
"--workdir",
|
|
1573
|
+
proposal.workingDirectory === "." ? "/workspace" : `/workspace/${proposal.workingDirectory}`,
|
|
1574
|
+
"--entrypoint",
|
|
1575
|
+
proposal.executable,
|
|
1576
|
+
image2.Id,
|
|
1577
|
+
...proposal.args
|
|
1578
|
+
], createEnvironment);
|
|
1579
|
+
if (!/^[a-f0-9]{64}$/.test(container)) {
|
|
1580
|
+
container = undefined;
|
|
1581
|
+
throw new Error("Invalid MCP container identity.");
|
|
1582
|
+
}
|
|
1583
|
+
await rm2(privateRoot, { recursive: true, force: true });
|
|
1584
|
+
privateRoot = undefined;
|
|
1585
|
+
phase = "attestation";
|
|
1586
|
+
channel = await openDockerMcpChannel({
|
|
1587
|
+
containerId: container,
|
|
1588
|
+
imageId: image2.Id,
|
|
1589
|
+
sessionMs: limits.sessionMs,
|
|
1590
|
+
maxOutputBytes: limits.maxOutputBytes,
|
|
1591
|
+
attest: (inspection) => {
|
|
1592
|
+
try {
|
|
1593
|
+
attestMcpServer(inspection, { imageId: image2.Id, volumeName: workspace.volumeName, proposal, environment });
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
if (error instanceof McpServerAttestationError)
|
|
1596
|
+
phase = `attestation:${error.fields.join(",")}`;
|
|
1597
|
+
throw error;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
const serverChannel = channel, ownedWorkspace = workspace;
|
|
1602
|
+
const managed = {
|
|
1603
|
+
stdout: channel.stdout,
|
|
1604
|
+
write: (frame) => serverChannel.write(frame),
|
|
1605
|
+
async close() {
|
|
1606
|
+
let journalFailed = false, cleanupFailed = false;
|
|
1607
|
+
try {
|
|
1608
|
+
await resources?.advance("closing");
|
|
1609
|
+
} catch {
|
|
1610
|
+
journalFailed = true;
|
|
1611
|
+
}
|
|
1612
|
+
try {
|
|
1613
|
+
await serverChannel.close();
|
|
1614
|
+
} catch {
|
|
1615
|
+
cleanupFailed = true;
|
|
1616
|
+
}
|
|
1617
|
+
try {
|
|
1618
|
+
await ownedWorkspace.close();
|
|
1619
|
+
} catch {
|
|
1620
|
+
cleanupFailed = true;
|
|
1621
|
+
}
|
|
1622
|
+
try {
|
|
1623
|
+
await confirmResourceAbsence();
|
|
1624
|
+
} catch {
|
|
1625
|
+
cleanupFailed = true;
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
await resources?.advance(cleanupFailed ? "cleanup_required" : "closed");
|
|
1629
|
+
} catch {
|
|
1630
|
+
journalFailed = true;
|
|
1631
|
+
}
|
|
1632
|
+
if (cleanupFailed || journalFailed)
|
|
1633
|
+
throw new Error("MCP resource cleanup or its durable record was not confirmed.");
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
client = new IsolatedMcpStdioClient(managed, {
|
|
1637
|
+
callMs: limits.callMs,
|
|
1638
|
+
initializeMs: Math.min(1e4, limits.sessionMs),
|
|
1639
|
+
sessionMs: limits.sessionMs,
|
|
1640
|
+
maxFrameBytes: Math.min(1024 * 1024, limits.maxOutputBytes),
|
|
1641
|
+
closeMs: 60000
|
|
1642
|
+
});
|
|
1643
|
+
phase = "initialization";
|
|
1644
|
+
await client.initialize();
|
|
1645
|
+
phase = "seeder-stop";
|
|
1646
|
+
await workspace.stopSeeder();
|
|
1647
|
+
await resources?.advance("ready");
|
|
1648
|
+
return client;
|
|
1649
|
+
} });
|
|
1650
|
+
} catch {
|
|
1651
|
+
let cleanupFailed = false;
|
|
1652
|
+
if (!client) {
|
|
1653
|
+
try {
|
|
1654
|
+
await resources?.advance("closing");
|
|
1655
|
+
} catch {
|
|
1656
|
+
cleanupFailed = true;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
try {
|
|
1660
|
+
if (client)
|
|
1661
|
+
await client.close();
|
|
1662
|
+
else if (channel)
|
|
1663
|
+
await channel.close();
|
|
1664
|
+
else if (container && cli)
|
|
1665
|
+
await removeDockerMcpBoundary(cli, container);
|
|
1666
|
+
else if (attempted && cli) {
|
|
1667
|
+
await cli(["rm", "--force", containerName]).catch(() => {});
|
|
1668
|
+
if (await cli(["ps", "--all", "--quiet", "--filter", `name=^/${containerName}$`]))
|
|
1669
|
+
throw new Error("cleanup");
|
|
1670
|
+
}
|
|
1671
|
+
} catch {
|
|
1672
|
+
cleanupFailed = true;
|
|
1673
|
+
}
|
|
1674
|
+
try {
|
|
1675
|
+
await workspace?.close();
|
|
1676
|
+
} catch {
|
|
1677
|
+
cleanupFailed = true;
|
|
1678
|
+
}
|
|
1679
|
+
try {
|
|
1680
|
+
await confirmResourceAbsence();
|
|
1681
|
+
} catch {
|
|
1682
|
+
cleanupFailed = true;
|
|
1683
|
+
}
|
|
1684
|
+
if (!client) {
|
|
1685
|
+
try {
|
|
1686
|
+
await resources?.advance(cleanupFailed ? "cleanup_required" : "closed");
|
|
1687
|
+
} catch {
|
|
1688
|
+
cleanupFailed = true;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
if (cleanupFailed)
|
|
1692
|
+
throw new Error("MCP server cleanup was not confirmed.");
|
|
1693
|
+
throw new Error(`MCP admitted server launch failed (${phase}).`);
|
|
1694
|
+
} finally {
|
|
1695
|
+
if (privateRoot)
|
|
1696
|
+
await rm2(privateRoot, { recursive: true, force: true });
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
async function launchDockerMcpTools(options) {
|
|
1700
|
+
return launchMcpToolsOwned(options, false);
|
|
1701
|
+
}
|
|
1702
|
+
async function launchMcpToolsOwned(options, groupRecoveryComplete) {
|
|
1703
|
+
const journal = options.journal ? { ...options.journal, scope: structuredClone(options.journal.scope) } : undefined;
|
|
1704
|
+
if (!journal || !journal.hostId || !journal.store?.loadToolCall || !journal.store.saveToolCall || !journal.store?.claimToolExecution || !journal.store.completeToolExecution || !journal.store.listToolCalls || journal.runId !== options.scope.session || journal.scope?.tenantId !== options.scope.tenant || journal.scope.userId !== options.scope.principal) {
|
|
1705
|
+
await discardMcpWorkspaceSnapshot(options.snapshot);
|
|
1706
|
+
throw new Error("MCP tool launch requires a matching durable journal.");
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
const recovery = groupRecoveryComplete ? { deferred: 0 } : await recoverDockerMcpResources(journal);
|
|
1710
|
+
if (recovery.deferred > 0)
|
|
1711
|
+
throw new Error("Unresolved MCP resources.");
|
|
1712
|
+
} catch {
|
|
1713
|
+
await discardMcpWorkspaceSnapshot(options.snapshot);
|
|
1714
|
+
throw new Error("MCP tool launch requires confirmed recovery of prior resources.");
|
|
1715
|
+
}
|
|
1716
|
+
const proposal = structuredClone(options.proposal);
|
|
1717
|
+
const sensitiveValues = Object.values(proposal.environment);
|
|
1718
|
+
const client = await launchDockerMcpServer({ ...options, resourceJournal: journal, proposal, resolveSecret: async (reference, signal) => {
|
|
1719
|
+
const value = await options.resolveSecret(reference, signal);
|
|
1720
|
+
if (value !== undefined)
|
|
1721
|
+
sensitiveValues.push(value);
|
|
1722
|
+
return value;
|
|
1723
|
+
} });
|
|
1724
|
+
try {
|
|
1725
|
+
const tools = await createIsolatedMcpTools({ client, proposal, sensitiveValues, journal });
|
|
1726
|
+
const hostSession = issueMcpHostSession({ tools, ...journal, proposal, close: () => client.close() });
|
|
1727
|
+
return Object.freeze({ tools, hostSession, close: () => closeMcpHostSession(hostSession) });
|
|
1728
|
+
} catch {
|
|
1729
|
+
await client.close();
|
|
1730
|
+
throw new Error("MCP admitted tool setup failed.");
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
async function launchDockerMcpGroup(input, signal) {
|
|
1734
|
+
const launched = [];
|
|
1735
|
+
try {
|
|
1736
|
+
if (!input.length || input.length > 8)
|
|
1737
|
+
throw new Error("Invalid MCP group size.");
|
|
1738
|
+
const first = input[0];
|
|
1739
|
+
const prefixes = new Set;
|
|
1740
|
+
for (const item of input) {
|
|
1741
|
+
if (!item.journal || !first.journal || item.journal.store !== first.journal.store || item.journal.hostId !== first.journal.hostId || item.journal.runId !== first.journal.runId || item.journal.scope.tenantId !== first.journal.scope.tenantId || item.journal.scope.userId !== first.journal.scope.userId || item.journal.scope.namespace !== first.journal.scope.namespace || item.scope.workspace !== first.scope.workspace || item.proposal.snapshotDigest !== first.proposal.snapshotDigest)
|
|
1742
|
+
throw new Error("MCP group identity mismatch.");
|
|
1743
|
+
const prefix = item.proposal.serverId.replace(/-/g, "_");
|
|
1744
|
+
if (prefixes.has(prefix))
|
|
1745
|
+
throw new Error("MCP group namespace collision.");
|
|
1746
|
+
prefixes.add(prefix);
|
|
1747
|
+
}
|
|
1748
|
+
for (const item of input) {
|
|
1749
|
+
if (signal?.aborted)
|
|
1750
|
+
throw new Error("MCP group launch cancelled.");
|
|
1751
|
+
launched.push(await launchMcpToolsOwned(item, launched.length > 0));
|
|
1752
|
+
}
|
|
1753
|
+
if (signal?.aborted)
|
|
1754
|
+
throw new Error("MCP group launch cancelled.");
|
|
1755
|
+
const hostSession = combineMcpHostSessions(launched.map((session) => session.hostSession));
|
|
1756
|
+
return Object.freeze({ hostSession, close: () => closeMcpHostSession(hostSession) });
|
|
1757
|
+
} catch {
|
|
1758
|
+
const cleanup = await Promise.allSettled([
|
|
1759
|
+
...launched.map((session) => session.close()),
|
|
1760
|
+
...input.map((item) => discardMcpWorkspaceSnapshot(item.snapshot))
|
|
1761
|
+
]);
|
|
1762
|
+
if (cleanup.some((result) => result.status === "rejected"))
|
|
1763
|
+
throw new Error("MCP group cleanup was not confirmed.");
|
|
1764
|
+
throw new Error("MCP group admission or launch failed.");
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
export { prepareMcpWorkspaceSnapshot, discardMcpWorkspaceSnapshot, mcpResourcePlanSchema, mcpResourceOutputSchema, matchesMcpResourceScope, recoverDockerMcpResources, McpStdioAdmissionError, createMcpStdioAdmissionAuthority, launchDockerMcpTools, launchDockerMcpGroup };
|