@akira-tl/forgerelay 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -0
  3. package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
  4. package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
  5. package/dist/activity/runtime/mcp-query-tools.js +16 -10
  6. package/dist/cli/mcp/external-mcp.js +481 -0
  7. package/dist/cli/mcp/status.js +295 -0
  8. package/dist/cli.js +14 -1
  9. package/dist/lsp/test-support/server-fixture.js +1 -2
  10. package/dist/mcp/filesystem/filesystem-tools.js +7 -3
  11. package/dist/mcp/hooks/command-runner.js +12 -7
  12. package/dist/mcp/hooks/external-mcp-transform.js +224 -0
  13. package/dist/mcp/hooks/hook-cli.js +3 -0
  14. package/dist/mcp/hooks/hooks.js +49 -3
  15. package/dist/mcp/oauth/auth-protocol.js +375 -0
  16. package/dist/mcp/oauth/oauth-provider.js +11 -12
  17. package/dist/mcp/oauth/oauth-store.js +2 -2
  18. package/dist/mcp/oauth/router.js +2 -6
  19. package/dist/mcp/operations/batch/executor.js +1 -1
  20. package/dist/mcp/operations/bulk-read.js +7 -6
  21. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
  22. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +59 -0
  23. package/dist/mcp/operations/external-mcp/external-mcp.js +382 -0
  24. package/dist/mcp/operations/media-content.js +28 -0
  25. package/dist/mcp/operations/native-bulk-mutations.js +1 -1
  26. package/dist/mcp/process/tools.js +19 -15
  27. package/dist/mcp/request-context.js +18 -0
  28. package/dist/mcp/request-meta.js +28 -3
  29. package/dist/mcp/server/core/activity-support.js +2 -2
  30. package/dist/mcp/server/core/capabilities/external-mcp.js +31 -0
  31. package/dist/mcp/server/core/capabilities.js +7 -0
  32. package/dist/mcp/server/core/capability-registry.js +2 -0
  33. package/dist/mcp/server/core/tool-support.js +29 -0
  34. package/dist/mcp/server/operations/runtime/filesystem-tools.js +91 -48
  35. package/dist/mcp/server/operations/runtime/operation-runtime.js +18 -11
  36. package/dist/mcp/server/transport/http-server.js +42 -21
  37. package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
  38. package/dist/mcp/server/workspace/runtime/workspace-tools.js +29 -25
  39. package/dist/runtime/config/config.js +4 -0
  40. package/dist/runtime/config/external-mcp-auth-store.js +225 -0
  41. package/dist/runtime/config/external-mcp-config.js +146 -0
  42. package/dist/runtime/config/external-mcp-registry.js +165 -0
  43. package/dist/runtime/testing/server-fixture.js +10 -5
  44. package/dist/server.js +19 -10
  45. package/dist/subagents/sessions/mcp/audit.js +81 -0
  46. package/dist/ui/.vite/manifest.json +297 -298
  47. package/dist/ui/activity-panel-app.html +1 -1
  48. package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
  49. package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
  50. package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
  51. package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
  52. package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
  53. package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
  54. package/dist/ui/assets/c-BquuA6bv.js +1 -0
  55. package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
  56. package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
  57. package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
  58. package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
  59. package/dist/ui/assets/css-Be_T8idh.js +1 -0
  60. package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
  61. package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
  62. package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
  63. package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
  64. package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
  65. package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
  66. package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
  67. package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
  68. package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
  69. package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
  70. package/dist/ui/assets/haml-h18lG0pS.js +1 -0
  71. package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
  72. package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
  73. package/dist/ui/assets/html-DtILqTE1.js +1 -0
  74. package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
  75. package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
  76. package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
  77. package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
  78. package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
  79. package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
  80. package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
  81. package/dist/ui/assets/json-DW2UTv1q.js +1 -0
  82. package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
  83. package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
  84. package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
  85. package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
  86. package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
  87. package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
  88. package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
  89. package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
  90. package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
  91. package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
  92. package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
  93. package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
  94. package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
  95. package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
  96. package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
  97. package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
  98. package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
  99. package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
  100. package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
  101. package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
  102. package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
  103. package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
  104. package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
  105. package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
  106. package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
  107. package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
  108. package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
  109. package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
  110. package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
  111. package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
  112. package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
  113. package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
  114. package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
  115. package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
  116. package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
  117. package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
  118. package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
  119. package/dist/ui/assets/xml-_44rx9_2.js +1 -0
  120. package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
  121. package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
  122. package/dist/workspaces/relay/auth/remote-auth.js +3 -5
  123. package/dist/workspaces/relay/result-support.js +19 -0
  124. package/dist/workspaces/relay/tests/test-support.js +10 -4
  125. package/dist/workspaces/relay/workspace-relay.js +20 -12
  126. package/docs/configuration.md +200 -2
  127. package/docs/debugging.md +8 -5
  128. package/docs/roadmap.md +64 -3
  129. package/docs/security.md +18 -0
  130. package/package.json +12 -4
  131. package/scripts/debug/accept/bootstrap.mjs +5 -0
  132. package/scripts/debug/accept/harness.mjs +10 -3
  133. package/scripts/debug/accept/media.mjs +335 -0
  134. package/scripts/debug/accept/modern-http.mjs +169 -0
  135. package/scripts/debug/accept/support.mjs +10 -6
  136. package/scripts/debug/accept.mjs +12 -1
  137. package/scripts/debug/relay-accept/support.mjs +40 -0
  138. package/scripts/debug/relay-accept.mjs +8 -9
  139. package/scripts/debug/runtime.mjs +4 -1
  140. package/scripts/debug/runtime.test.mjs +9 -0
  141. package/scripts/release/parity-sandbox.mjs +35 -0
  142. package/scripts/release/release-gate.test.mjs +42 -1
  143. package/scripts/release-parity.mjs +3 -22
  144. package/scripts/release-proof.mjs +24 -4
  145. package/scripts/release-proof.test.mjs +12 -4
  146. package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
  147. package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
  148. package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
  149. package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
  150. package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
  151. package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
  152. package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
  153. package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
  154. package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
  155. package/dist/ui/assets/html-dUOs5pA9.js +0 -1
  156. package/dist/ui/assets/java-BFZCrORY.js +0 -1
  157. package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
  158. package/dist/ui/assets/json-BQC_Yp48.js +0 -1
  159. package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
  160. package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
  161. package/dist/ui/assets/r-B5nYsy-M.js +0 -1
  162. package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
  163. package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
  164. package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
  165. package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
  166. package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
  167. package/dist/ui/assets/typescript-BGIABksB.js +0 -1
  168. package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
  169. package/dist/ui/assets/yaml-umbj2r1g.js +0 -1
@@ -198,20 +198,20 @@ export function curlRequest({ method = "GET", url, headers = {}, body }) {
198
198
  return { status, headers: responseHeaders, body: responseBody };
199
199
  }
200
200
 
201
- export async function waitForHealth(child) {
201
+ export async function waitForHealth(child, baseUrl = debugBaseUrl) {
202
202
  for (let attempt = 0; attempt < 60; attempt += 1) {
203
203
  if (child.exitCode !== null) {
204
204
  throw new Error(`debug server exited before health check: ${child.exitCode}`);
205
205
  }
206
206
  try {
207
- const response = jsonRequest(`${debugBaseUrl}/healthz`);
207
+ const response = jsonRequest(`${baseUrl}/healthz`);
208
208
  if (response.status === 200) return response.json;
209
209
  } catch {
210
210
  // Server is still starting.
211
211
  }
212
212
  await delay(100);
213
213
  }
214
- throw new Error("debug server did not become healthy on 127.0.0.1:7677");
214
+ throw new Error(`debug server did not become healthy at ${baseUrl}`);
215
215
  }
216
216
 
217
217
  export async function stopServer(child) {
@@ -397,13 +397,13 @@ export function readHookEntries(path) {
397
397
  .map((line) => JSON.parse(line));
398
398
  }
399
399
 
400
- export async function assertDebugPortFree() {
400
+ export async function assertPortFree(port) {
401
401
  await new Promise((resolvePromise, rejectPromise) => {
402
- const socket = connect({ host: "127.0.0.1", port: 7677 });
402
+ const socket = connect({ host: "127.0.0.1", port });
403
403
  socket.setTimeout(500);
404
404
  socket.once("connect", () => {
405
405
  socket.destroy();
406
- rejectPromise(new Error("debug port 7677 is already in use; stop the existing debug server first"));
406
+ rejectPromise(new Error(`debug port ${port} is already in use; stop the existing debug server first`));
407
407
  });
408
408
  socket.once("error", (error) => {
409
409
  socket.destroy();
@@ -420,6 +420,10 @@ export async function assertDebugPortFree() {
420
420
  });
421
421
  }
422
422
 
423
+ export async function assertDebugPortFree() {
424
+ await assertPortFree(7677);
425
+ }
426
+
423
427
  export function assertCurlAvailable() {
424
428
  const result = spawnSync("curl", ["--version"], { encoding: "utf8" });
425
429
  if (result.status !== 0) {
@@ -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(
@@ -311,7 +320,9 @@ try {
311
320
  "host-integration",
312
321
  "shell-processes",
313
322
  "code-intelligence",
323
+ "external-mcp",
314
324
  "workspace-tasks",
325
+ "workspace-checkpoints",
315
326
  "batch-execution",
316
327
  ]);
317
328
  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",
@@ -90,21 +90,24 @@ export function createInteractiveDebugEnvironment({
90
90
 
91
91
  export function createDebugEnvironment({
92
92
  ownerToken,
93
+ configDir = debugConfigDir,
93
94
  stateDir = resolve(debugRoot, "state"),
94
95
  worktreeRoot = resolve(debugRoot, "worktrees"),
95
96
  hookLog = debugHookLog,
96
97
  widgets = process.env.FORGERELAY_DEBUG_WIDGETS ?? "off",
97
98
  } = {}) {
98
99
  const token = ownerToken ?? process.env.FORGERELAY_DEBUG_OWNER_TOKEN ?? createDebugOwnerToken();
100
+ const resolvedConfigDir = resolve(configDir);
99
101
  mkdirSync(debugRoot, { recursive: true });
100
102
 
101
103
  return {
102
104
  ownerToken: token,
105
+ configDir: resolvedConfigDir,
103
106
  env: {
104
107
  ...process.env,
105
108
  HOST: "127.0.0.1",
106
109
  PORT: "7677",
107
- FORGERELAY_CONFIG_DIR: debugConfigDir,
110
+ FORGERELAY_CONFIG_DIR: resolvedConfigDir,
108
111
  FORGERELAY_PUBLIC_BASE_URL: debugBaseUrl,
109
112
  FORGERELAY_ALLOWED_ROOTS: repoRoot,
110
113
  FORGERELAY_STATE_DIR: stateDir,
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import test from "node:test";
6
6
 
7
7
  import {
8
+ createDebugEnvironment,
8
9
  createInteractiveDebugEnvironment,
9
10
  interactiveDebugConfigDir,
10
11
  interactiveDebugUrls,
@@ -64,6 +65,14 @@ test("interactive debug uses one dedicated persisted config under ~/.forgerelay/
64
65
  assert.equal(result.env.FORGERELAY_LOG_LEVEL, "debug");
65
66
  });
66
67
 
68
+ test("acceptance debug environment may isolate its writable config directory", () => {
69
+ const isolatedConfigDir = join(tmpdir(), "forgerelay-acceptance-config");
70
+ const result = createDebugEnvironment({ configDir: isolatedConfigDir });
71
+
72
+ assert.equal(result.configDir, isolatedConfigDir);
73
+ assert.equal(result.env.FORGERELAY_CONFIG_DIR, isolatedConfigDir);
74
+ });
75
+
67
76
  test("interactive debug config directory may be explicitly relocated without falling back to product config", async (t) => {
68
77
  const { home } = await withDebugConfig(t);
69
78
  const customDir = join(home, "custom-debug-config");
@@ -0,0 +1,35 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { cpSync, mkdirSync, mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ export function createParitySandbox(repoRoot) {
7
+ const sandbox = mkdtempSync(join(tmpdir(), "forgerelay-release-parity-node22-"));
8
+ copyTrackedTree(repoRoot, sandbox);
9
+ initializeGitIndex(sandbox);
10
+ return sandbox;
11
+ }
12
+
13
+ function initializeGitIndex(sandbox) {
14
+ execFileSync("git", ["init", "--quiet"], {
15
+ cwd: sandbox,
16
+ stdio: "ignore",
17
+ });
18
+ execFileSync("git", ["add", "--force", "--all", "--", "."], {
19
+ cwd: sandbox,
20
+ stdio: "ignore",
21
+ });
22
+ }
23
+
24
+ function copyTrackedTree(repoRoot, destinationRoot) {
25
+ const files = execFileSync("git", ["ls-files", "-z"], {
26
+ cwd: repoRoot,
27
+ encoding: "utf8",
28
+ }).split("\0").filter(Boolean);
29
+ for (const relativePath of files) {
30
+ const source = join(repoRoot, relativePath);
31
+ const destination = join(destinationRoot, relativePath);
32
+ mkdirSync(dirname(destination), { recursive: true });
33
+ cpSync(source, destination, { recursive: true, force: true });
34
+ }
35
+ }
@@ -1,8 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
- import { readFile } from "node:fs/promises";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { readFile, realpath, rm } from "node:fs/promises";
3
4
  import { dirname, resolve } from "node:path";
4
5
  import test from "node:test";
5
6
  import { fileURLToPath } from "node:url";
7
+ import { createParitySandbox } from "./parity-sandbox.mjs";
6
8
 
7
9
  const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
8
10
 
@@ -67,6 +69,44 @@ test("architecture gate treats the append-only release-note archive as an explic
67
69
  assert.match(architecture, /"docs\/releases"/);
68
70
  });
69
71
 
72
+ test("release parity sandbox gives architecture an isolated non-empty Git index", async () => {
73
+ const sandbox = createParitySandbox(repoRoot);
74
+ try {
75
+ const architecture = spawnSync(process.execPath, ["scripts/ci/architecture.mjs"], {
76
+ cwd: sandbox,
77
+ encoding: "utf8",
78
+ windowsHide: true,
79
+ shell: false,
80
+ });
81
+ assert.equal(
82
+ architecture.status,
83
+ 0,
84
+ architecture.stderr || architecture.stdout || "architecture check did not exit cleanly",
85
+ );
86
+ assert.doesNotMatch(architecture.stdout, /Architecture check passed: 0 tracked files;/);
87
+
88
+ const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
89
+ cwd: sandbox,
90
+ encoding: "utf8",
91
+ }).trim();
92
+ const [canonicalGitRoot, canonicalSandbox, canonicalRepoRoot] = await Promise.all([
93
+ realpath(gitRoot),
94
+ realpath(sandbox),
95
+ realpath(repoRoot),
96
+ ]);
97
+ assert.equal(canonicalGitRoot, canonicalSandbox);
98
+ assert.notEqual(canonicalGitRoot, canonicalRepoRoot);
99
+
100
+ const tracked = execFileSync("git", ["ls-files", "-z"], {
101
+ cwd: sandbox,
102
+ encoding: "utf8",
103
+ }).split("\0").filter(Boolean);
104
+ assert.ok(tracked.length > 0, "parity sandbox must expose copied tracked files through its own Git index");
105
+ } finally {
106
+ await rm(sandbox, { recursive: true, force: true });
107
+ }
108
+ });
109
+
70
110
  test("release runtime and local parity share the checked-in Node contract", async () => {
71
111
  const nodeVersion = (await readFile(resolve(repoRoot, ".nvmrc"), "utf8")).trim();
72
112
  assert.equal(nodeVersion, "22.19.0");
@@ -77,6 +117,7 @@ test("release runtime and local parity share the checked-in Node contract", asyn
77
117
  const source = await readFile(resolve(repoRoot, "scripts/release-parity.mjs"), "utf8");
78
118
  assert.match(source, /readFileSync\(join\(repoRoot, "\.nvmrc"\), "utf8"\)/);
79
119
  assert.match(source, /const NPM_VERSION = "11\.19\.1"/);
120
+ assert.match(source, /const sandbox = createParitySandbox\(repoRoot\)/);
80
121
  const ciWorkflow = await readFile(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8");
81
122
  assert.match(ciWorkflow, /npm install --global npm@11\.19\.1/);
82
123
  assert.ok(source.includes('["npm", "ci", "--no-audit", "--no-fund"]'));
@@ -1,21 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync, spawnSync } from "node:child_process";
4
- import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { readFileSync, rmSync } from "node:fs";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { createParitySandbox } from "./release/parity-sandbox.mjs";
7
8
 
8
9
  const NPM_VERSION = "11.19.1";
9
10
  const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const NODE_VERSION = readFileSync(join(repoRoot, ".nvmrc"), "utf8").trim();
11
- const debugRoot = join(repoRoot, ".forgerelay-debug");
12
- const sandbox = mkdtempSync(join(ensureDirectory(debugRoot), "release-parity-node22-"));
13
12
  const npx = process.platform === "win32" ? "npx.cmd" : "npx";
14
13
 
15
14
  assertCleanReleaseHead();
15
+ const sandbox = createParitySandbox(repoRoot);
16
16
 
17
17
  try {
18
- copyTrackedTree(sandbox);
19
18
  const env = {
20
19
  ...process.env,
21
20
  FORGERELAY_ALLOWED_ROOTS: sandbox,
@@ -61,24 +60,6 @@ function assertCleanReleaseHead() {
61
60
  }
62
61
  }
63
62
 
64
- function ensureDirectory(path) {
65
- mkdirSync(path, { recursive: true });
66
- return `${path}/`;
67
- }
68
-
69
- function copyTrackedTree(destinationRoot) {
70
- const files = execFileSync("git", ["ls-files", "-z"], {
71
- cwd: repoRoot,
72
- encoding: "utf8",
73
- }).split("\0").filter(Boolean);
74
- for (const relativePath of files) {
75
- const source = join(repoRoot, relativePath);
76
- const destination = join(destinationRoot, relativePath);
77
- mkdirSync(dirname(destination), { recursive: true });
78
- cpSync(source, destination, { recursive: true, force: true });
79
- }
80
- }
81
-
82
63
  function runNodeNpm(cwd, env, command, label) {
83
64
  run(
84
65
  cwd,
@@ -164,6 +164,29 @@ function testEndpoint(name, fallback) {
164
164
  return fallback;
165
165
  }
166
166
 
167
+ function githubAuthHeaders() {
168
+ const token = process.env.GH_TOKEN?.trim()
169
+ || process.env.GITHUB_TOKEN?.trim()
170
+ || localGhToken();
171
+ return {
172
+ Accept: "application/vnd.github+json",
173
+ "User-Agent": "ForgeRelay-release-tag-gate",
174
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
175
+ };
176
+ }
177
+
178
+ function localGhToken() {
179
+ if (process.env.NODE_ENV === "test") return "";
180
+ try {
181
+ return execFileSync("gh", ["auth", "token", "--hostname", "github.com"], {
182
+ encoding: "utf8",
183
+ stdio: ["ignore", "pipe", "ignore"],
184
+ }).trim();
185
+ } catch {
186
+ return "";
187
+ }
188
+ }
189
+
167
190
  async function artifactExists(label, url, headers = {}) {
168
191
  let response;
169
192
  try {
@@ -195,10 +218,7 @@ async function assertReleaseTagMutable(tag, version) {
195
218
  ).toString();
196
219
  const [npmPublished, githubReleased] = await Promise.all([
197
220
  artifactExists(`npm package ${pkg.name}@${version}`, npmUrl),
198
- artifactExists(`GitHub Release ${tag}`, githubUrl, {
199
- Accept: "application/vnd.github+json",
200
- "User-Agent": "ForgeRelay-release-tag-gate",
201
- }),
221
+ artifactExists(`GitHub Release ${tag}`, githubUrl, githubAuthHeaders()),
202
222
  ]);
203
223
  if (npmPublished) {
204
224
  throw new Error(`npm package ${pkg.name}@${version} already exists; release tag ${tag} is immutable`);
@@ -22,11 +22,18 @@ async function runProof(cwd, action, env = {}) {
22
22
  });
23
23
  }
24
24
 
25
- async function artifactApiEnv(t, { npmStatus = 404, githubStatus = 404 } = {}) {
25
+ async function artifactApiEnv(
26
+ t,
27
+ { npmStatus = 404, githubStatus = 404, githubRequiredToken } = {},
28
+ ) {
26
29
  const server = createServer((request, response) => {
27
30
  if (request.url?.startsWith("/npm/")) response.statusCode = npmStatus;
28
- else if (request.url?.startsWith("/github/")) response.statusCode = githubStatus;
29
- else response.statusCode = 500;
31
+ else if (request.url?.startsWith("/github/")) {
32
+ response.statusCode = githubRequiredToken
33
+ && request.headers.authorization !== `Bearer ${githubRequiredToken}`
34
+ ? 403
35
+ : githubStatus;
36
+ } else response.statusCode = 500;
30
37
  response.end();
31
38
  });
32
39
  await new Promise((resolve, reject) => {
@@ -41,6 +48,7 @@ async function artifactApiEnv(t, { npmStatus = 404, githubStatus = 404 } = {}) {
41
48
  NODE_ENV: "test",
42
49
  FORGERELAY_TEST_NPM_REGISTRY_URL: `${base}/npm/`,
43
50
  FORGERELAY_TEST_GITHUB_API_URL: `${base}/github/`,
51
+ ...(githubRequiredToken ? { GH_TOKEN: githubRequiredToken } : {}),
44
52
  };
45
53
  }
46
54
 
@@ -121,7 +129,7 @@ test("release tag hook gate uses repository facts instead of requiring a local p
121
129
  await git(root, ["add", "tracked.txt"]);
122
130
  await git(root, ["commit", "--allow-empty", "-m", "new head"]);
123
131
  await runProof(root, "write");
124
- const mutableEnv = await artifactApiEnv(t);
132
+ const mutableEnv = await artifactApiEnv(t, { githubRequiredToken: "release-test-token" });
125
133
  const deletion = await runProof(root, "check-hook", {
126
134
  ...mutableEnv,
127
135
  FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push --delete origin v1.2.3" }),