@akira-tl/forgerelay 1.1.0 → 1.1.2

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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +4 -2
  3. package/dist/activity/runtime/mcp-query-tools.js +16 -10
  4. package/dist/cli/mcp/external-mcp.js +481 -0
  5. package/dist/cli/mcp/status.js +295 -0
  6. package/dist/cli.js +14 -1
  7. package/dist/lsp/test-support/server-fixture.js +1 -2
  8. package/dist/mcp/oauth/auth-protocol.js +375 -0
  9. package/dist/mcp/oauth/oauth-provider.js +11 -12
  10. package/dist/mcp/oauth/oauth-store.js +2 -2
  11. package/dist/mcp/oauth/router.js +2 -6
  12. package/dist/mcp/operations/batch/executor.js +1 -1
  13. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
  14. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +23 -6
  15. package/dist/mcp/operations/external-mcp/external-mcp.js +178 -23
  16. package/dist/mcp/operations/native-bulk-mutations.js +1 -1
  17. package/dist/mcp/process/tools.js +19 -15
  18. package/dist/mcp/request-context.js +18 -0
  19. package/dist/mcp/request-meta.js +28 -3
  20. package/dist/mcp/server/core/capabilities.js +1 -4
  21. package/dist/mcp/server/operations/runtime/filesystem-tools.js +35 -28
  22. package/dist/mcp/server/operations/runtime/operation-runtime.js +7 -7
  23. package/dist/mcp/server/transport/http-server.js +41 -20
  24. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +2 -0
  25. package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
  26. package/dist/mcp/server/workspace/runtime/workspace-tools.js +31 -27
  27. package/dist/runtime/config/external-mcp-auth-store.js +225 -0
  28. package/dist/runtime/config/external-mcp-config.js +76 -22
  29. package/dist/runtime/config/external-mcp-registry.js +165 -0
  30. package/dist/runtime/testing/server-fixture.js +4 -4
  31. package/dist/server.js +15 -9
  32. package/dist/ui/.vite/manifest.json +297 -298
  33. package/dist/ui/activity-panel-app.html +1 -1
  34. package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
  35. package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
  36. package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
  37. package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
  38. package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
  39. package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
  40. package/dist/ui/assets/c-BquuA6bv.js +1 -0
  41. package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
  42. package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
  43. package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
  44. package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
  45. package/dist/ui/assets/css-Be_T8idh.js +1 -0
  46. package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
  47. package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
  48. package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
  49. package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
  50. package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
  51. package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
  52. package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
  53. package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
  54. package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
  55. package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
  56. package/dist/ui/assets/haml-h18lG0pS.js +1 -0
  57. package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
  58. package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
  59. package/dist/ui/assets/html-DtILqTE1.js +1 -0
  60. package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
  61. package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
  62. package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
  63. package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
  64. package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
  65. package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
  66. package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
  67. package/dist/ui/assets/json-DW2UTv1q.js +1 -0
  68. package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
  69. package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
  70. package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
  71. package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
  72. package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
  73. package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
  74. package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
  75. package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
  76. package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
  77. package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
  78. package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
  79. package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
  80. package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
  81. package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
  82. package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
  83. package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
  84. package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
  85. package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
  86. package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
  87. package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
  88. package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
  89. package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
  90. package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
  91. package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
  92. package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
  93. package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
  94. package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
  95. package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
  96. package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
  97. package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
  98. package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
  99. package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
  100. package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
  101. package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
  102. package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
  103. package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
  104. package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
  105. package/dist/ui/assets/xml-_44rx9_2.js +1 -0
  106. package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
  107. package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
  108. package/dist/workspaces/relay/auth/remote-auth.js +3 -5
  109. package/dist/workspaces/relay/tests/test-support.js +7 -4
  110. package/dist/workspaces/relay/workspace-relay.js +14 -8
  111. package/dist/workspaces/resources/skills.js +1 -1
  112. package/docs/chatgpt-coding-workflow.md +12 -6
  113. package/docs/configuration.md +158 -6
  114. package/docs/debugging.md +8 -5
  115. package/docs/roadmap.md +64 -3
  116. package/docs/security.md +18 -0
  117. package/package.json +11 -4
  118. package/scripts/debug/accept/bootstrap.mjs +2 -0
  119. package/scripts/debug/accept/harness.mjs +9 -2
  120. package/scripts/debug/accept/modern-http.mjs +169 -0
  121. package/scripts/debug/accept/support.mjs +10 -6
  122. package/scripts/debug/accept.mjs +1 -0
  123. package/scripts/debug/runtime.mjs +4 -1
  124. package/scripts/debug/runtime.test.mjs +9 -0
  125. package/scripts/release/parity-sandbox.mjs +35 -0
  126. package/scripts/release/release-gate.test.mjs +42 -1
  127. package/scripts/release-parity.mjs +3 -22
  128. package/scripts/release-proof.mjs +24 -4
  129. package/scripts/release-proof.test.mjs +12 -4
  130. package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
  131. package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
  132. package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
  133. package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
  134. package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
  135. package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
  136. package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
  137. package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
  138. package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
  139. package/dist/ui/assets/html-dUOs5pA9.js +0 -1
  140. package/dist/ui/assets/java-BFZCrORY.js +0 -1
  141. package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
  142. package/dist/ui/assets/json-BQC_Yp48.js +0 -1
  143. package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
  144. package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
  145. package/dist/ui/assets/r-B5nYsy-M.js +0 -1
  146. package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
  147. package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
  148. package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
  149. package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
  150. package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
  151. package/dist/ui/assets/typescript-BGIABksB.js +0 -1
  152. package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
  153. package/dist/ui/assets/yaml-umbj2r1g.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -40,6 +40,7 @@
40
40
  "debug:serve": "node scripts/debug/serve.mjs",
41
41
  "debug:accept": "node scripts/debug/accept.mjs",
42
42
  "debug:accept:media": "node scripts/debug/accept/media.mjs",
43
+ "debug:accept:modern": "node scripts/debug/accept/modern-http.mjs",
43
44
  "debug:accept:relay": "node scripts/debug/relay-accept.mjs",
44
45
  "traffic:audit": "node --import tsx scripts/debug/traffic/traffic-audit.mjs",
45
46
  "lsp:interop": "node scripts/lsp/interop.mjs",
@@ -57,7 +58,7 @@
57
58
  "release:push-ready": "node scripts/release/push-ready.mjs",
58
59
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
59
60
  "start": "node dist/cli.js serve",
60
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/runtime/config/config.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
61
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/runtime/config/config.test.ts && tsx src/runtime/config/external-mcp-registry.test.ts && tsx src/runtime/config/external-mcp-auth-store.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-oauth.test.ts && tsx src/cli/mcp/status.test.ts && tsx src/cli/mcp/diagnostics.test.ts && tsx src/cli/mcp/external-mcp.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-context.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
61
62
  "typecheck": "tsc -p tsconfig.json --noEmit",
62
63
  "release:check": "node scripts/release-version.mjs check",
63
64
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -80,13 +81,18 @@
80
81
  "dependencies": {
81
82
  "@agentclientprotocol/sdk": "^1.1.0",
82
83
  "@clack/prompts": "^1.5.1",
83
- "@modelcontextprotocol/ext-apps": "^1.7.2",
84
- "@modelcontextprotocol/sdk": "^1.29.0",
84
+ "@modelcontextprotocol/client": "^2.0.0",
85
+ "@modelcontextprotocol/core": "^2.0.0",
86
+ "@modelcontextprotocol/express": "^2.0.0",
87
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
88
+ "@modelcontextprotocol/node": "^2.0.0",
89
+ "@modelcontextprotocol/server": "^2.0.0",
85
90
  "@pierre/diffs": "^1.2.5",
86
91
  "better-sqlite3": "^13.0.3",
87
92
  "diff": "^8.0.3",
88
93
  "drizzle-orm": "^0.45.2",
89
94
  "express": "^5.2.1",
95
+ "express-rate-limit": "8.5.2",
90
96
  "lucide": "^1.24.0",
91
97
  "react": "^19.2.6",
92
98
  "react-dom": "^19.2.6",
@@ -97,6 +103,7 @@
97
103
  "zod": "^4.4.3"
98
104
  },
99
105
  "devDependencies": {
106
+ "@modelcontextprotocol/sdk": "1.29.0",
100
107
  "@types/better-sqlite3": "^7.6.13",
101
108
  "@types/express": "^5.0.6",
102
109
  "@types/node": "^25.9.1",
@@ -195,6 +195,7 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
195
195
  "workspace.recovery",
196
196
  "workspace.checkpoint",
197
197
  "batch.execute",
198
+ "mcp.external",
198
199
  ...(process.platform === "linux" ? ["artifact.native-download"] : []),
199
200
  "ui.mcp-app",
200
201
  "review.changes",
@@ -207,6 +208,7 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
207
208
  "code.intelligence",
208
209
  "workspace.checkpoint",
209
210
  "workspace.tasks",
211
+ "mcp.external",
210
212
  "batch.execute",
211
213
  ...(process.platform === "linux" ? ["artifact.download"] : []),
212
214
  ]);
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import {
8
8
  createDebugEnvironment,
9
+ debugConfigDir,
9
10
  debugRoot,
10
11
  repoRoot,
11
12
  } from "../runtime.mjs";
@@ -19,6 +20,7 @@ import { assertCurlAvailable, assertDebugPortFree, pass, setupGitProject } from
19
20
  export async function createAcceptanceHarness() {
20
21
  const packageJson = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
21
22
  const acceptanceRoot = resolve(debugRoot, "acceptance");
23
+ const configDir = resolve(acceptanceRoot, "config");
22
24
  const stateDir = resolve(acceptanceRoot, "state");
23
25
  const worktreeRoot = resolve(acceptanceRoot, "worktrees");
24
26
  const hookLog = resolve(acceptanceRoot, "hooks.jsonl");
@@ -35,7 +37,11 @@ export async function createAcceptanceHarness() {
35
37
  assertCurlAvailable();
36
38
  await assertDebugPortFree();
37
39
  rmSync(acceptanceRoot, { recursive: true, force: true });
38
- mkdirSync(acceptanceRoot, { recursive: true });
40
+ mkdirSync(configDir, { recursive: true });
41
+ writeFileSync(
42
+ join(configDir, "config.json"),
43
+ readFileSync(join(debugConfigDir, "config.json"), "utf8"),
44
+ );
39
45
  setupGitProject(checkoutWorkspace);
40
46
  setupGitProject(lifecycleDeleteWorkspace);
41
47
  writeFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "keep checkout files\n");
@@ -44,6 +50,7 @@ export async function createAcceptanceHarness() {
44
50
 
45
51
  const { env } = createDebugEnvironment({
46
52
  ownerToken,
53
+ configDir,
47
54
  stateDir,
48
55
  worktreeRoot,
49
56
  hookLog,
@@ -69,7 +76,7 @@ export async function createAcceptanceHarness() {
69
76
  });
70
77
 
71
78
  return {
72
- packageJson, acceptanceRoot, stateDir, worktreeRoot, hookLog, checkoutWorkspace,
79
+ packageJson, acceptanceRoot, configDir, stateDir, worktreeRoot, hookLog, checkoutWorkspace,
73
80
  lifecycleDeleteWorkspace, codeIntelligenceLog, fakeLanguageServer, gitProject, releaseProject,
74
81
  releaseRemote, ownerToken, tempAcceptanceRoot, env, server,
75
82
  };
@@ -0,0 +1,169 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
7
+ import { debugRoot, repoRoot } from "../runtime.mjs";
8
+ import { assertPortFree, pass, stopServer, waitForHealth } from "./support.mjs";
9
+
10
+ const port = 7678;
11
+ const baseUrl = `http://127.0.0.1:${port}`;
12
+ const mcpUrl = `${baseUrl}/mcp`;
13
+ const acceptanceRoot = resolve(debugRoot, "modern-http-acceptance");
14
+ const configDir = join(acceptanceRoot, "config");
15
+ const stateDir = join(acceptanceRoot, "state");
16
+ const worktreeRoot = join(acceptanceRoot, "worktrees");
17
+ const workspaceRoot = join(acceptanceRoot, "workspace");
18
+ const ownerToken = randomBytes(32).toString("base64url");
19
+
20
+ await assertPortFree(port);
21
+ rmSync(acceptanceRoot, { recursive: true, force: true });
22
+ mkdirSync(configDir, { recursive: true });
23
+ mkdirSync(stateDir, { recursive: true });
24
+ mkdirSync(workspaceRoot, { recursive: true });
25
+ writeFileSync(join(workspaceRoot, "README.md"), "modern MCP acceptance workspace\n");
26
+ writeFileSync(join(configDir, "config.json"), `${JSON.stringify({
27
+ host: "127.0.0.1",
28
+ port,
29
+ allowedRoots: [workspaceRoot],
30
+ publicBaseUrl: baseUrl,
31
+ allowedHosts: ["localhost", "127.0.0.1", "::1"],
32
+ stateDir,
33
+ worktreeRoot,
34
+ }, null, 2)}\n`);
35
+ writeFileSync(join(configDir, "auth.json"), `${JSON.stringify({
36
+ ownerToken,
37
+ instanceId: "modern-http-acceptance-7678",
38
+ }, null, 2)}\n`, { mode: 0o600 });
39
+
40
+ const cleanEnv = Object.fromEntries(
41
+ Object.entries(process.env).filter(([name]) =>
42
+ name !== "HOST" && name !== "PORT" && !name.startsWith("FORGERELAY_")
43
+ ),
44
+ );
45
+ const env = {
46
+ ...cleanEnv,
47
+ HOST: "127.0.0.1",
48
+ PORT: String(port),
49
+ FORGERELAY_CONFIG_DIR: configDir,
50
+ FORGERELAY_OAUTH_OWNER_TOKEN: ownerToken,
51
+ FORGERELAY_PUBLIC_BASE_URL: baseUrl,
52
+ FORGERELAY_TOOL_MODE: "full",
53
+ FORGERELAY_WIDGETS: "off",
54
+ FORGERELAY_LOG_LEVEL: "debug",
55
+ FORGERELAY_LOG_FORMAT: "json",
56
+ };
57
+
58
+ let serverLogs = "";
59
+ const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
60
+ cwd: repoRoot,
61
+ env,
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ });
64
+ server.stdout?.on("data", (chunk) => { serverLogs += chunk.toString(); });
65
+ server.stderr?.on("data", (chunk) => { serverLogs += chunk.toString(); });
66
+
67
+ let client;
68
+ try {
69
+ await waitForHealth(server, baseUrl);
70
+ pass("modern HTTP health", baseUrl);
71
+
72
+ const unauthorized = await fetch(mcpUrl, {
73
+ method: "POST",
74
+ headers: {
75
+ "content-type": "application/json",
76
+ accept: "application/json, text/event-stream",
77
+ },
78
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "server/discover", params: {} }),
79
+ });
80
+ assert.equal(unauthorized.status, 401);
81
+ assert.match(unauthorized.headers.get("www-authenticate") ?? "", /Bearer .*resource_metadata=/i);
82
+ pass("modern HTTP bearer challenge", "unauthenticated MCP request advertises protected-resource metadata");
83
+
84
+ const tokenResponse = await fetch(`${baseUrl}/auth/cli`, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({ owner_token: ownerToken }),
88
+ });
89
+ const tokenBody = await tokenResponse.text();
90
+ assert.equal(tokenResponse.status, 200, tokenBody);
91
+ const tokens = JSON.parse(tokenBody);
92
+ assert.equal(tokens.token_type, "bearer");
93
+ assert.ok(tokens.access_token);
94
+ pass("modern HTTP auth", "real ForgeRelay bearer token issued on 7678");
95
+
96
+ const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
97
+ requestInit: {
98
+ headers: { authorization: `Bearer ${tokens.access_token}` },
99
+ },
100
+ });
101
+ client = new Client(
102
+ { name: "forgerelay-modern-http-acceptance", version: "1.0.0" },
103
+ { versionNegotiation: { mode: { pin: "2026-07-28" } } },
104
+ );
105
+ await client.connect(transport);
106
+ assert.equal(client.getProtocolEra(), "modern");
107
+ assert.equal(client.getNegotiatedProtocolVersion(), "2026-07-28");
108
+ assert.equal(transport.sessionId, undefined);
109
+ pass("modern protocol negotiation", "2026-07-28 connected without Mcp-Session-Id");
110
+
111
+ const listed = await client.listTools();
112
+ assert.ok(listed.tools.some((tool) => tool.name === "open_workspace"));
113
+ const opened = await client.callTool({
114
+ name: "open_workspace",
115
+ arguments: { path: workspaceRoot, context: "none" },
116
+ });
117
+ assert.notEqual(opened.isError, true);
118
+ const workspaceId = String(opened.structuredContent?.workspaceId ?? "");
119
+ assert.match(workspaceId, /^ws_/);
120
+ pass("modern tools call", "tools/list and open_workspace completed over stateless HTTP");
121
+
122
+ const conversationMeta = { "dev.forgerelay/conversation": "modern-http-acceptance-conversation" };
123
+ const panel = await client.callTool({
124
+ name: "activity_panel",
125
+ arguments: { workspaceId },
126
+ _meta: conversationMeta,
127
+ });
128
+ assert.notEqual(panel.isError, true);
129
+ const turnId = String(panel.structuredContent?.turnId ?? "");
130
+ assert.match(turnId, /^turn_/);
131
+ const restored = await client.callTool({
132
+ name: "activity_snapshot",
133
+ arguments: { workspaceId },
134
+ _meta: conversationMeta,
135
+ });
136
+ assert.notEqual(restored.isError, true);
137
+ assert.equal(restored.structuredContent?.turnId, turnId);
138
+ pass("modern vendor conversation continuity", "stable ForgeRelay metadata restored the Host Turn across stateless requests");
139
+
140
+ const requestScopedPanel = await client.callTool({
141
+ name: "activity_panel",
142
+ arguments: { workspaceId },
143
+ });
144
+ assert.notEqual(requestScopedPanel.isError, true);
145
+ const requestScopedSnapshot = await client.callTool({
146
+ name: "activity_snapshot",
147
+ arguments: { workspaceId },
148
+ });
149
+ assert.equal(requestScopedSnapshot.isError, true);
150
+ assert.match(
151
+ requestScopedSnapshot.content?.map((entry) => entry.type === "text" ? entry.text : "").join("\n") ?? "",
152
+ /could not resolve the current Host Turn/i,
153
+ );
154
+ pass("modern request scope isolation", "requests without stable metadata do not share Host Turn state");
155
+
156
+ assert.equal(serverLogs.includes("\"protocolEra\":\"modern\""), true);
157
+ pass("modern routing observability", "ForgeRelay classified the accepted request as modern");
158
+
159
+ console.log("Modern HTTP acceptance passed.");
160
+ } catch (error) {
161
+ if (serverLogs) process.stderr.write(`\n--- ForgeRelay 7678 logs ---\n${serverLogs}`);
162
+ throw error;
163
+ } finally {
164
+ try {
165
+ await client?.close();
166
+ } finally {
167
+ await stopServer(server);
168
+ }
169
+ }
@@ -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) {
@@ -320,6 +320,7 @@ try {
320
320
  "host-integration",
321
321
  "shell-processes",
322
322
  "code-intelligence",
323
+ "external-mcp",
323
324
  "workspace-tasks",
324
325
  "workspace-checkpoints",
325
326
  "batch-execution",
@@ -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" }),