@akira-tl/forgerelay 0.9.3 → 0.10.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 (109) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +10 -4
  3. package/dist/cli/init.js +111 -24
  4. package/dist/cli/maintenance-prune.js +479 -0
  5. package/dist/cli/maintenance-retention.js +93 -0
  6. package/dist/cli/maintenance.js +598 -0
  7. package/dist/cli/setup-support.js +21 -0
  8. package/dist/cli/shell/setup.js +99 -0
  9. package/dist/cli.js +72 -16
  10. package/dist/mcp/hooks/hooks.js +6 -3
  11. package/dist/mcp/oauth/public-url.js +3 -0
  12. package/dist/mcp/oauth/router.js +14 -9
  13. package/dist/mcp/process/process-platform.js +24 -26
  14. package/dist/mcp/process/process-sessions.js +6 -3
  15. package/dist/mcp/server/transport/http-server.js +17 -9
  16. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  17. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  18. package/dist/mcp/server-instructions.js +10 -1
  19. package/dist/runtime/config/config.js +60 -6
  20. package/dist/runtime/instructions/shell-instructions.js +100 -0
  21. package/dist/runtime/security/runtime-privilege.js +120 -0
  22. package/dist/runtime/shell/command-shell-runtime.js +235 -0
  23. package/dist/runtime/state/runtime-lease.js +109 -0
  24. package/dist/ui/.vite/manifest.json +276 -276
  25. package/dist/ui/activity-panel-app.html +2 -2
  26. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  27. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  28. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  29. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  30. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  31. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  32. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  33. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  34. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  35. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  36. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  37. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  38. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  39. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  40. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  41. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  42. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  43. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  44. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  45. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  46. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  47. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  48. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  49. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  50. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  51. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  52. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  53. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  54. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  55. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  56. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  57. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  58. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  59. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  60. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  61. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  62. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  63. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  64. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  65. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  66. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  67. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  68. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  69. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  70. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  71. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  72. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  73. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  74. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  75. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  76. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  77. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  78. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  79. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  80. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  81. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  82. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  83. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  84. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  85. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  86. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  87. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  88. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  89. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  90. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  91. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  92. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  93. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  94. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  95. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  96. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  97. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  98. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  99. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  100. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  101. package/dist/workspaces/context.js +32 -5
  102. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  103. package/dist/workspaces/sessions.js +2 -0
  104. package/dist/workspaces.js +2 -1
  105. package/docs/configuration.md +101 -14
  106. package/package.json +2 -2
  107. package/scripts/release/release-gate.test.mjs +1 -0
  108. package/scripts/release-proof.mjs +85 -9
  109. package/scripts/release-proof.test.mjs +79 -1
@@ -0,0 +1,99 @@
1
+ import { defaultCommandShellSelection, normalizeCommandShellSelection, } from "../../runtime/shell/command-shell-runtime.js";
2
+ export function commandShellSetupOptions(platform, current, launcher) {
3
+ const options = [];
4
+ if (current?.mode === "pinned") {
5
+ options.push({
6
+ value: "keep-pinned",
7
+ label: `Keep pinned ${current.family}`,
8
+ hint: current.executable,
9
+ });
10
+ }
11
+ options.push({
12
+ value: "follow-launcher",
13
+ label: "Follow the shell used to launch ForgeRelay",
14
+ hint: launcher
15
+ ? `Detected ${launcher.family ?? "shell"}: ${launcher.executable}`
16
+ : "Use the recorded fallback when no launcher shell can be identified.",
17
+ });
18
+ const families = platform === "win32"
19
+ ? ["pwsh", "powershell", "cmd", "bash"]
20
+ : ["bash", "zsh", "fish", "sh"];
21
+ for (const family of families) {
22
+ options.push({
23
+ value: family,
24
+ label: shellFamilyLabel(family),
25
+ hint: family === "bash" && platform !== "win32"
26
+ ? "Recommended compatibility path"
27
+ : undefined,
28
+ });
29
+ }
30
+ options.push({
31
+ value: "custom",
32
+ label: "Custom executable path",
33
+ hint: "Pin a portable/green install or a shell-development test binary.",
34
+ });
35
+ return options;
36
+ }
37
+ export function defaultCommandShellSetupChoice(current) {
38
+ return current?.mode === "pinned" ? "keep-pinned" : "follow-launcher";
39
+ }
40
+ export function followLauncherPreference(current, launcher, platform = process.platform, environment = process.env) {
41
+ const fallback = launcher
42
+ ?? (current?.mode === "follow-launcher" ? current : undefined)
43
+ ?? defaultCommandShellSelection(platform === "win32" ? "cmd" : "bash", platform, environment);
44
+ const normalized = normalizeCommandShellSelection(fallback, platform, environment, true);
45
+ const family = normalized.family ?? fallback.family;
46
+ if (!family)
47
+ throw new Error(`Unable to identify command-shell family for ${normalized.executable}.`);
48
+ return {
49
+ mode: "follow-launcher",
50
+ family,
51
+ executable: normalized.executable,
52
+ };
53
+ }
54
+ export function pinnedFamilyPreference(family, platform = process.platform, environment = process.env) {
55
+ const normalized = normalizeCommandShellSelection(defaultCommandShellSelection(family, platform, environment), platform, environment, true);
56
+ return {
57
+ mode: "pinned",
58
+ family,
59
+ executable: normalized.executable,
60
+ };
61
+ }
62
+ export function customPinnedPreference(family, executable, platform = process.platform, environment = process.env) {
63
+ const normalized = normalizeCommandShellSelection({ family, executable }, platform, environment, true);
64
+ return {
65
+ mode: "pinned",
66
+ family,
67
+ executable: normalized.executable,
68
+ };
69
+ }
70
+ export function preservePinnedPreference(current, platform = process.platform, environment = process.env) {
71
+ if (!current || current.mode !== "pinned") {
72
+ throw new Error("No pinned command-shell selection is available to preserve.");
73
+ }
74
+ return customPinnedPreference(current.family, current.executable, platform, environment);
75
+ }
76
+ export function commandShellCompatibilityWarning(family) {
77
+ if (family === "zsh") {
78
+ return "zsh is supported as an explicit command runtime, but Bash remains ForgeRelay's primary POSIX compatibility target. Agent commands and Hooks must use zsh syntax.";
79
+ }
80
+ if (family === "fish") {
81
+ return "fish may be selected explicitly, but ForgeRelay compatibility is less mature than Bash and native fish execution is not enabled until its runtime adapter is available. Agent commands and Hooks must use fish syntax when enabled.";
82
+ }
83
+ return undefined;
84
+ }
85
+ export function shellFamiliesForCustomSelection() {
86
+ return ["bash", "zsh", "fish", "sh", "pwsh", "powershell", "cmd"]
87
+ .map((family) => ({ value: family, label: shellFamilyLabel(family) }));
88
+ }
89
+ function shellFamilyLabel(family) {
90
+ switch (family) {
91
+ case "bash": return "Bash";
92
+ case "zsh": return "zsh";
93
+ case "fish": return "fish";
94
+ case "sh": return "POSIX sh";
95
+ case "pwsh": return "PowerShell 7 (pwsh)";
96
+ case "powershell": return "Windows PowerShell 5.1";
97
+ case "cmd": return "cmd.exe";
98
+ }
99
+ }
package/dist/cli.js CHANGED
@@ -9,7 +9,9 @@ import { join, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import * as prompts from "@clack/prompts";
11
11
  import { loadConfig } from "./runtime/config/config.js";
12
+ import { acquireRuntimeLease } from "./runtime/state/runtime-lease.js";
12
13
  import { runInit } from "./cli/init.js";
14
+ import { runMaintenanceCommand } from "./cli/maintenance.js";
13
15
  import { runHooksCommand } from "./mcp/hooks/hook-cli.js";
14
16
  import { executeSubagentSession } from "./subagents/sessions/execution.js";
15
17
  import { SubagentDeliveryMailbox } from "./subagents/sessions/delivery-mailbox.js";
@@ -19,21 +21,31 @@ import { parseSubagentRunArgs } from "./subagents/cli-target.js";
19
21
  import { ensureForgeRelayInstanceId, loadForgeRelayFiles, removeForgeRelayRemote, renameForgeRelayRemote, writeForgeRelayConfig, writeForgeRelayRemote, } from "./runtime/config/user-config.js";
20
22
  import { shutdownHttpServer } from "./mcp/server/transport/server-shutdown.js";
21
23
  import { publicEndpointUrl } from "./mcp/oauth/public-url.js";
24
+ import { assertRuntimePrivilegeAllowed, detectRuntimePrivilege, elevatedRuntimeWarning, formatRuntimePrivilege, } from "./runtime/security/runtime-privilege.js";
25
+ import { formatCommandShellRuntime } from "./runtime/shell/command-shell-runtime.js";
22
26
  import { authenticateRemote, defaultRemoteAlias, isRemoteMcpUnauthorized, normalizeRemoteServiceTarget, refreshRemoteAuthentication, verifyRemoteMcp, } from "./workspaces/relay/auth/remote-auth.js";
23
27
  import { defaultSshRouteAlias, parseSshRoute, readRemoteOwnerToken, withRemoteServiceEndpoint, } from "./workspaces/relay/transport/remote-transport.js";
24
28
  import { assertSupportedNode, checkBashShell, checkGitAvailable, checkSqliteNative, nodeVersionStatus, normalizeOptionalPublicBaseUrl, } from "./cli/setup-support.js";
25
29
  const require = createRequire(import.meta.url);
26
30
  async function main(argv) {
27
31
  assertSupportedNode();
28
- const [rawCommand, ...args] = argv;
29
- const command = normalizeCommand(rawCommand);
32
+ const [rawCommand, ...rest] = argv;
33
+ const defaultServeFlag = rawCommand === "--allow-elevated";
34
+ const command = defaultServeFlag ? "serve" : normalizeCommand(rawCommand);
35
+ const args = defaultServeFlag ? argv : rest;
30
36
  switch (command) {
31
- case "serve":
37
+ case "serve": {
38
+ const serveOptions = parseServeCommandArgs(args);
39
+ const runtimePrivilege = detectRuntimePrivilege();
40
+ assertRuntimePrivilegeAllowed(runtimePrivilege, serveOptions.allowElevated);
41
+ if (serveOptions.allowElevated)
42
+ console.warn(elevatedRuntimeWarning(runtimePrivilege));
32
43
  await ensureConfigured();
33
- await serve();
44
+ await serve(runtimePrivilege);
34
45
  return;
46
+ }
35
47
  case "init":
36
- await runInit({ force: args.includes("--force") });
48
+ await runInit({ force: args.includes("--force"), version: installedForgeRelayVersion() });
37
49
  return;
38
50
  case "doctor":
39
51
  await runDoctor();
@@ -50,6 +62,9 @@ async function main(argv) {
50
62
  case "auth":
51
63
  await runAuthCommand(args);
52
64
  return;
65
+ case "maintenance":
66
+ runMaintenanceCommand(args);
67
+ return;
53
68
  case "help":
54
69
  printHelp();
55
70
  return;
@@ -61,7 +76,7 @@ async function main(argv) {
61
76
  function normalizeCommand(command) {
62
77
  if (!command || command === "serve" || command === "start")
63
78
  return "serve";
64
- if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth")
79
+ if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth" || command === "maintenance")
65
80
  return command;
66
81
  if (command === "help" || command === "--help" || command === "-h")
67
82
  return "help";
@@ -69,6 +84,19 @@ function normalizeCommand(command) {
69
84
  return "version";
70
85
  throw new Error(`Unknown command: ${command}`);
71
86
  }
87
+ function parseServeCommandArgs(args) {
88
+ let allowElevated = false;
89
+ for (const arg of args) {
90
+ if (arg === "--allow-elevated") {
91
+ if (allowElevated)
92
+ throw new Error("--allow-elevated may only be supplied once.");
93
+ allowElevated = true;
94
+ continue;
95
+ }
96
+ throw new Error(`Unknown serve option: ${arg}`);
97
+ }
98
+ return { allowElevated };
99
+ }
72
100
  async function ensureConfigured() {
73
101
  const files = loadForgeRelayFiles();
74
102
  if (files.configExists && files.authExists) {
@@ -89,9 +117,9 @@ async function ensureConfigured() {
89
117
  "Or provide FORGERELAY_OAUTH_OWNER_TOKEN and FORGERELAY_ALLOWED_ROOTS.",
90
118
  ].join("\n"));
91
119
  }
92
- await runInit({ force: false });
120
+ await runInit({ force: false, version: installedForgeRelayVersion() });
93
121
  }
94
- async function serve() {
122
+ async function serve(runtimePrivilege) {
95
123
  const sqliteStatus = checkSqliteNative();
96
124
  if (sqliteStatus !== "ok") {
97
125
  throw new Error([
@@ -104,9 +132,19 @@ async function serve() {
104
132
  }
105
133
  const { createServer } = await import("./server.js");
106
134
  const config = loadConfig();
107
- const { app, close, subagentProviders } = createServer(config);
135
+ config.runtimePrivilege = runtimePrivilege;
136
+ const runtimeLease = acquireRuntimeLease(config.stateDir);
137
+ let server;
138
+ try {
139
+ server = createServer(config);
140
+ }
141
+ catch (error) {
142
+ runtimeLease.release();
143
+ throw error;
144
+ }
145
+ const { app, close, subagentProviders } = server;
108
146
  const httpServer = app.listen(config.port, config.host, () => {
109
- console.log(`forgerelay listening on http://${config.host}:${config.port}/mcp`);
147
+ console.log(`forgerelay listening on http://${config.host}:${config.port}${publicEndpointUrl(config.publicBaseUrl, "mcp").pathname}`);
110
148
  console.log(`client-facing base url: ${config.publicBaseUrl}`);
111
149
  console.log(`allowed roots: ${config.allowedRoots.join(", ")}`);
112
150
  console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`);
@@ -114,17 +152,27 @@ async function serve() {
114
152
  console.warn("warning: Host header allowlist is disabled because FORGERELAY_ALLOWED_HOSTS=*");
115
153
  }
116
154
  console.log("auth: Owner password approval required");
155
+ console.log(`command shell: ${formatCommandShellRuntime(config.commandShellRuntime)}`);
156
+ console.log(`runtime privilege: ${formatRuntimePrivilege(runtimePrivilege)}`);
117
157
  console.log(`logging: ${config.logging.level} ${config.logging.format}`);
118
158
  if (config.subagents) {
119
159
  console.log(`subagent providers: ${formatSubagentProviderAvailabilitySummary(subagentProviders)}`);
120
160
  }
121
161
  });
122
162
  let shuttingDown = false;
163
+ const releaseRuntimeLease = () => runtimeLease.release();
164
+ process.once("exit", releaseRuntimeLease);
123
165
  const shutdown = async () => {
124
166
  if (shuttingDown)
125
167
  return;
126
168
  shuttingDown = true;
127
- await shutdownHttpServer(httpServer, close);
169
+ try {
170
+ await shutdownHttpServer(httpServer, close);
171
+ }
172
+ finally {
173
+ process.removeListener("exit", releaseRuntimeLease);
174
+ runtimeLease.release();
175
+ }
128
176
  process.exit(0);
129
177
  };
130
178
  const handleShutdown = () => {
@@ -303,18 +351,20 @@ async function runDoctor() {
303
351
  console.log(`Node: ${process.version} (${nodeVersionStatus()})`);
304
352
  console.log(`Node ABI: ${process.versions.modules}`);
305
353
  console.log(`Platform: ${process.platform} ${process.arch}`);
354
+ console.log(`Runtime privilege: ${formatRuntimePrivilege(detectRuntimePrivilege())}`);
306
355
  console.log(`Git: ${checkGitAvailable()}`);
307
356
  console.log(`Bash shell: ${checkBashShell()}`);
308
357
  console.log(`SQLite native dependency: ${checkSqliteNative()}`);
309
358
  try {
310
359
  const config = loadConfig();
311
- console.log(`Bind MCP URL: http://${config.host}:${config.port}/mcp`);
360
+ console.log(`Bind MCP URL: http://${config.host}:${config.port}${publicEndpointUrl(config.publicBaseUrl, "mcp").pathname}`);
312
361
  console.log(`Client-facing base URLs: ${config.publicBaseUrls.join(", ")}`);
313
362
  console.log(`Client-facing base URL: ${config.publicBaseUrl}`);
314
363
  console.log(`Client-facing MCP URL: ${publicEndpointUrl(config.publicBaseUrl, "mcp").toString()}`);
364
+ console.log(`Command shell: ${formatCommandShellRuntime(config.commandShellRuntime)}`);
315
365
  console.log(`Tool mode: ${config.toolMode}`);
316
366
  console.log(`Widgets: ${config.widgets}`);
317
- console.log(`Trust proxy: ${config.logging.trustProxy ? "one hop" : "off"}`);
367
+ console.log(`Trust proxy: ${config.proxyTrust === false ? "off" : config.proxyTrust.join(", ")}`);
318
368
  console.log(`Artifacts: ${config.artifactsEnabled ? "enabled" : "disabled"}`);
319
369
  console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`);
320
370
  console.log(`Skills: ${config.skillsEnabled ? "enabled" : "disabled"}`);
@@ -355,6 +405,8 @@ function printHelp() {
355
405
  "Usage:",
356
406
  " forgerelay Run first-time setup if needed, then start the server",
357
407
  " forgerelay serve Start the server",
408
+ " forgerelay serve --allow-elevated",
409
+ " Explicitly allow this invocation to run with elevated/unknown OS privilege",
358
410
  " forgerelay init Create or update ~/.forgerelay/config.json and auth.json",
359
411
  " forgerelay doctor Show config, runtime, and native dependency status",
360
412
  " forgerelay config get Print persisted config",
@@ -370,6 +422,7 @@ function printHelp() {
370
422
  " forgerelay auth test <alias>",
371
423
  " forgerelay auth rename <old-alias> <new-alias>",
372
424
  " forgerelay auth remove <alias>",
425
+ " forgerelay maintenance inspect [--json]",
373
426
  " forgerelay -v, --version Print the installed version",
374
427
  "",
375
428
  "For temporary tunnels:",
@@ -547,12 +600,15 @@ function printAgentsHelp() {
547
600
  " forgerelay agents show <id>",
548
601
  ].join("\n"));
549
602
  }
550
- function printVersion() {
603
+ function installedForgeRelayVersion() {
551
604
  const packageJson = require("../package.json");
552
- if (typeof packageJson.version !== "string") {
605
+ if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
553
606
  throw new Error("Unable to read ForgeRelay package version.");
554
607
  }
555
- console.log(packageJson.version);
608
+ return packageJson.version;
609
+ }
610
+ function printVersion() {
611
+ console.log(installedForgeRelayVersion());
556
612
  }
557
613
  main(process.argv.slice(2)).catch((error) => {
558
614
  console.error(error instanceof Error ? error.message : String(error));
@@ -2,7 +2,8 @@ import { readFile, readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { performance } from "node:perf_hooks";
4
4
  import { commandPreview, logEvent, workspaceLogLabel } from "../../runtime/logging/logger.js";
5
- import { resolveShellCommand } from "../process/process-platform.js";
5
+ import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime, } from "../../runtime/shell/command-shell-runtime.js";
6
+ import { resolveShellCommandForRuntime } from "../process/process-platform.js";
6
7
  import { executeHookCommand, hookFailureOutput } from "./command-runner.js";
7
8
  export const HOOK_EVENTS = [
8
9
  "WorkspaceOpen",
@@ -212,11 +213,13 @@ export class HookRunner {
212
213
  logging;
213
214
  baseEnv;
214
215
  resultDecorator;
215
- constructor(hooks, logging, baseEnv = process.env, resultDecorator) {
216
+ commandShellRuntime;
217
+ constructor(hooks, logging, baseEnv = process.env, resultDecorator, commandShellRuntime) {
216
218
  this.hooks = hooks;
217
219
  this.logging = logging;
218
220
  this.baseEnv = baseEnv;
219
221
  this.resultDecorator = resultDecorator;
222
+ this.commandShellRuntime = snapshotCommandShellRuntime(commandShellRuntime ?? resolveCompatibilityCommandShellRuntime(process.platform, baseEnv));
220
223
  }
221
224
  decorateResult(workspaceId, result) {
222
225
  return (this.resultDecorator?.(workspaceId, result) ?? result);
@@ -274,7 +277,7 @@ export class HookRunner {
274
277
  async runHandler(event, handler, index, invocation, scope, signal) {
275
278
  const startedAt = performance.now();
276
279
  const name = handler.name ?? `${event} handler ${index + 1}`;
277
- const shell = resolveShellCommand(handler.command, process.platform, this.baseEnv);
280
+ const shell = resolveShellCommandForRuntime(handler.command, this.commandShellRuntime);
278
281
  const detached = process.platform !== "win32";
279
282
  const env = hookEnvironment(this.baseEnv, event, invocation);
280
283
  try {
@@ -7,6 +7,9 @@ export function publicEndpointUrl(baseUrl, suffix) {
7
7
  url.hash = "";
8
8
  return url;
9
9
  }
10
+ export function publicEndpointPaths(baseUrls, suffix) {
11
+ return Array.from(new Set(baseUrls.map((baseUrl) => publicEndpointUrl(baseUrl, suffix).pathname)));
12
+ }
10
13
  export function oauthAuthorizationServerMetadataPath(issuerUrl) {
11
14
  const issuer = new URL(issuerUrl instanceof URL ? issuerUrl.href : issuerUrl);
12
15
  const issuerPath = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/+$/, "");
@@ -5,9 +5,9 @@ import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/tok
5
5
  import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
6
6
  import { revocationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/revoke.js";
7
7
  import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
8
- import { oauthAuthorizationServerMetadataPath, publicEndpointUrl, } from "./public-url.js";
8
+ import { oauthAuthorizationServerMetadataPath, publicEndpointPaths, publicEndpointUrl, } from "./public-url.js";
9
9
  export function createForgeRelayAuthRouter(options) {
10
- const { provider, cliAuthenticationProvider, instanceId, issuerUrl, resourceServerUrl, scopesSupported, resourceName, } = options;
10
+ const { provider, cliAuthenticationProvider, instanceId, issuerUrl, resourceServerUrl, routeBaseUrls = [issuerUrl], scopesSupported, resourceName, } = options;
11
11
  const authorizationEndpoint = publicEndpointUrl(issuerUrl, "authorize");
12
12
  const tokenEndpoint = publicEndpointUrl(issuerUrl, "token");
13
13
  const registrationEndpoint = provider.clientsStore.registerClient
@@ -16,6 +16,11 @@ export function createForgeRelayAuthRouter(options) {
16
16
  const revocationEndpoint = provider.revokeToken
17
17
  ? publicEndpointUrl(issuerUrl, "revoke")
18
18
  : undefined;
19
+ const cliAuthenticationPaths = publicEndpointPaths(routeBaseUrls, "auth/cli");
20
+ const authorizationPaths = publicEndpointPaths(routeBaseUrls, "authorize");
21
+ const tokenPaths = publicEndpointPaths(routeBaseUrls, "token");
22
+ const registrationPaths = publicEndpointPaths(routeBaseUrls, "register");
23
+ const revocationPaths = publicEndpointPaths(routeBaseUrls, "revoke");
19
24
  const oauthMetadata = {
20
25
  ...createOAuthMetadata({
21
26
  provider,
@@ -36,7 +41,7 @@ export function createForgeRelayAuthRouter(options) {
36
41
  };
37
42
  const router = express.Router();
38
43
  if (cliAuthenticationProvider) {
39
- router.post("/auth/cli", express.json({ limit: "4kb" }), (req, res) => {
44
+ router.post(cliAuthenticationPaths, express.json({ limit: "4kb" }), (req, res) => {
40
45
  const ownerToken = typeof req.body?.owner_token === "string" ? req.body.owner_token : undefined;
41
46
  const refreshToken = typeof req.body?.refresh_token === "string" ? req.body.refresh_token : undefined;
42
47
  if ((ownerToken ? 1 : 0) + (refreshToken ? 1 : 0) !== 1) {
@@ -55,13 +60,13 @@ export function createForgeRelayAuthRouter(options) {
55
60
  res.status(200).json({ ...tokens, ...(instanceId ? { instance_id: instanceId } : {}) });
56
61
  });
57
62
  }
58
- router.use("/authorize", authorizationHandler({ provider }));
59
- router.use("/token", tokenHandler({ provider }));
60
- if (provider.clientsStore.registerClient) {
61
- router.use("/register", clientRegistrationHandler({ clientsStore: provider.clientsStore }));
63
+ router.use(authorizationPaths, authorizationHandler({ provider }));
64
+ router.use(tokenPaths, tokenHandler({ provider }));
65
+ if (provider.clientsStore.registerClient && registrationEndpoint) {
66
+ router.use(registrationPaths, clientRegistrationHandler({ clientsStore: provider.clientsStore }));
62
67
  }
63
- if (provider.revokeToken) {
64
- router.use("/revoke", revocationHandler({ provider }));
68
+ if (provider.revokeToken && revocationEndpoint) {
69
+ router.use(revocationPaths, revocationHandler({ provider }));
65
70
  }
66
71
  router.use(oauthAuthorizationServerMetadataPath(issuerUrl), metadataHandler(oauthMetadata));
67
72
  router.use(new URL(getOAuthProtectedResourceMetadataUrl(resourceServerUrl)).pathname, metadataHandler(protectedResourceMetadata));
@@ -1,5 +1,5 @@
1
- import { basename } from "node:path";
2
1
  import { spawnSync } from "node:child_process";
2
+ import { resolveCompatibilityCommandShellRuntime, } from "../../runtime/shell/command-shell-runtime.js";
3
3
  const defaultProcessTreeRuntime = {
4
4
  platform: process.platform,
5
5
  killGroup: (pid, signal) => process.kill(-pid, signal),
@@ -11,37 +11,35 @@ const defaultProcessTreeRuntime = {
11
11
  return !result.error && result.status === 0;
12
12
  },
13
13
  };
14
- const POSIX_SHELLS = new Set(["ash", "dash", "ksh", "sh"]);
15
14
  export function resolveShellCommand(command, platform = process.platform, environment = process.env) {
16
- if (platform === "win32") {
17
- return {
18
- executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
19
- // Match Node's native `spawn(command, { shell: cmd.exe })` quoting.
20
- // cmd.exe /S applies special quote stripping, so the whole command must
21
- // be wrapped even when the executable inside it is already quoted.
22
- args: ["/d", "/s", "/c", `"${command}"`],
23
- windowsVerbatimArguments: true,
24
- };
25
- }
15
+ return resolveShellCommandForRuntime(command, resolveCompatibilityCommandShellRuntime(platform, environment));
16
+ }
17
+ export function resolveShellCommandForRuntime(command, runtime) {
26
18
  // Agent and Hook commands must not source the user's interactive/login shell
27
19
  // configuration. ForgeRelay already inherits PATH and other environment from
28
20
  // the process that launched the server; re-running zsh/bash as a login shell
29
21
  // can inject prompts, banners, aliases, plugins, or other user-only behavior.
30
- const configuredShell = environment.FORGERELAY_COMMAND_SHELL?.trim();
31
- const executable = configuredShell || (platform === "linux" || platform === "darwin"
32
- ? "/bin/bash"
33
- : "/bin/sh");
34
- const shellName = basename(executable);
35
- if (shellName === "bash") {
36
- return { executable, args: ["--noprofile", "--norc", "-c", command] };
37
- }
38
- if (shellName === "zsh") {
39
- return { executable, args: ["-f", "-c", command] };
40
- }
41
- if (POSIX_SHELLS.has(shellName)) {
42
- return { executable, args: ["-c", command] };
22
+ switch (runtime.family) {
23
+ case "cmd":
24
+ return {
25
+ executable: runtime.executable,
26
+ // Match Node's native `spawn(command, { shell: cmd.exe })` quoting.
27
+ // cmd.exe /S applies special quote stripping, so the whole command must
28
+ // be wrapped even when the executable inside it is already quoted.
29
+ args: ["/d", "/s", "/c", `"${command}"`],
30
+ windowsVerbatimArguments: true,
31
+ };
32
+ case "bash":
33
+ return { executable: runtime.executable, args: ["--noprofile", "--norc", "-c", command] };
34
+ case "zsh":
35
+ return { executable: runtime.executable, args: ["-f", "-c", command] };
36
+ case "sh":
37
+ return { executable: runtime.executable, args: ["-c", command] };
38
+ case "fish":
39
+ case "pwsh":
40
+ case "powershell":
41
+ throw new Error(`Command shell runtime ${runtime.family} is identified but native execution support is not enabled in this release stage. ForgeRelay will not silently execute the command through another shell.`);
43
42
  }
44
- return { executable: "/bin/sh", args: ["-c", command] };
45
43
  }
46
44
  export function terminateProcessTree(child, signal, detached, runtime = defaultProcessTreeRuntime) {
47
45
  if (runtime.platform === "win32" && child.pid) {
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { resolveShellCommand, terminateProcessTree } from "./process-platform.js";
2
+ import { resolveShellCommandForRuntime, terminateProcessTree } from "./process-platform.js";
3
+ import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime } from "../../runtime/shell/command-shell-runtime.js";
3
4
  const DEFAULT_EXEC_YIELD_MS = 10_000;
4
5
  const DEFAULT_INTERACTIVE_YIELD_MS = 250;
5
6
  export const DEFAULT_POLL_YIELD_MS = 60_000;
@@ -210,6 +211,7 @@ export class ProcessManager {
210
211
  maxStartYieldMs;
211
212
  monotonicNow;
212
213
  outputAudit;
214
+ commandShellRuntime;
213
215
  nextProcessId = 1;
214
216
  constructor(options = {}) {
215
217
  this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
@@ -227,6 +229,7 @@ export class ProcessManager {
227
229
  this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
228
230
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
229
231
  this.outputAudit = options.outputAudit;
232
+ this.commandShellRuntime = snapshotCommandShellRuntime(options.commandShellRuntime ?? resolveCompatibilityCommandShellRuntime());
230
233
  }
231
234
  async start(input) {
232
235
  input.signal?.throwIfAborted();
@@ -435,7 +438,7 @@ export class ProcessManager {
435
438
  };
436
439
  }
437
440
  startPipe(processEntry, input) {
438
- const shell = resolveShellCommand(input.command);
441
+ const shell = resolveShellCommandForRuntime(input.command, this.commandShellRuntime);
439
442
  const detached = process.platform !== "win32";
440
443
  const child = spawn(shell.executable, shell.args, {
441
444
  cwd: input.cwd,
@@ -467,7 +470,7 @@ export class ProcessManager {
467
470
  catch {
468
471
  throw new Error("PTY support requires the optional node-pty dependency.");
469
472
  }
470
- const shell = resolveShellCommand(input.command);
473
+ const shell = resolveShellCommandForRuntime(input.command, this.commandShellRuntime);
471
474
  let pty;
472
475
  try {
473
476
  pty = nodePty.spawn(shell.executable, shell.args, {
@@ -16,7 +16,7 @@ import { createOpenAIIncomingArtifactAdapter } from "../../artifacts/incoming-ar
16
16
  import { logEvent, requestPath, transportSessionIdPrefix } from "../../../runtime/logging/logger.js";
17
17
  import { SingleUserOAuthProvider } from "../../oauth/oauth-provider.js";
18
18
  import { createForgeRelayAuthRouter } from "../../oauth/router.js";
19
- import { publicEndpointUrl } from "../../oauth/public-url.js";
19
+ import { publicEndpointPaths, publicEndpointUrl } from "../../oauth/public-url.js";
20
20
  import { McpTransportRegistry } from "./mcp-sessions.js";
21
21
  import { ProcessManager } from "../../process/process-sessions.js";
22
22
  import { createReviewCheckpointManager } from "../../../workspaces/review/review-checkpoints.js";
@@ -45,7 +45,11 @@ export function createHttpServer(config, options, createMcpServer) {
45
45
  const transports = new McpTransportRegistry({
46
46
  maxTransports: MAX_MCP_TRANSPORT_SESSIONS,
47
47
  });
48
+ const routeBaseUrls = config.publicBaseUrls.map((baseUrl) => new URL(baseUrl));
48
49
  const mcpUrl = publicEndpointUrl(config.publicBaseUrl, "mcp");
50
+ const mcpPaths = publicEndpointPaths(routeBaseUrls, "mcp");
51
+ const activityPanelAssetsPaths = publicEndpointPaths(routeBaseUrls, "mcp-app-assets");
52
+ const healthPaths = publicEndpointPaths(routeBaseUrls, "healthz");
49
53
  const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
50
54
  const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
51
55
  const bearerAuth = requireBearerAuth({
@@ -67,7 +71,10 @@ export function createHttpServer(config, options, createMcpServer) {
67
71
  turnIdForConversation: (conversationScopeId, workspaceId) => activityQueries.currentTurnId(conversationScopeId, workspaceId),
68
72
  });
69
73
  const reviewCheckpoints = createReviewCheckpointManager();
70
- const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
74
+ const processSessions = new ProcessManager({
75
+ outputAudit: bashOutputStore,
76
+ commandShellRuntime: config.commandShellRuntime,
77
+ });
71
78
  const codeIntelligence = new CodeIntelligenceManager(config);
72
79
  const subagentProviders = config.subagents
73
80
  ? getSubagentProviderAvailabilitySnapshot()
@@ -140,8 +147,8 @@ export function createHttpServer(config, options, createMcpServer) {
140
147
  }, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
141
148
  transportCleanupTimer.unref();
142
149
  logRuntimeResources();
143
- if (config.logging.trustProxy) {
144
- app.set("trust proxy", 1);
150
+ if (config.proxyTrust !== false) {
151
+ app.set("trust proxy", config.proxyTrust);
145
152
  }
146
153
  app.use((req, res, next) => {
147
154
  const requestId = randomUUID();
@@ -151,7 +158,7 @@ export function createHttpServer(config, options, createMcpServer) {
151
158
  const path = requestPath(req);
152
159
  if (!config.logging.requests)
153
160
  return;
154
- if (!config.logging.assets && path.startsWith("/mcp-app-assets"))
161
+ if (!config.logging.assets && activityPanelAssetsPaths.some((assetPath) => path.startsWith(assetPath)))
155
162
  return;
156
163
  logEvent(config.logging, "info", "http_request", {
157
164
  requestId,
@@ -170,23 +177,24 @@ export function createHttpServer(config, options, createMcpServer) {
170
177
  instanceId: config.instanceId,
171
178
  issuerUrl: new URL(config.publicBaseUrl),
172
179
  resourceServerUrl,
180
+ routeBaseUrls,
173
181
  scopesSupported: config.oauth.scopes,
174
182
  resourceName: "ForgeRelay",
175
183
  }));
176
- app.options("/mcp-app-assets/{*asset}", (_req, res) => {
184
+ app.options(activityPanelAssetsPaths.map((assetPath) => `${assetPath}/{*asset}`), (_req, res) => {
177
185
  setActivityPanelAssetHeaders(res);
178
186
  res.sendStatus(204);
179
187
  });
180
- app.use("/mcp-app-assets", express.static(activityPanelAssetDirectory(), {
188
+ app.use(activityPanelAssetsPaths, express.static(activityPanelAssetDirectory(), {
181
189
  immutable: true,
182
190
  maxAge: "1y",
183
191
  fallthrough: false,
184
192
  setHeaders: setActivityPanelAssetHeaders,
185
193
  }));
186
- app.get("/healthz", (_req, res) => {
194
+ app.get(healthPaths, (_req, res) => {
187
195
  res.json({ ok: true, name: "forgerelay" });
188
196
  });
189
- app.all("/mcp", async (req, res) => {
197
+ app.all(mcpPaths, async (req, res) => {
190
198
  const requestId = res.locals.requestId;
191
199
  const transportSessionId = req.header("mcp-session-id");
192
200
  const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
@@ -71,6 +71,10 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
71
71
  const cardAvailableAgentsFiles = availableAgentsFiles.map((file) => ({
72
72
  path: formatAgentsPath(file.path, workspace.root),
73
73
  }));
74
+ const cardWorkspaceInstructions = workspace.workspaceInstructions.map((instruction) => ({
75
+ path: formatAgentsPath(instruction.path, workspace.root),
76
+ status: instruction.status,
77
+ }));
74
78
  const bootstrapComponents = new Set(effectiveBootstrapContextComponents);
75
79
  const visibleSkills = bootstrapComponents.has("skills") ? cardSkills : [];
76
80
  const visibleSkillDiagnostics = bootstrapComponents.has("skillDiagnostics")
@@ -185,6 +189,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
185
189
  capabilityCatalog,
186
190
  agentsFiles: cardAgentsFiles,
187
191
  availableAgentsFiles: cardAvailableAgentsFiles,
192
+ workspaceInstructions: cardWorkspaceInstructions,
188
193
  skills: cardSkills,
189
194
  agentProviders: cardAgentProviders,
190
195
  agents: cardAgents,
@@ -193,6 +198,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
193
198
  mode: workspace.mode,
194
199
  agentsFiles: cardAgentsFiles.length,
195
200
  availableAgentsFiles: cardAvailableAgentsFiles.length,
201
+ workspaceInstructions: cardWorkspaceInstructions.length,
196
202
  skills: cardSkills.length,
197
203
  capabilities: capabilityCatalog.length,
198
204
  agentProviders: cardAgentProviders.length,
@@ -26,7 +26,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
26
26
  outputSchema: {
27
27
  path: z.string(),
28
28
  content: z.string(),
29
- status: z.enum(["loaded", "available"]),
29
+ status: z.enum(["loaded", "available", "disabled"]),
30
30
  },
31
31
  _meta: { ui: { visibility: ["app"] } },
32
32
  annotations: {