@akira-tl/forgerelay 1.0.0 → 1.1.0
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 +25 -0
- package/README.md +2 -0
- package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
- package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
- package/dist/mcp/filesystem/filesystem-tools.js +7 -3
- package/dist/mcp/hooks/command-runner.js +12 -7
- package/dist/mcp/hooks/external-mcp-transform.js +224 -0
- package/dist/mcp/hooks/hook-cli.js +3 -0
- package/dist/mcp/hooks/hooks.js +49 -3
- package/dist/mcp/operations/bulk-read.js +7 -6
- package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +42 -0
- package/dist/mcp/operations/external-mcp/external-mcp.js +227 -0
- package/dist/mcp/operations/media-content.js +28 -0
- package/dist/mcp/server/core/activity-support.js +2 -2
- package/dist/mcp/server/core/capabilities/external-mcp.js +31 -0
- package/dist/mcp/server/core/capabilities.js +10 -0
- package/dist/mcp/server/core/capability-registry.js +2 -0
- package/dist/mcp/server/core/tool-support.js +35 -0
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +57 -21
- package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -4
- package/dist/mcp/server/transport/http-server.js +1 -1
- package/dist/mcp/server/workspace/runtime/workspace-open.js +11 -1
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +40 -2
- package/dist/runtime/config/config.js +4 -0
- package/dist/runtime/config/external-mcp-config.js +92 -0
- package/dist/runtime/logging/logger.js +11 -0
- package/dist/runtime/testing/server-fixture.js +6 -1
- package/dist/server.js +4 -1
- package/dist/subagents/sessions/mcp/audit.js +81 -0
- package/dist/workspaces/relay/result-support.js +19 -0
- package/dist/workspaces/relay/tests/test-support.js +3 -0
- package/dist/workspaces/relay/workspace-relay.js +6 -4
- package/docs/configuration.md +50 -2
- package/package.json +2 -1
- package/scripts/debug/accept/bootstrap.mjs +3 -0
- package/scripts/debug/accept/harness.mjs +1 -1
- package/scripts/debug/accept/media.mjs +335 -0
- package/scripts/debug/accept.mjs +11 -1
- package/scripts/debug/relay-accept/support.mjs +40 -0
- package/scripts/debug/relay-accept.mjs +8 -9
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { deflateSync } from "node:zlib";
|
|
16
|
+
import { debugBaseUrl, debugMcpUrl, debugRoot, repoRoot } from "../runtime.mjs";
|
|
17
|
+
import {
|
|
18
|
+
assertDebugPortFree,
|
|
19
|
+
authorizeDebugClient,
|
|
20
|
+
callTool,
|
|
21
|
+
curlRequest,
|
|
22
|
+
initializeRequest,
|
|
23
|
+
jsonRequest,
|
|
24
|
+
mcpHeaders,
|
|
25
|
+
mcpRequest,
|
|
26
|
+
pass,
|
|
27
|
+
stopServer,
|
|
28
|
+
toolText,
|
|
29
|
+
waitForHealth,
|
|
30
|
+
} from "./support.mjs";
|
|
31
|
+
|
|
32
|
+
const acceptanceRoot = resolve(debugRoot, "media-acceptance");
|
|
33
|
+
const configDir = join(acceptanceRoot, "config");
|
|
34
|
+
const stateDir = join(acceptanceRoot, "state");
|
|
35
|
+
const workspaceRoot = join(acceptanceRoot, "workspace");
|
|
36
|
+
const outsideRoot = resolve(tmpdir(), `forgerelay-media-outside-${randomUUID()}`);
|
|
37
|
+
const ownerToken = randomBytes(32).toString("base64url");
|
|
38
|
+
const credentialSentinel = "MEDIA_ACCEPTANCE_EXTERNAL_MCP_CREDENTIAL_SECRET";
|
|
39
|
+
const directImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZcXcAAAAASUVORK5CYII=";
|
|
40
|
+
const externalFixture = fileURLToPath(
|
|
41
|
+
new URL("../../../src/mcp/operations/external-mcp/test-fixtures/external-mcp-server.mjs", import.meta.url),
|
|
42
|
+
);
|
|
43
|
+
const transformScriptPath = join(workspaceRoot, ".forgerelay", "path-image-transform.mjs");
|
|
44
|
+
const transformHookPath = join(workspaceRoot, ".forgerelay", "hooks", "20-render-path-image.json");
|
|
45
|
+
|
|
46
|
+
await assertDebugPortFree();
|
|
47
|
+
rmSync(acceptanceRoot, { recursive: true, force: true });
|
|
48
|
+
rmSync(outsideRoot, { recursive: true, force: true });
|
|
49
|
+
mkdirSync(configDir, { recursive: true });
|
|
50
|
+
mkdirSync(stateDir, { recursive: true });
|
|
51
|
+
mkdirSync(join(workspaceRoot, "renders"), { recursive: true });
|
|
52
|
+
mkdirSync(join(workspaceRoot, ".forgerelay", "hooks"), { recursive: true });
|
|
53
|
+
mkdirSync(outsideRoot, { recursive: true });
|
|
54
|
+
|
|
55
|
+
const visualSentinel = createVisualSentinelPng();
|
|
56
|
+
const visualSentinelBase64 = visualSentinel.toString("base64");
|
|
57
|
+
writeFileSync(join(workspaceRoot, "visual-sentinel.png"), visualSentinel);
|
|
58
|
+
writeFileSync(join(workspaceRoot, "renders", "output.png"), visualSentinel);
|
|
59
|
+
writeFileSync(join(outsideRoot, "outside.txt"), "outside allowed root\n");
|
|
60
|
+
writeFileSync(join(workspaceRoot, "README.md"), "media acceptance workspace\n");
|
|
61
|
+
|
|
62
|
+
const mediaMaxBytes = visualSentinel.byteLength + Math.max(32, Math.floor(visualSentinel.byteLength / 2));
|
|
63
|
+
assert.ok(mediaMaxBytes >= visualSentinel.byteLength);
|
|
64
|
+
assert.ok(mediaMaxBytes < visualSentinel.byteLength * 2);
|
|
65
|
+
|
|
66
|
+
writeFileSync(join(configDir, "config.json"), `${JSON.stringify({
|
|
67
|
+
host: "127.0.0.1",
|
|
68
|
+
port: 7677,
|
|
69
|
+
allowedRoots: [workspaceRoot],
|
|
70
|
+
publicBaseUrl: debugBaseUrl,
|
|
71
|
+
allowedHosts: ["localhost", "127.0.0.1", "::1"],
|
|
72
|
+
stateDir,
|
|
73
|
+
worktreeRoot: join(acceptanceRoot, "worktrees"),
|
|
74
|
+
mediaMaxBytes,
|
|
75
|
+
mcpServers: {
|
|
76
|
+
blender: {
|
|
77
|
+
transport: "stdio",
|
|
78
|
+
command: process.execPath,
|
|
79
|
+
args: [externalFixture],
|
|
80
|
+
env: { FORGERELAY_MEDIA_ACCEPTANCE_SECRET: credentialSentinel },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}, null, 2)}\n`);
|
|
84
|
+
writeFileSync(join(configDir, "auth.json"), `${JSON.stringify({
|
|
85
|
+
ownerToken,
|
|
86
|
+
instanceId: "media-acceptance-7677",
|
|
87
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
88
|
+
|
|
89
|
+
const cleanEnv = Object.fromEntries(
|
|
90
|
+
Object.entries(process.env).filter(([name]) =>
|
|
91
|
+
name !== "HOST" && name !== "PORT" && !name.startsWith("FORGERELAY_")
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
const env = {
|
|
95
|
+
...cleanEnv,
|
|
96
|
+
HOST: "127.0.0.1",
|
|
97
|
+
PORT: "7677",
|
|
98
|
+
FORGERELAY_CONFIG_DIR: configDir,
|
|
99
|
+
FORGERELAY_OAUTH_OWNER_TOKEN: ownerToken,
|
|
100
|
+
FORGERELAY_PUBLIC_BASE_URL: debugBaseUrl,
|
|
101
|
+
FORGERELAY_TOOL_MODE: "full",
|
|
102
|
+
FORGERELAY_WIDGETS: "off",
|
|
103
|
+
FORGERELAY_LOG_LEVEL: "info",
|
|
104
|
+
FORGERELAY_LOG_FORMAT: "json",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let serverLogs = "";
|
|
108
|
+
const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
|
|
109
|
+
cwd: repoRoot,
|
|
110
|
+
env,
|
|
111
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
+
});
|
|
113
|
+
server.stdout?.on("data", (chunk) => { serverLogs += chunk.toString(); });
|
|
114
|
+
server.stderr?.on("data", (chunk) => { serverLogs += chunk.toString(); });
|
|
115
|
+
|
|
116
|
+
let completed = false;
|
|
117
|
+
try {
|
|
118
|
+
await waitForHealth(server);
|
|
119
|
+
pass("media acceptance health", debugBaseUrl);
|
|
120
|
+
|
|
121
|
+
const authorizationServer = jsonRequest(`${debugBaseUrl}/.well-known/oauth-authorization-server`);
|
|
122
|
+
assert.equal(authorizationServer.status, 200);
|
|
123
|
+
const oauth = authorizeDebugClient(authorizationServer.json, ownerToken);
|
|
124
|
+
const initialized = mcpRequest(oauth.accessToken, undefined, initializeRequest(1));
|
|
125
|
+
const sessionId = initialized.response.headers.get("mcp-session-id");
|
|
126
|
+
assert.ok(sessionId);
|
|
127
|
+
const initializedNotification = curlRequest({
|
|
128
|
+
method: "POST",
|
|
129
|
+
url: debugMcpUrl,
|
|
130
|
+
headers: mcpHeaders(oauth.accessToken, sessionId),
|
|
131
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
132
|
+
});
|
|
133
|
+
assert.equal(initializedNotification.status, 202);
|
|
134
|
+
pass("media acceptance OAuth/MCP", "real HTTP MCP session initialized on 7677");
|
|
135
|
+
|
|
136
|
+
let requestId = 10;
|
|
137
|
+
const nextId = () => requestId++;
|
|
138
|
+
const conversation = { "openai/session": "media-acceptance-7677" };
|
|
139
|
+
const opened = callTool(oauth.accessToken, sessionId, nextId(), "open_workspace", {
|
|
140
|
+
path: workspaceRoot,
|
|
141
|
+
context: "none",
|
|
142
|
+
}, conversation);
|
|
143
|
+
assert.equal(opened.isError, undefined, toolText(opened));
|
|
144
|
+
const workspaceId = opened.structuredContent.workspaceId;
|
|
145
|
+
assert.match(workspaceId, /^ws_/);
|
|
146
|
+
|
|
147
|
+
const readImage = callTool(oauth.accessToken, sessionId, nextId(), "read", {
|
|
148
|
+
workspaceId,
|
|
149
|
+
path: "visual-sentinel.png",
|
|
150
|
+
}, conversation);
|
|
151
|
+
assert.equal(readImage.isError, undefined, toolText(readImage));
|
|
152
|
+
const readImageContent = readImage.content.find((entry) => entry.type === "image");
|
|
153
|
+
assert.ok(readImageContent);
|
|
154
|
+
assert.equal(readImageContent.mimeType, "image/png");
|
|
155
|
+
assert.equal(readImageContent.data, visualSentinelBase64);
|
|
156
|
+
assert.deepEqual(readImage.structuredContent.media, {
|
|
157
|
+
type: "image",
|
|
158
|
+
mimeType: "image/png",
|
|
159
|
+
bytes: visualSentinel.byteLength,
|
|
160
|
+
});
|
|
161
|
+
assert.equal(JSON.stringify(readImage.structuredContent).includes(visualSentinelBase64), false);
|
|
162
|
+
assert.equal(JSON.stringify(readImage._meta ?? {}).includes(visualSentinelBase64), false);
|
|
163
|
+
pass(
|
|
164
|
+
"real 7677 image Read",
|
|
165
|
+
`${visualSentinel.byteLength} PNG bytes reached the Host-facing MCP result unchanged`,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const outsideRead = callTool(oauth.accessToken, sessionId, nextId(), "read", {
|
|
169
|
+
path: join(outsideRoot, "outside.txt"),
|
|
170
|
+
}, conversation);
|
|
171
|
+
assert.equal(outsideRead.isError, true);
|
|
172
|
+
pass("real 7677 allowed-root containment", "unscoped Read outside the configured root was rejected");
|
|
173
|
+
|
|
174
|
+
const externalCall = (tool) => callTool(oauth.accessToken, sessionId, nextId(), "capability", {
|
|
175
|
+
workspaceId,
|
|
176
|
+
name: "mcp.external",
|
|
177
|
+
action: "run",
|
|
178
|
+
arguments: { operation: "call", server: "blender", tool },
|
|
179
|
+
}, conversation);
|
|
180
|
+
|
|
181
|
+
const directImage = externalCall("direct_image");
|
|
182
|
+
assert.equal(directImage.isError, undefined, toolText(directImage));
|
|
183
|
+
const externalImage = directImage.content.find((entry) => entry.type === "image");
|
|
184
|
+
assert.ok(externalImage);
|
|
185
|
+
assert.equal(externalImage.mimeType, "image/png");
|
|
186
|
+
assert.equal(externalImage.data, directImageBase64);
|
|
187
|
+
assert.equal(JSON.stringify(directImage.structuredContent).includes(directImageBase64), false);
|
|
188
|
+
pass("real 7677 external MCP ImageContent", "published mcp.external Capability forwarded direct image content");
|
|
189
|
+
|
|
190
|
+
const pathOnly = externalCall("path_only");
|
|
191
|
+
assert.equal(pathOnly.isError, undefined, toolText(pathOnly));
|
|
192
|
+
assert.equal(pathOnly.content.some((entry) => entry.type === "image"), false);
|
|
193
|
+
assert.equal(toolText(pathOnly), "renders/output.png");
|
|
194
|
+
pass("external MCP default reference pass-through", "path-only result stayed path-only without a transform Hook");
|
|
195
|
+
|
|
196
|
+
installPathTransform({ duplicate: false });
|
|
197
|
+
const transformed = externalCall("path_only");
|
|
198
|
+
assert.equal(transformed.isError, undefined, toolText(transformed));
|
|
199
|
+
const transformedImage = transformed.content.find((entry) => entry.type === "image");
|
|
200
|
+
assert.ok(transformedImage);
|
|
201
|
+
assert.equal(transformedImage.mimeType, "image/png");
|
|
202
|
+
assert.equal(transformedImage.data, visualSentinelBase64);
|
|
203
|
+
assert.equal(JSON.stringify(transformed.structuredContent).includes(visualSentinelBase64), false);
|
|
204
|
+
pass("external MCP explicit transform", "the same path-only result became ImageContent only after the matching Hook was installed");
|
|
205
|
+
|
|
206
|
+
installPathTransform({ duplicate: true });
|
|
207
|
+
const transformedOversize = externalCall("path_only");
|
|
208
|
+
assert.equal(transformedOversize.isError, true);
|
|
209
|
+
assert.match(toolText(transformedOversize), /mcp\.media_too_large/i);
|
|
210
|
+
assert.equal(transformedOversize.content.some((entry) => entry.type === "image"), false);
|
|
211
|
+
assert.equal(JSON.stringify(transformedOversize).includes(visualSentinelBase64), false);
|
|
212
|
+
pass("transformed media budget", "post-transform media was revalidated against the configured aggregate limit");
|
|
213
|
+
|
|
214
|
+
const malformed = externalCall("malformed_image");
|
|
215
|
+
assert.equal(malformed.isError, true);
|
|
216
|
+
assert.match(toolText(malformed), /mcp\.media_malformed/i);
|
|
217
|
+
const upstreamFailure = externalCall("fail");
|
|
218
|
+
assert.equal(upstreamFailure.isError, true);
|
|
219
|
+
assert.equal(JSON.stringify(upstreamFailure).includes(credentialSentinel), false);
|
|
220
|
+
pass("external MCP negative boundaries", "malformed media and upstream failure stayed bounded and credential-safe");
|
|
221
|
+
|
|
222
|
+
completed = true;
|
|
223
|
+
} finally {
|
|
224
|
+
await stopServer(server).catch(() => undefined);
|
|
225
|
+
if (completed) {
|
|
226
|
+
assertDirectoryDoesNotContain(stateDir, visualSentinelBase64);
|
|
227
|
+
assertDirectoryDoesNotContain(stateDir, directImageBase64);
|
|
228
|
+
assert.equal(serverLogs.includes(visualSentinelBase64), false, "server logs persisted transformed/read image base64");
|
|
229
|
+
assert.equal(serverLogs.includes(directImageBase64), false, "server logs persisted external image base64");
|
|
230
|
+
assert.equal(serverLogs.includes(credentialSentinel), false, "server logs leaked external MCP credentials");
|
|
231
|
+
pass("media persistence boundary", "image base64 stayed out of ForgeRelay state and logs");
|
|
232
|
+
}
|
|
233
|
+
rmSync(outsideRoot, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (completed) {
|
|
237
|
+
console.log("Media acceptance passed.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function installPathTransform({ duplicate }) {
|
|
241
|
+
writeFileSync(transformScriptPath, [
|
|
242
|
+
'import { readFileSync } from "node:fs";',
|
|
243
|
+
'import { resolve } from "node:path";',
|
|
244
|
+
'let input = "";',
|
|
245
|
+
'for await (const chunk of process.stdin) input += chunk;',
|
|
246
|
+
'const envelope = JSON.parse(input);',
|
|
247
|
+
'const path = envelope.result?.content?.find((entry) => entry.type === "text")?.text;',
|
|
248
|
+
'if (typeof path !== "string") throw new Error("expected path-only upstream result");',
|
|
249
|
+
'const data = readFileSync(resolve(process.cwd(), path)).toString("base64");',
|
|
250
|
+
`const images = Array.from({ length: ${duplicate ? 2 : 1} }, () => ({ type: "image", data, mimeType: "image/png" }));`,
|
|
251
|
+
'process.stdout.write(JSON.stringify({ version: 1, result: { content: images } }));',
|
|
252
|
+
'',
|
|
253
|
+
].join("\n"));
|
|
254
|
+
writeFileSync(transformHookPath, `${JSON.stringify({
|
|
255
|
+
event: "ExternalMcpAfterForward",
|
|
256
|
+
matcher: {
|
|
257
|
+
capability: "mcp.external",
|
|
258
|
+
externalServer: "blender",
|
|
259
|
+
externalTool: "path_only",
|
|
260
|
+
},
|
|
261
|
+
command: 'node ".forgerelay/path-image-transform.mjs"',
|
|
262
|
+
timeoutSeconds: 10,
|
|
263
|
+
report: true,
|
|
264
|
+
}, null, 2)}\n`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function assertDirectoryDoesNotContain(root, needle) {
|
|
268
|
+
if (!existsSync(root)) return;
|
|
269
|
+
const target = Buffer.from(needle, "utf8");
|
|
270
|
+
const pending = [root];
|
|
271
|
+
while (pending.length > 0) {
|
|
272
|
+
const directory = pending.pop();
|
|
273
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
274
|
+
const path = join(directory, entry.name);
|
|
275
|
+
if (entry.isDirectory()) pending.push(path);
|
|
276
|
+
else if (entry.isFile()) {
|
|
277
|
+
assert.equal(readFileSync(path).includes(target), false, `ForgeRelay persisted media base64 in ${path}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function createVisualSentinelPng() {
|
|
284
|
+
const width = 180;
|
|
285
|
+
const height = 100;
|
|
286
|
+
const stride = (width * 4) + 1;
|
|
287
|
+
const raw = Buffer.alloc(stride * height);
|
|
288
|
+
for (let y = 0; y < height; y += 1) {
|
|
289
|
+
const row = y * stride;
|
|
290
|
+
raw[row] = 0;
|
|
291
|
+
for (let x = 0; x < width; x += 1) {
|
|
292
|
+
const offset = row + 1 + (x * 4);
|
|
293
|
+
let pixel = [246, 246, 246, 255];
|
|
294
|
+
if (x >= 14 && x < 64 && y >= 20 && y < 80) pixel = [220, 36, 48, 255];
|
|
295
|
+
if (x >= 116 && x < 166 && y >= 20 && y < 80) pixel = [36, 86, 220, 255];
|
|
296
|
+
if (x >= 84 && x < 96 && y >= 10 && y < 90) pixel = [18, 18, 18, 255];
|
|
297
|
+
raw[offset] = pixel[0];
|
|
298
|
+
raw[offset + 1] = pixel[1];
|
|
299
|
+
raw[offset + 2] = pixel[2];
|
|
300
|
+
raw[offset + 3] = pixel[3];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const ihdr = Buffer.alloc(13);
|
|
305
|
+
ihdr.writeUInt32BE(width, 0);
|
|
306
|
+
ihdr.writeUInt32BE(height, 4);
|
|
307
|
+
ihdr[8] = 8;
|
|
308
|
+
ihdr[9] = 6;
|
|
309
|
+
return Buffer.concat([
|
|
310
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
311
|
+
pngChunk("IHDR", ihdr),
|
|
312
|
+
pngChunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
313
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function pngChunk(type, data) {
|
|
318
|
+
const typeBuffer = Buffer.from(type, "ascii");
|
|
319
|
+
const length = Buffer.alloc(4);
|
|
320
|
+
length.writeUInt32BE(data.length, 0);
|
|
321
|
+
const crc = Buffer.alloc(4);
|
|
322
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
|
323
|
+
return Buffer.concat([length, typeBuffer, data, crc]);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function crc32(buffer) {
|
|
327
|
+
let crc = 0xffffffff;
|
|
328
|
+
for (const byte of buffer) {
|
|
329
|
+
crc ^= byte;
|
|
330
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
331
|
+
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
335
|
+
}
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -263,7 +263,16 @@ try {
|
|
|
263
263
|
assert.ok(Array.isArray(codeIntelligenceSchema.oneOf));
|
|
264
264
|
assert.deepEqual(
|
|
265
265
|
codeIntelligenceSchema.oneOf.map((variant) => variant.properties.operation.const),
|
|
266
|
-
[
|
|
266
|
+
[
|
|
267
|
+
"definition",
|
|
268
|
+
"hover",
|
|
269
|
+
"references",
|
|
270
|
+
"documentSymbols",
|
|
271
|
+
"workspaceSymbols",
|
|
272
|
+
"diagnostics",
|
|
273
|
+
"managed.status",
|
|
274
|
+
"managed.install",
|
|
275
|
+
],
|
|
267
276
|
);
|
|
268
277
|
for (const operation of ["references", "documentSymbols", "workspaceSymbols", "diagnostics"]) {
|
|
269
278
|
const boundedSchema = codeIntelligenceSchema.oneOf.find(
|
|
@@ -312,6 +321,7 @@ try {
|
|
|
312
321
|
"shell-processes",
|
|
313
322
|
"code-intelligence",
|
|
314
323
|
"workspace-tasks",
|
|
324
|
+
"workspace-checkpoints",
|
|
315
325
|
"batch-execution",
|
|
316
326
|
]);
|
|
317
327
|
const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
|
|
@@ -119,6 +119,46 @@ export function assertSafeRelayInspection(result, { executionWorkspaceId, execut
|
|
|
119
119
|
assert.doesNotMatch(json, /"ws_[0-9a-f]{10}"/);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
const RELAY_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
|
123
|
+
|
|
124
|
+
export function installRelayImageFixture(executionCheckout) {
|
|
125
|
+
writeFileSync(join(executionCheckout, "relay-image.png"), Buffer.from(RELAY_IMAGE_BASE64, "base64"));
|
|
126
|
+
return RELAY_IMAGE_BASE64;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function assertRelayedImageResult(result, expectedBase64, gatewayStateDir) {
|
|
130
|
+
assertToolOk(result, "read image through Workspace Relay");
|
|
131
|
+
const image = result.content.find((entry) => entry.type === "image");
|
|
132
|
+
assert.ok(image, "Workspace Relay did not preserve MCP ImageContent");
|
|
133
|
+
assert.equal(image.mimeType, "image/png");
|
|
134
|
+
assert.equal(image.data, expectedBase64);
|
|
135
|
+
assert.deepEqual(result.structuredContent.media, {
|
|
136
|
+
type: "image",
|
|
137
|
+
mimeType: "image/png",
|
|
138
|
+
bytes: Buffer.byteLength(expectedBase64, "base64"),
|
|
139
|
+
});
|
|
140
|
+
assert.equal(JSON.stringify(result.structuredContent).includes(expectedBase64), false);
|
|
141
|
+
assert.equal(JSON.stringify(result._meta ?? {}).includes(expectedBase64), false);
|
|
142
|
+
assertDirectoryDoesNotContain(gatewayStateDir, expectedBase64);
|
|
143
|
+
pass("Relay ImageContent passthrough", "Execution 7678 image bytes and MIME reached Gateway 7677 unchanged without Gateway persistence");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertDirectoryDoesNotContain(root, needle) {
|
|
147
|
+
if (!existsSync(root)) return;
|
|
148
|
+
const target = Buffer.from(needle, "utf8");
|
|
149
|
+
const pending = [root];
|
|
150
|
+
while (pending.length > 0) {
|
|
151
|
+
const directory = pending.pop();
|
|
152
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
153
|
+
const path = join(directory, entry.name);
|
|
154
|
+
if (entry.isDirectory()) pending.push(path);
|
|
155
|
+
else if (entry.isFile()) {
|
|
156
|
+
assert.equal(readFileSync(path).includes(target), false, `Gateway persisted relayed image base64 in ${path}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
122
162
|
export function assertToolOk(result, label) {
|
|
123
163
|
assert.equal(result.isError, undefined, `${label}: ${toolText(result)}`);
|
|
124
164
|
}
|
|
@@ -3,18 +3,11 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
4
|
import { once } from "node:events";
|
|
5
5
|
import { connect } from "node:net";
|
|
6
|
-
import {
|
|
7
|
-
existsSync,
|
|
8
|
-
mkdirSync,
|
|
9
|
-
readFileSync,
|
|
10
|
-
readdirSync,
|
|
11
|
-
rmSync,
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
7
|
import { join, resolve } from "node:path";
|
|
15
8
|
import { setTimeout as delay } from "node:timers/promises";
|
|
16
9
|
import { debugRoot, repoRoot } from "./runtime.mjs";
|
|
17
|
-
import { relayAcceptanceTopology, assertPortsFree, setupGitProject, runGit, taskStateFiles, findTaskStateFile, assertTaskBodyAbsentFromGateway, assertSafeRelayInspection, assertToolOk, toolText, pass } from "./relay-accept/support.mjs";
|
|
10
|
+
import { relayAcceptanceTopology, assertPortsFree, setupGitProject, runGit, taskStateFiles, findTaskStateFile, assertTaskBodyAbsentFromGateway, assertSafeRelayInspection, installRelayImageFixture, assertRelayedImageResult, assertToolOk, toolText, pass } from "./relay-accept/support.mjs";
|
|
18
11
|
|
|
19
12
|
const {
|
|
20
13
|
gatewayPort, executionPort, gatewayBaseUrl, gatewayMcpUrl, executionBaseUrl,
|
|
@@ -30,6 +23,7 @@ await assertPortsFree([gatewayPort, executionPort]);
|
|
|
30
23
|
rmSync(acceptanceRoot, { recursive: true, force: true });
|
|
31
24
|
mkdirSync(gatewayLocalProject, { recursive: true });
|
|
32
25
|
mkdirSync(executionCheckout, { recursive: true });
|
|
26
|
+
const relayImageBase64 = installRelayImageFixture(executionCheckout);
|
|
33
27
|
mkdirSync(gatewayConfigDir, { recursive: true });
|
|
34
28
|
mkdirSync(executionConfigDir, { recursive: true });
|
|
35
29
|
writeFileSync(join(gatewayLocalProject, "sentinel.txt"), "gateway-local-content\n");
|
|
@@ -112,6 +106,10 @@ try {
|
|
|
112
106
|
assert.equal(openedB.structuredContent.workspaceId, checkoutRelayId);
|
|
113
107
|
pass("relay persistent identity", `two Gateway conversations reused ${checkoutRelayId}`);
|
|
114
108
|
|
|
109
|
+
assertRelayedImageResult(callTool(gatewayMcpUrl, oauth.accessToken, sessionB, nextId(), "read", {
|
|
110
|
+
workspaceId: checkoutRelayId, path: "relay-image.png",
|
|
111
|
+
}, conversationB), relayImageBase64, gatewayStateDir);
|
|
112
|
+
|
|
115
113
|
const checkoutList = callTool(gatewayMcpUrl, oauth.accessToken, sessionA, nextId(), "capability", {
|
|
116
114
|
workspaceId: checkoutRelayId,
|
|
117
115
|
name: "workspace.tasks",
|
|
@@ -508,6 +506,7 @@ try {
|
|
|
508
506
|
ports: [gatewayPort, executionPort],
|
|
509
507
|
verified: [
|
|
510
508
|
"persistent relay checkout identity across Gateway conversations",
|
|
509
|
+
"MCP ImageContent passthrough from Execution 7678 through Gateway 7677",
|
|
511
510
|
"Execution-owned Task state and body-free reminders",
|
|
512
511
|
"safe relayed inspection projection",
|
|
513
512
|
"close/reopen/delete parity",
|