@akira-tl/forgerelay 0.9.4 → 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 (102) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/cli/init.js +72 -1
  4. package/dist/cli/shell/setup.js +99 -0
  5. package/dist/cli.js +43 -10
  6. package/dist/mcp/hooks/hooks.js +6 -3
  7. package/dist/mcp/process/process-platform.js +24 -26
  8. package/dist/mcp/process/process-sessions.js +6 -3
  9. package/dist/mcp/server/transport/http-server.js +4 -1
  10. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  11. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  12. package/dist/mcp/server-instructions.js +10 -1
  13. package/dist/runtime/config/config.js +6 -0
  14. package/dist/runtime/instructions/shell-instructions.js +100 -0
  15. package/dist/runtime/security/runtime-privilege.js +120 -0
  16. package/dist/runtime/shell/command-shell-runtime.js +235 -0
  17. package/dist/ui/.vite/manifest.json +276 -276
  18. package/dist/ui/activity-panel-app.html +2 -2
  19. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  20. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  21. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  22. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  23. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  24. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  25. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  26. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  27. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  28. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  29. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  30. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  31. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  32. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  33. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  34. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  35. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  36. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  37. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  38. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  39. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  40. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  41. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  42. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  43. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  44. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  45. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  46. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  47. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  48. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  49. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  50. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  51. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  52. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  53. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  54. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  55. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  56. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  57. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  58. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  59. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  60. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  61. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  62. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  63. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  64. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  65. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  66. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  67. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  68. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  69. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  70. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  71. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  72. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  73. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  74. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  75. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  76. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  77. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  78. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  79. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  80. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  81. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  82. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  83. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  84. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  85. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  86. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  87. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  88. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  89. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  90. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  91. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  92. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  93. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  94. package/dist/workspaces/context.js +32 -5
  95. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  96. package/dist/workspaces/sessions.js +2 -0
  97. package/dist/workspaces.js +2 -1
  98. package/docs/configuration.md +1 -1
  99. package/package.json +1 -1
  100. package/scripts/release/release-gate.test.mjs +1 -0
  101. package/scripts/release-proof.mjs +85 -9
  102. package/scripts/release-proof.test.mjs +79 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.0] - 2026-09-05
8
+
9
+ ### Added
10
+
11
+ - Added a first-class Command Shell Runtime identity with launch-shell/fallback selection, explicit pinned shell executable support, owner-visible diagnostics, and one shared shell contract for Agent commands and Hooks.
12
+ - Added editable ForgeRelay shell Instructions for PowerShell 7, Windows PowerShell 5.1, cmd, zsh, and fish. `forgerelay init` seeds exact-release-tag templates into the user config directory, preserves user edits, and projects loaded/disabled/unavailable state through the existing Workspace Instructions UI and context-delta path.
13
+
14
+ ### Security
15
+
16
+ - ForgeRelay now refuses elevated root/Administrator/SYSTEM-equivalent runtime authority by default across supported platforms. Intentional elevated startup requires the invocation-scoped `--allow-elevated` acknowledgement and always exposes a mandatory Agent safety notice.
17
+
7
18
  ## [0.9.4] - 2026-09-04
8
19
 
9
20
  ### Added
package/README.md CHANGED
@@ -222,7 +222,7 @@ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一
222
222
  }
223
223
  ```
224
224
 
225
- 命中 `BeforeTool` 后,Hook 只快速验证 clean working tree(含 untracked)、tag 与 package version 一致,以及本地 tag 指向当前 HEAD;成功才继续原始 `git push`,失败则直接阻断。Hook 不运行、也不要求本地 CI。tag 推送后由 GitHub Actions 的 Linux/macOS/Windows 矩阵执行权威验证,全部通过后才进入发布。`npm run release:verify` 仅作为可选的本地云端复现工具。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
225
+ 命中 `BeforeTool` 后,普通 tag push 的 Hook 只快速验证 clean working tree(含 untracked)、tag 与 package version 一致,以及本地 tag 指向当前 HEAD;成功才继续原始 `git push`,失败则直接阻断。force release-tag push 始终拒绝。对于失败发布后显式执行的 `git push --delete origin vX.Y.Z`,Hook 仅在当前 HEAD 有有效 `release:verify` proof,且联网确认 npm 对应版本与 GitHub Release 都不存在时允许删除;任一不可撤回产物存在或远端状态无法确认都会拒绝。因此仅有 tag 时可以重建同版本,一旦 GitHub Release 或包仓库版本存在就进入不可变状态。tag 推送后由 GitHub Actions 的 Linux/macOS/Windows 矩阵执行权威验证,全部通过后才进入发布。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
226
226
 
227
227
  旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
228
228
 
package/dist/cli/init.js CHANGED
@@ -2,10 +2,13 @@ import { resolve } from "node:path";
2
2
  import * as prompts from "@clack/prompts";
3
3
  import { publicEndpointUrl } from "../mcp/oauth/public-url.js";
4
4
  import { expandHomePath } from "../mcp/filesystem/roots.js";
5
+ import { detectLauncherCommandShell, } from "../runtime/shell/command-shell-runtime.js";
6
+ import { seedShellInstructionFiles, shellInstructionFamiliesToSeed, } from "../runtime/instructions/shell-instructions.js";
5
7
  import { installManagedLanguageServers, installedManagedLanguageServers, managedLanguageServerOptions, } from "../lsp/runtime/managed-language-servers.js";
6
8
  import { generateInstanceId, generateOwnerToken, loadForgeRelayFiles, resolveSubagentsFlag, writeForgeRelayAuth, writeForgeRelayConfig, } from "../runtime/config/user-config.js";
7
9
  import { classifyClientFacingBaseUrl, compactPublicBaseUrlConfig, hasInsecureLanBaseUrl, isLoopbackBindAddress, normalizePublicBaseUrlsInput, setupBindAddress, SetupCancelledError, textPrompt, validateHttpsProxyBaseUrls, validateLanClientFacingBaseUrls, validatePort, } from "./setup-support.js";
8
- export async function runInit({ force }) {
10
+ import { commandShellCompatibilityWarning, commandShellSetupOptions, customPinnedPreference, defaultCommandShellSetupChoice, followLauncherPreference, pinnedFamilyPreference, preservePinnedPreference, shellFamiliesForCustomSelection, } from "./shell/setup.js";
11
+ export async function runInit({ force, version }) {
9
12
  const files = loadForgeRelayFiles();
10
13
  if (!force && files.configExists && files.authExists) {
11
14
  prompts.log.info(`ForgeRelay is already configured at ${files.dir}`);
@@ -116,6 +119,64 @@ export async function runInit({ force }) {
116
119
  }
117
120
  publicBaseUrl = compactPublicBaseUrlConfig(clientFacingBaseUrls);
118
121
  }
122
+ const launcherShell = detectLauncherCommandShell();
123
+ const selectedShellChoice = await prompts.select({
124
+ message: "Which command shell should Agent commands and Hooks use?",
125
+ initialValue: defaultCommandShellSetupChoice(files.config.commandShell),
126
+ options: commandShellSetupOptions(process.platform, files.config.commandShell, launcherShell),
127
+ });
128
+ if (prompts.isCancel(selectedShellChoice))
129
+ throw new SetupCancelledError();
130
+ let commandShell;
131
+ const shellChoice = selectedShellChoice;
132
+ if (shellChoice === "follow-launcher") {
133
+ commandShell = followLauncherPreference(files.config.commandShell, launcherShell);
134
+ }
135
+ else if (shellChoice === "keep-pinned") {
136
+ commandShell = preservePinnedPreference(files.config.commandShell);
137
+ }
138
+ else if (shellChoice === "custom") {
139
+ const customFamily = await prompts.select({
140
+ message: "Which command language does the custom executable implement?",
141
+ options: shellFamiliesForCustomSelection(),
142
+ });
143
+ if (prompts.isCancel(customFamily))
144
+ throw new SetupCancelledError();
145
+ const executable = await textPrompt({
146
+ message: "What is the command-shell executable path?",
147
+ placeholder: process.platform === "win32" ? "D:\\Portable\\PowerShell\\pwsh.exe" : "/opt/custom/bin/zsh",
148
+ defaultValue: "",
149
+ validate: (value) => value?.trim() ? undefined : "Enter an executable path.",
150
+ });
151
+ commandShell = customPinnedPreference(customFamily, executable);
152
+ }
153
+ else {
154
+ commandShell = pinnedFamilyPreference(shellChoice);
155
+ }
156
+ const shellWarning = commandShellCompatibilityWarning(commandShell.family);
157
+ if (shellWarning)
158
+ prompts.note(shellWarning, "Command shell compatibility");
159
+ const shellInstructionFamilies = shellInstructionFamiliesToSeed(process.platform, commandShell.family);
160
+ let shellInstructions = files.config.shellInstructions;
161
+ let shellInstructionSeedResults = [];
162
+ if (shellInstructionFamilies.length > 0) {
163
+ const shellInstructionsAnswer = await prompts.confirm({
164
+ message: "Load ForgeRelay-provided extra Instructions for this command shell?",
165
+ initialValue: files.config.shellInstructions !== false,
166
+ });
167
+ if (prompts.isCancel(shellInstructionsAnswer))
168
+ throw new SetupCancelledError();
169
+ shellInstructions = shellInstructionsAnswer === true;
170
+ shellInstructionSeedResults = await seedShellInstructionFiles({
171
+ configDir: files.dir,
172
+ version,
173
+ families: shellInstructionFamilies,
174
+ });
175
+ const failedShellInstructions = shellInstructionSeedResults.filter((result) => result.status === "failed");
176
+ if (failedShellInstructions.length > 0) {
177
+ prompts.note(failedShellInstructions.map((result) => `${result.family}: ${result.error ?? "download failed"}\n${result.sourceUrl}`).join("\n\n"), "Shell Instructions unavailable");
178
+ }
179
+ }
119
180
  const installedManaged = installedManagedLanguageServers(files.dir);
120
181
  const selectedManaged = await prompts.multiselect({
121
182
  message: "Which Language Servers should ForgeRelay manage with npm?",
@@ -150,6 +211,7 @@ export async function runInit({ force }) {
150
211
  }
151
212
  }
152
213
  const config = {
214
+ ...files.config,
153
215
  host,
154
216
  port,
155
217
  allowedRoots,
@@ -158,6 +220,8 @@ export async function runInit({ force }) {
158
220
  trustedProxies,
159
221
  workflowInstructions: files.config.workflowInstructions,
160
222
  appendInstructions: files.config.appendInstructions,
223
+ commandShell,
224
+ shellInstructions,
161
225
  subagents: resolveSubagentsFlag(files.config),
162
226
  languageServers: files.config.languageServers,
163
227
  allowAgentLanguageServerInstall,
@@ -173,6 +237,13 @@ export async function runInit({ force }) {
173
237
  `Config: ${configPath}`,
174
238
  `Auth: ${authPath}`,
175
239
  `Bind: http://${config.host}:${config.port}`,
240
+ `Command shell: ${commandShell.mode} ${commandShell.family} (${commandShell.executable})`,
241
+ ...(shellInstructionFamilies.length > 0
242
+ ? [
243
+ `Shell Instructions: ${shellInstructions ? "enabled" : "disabled"}`,
244
+ ...shellInstructionSeedResults.map((result) => `Shell Instructions ${result.family}: ${result.status} (${result.path})`),
245
+ ]
246
+ : []),
176
247
  ...clientFacingBaseUrls.map((baseUrl, index) => `${index === 0 ? "Client-facing MCP URL" : "Client-facing MCP alias"}: ${publicEndpointUrl(baseUrl, "mcp").toString()}`),
177
248
  ];
178
249
  if (networkMode === "ssh") {
@@ -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
@@ -21,21 +21,31 @@ import { parseSubagentRunArgs } from "./subagents/cli-target.js";
21
21
  import { ensureForgeRelayInstanceId, loadForgeRelayFiles, removeForgeRelayRemote, renameForgeRelayRemote, writeForgeRelayConfig, writeForgeRelayRemote, } from "./runtime/config/user-config.js";
22
22
  import { shutdownHttpServer } from "./mcp/server/transport/server-shutdown.js";
23
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";
24
26
  import { authenticateRemote, defaultRemoteAlias, isRemoteMcpUnauthorized, normalizeRemoteServiceTarget, refreshRemoteAuthentication, verifyRemoteMcp, } from "./workspaces/relay/auth/remote-auth.js";
25
27
  import { defaultSshRouteAlias, parseSshRoute, readRemoteOwnerToken, withRemoteServiceEndpoint, } from "./workspaces/relay/transport/remote-transport.js";
26
28
  import { assertSupportedNode, checkBashShell, checkGitAvailable, checkSqliteNative, nodeVersionStatus, normalizeOptionalPublicBaseUrl, } from "./cli/setup-support.js";
27
29
  const require = createRequire(import.meta.url);
28
30
  async function main(argv) {
29
31
  assertSupportedNode();
30
- const [rawCommand, ...args] = argv;
31
- 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;
32
36
  switch (command) {
33
- 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));
34
43
  await ensureConfigured();
35
- await serve();
44
+ await serve(runtimePrivilege);
36
45
  return;
46
+ }
37
47
  case "init":
38
- await runInit({ force: args.includes("--force") });
48
+ await runInit({ force: args.includes("--force"), version: installedForgeRelayVersion() });
39
49
  return;
40
50
  case "doctor":
41
51
  await runDoctor();
@@ -74,6 +84,19 @@ function normalizeCommand(command) {
74
84
  return "version";
75
85
  throw new Error(`Unknown command: ${command}`);
76
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
+ }
77
100
  async function ensureConfigured() {
78
101
  const files = loadForgeRelayFiles();
79
102
  if (files.configExists && files.authExists) {
@@ -94,9 +117,9 @@ async function ensureConfigured() {
94
117
  "Or provide FORGERELAY_OAUTH_OWNER_TOKEN and FORGERELAY_ALLOWED_ROOTS.",
95
118
  ].join("\n"));
96
119
  }
97
- await runInit({ force: false });
120
+ await runInit({ force: false, version: installedForgeRelayVersion() });
98
121
  }
99
- async function serve() {
122
+ async function serve(runtimePrivilege) {
100
123
  const sqliteStatus = checkSqliteNative();
101
124
  if (sqliteStatus !== "ok") {
102
125
  throw new Error([
@@ -109,6 +132,7 @@ async function serve() {
109
132
  }
110
133
  const { createServer } = await import("./server.js");
111
134
  const config = loadConfig();
135
+ config.runtimePrivilege = runtimePrivilege;
112
136
  const runtimeLease = acquireRuntimeLease(config.stateDir);
113
137
  let server;
114
138
  try {
@@ -128,6 +152,8 @@ async function serve() {
128
152
  console.warn("warning: Host header allowlist is disabled because FORGERELAY_ALLOWED_HOSTS=*");
129
153
  }
130
154
  console.log("auth: Owner password approval required");
155
+ console.log(`command shell: ${formatCommandShellRuntime(config.commandShellRuntime)}`);
156
+ console.log(`runtime privilege: ${formatRuntimePrivilege(runtimePrivilege)}`);
131
157
  console.log(`logging: ${config.logging.level} ${config.logging.format}`);
132
158
  if (config.subagents) {
133
159
  console.log(`subagent providers: ${formatSubagentProviderAvailabilitySummary(subagentProviders)}`);
@@ -325,6 +351,7 @@ async function runDoctor() {
325
351
  console.log(`Node: ${process.version} (${nodeVersionStatus()})`);
326
352
  console.log(`Node ABI: ${process.versions.modules}`);
327
353
  console.log(`Platform: ${process.platform} ${process.arch}`);
354
+ console.log(`Runtime privilege: ${formatRuntimePrivilege(detectRuntimePrivilege())}`);
328
355
  console.log(`Git: ${checkGitAvailable()}`);
329
356
  console.log(`Bash shell: ${checkBashShell()}`);
330
357
  console.log(`SQLite native dependency: ${checkSqliteNative()}`);
@@ -334,6 +361,7 @@ async function runDoctor() {
334
361
  console.log(`Client-facing base URLs: ${config.publicBaseUrls.join(", ")}`);
335
362
  console.log(`Client-facing base URL: ${config.publicBaseUrl}`);
336
363
  console.log(`Client-facing MCP URL: ${publicEndpointUrl(config.publicBaseUrl, "mcp").toString()}`);
364
+ console.log(`Command shell: ${formatCommandShellRuntime(config.commandShellRuntime)}`);
337
365
  console.log(`Tool mode: ${config.toolMode}`);
338
366
  console.log(`Widgets: ${config.widgets}`);
339
367
  console.log(`Trust proxy: ${config.proxyTrust === false ? "off" : config.proxyTrust.join(", ")}`);
@@ -377,6 +405,8 @@ function printHelp() {
377
405
  "Usage:",
378
406
  " forgerelay Run first-time setup if needed, then start the server",
379
407
  " forgerelay serve Start the server",
408
+ " forgerelay serve --allow-elevated",
409
+ " Explicitly allow this invocation to run with elevated/unknown OS privilege",
380
410
  " forgerelay init Create or update ~/.forgerelay/config.json and auth.json",
381
411
  " forgerelay doctor Show config, runtime, and native dependency status",
382
412
  " forgerelay config get Print persisted config",
@@ -570,12 +600,15 @@ function printAgentsHelp() {
570
600
  " forgerelay agents show <id>",
571
601
  ].join("\n"));
572
602
  }
573
- function printVersion() {
603
+ function installedForgeRelayVersion() {
574
604
  const packageJson = require("../package.json");
575
- if (typeof packageJson.version !== "string") {
605
+ if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
576
606
  throw new Error("Unable to read ForgeRelay package version.");
577
607
  }
578
- console.log(packageJson.version);
608
+ return packageJson.version;
609
+ }
610
+ function printVersion() {
611
+ console.log(installedForgeRelayVersion());
579
612
  }
580
613
  main(process.argv.slice(2)).catch((error) => {
581
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 {
@@ -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, {
@@ -71,7 +71,10 @@ export function createHttpServer(config, options, createMcpServer) {
71
71
  turnIdForConversation: (conversationScopeId, workspaceId) => activityQueries.currentTurnId(conversationScopeId, workspaceId),
72
72
  });
73
73
  const reviewCheckpoints = createReviewCheckpointManager();
74
- const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
74
+ const processSessions = new ProcessManager({
75
+ outputAudit: bashOutputStore,
76
+ commandShellRuntime: config.commandShellRuntime,
77
+ });
75
78
  const codeIntelligence = new CodeIntelligenceManager(config);
76
79
  const subagentProviders = config.subagents
77
80
  ? getSubagentProviderAvailabilitySnapshot()
@@ -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: {
@@ -1,3 +1,4 @@
1
+ import { commandShellAgentInstruction } from "../runtime/shell/command-shell-runtime.js";
1
2
  export const toolNames = {
2
3
  openWorkspace: "open_workspace",
3
4
  closeWorkspace: "close_workspace",
@@ -14,7 +15,7 @@ export function buildShellMutationPolicy() {
14
15
  return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. They may also perform external device or hardware mutations when the user's current request explicitly asks for the actual device-changing operation, including firmware flashing or equivalent persistent device updates; do not infer such authorization from a check, audit, probe, backup, verification, dry-run, or build-only request. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
15
16
  }
16
17
  export function buildServerInstructions(config) {
17
- return joinInstructions(capabilityContractInstructions(config), selectedWorkflowInstructions(config), config.appendInstructions);
18
+ return joinInstructions(runtimePrivilegeInstructions(config), commandShellAgentInstruction(config.commandShellRuntime), capabilityContractInstructions(config), selectedWorkflowInstructions(config), config.appendInstructions);
18
19
  }
19
20
  export function buildToolDescriptions(config) {
20
21
  const skillCapability = config.skillsEnabled
@@ -34,6 +35,14 @@ export function buildToolDescriptions(config) {
34
35
  shellCommand: "Shell command to run with the local user's authority.",
35
36
  };
36
37
  }
38
+ function runtimePrivilegeInstructions(config) {
39
+ if (!config.runtimePrivilege || config.runtimePrivilege.level === "standard")
40
+ return "";
41
+ const status = config.runtimePrivilege.level === "elevated"
42
+ ? "elevated operating-system privileges"
43
+ : "an unverified operating-system privilege level accepted through --allow-elevated";
44
+ return `Runtime security: ForgeRelay is running with ${status}. Agent and Hook shell commands inherit this authority. Do not perform system-wide, privileged, security-sensitive, or irreversible mutations unless the user's current request explicitly authorizes that exact change. This safety notice is mandatory and is not disabled by shell-guidance settings.`;
45
+ }
37
46
  function capabilityContractInstructions(config) {
38
47
  const staleWorkspacePolicy = config.toolMode === "codex"
39
48
  ? ""
@@ -4,6 +4,8 @@ import { join, resolve } from "node:path";
4
4
  import { expandHomePath } from "../../mcp/filesystem/roots.js";
5
5
  import { mergeHookConfigs, parseHookConfig } from "../../mcp/hooks/hooks.js";
6
6
  import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
7
+ import { shellInstructionPath } from "../instructions/shell-instructions.js";
8
+ import { resolveConfiguredCommandShellRuntime, } from "../shell/command-shell-runtime.js";
7
9
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
8
10
  const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
9
11
  const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
@@ -268,6 +270,7 @@ export function loadConfig(env = process.env) {
268
270
  const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
269
271
  const publicBaseUrl = publicDeployment.canonicalBaseUrl;
270
272
  const proxyTrust = resolveProxyTrust(env, files.config, host, publicBaseUrl);
273
+ const commandShellRuntime = resolveConfiguredCommandShellRuntime(files.config.commandShell, process.platform, env);
271
274
  const derivedAllowedHosts = [
272
275
  "localhost",
273
276
  "127.0.0.1",
@@ -314,6 +317,9 @@ export function loadConfig(env = process.env) {
314
317
  systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
315
318
  hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
316
319
  logging: parseLoggingConfig(env, proxyTrust !== false),
320
+ commandShellRuntime,
321
+ shellInstructionsEnabled: files.config.shellInstructions !== false,
322
+ shellInstructionPath: shellInstructionPath(files.dir, commandShellRuntime.family),
317
323
  };
318
324
  }
319
325
  function numberConfigValue(value) {