@akira-tl/forgerelay 0.9.4 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +4 -3
  3. package/capabilities/shell-processes/GUIDE.md +1 -1
  4. package/dist/cli/init.js +86 -1
  5. package/dist/cli/shell/setup.js +99 -0
  6. package/dist/cli.js +43 -10
  7. package/dist/mcp/hooks/hooks.js +6 -3
  8. package/dist/mcp/process/process-platform.js +34 -26
  9. package/dist/mcp/process/process-sessions.js +6 -3
  10. package/dist/mcp/server/transport/http-server.js +4 -1
  11. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  12. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  13. package/dist/mcp/server-instructions.js +10 -1
  14. package/dist/runtime/config/config.js +6 -0
  15. package/dist/runtime/instructions/powershell-skill.js +65 -0
  16. package/dist/runtime/instructions/shell-instructions.js +100 -0
  17. package/dist/runtime/security/runtime-privilege.js +120 -0
  18. package/dist/runtime/shell/command-shell-runtime.js +278 -0
  19. package/dist/ui/.vite/manifest.json +276 -276
  20. package/dist/ui/activity-panel-app.html +2 -2
  21. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  22. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  23. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  24. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  25. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  26. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  27. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  28. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  29. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  30. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  31. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  32. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  33. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  34. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  35. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  36. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  37. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  38. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  39. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  40. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  41. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  42. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  43. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  44. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  45. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  46. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  47. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  48. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  49. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  50. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  51. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  52. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  53. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  54. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  55. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  56. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  57. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  58. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  59. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  60. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  61. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  62. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  63. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  64. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  65. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  66. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  67. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  68. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  69. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  70. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  71. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  72. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  73. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  74. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  75. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  76. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  77. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  78. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  79. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  80. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  81. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  82. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  83. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  84. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  85. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  86. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  87. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  88. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  89. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  90. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  91. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  92. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  93. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  94. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  95. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  96. package/dist/workspaces/context.js +32 -5
  97. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  98. package/dist/workspaces/sessions.js +2 -0
  99. package/dist/workspaces.js +2 -1
  100. package/docs/configuration.md +1 -1
  101. package/docs/gotchas.md +5 -3
  102. package/package.json +3 -2
  103. package/scripts/ci/pwsh-acceptance.mjs +308 -0
  104. package/scripts/ci/verify.mjs +1 -0
  105. package/scripts/release/release-gate.test.mjs +1 -0
  106. package/scripts/release-proof.mjs +85 -9
  107. package/scripts/release-proof.test.mjs +79 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.1] - 2026-09-05
8
+
9
+ ### Added
10
+
11
+ - Added native PowerShell 7 (`pwsh`) execution for Agent commands, Hooks, and PTY sessions with one versioned Command Shell Runtime, no-profile invocation, PowerShell-native command semantics, and Windows packaged-shim acceptance in cloud CI.
12
+ - Added an exact-release-tag PowerShell guidance Skill seeded into the ForgeRelay config Skills directory without bundling prompt templates into the npm package or overwriting user edits.
13
+
14
+ ## [0.10.0] - 2026-09-05
15
+
16
+ ### Added
17
+
18
+ - 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.
19
+ - 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.
20
+
21
+ ### Security
22
+
23
+ - 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.
24
+
7
25
  ## [0.9.4] - 2026-09-04
8
26
 
9
27
  ### 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
 
@@ -295,8 +295,9 @@ See [Security Model](docs/security.md) for the full boundary and threat model.
295
295
  | --- | --- | --- |
296
296
  | Linux | Supported | Requires Node, npm, Git, and Bash. |
297
297
  | macOS | Supported | Requires Node, npm, Git, and Bash. |
298
- | Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | Supported | Git Bash is the simplest native Windows setup. |
299
- | Windows PowerShell or `cmd.exe` only | Not supported yet | Install Git Bash or use WSL. |
298
+ | Windows with PowerShell 7 (`pwsh`) | Supported | Agent commands and Hooks use one native `pwsh` runtime without loading the user profile. |
299
+ | Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | Supported | Bash remains available as a compatibility path. |
300
+ | Windows PowerShell 5.1 (`powershell.exe`) or `cmd.exe` only | Not supported yet | These runtimes are separate follow-up stages. |
300
301
 
301
302
  You can check the local runtime with:
302
303
 
@@ -48,6 +48,6 @@ PTY 依赖 optional `node-pty`。缺少该依赖时 ForgeRelay 会明确报错
48
48
 
49
49
  ## Platform notes
50
50
 
51
- ForgeRelay shell runtime 需要 Bash。Windows 上原生 PowerShell `cmd.exe` 当前不是该 runtime 的执行 shell;使用 Git Bash、WSL、MSYS2 或 Cygwin Bash,并用 `forgerelay doctor` 检查环境。
51
+ ForgeRelay Windows 上支持 PowerShell 7 (`pwsh`) 作为原生 Command Shell Runtime;非 TTY Agent 命令与 Hooks 使用同一个 `pwsh`,禁止加载用户 profile,PTY 也复用同一 executable。公共工具名仍保留为 `bash` 以兼容 Host contract,实际命令语言以 ForgeRelay 报告的 Command Shell Runtime 为准。Windows PowerShell 5.1 (`powershell.exe`) 与 `cmd.exe` 仍属于后续 runtime 阶段;Git Bash、WSL、MSYS2 或 Cygwin Bash 仍可作为 Bash 兼容路径。用 `forgerelay doctor` 检查当前 shell identity、version 与 executable。
52
52
 
53
53
  Shell 可以作为用户开发任务的一部分修改普通项目文件,但始终受 ForgeRelay core mutation/safety contract 约束。涉及 privileged OS files、credentials、configuration 或外部硬件持久写入时,不要用本指南替代 core authorization/safety 规则。
package/dist/cli/init.js CHANGED
@@ -2,10 +2,14 @@ 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";
7
+ import { seedPowerShellSkill, } from "../runtime/instructions/powershell-skill.js";
5
8
  import { installManagedLanguageServers, installedManagedLanguageServers, managedLanguageServerOptions, } from "../lsp/runtime/managed-language-servers.js";
6
9
  import { generateInstanceId, generateOwnerToken, loadForgeRelayFiles, resolveSubagentsFlag, writeForgeRelayAuth, writeForgeRelayConfig, } from "../runtime/config/user-config.js";
7
10
  import { classifyClientFacingBaseUrl, compactPublicBaseUrlConfig, hasInsecureLanBaseUrl, isLoopbackBindAddress, normalizePublicBaseUrlsInput, setupBindAddress, SetupCancelledError, textPrompt, validateHttpsProxyBaseUrls, validateLanClientFacingBaseUrls, validatePort, } from "./setup-support.js";
8
- export async function runInit({ force }) {
11
+ import { commandShellCompatibilityWarning, commandShellSetupOptions, customPinnedPreference, defaultCommandShellSetupChoice, followLauncherPreference, pinnedFamilyPreference, preservePinnedPreference, shellFamiliesForCustomSelection, } from "./shell/setup.js";
12
+ export async function runInit({ force, version }) {
9
13
  const files = loadForgeRelayFiles();
10
14
  if (!force && files.configExists && files.authExists) {
11
15
  prompts.log.info(`ForgeRelay is already configured at ${files.dir}`);
@@ -116,6 +120,74 @@ export async function runInit({ force }) {
116
120
  }
117
121
  publicBaseUrl = compactPublicBaseUrlConfig(clientFacingBaseUrls);
118
122
  }
123
+ const launcherShell = detectLauncherCommandShell();
124
+ const selectedShellChoice = await prompts.select({
125
+ message: "Which command shell should Agent commands and Hooks use?",
126
+ initialValue: defaultCommandShellSetupChoice(files.config.commandShell),
127
+ options: commandShellSetupOptions(process.platform, files.config.commandShell, launcherShell),
128
+ });
129
+ if (prompts.isCancel(selectedShellChoice))
130
+ throw new SetupCancelledError();
131
+ let commandShell;
132
+ const shellChoice = selectedShellChoice;
133
+ if (shellChoice === "follow-launcher") {
134
+ commandShell = followLauncherPreference(files.config.commandShell, launcherShell);
135
+ }
136
+ else if (shellChoice === "keep-pinned") {
137
+ commandShell = preservePinnedPreference(files.config.commandShell);
138
+ }
139
+ else if (shellChoice === "custom") {
140
+ const customFamily = await prompts.select({
141
+ message: "Which command language does the custom executable implement?",
142
+ options: shellFamiliesForCustomSelection(),
143
+ });
144
+ if (prompts.isCancel(customFamily))
145
+ throw new SetupCancelledError();
146
+ const executable = await textPrompt({
147
+ message: "What is the command-shell executable path?",
148
+ placeholder: process.platform === "win32" ? "D:\\Portable\\PowerShell\\pwsh.exe" : "/opt/custom/bin/zsh",
149
+ defaultValue: "",
150
+ validate: (value) => value?.trim() ? undefined : "Enter an executable path.",
151
+ });
152
+ commandShell = customPinnedPreference(customFamily, executable);
153
+ }
154
+ else {
155
+ commandShell = pinnedFamilyPreference(shellChoice);
156
+ }
157
+ const shellWarning = commandShellCompatibilityWarning(commandShell.family);
158
+ if (shellWarning)
159
+ prompts.note(shellWarning, "Command shell compatibility");
160
+ const shellInstructionFamilies = shellInstructionFamiliesToSeed(process.platform, commandShell.family);
161
+ let shellInstructions = files.config.shellInstructions;
162
+ let shellInstructionSeedResults = [];
163
+ let powerShellSkillSeedResult;
164
+ if (shellInstructionFamilies.length > 0) {
165
+ const shellInstructionsAnswer = await prompts.confirm({
166
+ message: "Load ForgeRelay-provided extra Instructions for this command shell?",
167
+ initialValue: files.config.shellInstructions !== false,
168
+ });
169
+ if (prompts.isCancel(shellInstructionsAnswer))
170
+ throw new SetupCancelledError();
171
+ shellInstructions = shellInstructionsAnswer === true;
172
+ shellInstructionSeedResults = await seedShellInstructionFiles({
173
+ configDir: files.dir,
174
+ version,
175
+ families: shellInstructionFamilies,
176
+ });
177
+ const failedShellInstructions = shellInstructionSeedResults.filter((result) => result.status === "failed");
178
+ if (failedShellInstructions.length > 0) {
179
+ prompts.note(failedShellInstructions.map((result) => `${result.family}: ${result.error ?? "download failed"}\n${result.sourceUrl}`).join("\n\n"), "Shell Instructions unavailable");
180
+ }
181
+ }
182
+ if (commandShell.family === "pwsh") {
183
+ powerShellSkillSeedResult = await seedPowerShellSkill({
184
+ configDir: files.dir,
185
+ version,
186
+ });
187
+ if (powerShellSkillSeedResult.status === "failed") {
188
+ prompts.note(`${powerShellSkillSeedResult.error ?? "download failed"}\n${powerShellSkillSeedResult.sourceUrl}`, "PowerShell Skill unavailable");
189
+ }
190
+ }
119
191
  const installedManaged = installedManagedLanguageServers(files.dir);
120
192
  const selectedManaged = await prompts.multiselect({
121
193
  message: "Which Language Servers should ForgeRelay manage with npm?",
@@ -150,6 +222,7 @@ export async function runInit({ force }) {
150
222
  }
151
223
  }
152
224
  const config = {
225
+ ...files.config,
153
226
  host,
154
227
  port,
155
228
  allowedRoots,
@@ -158,6 +231,8 @@ export async function runInit({ force }) {
158
231
  trustedProxies,
159
232
  workflowInstructions: files.config.workflowInstructions,
160
233
  appendInstructions: files.config.appendInstructions,
234
+ commandShell,
235
+ shellInstructions,
161
236
  subagents: resolveSubagentsFlag(files.config),
162
237
  languageServers: files.config.languageServers,
163
238
  allowAgentLanguageServerInstall,
@@ -173,6 +248,16 @@ export async function runInit({ force }) {
173
248
  `Config: ${configPath}`,
174
249
  `Auth: ${authPath}`,
175
250
  `Bind: http://${config.host}:${config.port}`,
251
+ `Command shell: ${commandShell.mode} ${commandShell.family} (${commandShell.executable})`,
252
+ ...(shellInstructionFamilies.length > 0
253
+ ? [
254
+ `Shell Instructions: ${shellInstructions ? "enabled" : "disabled"}`,
255
+ ...shellInstructionSeedResults.map((result) => `Shell Instructions ${result.family}: ${result.status} (${result.path})`),
256
+ ]
257
+ : []),
258
+ ...(powerShellSkillSeedResult
259
+ ? [`PowerShell Skill: ${powerShellSkillSeedResult.status} (${powerShellSkillSeedResult.path})`]
260
+ : []),
176
261
  ...clientFacingBaseUrls.map((baseUrl, index) => `${index === 0 ? "Client-facing MCP URL" : "Client-facing MCP alias"}: ${publicEndpointUrl(baseUrl, "mcp").toString()}`),
177
262
  ];
178
263
  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,45 @@ 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, options = {}) {
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 "pwsh":
39
+ return {
40
+ executable: runtime.executable,
41
+ args: [
42
+ "-NoLogo",
43
+ "-NoProfile",
44
+ ...(options.interactive ? [] : ["-NonInteractive"]),
45
+ "-Command",
46
+ command,
47
+ ],
48
+ };
49
+ case "fish":
50
+ case "powershell":
51
+ 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
52
  }
44
- return { executable: "/bin/sh", args: ["-c", command] };
45
53
  }
46
54
  export function terminateProcessTree(child, signal, detached, runtime = defaultProcessTreeRuntime) {
47
55
  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, { interactive: true });
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: {