@akira-tl/forgerelay 1.2.3 → 1.2.5

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 (114) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +19 -11
  3. package/capabilities/workspace/managed-worktrees/GUIDE.md +4 -2
  4. package/dist/cli/config/migrate.js +5 -2
  5. package/dist/cli/init/setup-config.js +0 -2
  6. package/dist/cli/setup-support.js +3 -2
  7. package/dist/mcp/server/core/schemas.js +6 -0
  8. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +4 -2
  9. package/dist/mcp/server/workspace/runtime/workspace-open-schema.js +6 -1
  10. package/dist/mcp/server/workspace/runtime/workspace-open.js +19 -13
  11. package/dist/runtime/config/definition/general-config.js +4 -3
  12. package/dist/runtime/config/external-mcp-config.js +4 -3
  13. package/dist/runtime/config/user-config.js +4 -1
  14. package/dist/runtime/config/validation/ports.js +9 -0
  15. package/dist/runtime/logging/logger.js +19 -19
  16. package/dist/ui/.vite/manifest.json +276 -276
  17. package/dist/ui/activity-panel-app.html +2 -2
  18. package/dist/ui/assets/activity-panel-app-Dq2xLIrs.css +1 -0
  19. package/dist/ui/assets/{activity-panel-app-BeDiwd7D.js → activity-panel-app-hkM8rX3z.js} +1 -1
  20. package/dist/ui/assets/{angular-html-DuoCE7nn.js → angular-html-UD0GEFFl.js} +1 -1
  21. package/dist/ui/assets/{angular-ts-D5tGk0hL.js → angular-ts-B2awh-Q1.js} +1 -1
  22. package/dist/ui/assets/{apl-lciOTPbw.js → apl-CpbhnJc8.js} +1 -1
  23. package/dist/ui/assets/{astro-vJbqfNnJ.js → astro-CkVVuF5B.js} +1 -1
  24. package/dist/ui/assets/{blade-C7655Q4K.js → blade-czG58aop.js} +1 -1
  25. package/dist/ui/assets/{c-DdHp1Jrw.js → c-DSq6h06b.js} +1 -1
  26. package/dist/ui/assets/{cobol-DgvzQxZP.js → cobol-CYnmhT2E.js} +1 -1
  27. package/dist/ui/assets/{coffee-BIjVUOUd.js → coffee-ClLhNqd5.js} +1 -1
  28. package/dist/ui/assets/{cpp-CU8tGzvI.js → cpp-BZDC6U43.js} +1 -1
  29. package/dist/ui/assets/{crystal-CUQAwsg9.js → crystal-BN9jCJyU.js} +1 -1
  30. package/dist/ui/assets/{css-DtMTqD3F.js → css-8dMfiihi.js} +1 -1
  31. package/dist/ui/assets/{edge-DoCX9RnL.js → edge-DemPBRNz.js} +1 -1
  32. package/dist/ui/assets/{elixir-DJxV9_U6.js → elixir-bLdHW8dz.js} +1 -1
  33. package/dist/ui/assets/{elm-DIgWqK4r.js → elm-DlZPcXmu.js} +1 -1
  34. package/dist/ui/assets/{erb-BHxP0c_e.js → erb-wmTqbHwm.js} +1 -1
  35. package/dist/ui/assets/{git-rebase-HbI_Vo8d.js → git-rebase-BqrZJVYO.js} +1 -1
  36. package/dist/ui/assets/{glimmer-js-CthY4Ty_.js → glimmer-js-CkPzdNHI.js} +1 -1
  37. package/dist/ui/assets/{glimmer-ts-B2-_A1Xz.js → glimmer-ts-Bd0vTdJC.js} +1 -1
  38. package/dist/ui/assets/{glsl-D9V9gq98.js → glsl-DKK0Kibz.js} +1 -1
  39. package/dist/ui/assets/{graphql-CddeAaS3.js → graphql-DmFr8ZvB.js} +1 -1
  40. package/dist/ui/assets/{hack-D_loGkOT.js → hack-BQcuk9lx.js} +1 -1
  41. package/dist/ui/assets/{haml-BoWDczd0.js → haml-Dbx0cG5B.js} +1 -1
  42. package/dist/ui/assets/{handlebars-ZoSOvw9y.js → handlebars-D0nD8yLi.js} +1 -1
  43. package/dist/ui/assets/{heavy-payload-DO34LwoZ.js → heavy-payload-CPOM8G8z.js} +3 -3
  44. package/dist/ui/assets/{html-CE-obXVo.js → html-9xkoIci1.js} +1 -1
  45. package/dist/ui/assets/{html-derivative-Cd-smAbW.js → html-derivative-Cg7n4YW0.js} +1 -1
  46. package/dist/ui/assets/{http-CZrkbrSl.js → http-Ckt_4_pP.js} +1 -1
  47. package/dist/ui/assets/{hurl-CxTuxBt1.js → hurl-BxMgqn9u.js} +1 -1
  48. package/dist/ui/assets/{java-BshLjCat.js → java-DxijgO-5.js} +1 -1
  49. package/dist/ui/assets/{javascript-D2pxQTiX.js → javascript-CusLzlRb.js} +1 -1
  50. package/dist/ui/assets/{jinja-kbfPP7-F.js → jinja-DBTRfImq.js} +1 -1
  51. package/dist/ui/assets/{jison-C2mt9l1M.js → jison-oCbVte0g.js} +1 -1
  52. package/dist/ui/assets/{json-DU_rg_AU.js → json-uq2yfoO2.js} +1 -1
  53. package/dist/ui/assets/{jsx-D_8aQMlx.js → jsx-B3AfJWyQ.js} +1 -1
  54. package/dist/ui/assets/{julia-BmefTaVV.js → julia-BFvq-UTm.js} +1 -1
  55. package/dist/ui/assets/{just-DXV8vk1S.js → just-Ck0W9Rcj.js} +1 -1
  56. package/dist/ui/assets/{latex-Cjm2MvGW.js → latex-BjKhTmaO.js} +1 -1
  57. package/dist/ui/assets/{liquid-DyQDlo0u.js → liquid-CsaE0q4b.js} +1 -1
  58. package/dist/ui/assets/{lua-B2A1vzzy.js → lua-D0MSBhv0.js} +1 -1
  59. package/dist/ui/assets/{marko-B9vOkXOU.js → marko-CTLxSPPr.js} +1 -1
  60. package/dist/ui/assets/{mdc-D42m6spU.js → mdc-BwDM_Bct.js} +1 -1
  61. package/dist/ui/assets/{nginx-TpfVxSUY.js → nginx-C5Q5rNaB.js} +1 -1
  62. package/dist/ui/assets/{nim-CJr5T1FK.js → nim-BjKHRt6k.js} +1 -1
  63. package/dist/ui/assets/{perl-cJZcsdm9.js → perl-BrABfniL.js} +1 -1
  64. package/dist/ui/assets/{php-CSZL5KRT.js → php-BWY__Khq.js} +1 -1
  65. package/dist/ui/assets/{pug-B1eMKzuA.js → pug-BD4PlwIK.js} +1 -1
  66. package/dist/ui/assets/{qml-Dl2_7zc9.js → qml-yQtdZNrK.js} +1 -1
  67. package/dist/ui/assets/{r-F0aaO9gG.js → r-D0tlixrf.js} +1 -1
  68. package/dist/ui/assets/{razor-BPnkSMCw.js → razor-CsmyuKJs.js} +1 -1
  69. package/dist/ui/assets/{regexp-BnGHMFbv.js → regexp-BXU4cTCD.js} +1 -1
  70. package/dist/ui/assets/{rst-B3CO7WYO.js → rst-CGlzAlrM.js} +1 -1
  71. package/dist/ui/assets/{ruby-B6VMzT1T.js → ruby-CRKDbfJj.js} +1 -1
  72. package/dist/ui/assets/{sas-Df2lHVuU.js → sas-CKDY6iPG.js} +1 -1
  73. package/dist/ui/assets/{scss-4XxI6sgu.js → scss-BCoXLSqD.js} +1 -1
  74. package/dist/ui/assets/{shellscript-DYASCN4r.js → shellscript-BVuRs_tF.js} +1 -1
  75. package/dist/ui/assets/{shellsession-Br6AGJO_.js → shellsession-alj1b7vN.js} +1 -1
  76. package/dist/ui/assets/{soy-p3ZNq1We.js → soy-BbQDrw9i.js} +1 -1
  77. package/dist/ui/assets/{sql-CAdv2OXL.js → sql-BdcqHaEA.js} +1 -1
  78. package/dist/ui/assets/{stata-Dx7yUbG_.js → stata-5IC5m_XB.js} +1 -1
  79. package/dist/ui/assets/{surrealql-6YsE8HQa.js → surrealql-BfVFLYzV.js} +1 -1
  80. package/dist/ui/assets/{svelte-YgzedwpD.js → svelte-B22aFi25.js} +1 -1
  81. package/dist/ui/assets/{templ-Bahqx3uD.js → templ-BRo7TcBX.js} +1 -1
  82. package/dist/ui/assets/{tex-o6fTQjkH.js → tex-CYhw-JbD.js} +1 -1
  83. package/dist/ui/assets/{ts-tags-BO-Co0Ju.js → ts-tags-C56aGOzq.js} +1 -1
  84. package/dist/ui/assets/{tsx-DtQe5AoH.js → tsx-IkFyQoro.js} +1 -1
  85. package/dist/ui/assets/{twig-BcUs6Kqc.js → twig-NkXaxf7l.js} +1 -1
  86. package/dist/ui/assets/{typescript-BPtM8h4u.js → typescript-AgbZakqy.js} +1 -1
  87. package/dist/ui/assets/{vue-5KkGUzJJ.js → vue-Cxz1Rk-f.js} +1 -1
  88. package/dist/ui/assets/{vue-html-Df_Xfsz9.js → vue-html-DWHLzP-w.js} +1 -1
  89. package/dist/ui/assets/{vue-vine-hUIe_u_Y.js → vue-vine-WR64TKte.js} +1 -1
  90. package/dist/ui/assets/{xml-C3Jy7as5.js → xml-CNdUn7jo.js} +1 -1
  91. package/dist/ui/assets/{xsl-BnF9OuFo.js → xsl-DLRRUZ98.js} +1 -1
  92. package/dist/ui/assets/{yaml-DyrFgCou.js → yaml-gltXViA8.js} +1 -1
  93. package/dist/workspace-colors.js +54 -0
  94. package/dist/workspaces/git/git-worktrees.js +53 -14
  95. package/dist/workspaces/inventory.js +2 -0
  96. package/dist/workspaces/presentation/workspace-presentation.js +13 -0
  97. package/dist/workspaces/relay/workspace-relay.js +3 -0
  98. package/dist/workspaces/sessions.js +28 -8
  99. package/dist/workspaces.js +9 -9
  100. package/docs/chatgpt-coding-workflow.md +16 -8
  101. package/docs/gotchas.md +2 -2
  102. package/package.json +2 -1
  103. package/schemas/v1/config.user.schema.json +2 -2
  104. package/scripts/ci/cmd-acceptance.mjs +3 -85
  105. package/scripts/ci/gates/acceptance-runtime.mjs +45 -0
  106. package/scripts/ci/gates/platform-tests.mjs +27 -17
  107. package/scripts/ci/gates/prepare-windows-shell-runtime.mjs +45 -0
  108. package/scripts/ci/gates/test-shard.mjs +29 -7
  109. package/scripts/ci/powershell51-acceptance.mjs +10 -91
  110. package/scripts/ci/pwsh-acceptance.mjs +7 -87
  111. package/scripts/ci/windows-product-acceptance.mjs +55 -0
  112. package/scripts/release/release-gate.test.mjs +121 -13
  113. package/scripts/release-parity.mjs +12 -1
  114. package/dist/ui/assets/activity-panel-app-DkfJzTZ3.css +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.5] - 2026-09-16
8
+
9
+ ### Changed
10
+
11
+ - Config v2 现在把监听端口的支持范围统一为 `1`–`65535`,并让 JSON Schema、环境变量、`init --advanced`、持久化写入、迁移与运行时使用同一验证约束;External MCP OAuth `callbackPort` 继续保持独立的 `1024`–`65535` 范围。
12
+ - 初始化只持久化用户真正选择的网络 override;HTTPS proxy 场景不再重复写入可从 loopback bind 与 public URL 安全推导的 `trustedProxies: ["loopback"]`。
13
+
14
+ ### Fixed
15
+
16
+ - `config.json` 写入与 legacy migration 现在会在持久化前通过 Config v2 schema 校验,避免 CLI 或迁移先写出运行时随后拒绝的非法配置;迁移遇到非法 General Config 时会在创建 canonical target 或 backup 之前失败。
17
+ - 明确 `host` / `port`、`publicBaseUrl`、`allowedHosts` 与 `trustedProxies` 的配置权威关系:显式用户值始终优先,派生值只在对应 override 缺失时生效。
18
+
19
+ ## [1.2.4] - 2026-09-15
20
+
21
+ ### Added
22
+
23
+ - Managed worktrees can now start from arbitrary Git commit-ish refs while keeping their integration target branch independent, so a pinned historical baseline no longer has to be a local branch.
24
+
25
+ ### Changed
26
+
27
+ - Release verification now splits more independent CI work in parallel and reuses pinned npm / packaged shell runtime state where safe, reducing repeated setup in the cross-platform release matrix.
28
+
29
+ ### Fixed
30
+
31
+ - Pinned-base managed worktrees now preserve their baseline metadata across reuse, reopen, recovery, rebase, and close, while still integrating into the configured target branch; pinned workspaces with different immutable baselines no longer collapse onto one reuse identity.
32
+ - Workspace Panel long paths now wrap instead of truncating important Root / checkout details, and Workspace identity color is shared consistently between pretty logs and Panel SVG icons. The identity palette now uses 14 normal/bright terminal color slots, avoids duplicates for the first 14 distinct Workspace names in one process, and normalizes both POSIX and Windows-style paths consistently.
33
+
7
34
  ## [1.2.3] - 2026-09-14
8
35
 
9
36
  ### Fixed
package/README.md CHANGED
@@ -64,7 +64,7 @@ forgerelay config sources
64
64
  forgerelay config explain <logical-path>
65
65
  ```
66
66
 
67
- v1.2 的 Config System 统一解析 User、Project 和机器私有 Project Local 配置,统一优先级是 `runtime > project-local > project > user > built-in`。External MCP、Language Servers、Hooks 和 Subagent Profiles 支持按需热刷新与 last-known-good;需要重启的 General 配置会区分 configured / applied,不会自动重启 Server。Canonical JSON 可以引用 npm package 中的 `schemas/v1/*` `$schema`,受支持的敏感字段可用 `${ENV_NAME}`,诊断不会输出解析后的 secretForgeRelay 启动不会自动重写旧配置,需要迁移时使用 `forgerelay config migrate --dry-run ...` 先预览。
67
+ ForgeRelay 的 Config System 统一解析 User、Project 和机器私有 Project Local 配置,优先级是 `runtime > project-local > project > user > built-in`。External MCP、Language Servers、Hooks 和 Subagent Profiles 支持按需刷新与 last-known-good;启动不会自动重写旧配置。需要深入配置、迁移或排查来源时,直接看 [配置指南](https://github.com/Akira-TL/forgerelay/wiki/Configuration)
68
68
 
69
69
  ### ChatGPT 访问不到 localhost?
70
70
 
@@ -100,9 +100,9 @@ bash(command="npm test")
100
100
  bash(command="git status --short")
101
101
  ```
102
102
 
103
- ForgeRelay 不会默认为每个任务创建 worktree。只有你明确要求隔离或并行开发时,才使用 managed worktree。
103
+ ForgeRelay 不会默认为每个任务创建 worktree。只有你明确要求隔离或并行开发时,才使用 managed worktree。Worktree 可以从本地 branch、tag 或历史 commit SHA 启动,同时把最终集成的本地 `targetBranch` 单独记录;不需要为了历史基线先造一个临时 branch。
104
104
 
105
- 长命令也不会要求 Agent 高频轮询。命令超过当前等待窗口时会返回稳定的 `processId`,后续继续等待或中断同一个进程即可。
105
+ 长命令也不会要求 Agent 高频轮询。命令超过当前等待窗口时会返回稳定的 `processId`,后续继续等待、交互或中断同一个进程即可。
106
106
 
107
107
  ## 主要能力
108
108
 
@@ -110,8 +110,9 @@ ForgeRelay 不会默认为每个任务创建 worktree。只有你明确要求隔
110
110
  - Linux / macOS 可以用 Bash、zsh、POSIX sh;Windows 原生支持 PowerShell 7、Windows PowerShell 5.1 和 `cmd.exe`。
111
111
  - 同一个 checkout 会保留自己的 Workspace 身份。换一次对话,不需要重新创建工作区。
112
112
  - `code.intelligence` 可以查 definition、hover、references、symbols 和 diagnostics。
113
- - 项目里的 `AGENTS.md`、`CLAUDE.md` 和 Agent Skills 按需加载,不会每次都把整套说明重新塞进上下文。
114
- - 需要并行开发时可以创建真实 Git worktree;集成回主分支时只接受安全的 fast-forward,不自动制造 merge conflict。
113
+ - 项目里的 `AGENTS.md`、`CLAUDE.md` 和 Agent Skills 按需加载,不会每次都把整套说明重新塞进上下文。Skill 来自项目级 `.agents/skills`、项目/系统 ForgeRelay Skills 和显式附加路径;不会自动扫描全局 `~/.agents/skills`。
114
+ - 需要并行开发时可以创建真实 Git worktree;起始 commit 与最终 `targetBranch` 独立,集成仍只接受安全的 fast-forward,不自动制造 merge conflict。
115
+ - 支持 MCP Apps 的 Host 会显示 Workspace Summary / Activity Panel;Workspace 身份色在 Panel 与 pretty log 中保持一致,长路径会在详情区正常换行。
115
116
  - Workspace Relay 可以把执行放到另一台 ForgeRelay;Composite Workspace 可以同时协调几个独立环境。
116
117
  - `read` 可以直接把 PNG、JPEG、WebP 和 GIF 作为临时 MCP Media content 返回;外部 MCP 通过独立的 global/Project `mcp.json` 热加载到 `mcp.external` Capability,并支持人工 OAuth 认证,而不会自动打开它返回的路径或 URL。
117
118
 
@@ -125,7 +126,7 @@ ForgeRelay 给 Agent 的是真实本机执行权限,不是模拟环境。
125
126
 
126
127
  文件工具受 Workspace 和 allowed roots 限制;Shell 命令使用启动 ForgeRelay 的本地用户权限执行,**Shell 不是 OS sandbox**。因此只连接你信任的 MCP Host,只开放确实需要的项目目录,并保护好 Owner password。
127
128
 
128
- Allowed roots 是文件系统 / Workspace authority,不等于 Project Trust approval。v1.2.0 已把项目中的可执行配置统一接入 Project execution trust seam,但首个稳定策略是 compatibility-allow;完整的交互式 Project Trust approval UI 不是 v1.2.0 已实现功能。
129
+ Allowed roots 是文件系统 / Workspace authority,不等于 Project Trust approval。项目中的可执行配置已经统一接入 Project execution trust seam;当前稳定策略仍是 compatibility-allow,完整的交互式 Project Trust approval UI 尚未实现。
129
130
 
130
131
  ForgeRelay 默认拒绝 elevated / administrator 启动。只有你显式选择高权限运行时才会继续,并会提示系统级修改可能不可逆。
131
132
 
@@ -135,6 +136,8 @@ ForgeRelay 默认拒绝 elevated / administrator 启动。只有你显式选择
135
136
 
136
137
  - [快速开始](https://github.com/Akira-TL/forgerelay/wiki/Getting-Started)
137
138
  - [配置](https://github.com/Akira-TL/forgerelay/wiki/Configuration)
139
+ - [Managed Worktree](https://github.com/Akira-TL/forgerelay/wiki/Managed-Worktrees)
140
+ - [ChatGPT 与 MCP 工作流](https://github.com/Akira-TL/forgerelay/wiki/ChatGPT-and-MCP-Workflow)
138
141
  - [External MCP](https://github.com/Akira-TL/forgerelay/wiki/External-MCP)
139
142
  - [安全模型](https://github.com/Akira-TL/forgerelay/wiki/Security)
140
143
  - [故障排查](https://github.com/Akira-TL/forgerelay/wiki/Troubleshooting)
@@ -185,7 +188,7 @@ forgerelay config sources
185
188
  forgerelay config explain <logical-path>
186
189
  ```
187
190
 
188
- Config System v2 in v1.2 resolves User, Project, and machine-private Project Local sources using `runtime > project-local > project > user > built-in`. External MCP, Language Servers, Hooks, and Subagent Profiles refresh on demand with last-known-good protection; restart-required General settings keep configured and applied values distinct and never auto-restart the server. Canonical JSON can point at the packaged `schemas/v1/*` `$schema` files, supported sensitive fields may use `${ENV_NAME}`, and diagnostics do not expose resolved secrets. Startup never rewrites legacy configuration automatically; preview an explicit migration with `forgerelay config migrate --dry-run ...` first.
191
+ ForgeRelay's Config System resolves User, Project, and machine-private Project Local sources using `runtime > project-local > project > user > built-in`. External MCP, Language Servers, Hooks, and Subagent Profiles refresh on demand with last-known-good protection, and startup never rewrites legacy configuration automatically. See [Configuration](https://github.com/Akira-TL/forgerelay/wiki/Configuration) for scopes, migration, schemas, and diagnostics.
189
192
 
190
193
  ### Host cannot reach localhost?
191
194
 
@@ -221,7 +224,7 @@ bash(command="npm test")
221
224
  bash(command="git status --short")
222
225
  ```
223
226
 
224
- ForgeRelay does not create a worktree for every task. Managed worktrees are for cases where you explicitly want isolation or parallel development.
227
+ ForgeRelay does not create a worktree for every task. Managed worktrees are for cases where you explicitly want isolation or parallel development. A worktree may start from a local branch, tag, or historical commit SHA while keeping the eventual local `targetBranch` separate, so a temporary baseline branch is unnecessary.
225
228
 
226
229
  Long commands do not require tight polling either. Once the current wait window expires, ForgeRelay returns a stable `processId`; later calls wait on, interact with, or interrupt that same process.
227
230
 
@@ -231,8 +234,9 @@ Long commands do not require tight polling either. Once the current wait window
231
234
  - Linux/macOS can use Bash, zsh, or POSIX sh. Windows has native PowerShell 7, Windows PowerShell 5.1, and `cmd.exe` support.
232
235
  - Reopening the same checkout reuses the same Workspace identity instead of creating another one for every conversation.
233
236
  - `code.intelligence` provides definition, hover, references, symbols, and diagnostics.
234
- - `AGENTS.md`, `CLAUDE.md`, and Agent Skills are loaded as needed instead of being resent in full on every open.
235
- - Managed worktrees provide real Git isolation when you ask for parallel work, with fast-forward-only finalization.
237
+ - `AGENTS.md`, `CLAUDE.md`, and Agent Skills are loaded as needed instead of being resent in full on every open. Skills come from project `.agents/skills`, project/system ForgeRelay Skill directories, and explicit extra paths; global `~/.agents/skills` is not scanned automatically.
238
+ - Managed worktrees provide real Git isolation when you ask for parallel work. Starting commit and local `targetBranch` are tracked independently, while finalization remains fast-forward-only.
239
+ - MCP Apps-capable hosts can render the Workspace Summary / Activity Panel. Workspace identity colors stay consistent between the panel and pretty logs, and long paths wrap in detail rows instead of hiding their tail.
236
240
  - Workspace Relay runs work on another ForgeRelay instance; Composite Workspaces coordinate several independent environments from one Host.
237
241
  - `read` can return PNG, JPEG, WebP, and GIF directly as transient MCP Media content; external MCP servers hot-reload from standalone global/Project `mcp.json` files into the `mcp.external` Capability, with explicit human OAuth when needed and no automatic dereferencing of returned paths or URLs.
238
242
 
@@ -246,7 +250,7 @@ ForgeRelay gives an Agent real local execution capability.
246
250
 
247
251
  Filesystem tools are constrained by the opened Workspace and configured allowed roots. Shell commands run with the authority of the local user running ForgeRelay; **the shell is not an OS sandbox**. Connect only MCP hosts you trust, expose only project roots you want an Agent to access, and keep the Owner password private.
248
252
 
249
- Allowed roots grant filesystem/Workspace authority; they are not a Project Trust approval. v1.2.0 routes executable project configuration through a shared Project execution trust seam, but its initial policy is compatibility-allow. A full interactive Project Trust approval UI is not claimed for v1.2.0.
253
+ Allowed roots grant filesystem/Workspace authority; they are not a Project Trust approval. Executable project configuration is routed through a shared Project execution trust seam; the current stable policy remains compatibility-allow, and a full interactive Project Trust approval UI is not implemented yet.
250
254
 
251
255
  Elevated / administrator startup is rejected by default. It only proceeds after explicit opt-in, with a warning that system-level AI-driven changes may be irreversible.
252
256
 
@@ -256,6 +260,8 @@ See the [Security model](https://github.com/Akira-TL/forgerelay/wiki/Security) f
256
260
 
257
261
  - [Getting Started](https://github.com/Akira-TL/forgerelay/wiki/Getting-Started)
258
262
  - [Configuration](https://github.com/Akira-TL/forgerelay/wiki/Configuration)
263
+ - [Managed Worktrees](https://github.com/Akira-TL/forgerelay/wiki/Managed-Worktrees)
264
+ - [ChatGPT and MCP Workflow](https://github.com/Akira-TL/forgerelay/wiki/ChatGPT-and-MCP-Workflow)
259
265
  - [External MCP](https://github.com/Akira-TL/forgerelay/wiki/External-MCP)
260
266
  - [Security model](https://github.com/Akira-TL/forgerelay/wiki/Security)
261
267
  - [Troubleshooting](https://github.com/Akira-TL/forgerelay/wiki/Troubleshooting)
@@ -276,3 +282,5 @@ npm run build
276
282
  ```
277
283
 
278
284
  `npm run dev` uses the 7677 debug runtime. Product port 7676 is kept separate from development acceptance.
285
+
286
+ Release tags are created only after a developer has manually exercised and explicitly accepted the exact release-ready state. Automated verification and the Linux/macOS/Windows release matrix remain additional gates; they do not replace manual acceptance.
@@ -15,8 +15,10 @@
15
15
 
16
16
  - 默认 `mode="checkout"`,直接使用用户已有 checkout;
17
17
  - 只有明确需要隔离/并行 Git 工作时才选 `mode="worktree"`;
18
- - `baseRef` 只用于 managed worktree,默认取 source checkout 当前分支;
19
- - 已有 `workspaceId` 用于恢复同一个逻辑 workspace
18
+ - `baseRef` 只用于 managed worktree,表示新 managed branch 的起始 commit-ish;可以是本地 branch、tag、commit SHA 等可解析到 commit 的 ref,默认使用 source checkout 当前 `HEAD`;
19
+ - `targetBranch` 表示最终允许 `close_workspace` fast-forward 的本地 branch;未显式指定时,本地 branch `baseRef` 自身优先作为 target,否则使用 source checkout 当前本地 branchdetached source 无法推导 target 时必须显式提供;
20
+ - 显式固定到历史 base 的请求按解析后的 `baseSha` 区分复用身份,不会与同一 target 上另一历史 base 或普通 branch-following backing 混用;
21
+ - 已有 `workspaceId` 用于恢复同一个逻辑 workspace;closed managed-worktree Workspace reopen 时从当前持久化 `targetBranch` 建立新 backing,不重新钉回最初的历史 `baseRef`;
20
22
  - `newWorkspace` 只创建新的逻辑 handle;
21
23
  - `newWorktree` 才表示同一项目再创建一个独立物理 worktree。
22
24
 
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { basename, join, resolve } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
+ import { parseConfigSource } from "../../runtime/config/definition/definition.js";
5
6
  import { configSchemaId } from "../../runtime/config/definition/schema.js";
6
7
  import { generalConfigDefinition } from "../../runtime/config/definition/general-config.js";
7
8
  import { externalMcpConfigDefinition } from "../../runtime/config/definition/external-mcp.js";
@@ -137,8 +138,10 @@ function buildGlobalMigrationPlan(configDir) {
137
138
  nextConfig.$schema = generalSchema;
138
139
  configChanged = true;
139
140
  }
140
- if (configChanged)
141
- writes.push(jsonWrite(files.configPath, nextConfig));
141
+ if (configChanged) {
142
+ const validatedConfig = parseConfigSource(generalConfigDefinition, "user", nextConfig);
143
+ writes.push(jsonWrite(files.configPath, validatedConfig));
144
+ }
142
145
  if (configChanged && files.configExists)
143
146
  backupSources.push({ path: files.configPath, relativePath: "config.json" });
144
147
  if (files.hooksExists)
@@ -21,8 +21,6 @@ function applyNetworkSelection(config, selection) {
21
21
  }
22
22
  config.publicBaseUrl = selection.publicBaseUrl;
23
23
  }
24
- if (selection.mode === "proxy")
25
- config.trustedProxies = ["loopback"];
26
24
  }
27
25
  function applyAdvancedSelection(config, selection) {
28
26
  if (selection.port === 7676)
@@ -2,6 +2,7 @@ import { isIP } from "node:net";
2
2
  import { createRequire } from "node:module";
3
3
  import * as prompts from "@clack/prompts";
4
4
  import { satisfies } from "semver";
5
+ import { isIntegerPort, LISTEN_PORT_MIN, PORT_MAX } from "../runtime/config/validation/ports.js";
5
6
  const SUPPORTED_NODE_RANGE = ">=20.12 <27";
6
7
  const require = createRequire(import.meta.url);
7
8
  export function isNullConfigValue(value) {
@@ -118,9 +119,9 @@ export async function textPrompt(options) {
118
119
  }
119
120
  export function validatePort(value) {
120
121
  const port = Number(value);
121
- return Number.isInteger(port) && port >= 1 && port <= 65535
122
+ return isIntegerPort(port, LISTEN_PORT_MIN, PORT_MAX)
122
123
  ? undefined
123
- : "Enter a port between 1 and 65535.";
124
+ : `Enter a port between ${LISTEN_PORT_MIN} and ${PORT_MAX}.`;
124
125
  }
125
126
  export function isLoopbackBindAddress(value) {
126
127
  const host = value.trim().toLowerCase();
@@ -122,6 +122,8 @@ export const workspaceInventoryEntryOutputSchema = z.object({
122
122
  state: z.enum(["active", "stale", "invalid", "closed"]),
123
123
  mode: z.enum(["checkout", "worktree"]),
124
124
  sourceRoot: z.string().optional(),
125
+ baseRef: z.string().optional(),
126
+ baseSha: z.string().optional(),
125
127
  branch: z.string().optional(),
126
128
  targetBranch: z.string().optional(),
127
129
  managed: z.boolean(),
@@ -181,6 +183,8 @@ export const workspaceInspectionOutputSchema = z.union([
181
183
  state: z.enum(["active", "stale", "invalid", "closed"]),
182
184
  mode: z.enum(["checkout", "worktree"]),
183
185
  sourceRoot: z.string().optional(),
186
+ baseRef: z.string().optional(),
187
+ baseSha: z.string().optional(),
184
188
  branch: z.string().optional(),
185
189
  targetBranch: z.string().optional(),
186
190
  managed: z.boolean(),
@@ -201,6 +205,8 @@ export const workspaceInspectionOutputSchema = z.union([
201
205
  state: z.enum(["active", "stale", "invalid", "closed"]).optional(),
202
206
  mode: z.enum(["checkout", "worktree"]),
203
207
  sourceRoot: z.string().optional(),
208
+ baseRef: z.string().optional(),
209
+ baseSha: z.string().optional(),
204
210
  branch: z.string().optional(),
205
211
  targetBranch: z.string().optional(),
206
212
  managed: z.boolean().optional(),
@@ -10,13 +10,14 @@ import { capabilityContextFor } from "../../core/capability-support.js";
10
10
  import { redactSkillDiagnosticPaths } from "../../core/schemas.js";
11
11
  import { logToolCall, workspaceLogContext } from "../../core/tool-support.js";
12
12
  import { buildExecutionShellContext } from "../../../server-instructions.js";
13
+ import { workspaceColorFor, workspaceIdentityName } from "../../../../workspace-colors.js";
13
14
  export const workspaceTaskUsageInstruction = "Use workspace.tasks proactively for work that spans multiple steps or sessions: create or update Tasks to preserve useful next steps and current state, and read its summary when resuming relevant unfinished work. Do not query it mechanically on every open_workspace call.";
14
15
  export const capabilityDiscoveryInstruction = "For capability describe/run, use only names present in capabilityCatalog. capabilityFingerprint.capabilities is a semantic feature fingerprint, not a callable registry; capabilityGuides are documentation descriptors, not callable capabilities.";
15
16
  export async function presentLocalWorkspaceOpen(options, input, contextData) {
16
17
  const { config, forgerelayVersion: FORGERELAY_VERSION, workspaces, workspaceTasks, reviewCheckpoints, capabilityRegistry, subagentProviders, hooks, rememberWorkspacePanelState, } = options;
17
- const { path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context } = input;
18
+ const { path, workspaceId, mode, baseRef, targetBranch, newWorktree, newWorkspace, context } = input;
18
19
  const { conversationScopeId, protectedWorkspaceIds, startedAt, sessionId } = contextData;
19
- const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, bootstrapContextComponents, contextFingerprint, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context }, {
20
+ const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, bootstrapContextComponents, contextFingerprint, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, targetBranch, newWorktree, newWorkspace, context }, {
20
21
  conversationScopeId,
21
22
  protectedWorkspaceIds,
22
23
  });
@@ -189,6 +190,7 @@ export async function presentLocalWorkspaceOpen(options, input, contextData) {
189
190
  kind: "workspace",
190
191
  root: workspace.root,
191
192
  path: workspace.root,
193
+ workspaceColor: workspaceColorFor(workspaceIdentityName(workspace.root)),
192
194
  mode: workspace.mode,
193
195
  workspaceReused,
194
196
  includeBootstrapContext: effectiveIncludeBootstrapContext,
@@ -21,6 +21,7 @@ export const openWorkspaceToolDefinition = {
21
21
  relay: z.string().optional().describe("Optional registered remote ForgeRelay alias for a path-backed member."),
22
22
  mode: z.enum(["checkout", "worktree"]).optional(),
23
23
  baseRef: z.string().optional(),
24
+ targetBranch: z.string().optional(),
24
25
  newWorktree: z.boolean().optional(),
25
26
  newWorkspace: z.boolean().optional(),
26
27
  }).optional().describe("Composite member definition used by action=member."),
@@ -55,7 +56,11 @@ export const openWorkspaceToolDefinition = {
55
56
  baseRef: z
56
57
  .string()
57
58
  .optional()
58
- .describe("Local branch to base a managed worktree on and eventually merge back into. Only used with mode=\"worktree\". Defaults to the source checkout's current branch."),
59
+ .describe("Commit-ish to base a managed worktree on. Only used with mode=\"worktree\"; may be a local branch, tag, commit SHA, or other ref that resolves to a commit. Defaults to HEAD."),
60
+ targetBranch: z
61
+ .string()
62
+ .optional()
63
+ .describe("Existing local branch that close_workspace may fast-forward when finalizing the managed worktree. Only used with mode=\"worktree\". Defaults to baseRef when baseRef names a local branch, otherwise the source checkout's current local branch."),
59
64
  newWorktree: z
60
65
  .boolean()
61
66
  .optional()
@@ -4,6 +4,7 @@ import { formatPathForPrompt } from "../../../../workspaces/resources/skills.js"
4
4
  import { compositeCapabilityContext } from "../../core/capability-support.js";
5
5
  import { compositeWorkspaceLogContext, logToolCall, textBlock } from "../../core/tool-support.js";
6
6
  import { openWorkspaceToolDefinition } from "./workspace-open-schema.js";
7
+ import { workspaceColorFor, workspaceIdentityName } from "../../../../workspace-colors.js";
7
8
  import { presentLocalWorkspaceOpen, workspaceTaskUsageInstruction, } from "./workspace-open-presentation.js";
8
9
  export function registerOpenWorkspaceTool(options) {
9
10
  const { server } = options;
@@ -11,7 +12,7 @@ export function registerOpenWorkspaceTool(options) {
11
12
  }
12
13
  async function handleOpenWorkspace(options, input, requestContext) {
13
14
  const { config, workspaces, remoteWorkspaces, compositeWorkspaces, workspaceTasks, processSessions, capabilityRegistry, compositeTaskGuides, loadCompositeMemberContext, rememberWorkspacePanelState, hostScopeIdFor, contextDeliveryScopeIdFor, presentation, } = options;
14
- const { action = "open", memberAction, member, kind, name, memberName, path, relay, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, } = input;
15
+ const { action = "open", memberAction, member, kind, name, memberName, path, relay, workspaceId, mode, baseRef, targetBranch, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, } = input;
15
16
  const startedAt = performance.now();
16
17
  const conversationScopeId = contextDeliveryScopeIdFor(requestContext);
17
18
  const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
@@ -82,7 +83,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
82
83
  }
83
84
  if (memberAction !== undefined || member !== undefined || kind !== undefined || name !== undefined ||
84
85
  memberName !== undefined || path !== undefined || relay !== undefined || mode !== undefined ||
85
- baseRef !== undefined || newWorktree !== undefined || newWorkspace !== undefined || context !== undefined ||
86
+ baseRef !== undefined || targetBranch !== undefined || newWorktree !== undefined || newWorkspace !== undefined || context !== undefined ||
86
87
  root !== undefined || status !== undefined || state !== undefined || staleOnly !== undefined ||
87
88
  offset !== undefined || limit !== undefined) {
88
89
  throw new Error("open_workspace action=inspect accepts only workspaceId. It never opens, resumes, binds, or mutates the inspected Workspace.");
@@ -156,7 +157,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
156
157
  throw new Error("open_workspace action=member requires memberAction and member.");
157
158
  }
158
159
  if (kind !== undefined || name !== undefined || memberName !== undefined || path !== undefined || relay !== undefined || mode !== undefined ||
159
- baseRef !== undefined || newWorktree !== undefined || newWorkspace !== undefined || context !== undefined ||
160
+ baseRef !== undefined || targetBranch !== undefined || newWorktree !== undefined || newWorkspace !== undefined || context !== undefined ||
160
161
  root !== undefined || status !== undefined || state !== undefined || staleOnly !== undefined ||
161
162
  offset !== undefined || limit !== undefined) {
162
163
  throw new Error("open_workspace action=member accepts workspaceId, memberAction, and member only. Put any Workspace open definition inside member.");
@@ -173,8 +174,8 @@ async function handleOpenWorkspace(options, input, requestContext) {
173
174
  throw new Error("A Composite Workspace cannot be mounted as a Composite Workspace member.");
174
175
  }
175
176
  if (member.relay !== undefined || member.mode !== undefined || member.baseRef !== undefined ||
176
- member.newWorktree !== undefined || member.newWorkspace !== undefined) {
177
- throw new Error("member.workspaceId cannot be combined with relay/mode/baseRef/newWorktree/newWorkspace.");
177
+ member.targetBranch !== undefined || member.newWorktree !== undefined || member.newWorkspace !== undefined) {
178
+ throw new Error("member.workspaceId cannot be combined with relay/mode/baseRef/targetBranch/newWorktree/newWorkspace.");
178
179
  }
179
180
  if (!remoteWorkspaces.has(targetWorkspaceId))
180
181
  workspaces.getWorkspace(targetWorkspaceId);
@@ -185,6 +186,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
185
186
  path: member.path,
186
187
  ...(member.mode ? { mode: member.mode } : {}),
187
188
  ...(member.baseRef ? { baseRef: member.baseRef } : {}),
189
+ ...(member.targetBranch ? { targetBranch: member.targetBranch } : {}),
188
190
  ...(member.newWorktree !== undefined ? { newWorktree: member.newWorktree } : {}),
189
191
  ...(member.newWorkspace !== undefined ? { newWorkspace: member.newWorkspace } : {}),
190
192
  context: "none",
@@ -195,6 +197,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
195
197
  path: member.path,
196
198
  ...(member.mode ? { mode: member.mode } : {}),
197
199
  ...(member.baseRef ? { baseRef: member.baseRef } : {}),
200
+ ...(member.targetBranch ? { targetBranch: member.targetBranch } : {}),
198
201
  ...(member.newWorktree !== undefined ? { newWorktree: member.newWorktree } : {}),
199
202
  ...(member.newWorkspace !== undefined ? { newWorkspace: member.newWorkspace } : {}),
200
203
  context: "none",
@@ -218,8 +221,8 @@ async function handleOpenWorkspace(options, input, requestContext) {
218
221
  }
219
222
  else if (memberAction === "update") {
220
223
  const targetFieldsPresent = member.workspaceId !== undefined || member.path !== undefined || member.relay !== undefined ||
221
- member.mode !== undefined || member.baseRef !== undefined || member.newWorktree !== undefined ||
222
- member.newWorkspace !== undefined;
224
+ member.mode !== undefined || member.baseRef !== undefined || member.targetBranch !== undefined ||
225
+ member.newWorktree !== undefined || member.newWorkspace !== undefined;
223
226
  if (member.newName === undefined && member.purpose === undefined && !targetFieldsPresent) {
224
227
  throw new Error("Updating a Composite Workspace member requires newName, purpose, or a replacement Workspace target.");
225
228
  }
@@ -235,7 +238,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
235
238
  else {
236
239
  if (member.newName !== undefined || member.purpose !== undefined || member.workspaceId !== undefined || member.path !== undefined ||
237
240
  member.relay !== undefined || member.mode !== undefined || member.baseRef !== undefined ||
238
- member.newWorktree !== undefined || member.newWorkspace !== undefined) {
241
+ member.targetBranch !== undefined || member.newWorktree !== undefined || member.newWorkspace !== undefined) {
239
242
  throw new Error("Removing a Composite Workspace member accepts only member.name.");
240
243
  }
241
244
  composite = compositeWorkspaces.removeMember(workspaceId, member.name);
@@ -291,9 +294,9 @@ async function handleOpenWorkspace(options, input, requestContext) {
291
294
  return response;
292
295
  }
293
296
  if (action === "list") {
294
- if (path !== undefined || relay !== undefined || name !== undefined || memberName !== undefined || baseRef !== undefined || newWorktree !== undefined ||
295
- newWorkspace !== undefined || context !== undefined) {
296
- throw new Error("open_workspace action=list does not accept path, relay, name, memberName, baseRef, newWorktree, newWorkspace, or context. Use kind/root/workspaceId/mode/status/state/staleOnly for inventory filters.");
297
+ if (path !== undefined || relay !== undefined || name !== undefined || memberName !== undefined || baseRef !== undefined || targetBranch !== undefined ||
298
+ newWorktree !== undefined || newWorkspace !== undefined || context !== undefined) {
299
+ throw new Error("open_workspace action=list does not accept path, relay, name, memberName, baseRef, targetBranch, newWorktree, newWorkspace, or context. Use kind/root/workspaceId/mode/status/state/staleOnly for inventory filters.");
297
300
  }
298
301
  const compositeInventory = () => compositeWorkspaces.list()
299
302
  .filter((entry) => workspaceId === undefined || entry.id === workspaceId)
@@ -382,7 +385,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
382
385
  const openingComposite = kind === "composite" ||
383
386
  (workspaceId !== undefined && compositeWorkspaces.has(workspaceId));
384
387
  if (openingComposite) {
385
- if (relay !== undefined || path !== undefined || mode !== undefined || baseRef !== undefined ||
388
+ if (relay !== undefined || path !== undefined || mode !== undefined || baseRef !== undefined || targetBranch !== undefined ||
386
389
  newWorktree !== undefined || newWorkspace !== undefined) {
387
390
  throw new Error("Composite Workspace open accepts name/workspaceId/context only; members are attached separately and keep their own Workspace definitions.");
388
391
  }
@@ -432,6 +435,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
432
435
  kind: "composite",
433
436
  name: composite.name,
434
437
  path: composite.name,
438
+ workspaceColor: workspaceColorFor(workspaceIdentityName(composite.name)),
435
439
  members: composite.members,
436
440
  instruction,
437
441
  summary: { members: composite.members.length, status: composite.status },
@@ -464,7 +468,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
464
468
  throw new Error("open_workspace name/memberName are only valid for a Composite Workspace.");
465
469
  }
466
470
  if (workspaceId !== undefined && remoteWorkspaces.has(workspaceId)) {
467
- if (path !== undefined || relay !== undefined || mode !== undefined || baseRef !== undefined ||
471
+ if (path !== undefined || relay !== undefined || mode !== undefined || baseRef !== undefined || targetBranch !== undefined ||
468
472
  newWorktree !== undefined || newWorkspace !== undefined) {
469
473
  throw new Error("Resuming a relayed Workspace by workspaceId accepts context only.");
470
474
  }
@@ -479,6 +483,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
479
483
  path,
480
484
  mode,
481
485
  baseRef,
486
+ targetBranch,
482
487
  newWorktree,
483
488
  newWorkspace,
484
489
  context,
@@ -511,6 +516,7 @@ async function handleOpenWorkspace(options, input, requestContext) {
511
516
  kind: "workspace",
512
517
  root: opened.root,
513
518
  path: opened.root,
519
+ workspaceColor: workspaceColorFor(workspaceIdentityName(opened.root)),
514
520
  mode: opened.mode,
515
521
  relay,
516
522
  instruction: opened.instruction,
@@ -1,5 +1,6 @@
1
1
  import * as z from "zod/v4";
2
2
  import { defineConfigDomain } from "./definition.js";
3
+ import { LISTEN_PORT_MIN, PORT_MAX } from "../validation/ports.js";
3
4
  const USER_RUNTIME_SCOPES = ["runtime", "user", "built-in"];
4
5
  const USER_SCOPES = ["user", "built-in"];
5
6
  const USER_ONLY_SCOPES = ["user"];
@@ -24,11 +25,11 @@ export const generalConfigDefinition = defineConfigDomain({
24
25
  reload: "restart-required",
25
26
  runtimeOverride: runtimeEnv("HOST", (env) => env.HOST),
26
27
  }),
27
- port: field(z.number().int().min(1).max(65535), "Local listening port.", {
28
+ port: field(z.number().int().min(LISTEN_PORT_MIN).max(PORT_MAX), `Local listening port (${LISTEN_PORT_MIN}-${PORT_MAX}).`, {
28
29
  scopes: USER_RUNTIME_SCOPES,
29
30
  builtIn: literal(7676),
30
31
  reload: "restart-required",
31
- runtimeOverride: runtimeEnv("PORT", (env) => readIntegerEnv(env, "PORT", 1, 65535)),
32
+ runtimeOverride: runtimeEnv("PORT", (env) => readIntegerEnv(env, "PORT", LISTEN_PORT_MIN, PORT_MAX)),
32
33
  }),
33
34
  allowedRoots: field(z.array(z.string()), "Filesystem roots that Workspaces may open.", {
34
35
  scopes: USER_RUNTIME_SCOPES,
@@ -119,7 +120,7 @@ export const generalConfigDefinition = defineConfigDomain({
119
120
  }),
120
121
  commandShell: field(commandShellSchema, "Recorded command-shell preference for ForgeRelay command and Hook execution.", {
121
122
  scopes: USER_SCOPES,
122
- builtIn: computed("Detected launcher shell with the recorded compatibility fallback used when needed."),
123
+ builtIn: computed("Platform compatibility default unless an explicit shell preference is recorded."),
123
124
  reload: "restart-required",
124
125
  }),
125
126
  shellInstructions: field(z.boolean(), "Enable ForgeRelay-owned runtime shell instructions.", {
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod/v4";
2
+ import { isIntegerPort, OAUTH_CALLBACK_PORT_MIN, PORT_MAX } from "./validation/ports.js";
2
3
  const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
3
4
  const MAX_MCP_SERVERS = 32;
4
5
  const nonEmptyStringSchema = z.string().trim().min(1);
@@ -13,7 +14,7 @@ const clientMetadataUrlSchema = z.string().url().refine((value) => {
13
14
  }, "clientMetadataUrl must use https and contain a non-root path.");
14
15
  const oauthSourceSchema = z.object({
15
16
  clientMetadataUrl: clientMetadataUrlSchema,
16
- callbackPort: z.number().int().min(1024).max(65535),
17
+ callbackPort: z.number().int().min(OAUTH_CALLBACK_PORT_MIN).max(PORT_MAX),
17
18
  }).strict();
18
19
  const stdioSourceSchema = z.object({
19
20
  transport: z.literal("stdio"),
@@ -136,8 +137,8 @@ function parseOAuthClientConfig(value, label) {
136
137
  throw new Error(`${label}.clientMetadataUrl must use https and contain a non-root path.`);
137
138
  }
138
139
  const callbackPort = value.callbackPort;
139
- if (!Number.isInteger(callbackPort) || Number(callbackPort) < 1024 || Number(callbackPort) > 65535) {
140
- throw new Error(`${label}.callbackPort must be an integer from 1024 to 65535.`);
140
+ if (!isIntegerPort(callbackPort, OAUTH_CALLBACK_PORT_MIN, PORT_MAX)) {
141
+ throw new Error(`${label}.callbackPort must be an integer from ${OAUTH_CALLBACK_PORT_MIN} to ${PORT_MAX}.`);
141
142
  }
142
143
  return {
143
144
  clientMetadataUrl: parsed.toString(),
@@ -5,6 +5,8 @@ import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { withFileLock } from "../state/lock/file-lock.js";
7
7
  import { expandHomePath } from "../../mcp/filesystem/roots.js";
8
+ import { parseConfigSource } from "./definition/definition.js";
9
+ import { generalConfigDefinition } from "./definition/general-config.js";
8
10
  export function forgerelayConfigDir(env = process.env) {
9
11
  const explicit = env.FORGERELAY_CONFIG_DIR;
10
12
  if (explicit)
@@ -49,8 +51,9 @@ export function loadForgeRelayFiles(env = process.env, options = {}) {
49
51
  }
50
52
  export function writeForgeRelayConfig(config, env = process.env) {
51
53
  const filePath = forgerelayConfigPath(env);
54
+ const validated = parseConfigSource(generalConfigDefinition, "user", config);
52
55
  mkdirSync(forgerelayConfigDir(env), { recursive: true });
53
- writeJsonFile(filePath, config, 0o600);
56
+ writeJsonFile(filePath, validated, 0o600);
54
57
  return filePath;
55
58
  }
56
59
  export async function writeForgeRelayAuth(auth, env = process.env) {
@@ -0,0 +1,9 @@
1
+ export const LISTEN_PORT_MIN = 1;
2
+ export const PORT_MAX = 65_535;
3
+ export const OAUTH_CALLBACK_PORT_MIN = 1_024;
4
+ export function isIntegerPort(value, minimum = LISTEN_PORT_MIN, maximum = PORT_MAX) {
5
+ return typeof value === "number"
6
+ && Number.isInteger(value)
7
+ && value >= minimum
8
+ && value <= maximum;
9
+ }
@@ -1,5 +1,5 @@
1
- import { basename } from "node:path";
2
1
  import { styleText } from "node:util";
2
+ import { workspaceColorFor, workspaceIdentityName, } from "../../workspace-colors.js";
3
3
  const LEVEL_WEIGHT = {
4
4
  silent: 0,
5
5
  error: 1,
@@ -13,14 +13,22 @@ const LEVEL_STYLE = {
13
13
  info: "green",
14
14
  debug: "gray",
15
15
  };
16
- const WORKSPACE_PROJECT_COLORS = [
17
- "cyanBright",
18
- "greenBright",
19
- "yellowBright",
20
- "magentaBright",
21
- "blueBright",
22
- "whiteBright",
23
- ];
16
+ const WORKSPACE_PROJECT_COLORS = {
17
+ red: "red",
18
+ green: "green",
19
+ yellow: "yellow",
20
+ blue: "blue",
21
+ magenta: "magenta",
22
+ cyan: "cyan",
23
+ white: "white",
24
+ redBright: "redBright",
25
+ greenBright: "greenBright",
26
+ yellowBright: "yellowBright",
27
+ blueBright: "blueBright",
28
+ magentaBright: "magentaBright",
29
+ cyanBright: "cyanBright",
30
+ whiteBright: "whiteBright",
31
+ };
24
32
  export function shouldLog(config, level) {
25
33
  return LEVEL_WEIGHT[config.level] >= LEVEL_WEIGHT[level];
26
34
  }
@@ -59,7 +67,7 @@ export function transportSessionIdPrefix(transportSessionId) {
59
67
  /** @deprecated Use transportSessionIdPrefix. */
60
68
  export const sessionIdPrefix = transportSessionIdPrefix;
61
69
  export function workspaceLogLabel(root, workspaceId) {
62
- return `${basename(root)}/${workspaceId}`;
70
+ return `${workspaceIdentityName(root)}/${workspaceId}`;
63
71
  }
64
72
  export function commandPreview(command) {
65
73
  const normalized = command.replace(/\s+/g, " ").trim();
@@ -215,17 +223,9 @@ function formatPrettySource(source, options) {
215
223
  }
216
224
  const project = source.slice(0, separator);
217
225
  const workspace = source.slice(separator + 1);
218
- const projectColor = WORKSPACE_PROJECT_COLORS[stableColorIndex(project)];
226
+ const projectColor = WORKSPACE_PROJECT_COLORS[workspaceColorFor(project)];
219
227
  return `${style([projectColor, "bold"], project, options)}/${style(["cyan", "underline"], workspace, options)}`;
220
228
  }
221
- function stableColorIndex(value) {
222
- let hash = 2166136261;
223
- for (let index = 0; index < value.length; index += 1) {
224
- hash ^= value.charCodeAt(index);
225
- hash = Math.imul(hash, 16777619);
226
- }
227
- return (hash >>> 0) % WORKSPACE_PROJECT_COLORS.length;
228
- }
229
229
  function formatRuntimeResources(entry) {
230
230
  const rssMb = bytesToMegabytes(numberField(entry.rssBytes));
231
231
  const heapUsedMb = bytesToMegabytes(numberField(entry.heapUsedBytes));