@akira-tl/forgerelay 1.0.1 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +2 -0
  3. package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
  4. package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
  5. package/dist/mcp/filesystem/filesystem-tools.js +7 -3
  6. package/dist/mcp/hooks/command-runner.js +12 -7
  7. package/dist/mcp/hooks/external-mcp-transform.js +224 -0
  8. package/dist/mcp/hooks/hook-cli.js +3 -0
  9. package/dist/mcp/hooks/hooks.js +49 -3
  10. package/dist/mcp/operations/bulk-read.js +7 -6
  11. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +42 -0
  12. package/dist/mcp/operations/external-mcp/external-mcp.js +227 -0
  13. package/dist/mcp/operations/media-content.js +28 -0
  14. package/dist/mcp/server/core/activity-support.js +2 -2
  15. package/dist/mcp/server/core/capabilities/external-mcp.js +31 -0
  16. package/dist/mcp/server/core/capabilities.js +10 -0
  17. package/dist/mcp/server/core/capability-registry.js +2 -0
  18. package/dist/mcp/server/core/tool-support.js +29 -0
  19. package/dist/mcp/server/operations/runtime/filesystem-tools.js +57 -21
  20. package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -4
  21. package/dist/mcp/server/transport/http-server.js +1 -1
  22. package/dist/runtime/config/config.js +4 -0
  23. package/dist/runtime/config/external-mcp-config.js +92 -0
  24. package/dist/runtime/testing/server-fixture.js +6 -1
  25. package/dist/server.js +4 -1
  26. package/dist/subagents/sessions/mcp/audit.js +81 -0
  27. package/dist/workspaces/relay/result-support.js +19 -0
  28. package/dist/workspaces/relay/tests/test-support.js +3 -0
  29. package/dist/workspaces/relay/workspace-relay.js +6 -4
  30. package/docs/configuration.md +50 -2
  31. package/package.json +2 -1
  32. package/scripts/debug/accept/bootstrap.mjs +3 -0
  33. package/scripts/debug/accept/harness.mjs +1 -1
  34. package/scripts/debug/accept/media.mjs +335 -0
  35. package/scripts/debug/accept.mjs +11 -1
  36. package/scripts/debug/relay-accept/support.mjs +40 -0
  37. 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
+ }
@@ -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
- ["definition", "hover", "references", "documentSymbols", "workspaceSymbols", "diagnostics"],
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",