@akira-tl/forgerelay 1.1.2 → 1.2.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 (170) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +14 -0
  3. package/dist/activity/runtime/mcp-query-tools.js +15 -5
  4. package/dist/cli/config/inspect.js +331 -0
  5. package/dist/cli/config/migrate.js +351 -0
  6. package/dist/cli/init/setup-config.js +41 -0
  7. package/dist/cli/init.js +128 -130
  8. package/dist/cli/maintenance.js +15 -5
  9. package/dist/cli/mcp/external-mcp.js +14 -10
  10. package/dist/cli/mcp/status.js +11 -6
  11. package/dist/cli.js +40 -7
  12. package/dist/lsp/language-server-config.js +66 -144
  13. package/dist/lsp/runtime/manager.js +35 -4
  14. package/dist/mcp/hooks/config.js +141 -0
  15. package/dist/mcp/hooks/external-mcp-transform.js +22 -13
  16. package/dist/mcp/hooks/hook-cli.js +62 -52
  17. package/dist/mcp/hooks/hooks.js +111 -85
  18. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +8 -5
  19. package/dist/mcp/operations/external-mcp/external-mcp.js +9 -2
  20. package/dist/mcp/server/core/capability-support.js +7 -0
  21. package/dist/runtime/config/config.js +142 -64
  22. package/dist/runtime/config/definition/catalog.js +12 -0
  23. package/dist/runtime/config/definition/definition.js +115 -0
  24. package/dist/runtime/config/definition/external-mcp.js +58 -0
  25. package/dist/runtime/config/definition/general-config.js +258 -0
  26. package/dist/runtime/config/definition/language-servers.js +116 -0
  27. package/dist/runtime/config/definition/schema.js +113 -0
  28. package/dist/runtime/config/definition/types.js +2 -0
  29. package/dist/runtime/config/external-mcp-auth-store.js +105 -29
  30. package/dist/runtime/config/external-mcp-config.js +39 -0
  31. package/dist/runtime/config/external-mcp-registry.js +284 -106
  32. package/dist/runtime/config/inspection/live.js +77 -0
  33. package/dist/runtime/config/resolution/general.js +93 -0
  34. package/dist/runtime/config/resolution/hooks.js +315 -0
  35. package/dist/runtime/config/resolution/language-servers.js +237 -0
  36. package/dist/runtime/config/resolution/project-sources.js +159 -0
  37. package/dist/runtime/config/resolution/resolver.js +485 -0
  38. package/dist/runtime/config/resolution/types.js +1 -0
  39. package/dist/runtime/config/runtime/config-runtime.js +74 -0
  40. package/dist/runtime/config/runtime/source-refresh.js +263 -0
  41. package/dist/runtime/config/user-config.js +38 -31
  42. package/dist/runtime/security/project-execution-trust.js +34 -0
  43. package/dist/runtime/testing/server-fixture.js +9 -8
  44. package/dist/server.js +10 -6
  45. package/dist/subagents/profiles.js +336 -53
  46. package/dist/subagents/sessions/execution.js +6 -35
  47. package/dist/subagents/sessions/manager.js +14 -2
  48. package/dist/ui/.vite/manifest.json +275 -275
  49. package/dist/ui/activity-panel-app.html +1 -1
  50. package/dist/ui/assets/{activity-panel-app-Bw7ZFX2g.js → activity-panel-app-BeDiwd7D.js} +3 -3
  51. package/dist/ui/assets/{angular-html-DeUNP12X.js → angular-html-DuoCE7nn.js} +1 -1
  52. package/dist/ui/assets/{angular-ts-B3v8FKi_.js → angular-ts-D5tGk0hL.js} +1 -1
  53. package/dist/ui/assets/{apl-Bs3iQm4P.js → apl-lciOTPbw.js} +1 -1
  54. package/dist/ui/assets/{astro-D6GHecBY.js → astro-vJbqfNnJ.js} +1 -1
  55. package/dist/ui/assets/{blade-BMNbVOHv.js → blade-C7655Q4K.js} +1 -1
  56. package/dist/ui/assets/{c-BquuA6bv.js → c-DdHp1Jrw.js} +1 -1
  57. package/dist/ui/assets/{cobol-BuZ6kfps.js → cobol-DgvzQxZP.js} +1 -1
  58. package/dist/ui/assets/{coffee-DgC5uBzL.js → coffee-BIjVUOUd.js} +1 -1
  59. package/dist/ui/assets/{cpp-BdvWqCaB.js → cpp-CU8tGzvI.js} +1 -1
  60. package/dist/ui/assets/{crystal-0Q5qBvNI.js → crystal-CUQAwsg9.js} +1 -1
  61. package/dist/ui/assets/{css-Be_T8idh.js → css-DtMTqD3F.js} +1 -1
  62. package/dist/ui/assets/{edge-DaGLdgh_.js → edge-DoCX9RnL.js} +1 -1
  63. package/dist/ui/assets/{elixir-CMgiFIzB.js → elixir-DJxV9_U6.js} +1 -1
  64. package/dist/ui/assets/{elm-B-ufMlMC.js → elm-DIgWqK4r.js} +1 -1
  65. package/dist/ui/assets/{erb-CaGiOZM-.js → erb-BHxP0c_e.js} +1 -1
  66. package/dist/ui/assets/{git-rebase-CpwUWCzK.js → git-rebase-HbI_Vo8d.js} +1 -1
  67. package/dist/ui/assets/{glimmer-js-BRxccvnE.js → glimmer-js-CthY4Ty_.js} +1 -1
  68. package/dist/ui/assets/{glimmer-ts-CgQbPzlT.js → glimmer-ts-B2-_A1Xz.js} +1 -1
  69. package/dist/ui/assets/{glsl-D7JmHFr0.js → glsl-D9V9gq98.js} +1 -1
  70. package/dist/ui/assets/{graphql-C8n0eUBK.js → graphql-CddeAaS3.js} +1 -1
  71. package/dist/ui/assets/{hack-lVN7KVQv.js → hack-D_loGkOT.js} +1 -1
  72. package/dist/ui/assets/{haml-h18lG0pS.js → haml-BoWDczd0.js} +1 -1
  73. package/dist/ui/assets/{handlebars-lRFjRPfj.js → handlebars-ZoSOvw9y.js} +1 -1
  74. package/dist/ui/assets/{heavy-payload-Cqx9MOkH.js → heavy-payload-DO34LwoZ.js} +3 -3
  75. package/dist/ui/assets/{html-DtILqTE1.js → html-CE-obXVo.js} +1 -1
  76. package/dist/ui/assets/{html-derivative-CSTSmPup.js → html-derivative-Cd-smAbW.js} +1 -1
  77. package/dist/ui/assets/{http-DNI8fsbY.js → http-CZrkbrSl.js} +1 -1
  78. package/dist/ui/assets/{hurl-DgRi60JG.js → hurl-CxTuxBt1.js} +1 -1
  79. package/dist/ui/assets/{java-CW9pZ5bh.js → java-BshLjCat.js} +1 -1
  80. package/dist/ui/assets/{javascript-Cvg2UrWL.js → javascript-D2pxQTiX.js} +1 -1
  81. package/dist/ui/assets/{jinja-CZw_S1FB.js → jinja-kbfPP7-F.js} +1 -1
  82. package/dist/ui/assets/{jison-C56u_FZv.js → jison-C2mt9l1M.js} +1 -1
  83. package/dist/ui/assets/{json-DW2UTv1q.js → json-DU_rg_AU.js} +1 -1
  84. package/dist/ui/assets/{jsx-Zj5gobdD.js → jsx-D_8aQMlx.js} +1 -1
  85. package/dist/ui/assets/{julia-Cvcrre-L.js → julia-BmefTaVV.js} +1 -1
  86. package/dist/ui/assets/{just-8CZ2u6Tj.js → just-DXV8vk1S.js} +1 -1
  87. package/dist/ui/assets/{latex-D289AXWG.js → latex-Cjm2MvGW.js} +1 -1
  88. package/dist/ui/assets/{liquid-CFEX3SkZ.js → liquid-DyQDlo0u.js} +1 -1
  89. package/dist/ui/assets/{lua-BKpJ0ybO.js → lua-B2A1vzzy.js} +1 -1
  90. package/dist/ui/assets/{marko-D9maZ0a6.js → marko-B9vOkXOU.js} +1 -1
  91. package/dist/ui/assets/{mdc-rM9l0qWR.js → mdc-D42m6spU.js} +1 -1
  92. package/dist/ui/assets/{nginx-C9w6p2C9.js → nginx-TpfVxSUY.js} +1 -1
  93. package/dist/ui/assets/{nim-BRePHk0J.js → nim-CJr5T1FK.js} +1 -1
  94. package/dist/ui/assets/{perl-CskTmiPQ.js → perl-cJZcsdm9.js} +1 -1
  95. package/dist/ui/assets/{php-DCU_FvNI.js → php-CSZL5KRT.js} +1 -1
  96. package/dist/ui/assets/{pug-XLaVxTvv.js → pug-B1eMKzuA.js} +1 -1
  97. package/dist/ui/assets/{qml-CaONKSl-.js → qml-Dl2_7zc9.js} +1 -1
  98. package/dist/ui/assets/{r-BCVR0ZE3.js → r-F0aaO9gG.js} +1 -1
  99. package/dist/ui/assets/{razor-DDnGXxnU.js → razor-BPnkSMCw.js} +1 -1
  100. package/dist/ui/assets/{regexp-DWGTYYVx.js → regexp-BnGHMFbv.js} +1 -1
  101. package/dist/ui/assets/{rst-Dz3GWvQm.js → rst-B3CO7WYO.js} +1 -1
  102. package/dist/ui/assets/{ruby-DC3jGtMY.js → ruby-B6VMzT1T.js} +1 -1
  103. package/dist/ui/assets/{sas-7mNpvsjZ.js → sas-Df2lHVuU.js} +1 -1
  104. package/dist/ui/assets/{scss-uBsoCJVy.js → scss-4XxI6sgu.js} +1 -1
  105. package/dist/ui/assets/{shellscript-BCN9N_Jf.js → shellscript-DYASCN4r.js} +1 -1
  106. package/dist/ui/assets/{shellsession-Hsw46VXV.js → shellsession-Br6AGJO_.js} +1 -1
  107. package/dist/ui/assets/{soy-D6KV69HU.js → soy-p3ZNq1We.js} +1 -1
  108. package/dist/ui/assets/{sql-CVzVQkJh.js → sql-CAdv2OXL.js} +1 -1
  109. package/dist/ui/assets/{stata-BwUjXNhI.js → stata-Dx7yUbG_.js} +1 -1
  110. package/dist/ui/assets/{surrealql-CgloWqRa.js → surrealql-6YsE8HQa.js} +1 -1
  111. package/dist/ui/assets/{svelte-DcC3n6IC.js → svelte-YgzedwpD.js} +1 -1
  112. package/dist/ui/assets/{templ-DOFfnWTJ.js → templ-Bahqx3uD.js} +1 -1
  113. package/dist/ui/assets/{tex-ln89VPaX.js → tex-o6fTQjkH.js} +1 -1
  114. package/dist/ui/assets/{ts-tags-CWMzCstH.js → ts-tags-BO-Co0Ju.js} +1 -1
  115. package/dist/ui/assets/{tsx-DgzfiGeK.js → tsx-DtQe5AoH.js} +1 -1
  116. package/dist/ui/assets/{twig-Bi2itZL9.js → twig-BcUs6Kqc.js} +1 -1
  117. package/dist/ui/assets/{typescript-CQ24f4zW.js → typescript-BPtM8h4u.js} +1 -1
  118. package/dist/ui/assets/{vue-BS8jE5u6.js → vue-5KkGUzJJ.js} +1 -1
  119. package/dist/ui/assets/{vue-html-o38xqhyB.js → vue-html-Df_Xfsz9.js} +1 -1
  120. package/dist/ui/assets/{vue-vine-D2NvKv50.js → vue-vine-hUIe_u_Y.js} +1 -1
  121. package/dist/ui/assets/{xml-_44rx9_2.js → xml-C3Jy7as5.js} +1 -1
  122. package/dist/ui/assets/{xsl-B5QWQPoi.js → xsl-BnF9OuFo.js} +1 -1
  123. package/dist/ui/assets/{yaml-CKL2pEWe.js → yaml-DyrFgCou.js} +1 -1
  124. package/dist/workspaces/conversation-test-support.js +3 -11
  125. package/dist/workspaces/sessions.js +2 -0
  126. package/dist/workspaces/state/project-context.js +221 -0
  127. package/dist/workspaces.js +3 -1
  128. package/docs/chatgpt-coding-workflow.md +8 -3
  129. package/docs/configuration.md +99 -41
  130. package/docs/gotchas.md +24 -5
  131. package/docs/security.md +14 -3
  132. package/docs/setup.md +12 -7
  133. package/package.json +12 -3
  134. package/schemas/v1/config.project-local.schema.json +14 -0
  135. package/schemas/v1/config.project.schema.json +14 -0
  136. package/schemas/v1/config.user.schema.json +538 -0
  137. package/schemas/v1/hooks.project-local.schema.json +118 -0
  138. package/schemas/v1/hooks.project.schema.json +118 -0
  139. package/schemas/v1/hooks.user.schema.json +118 -0
  140. package/schemas/v1/language-servers.project-local.schema.json +230 -0
  141. package/schemas/v1/language-servers.project.schema.json +230 -0
  142. package/schemas/v1/language-servers.user.schema.json +230 -0
  143. package/schemas/v1/mcp.project-local.schema.json +143 -0
  144. package/schemas/v1/mcp.project.schema.json +143 -0
  145. package/schemas/v1/mcp.user.schema.json +143 -0
  146. package/scripts/ci/architecture.mjs +54 -4
  147. package/scripts/ci/cmd-acceptance.mjs +9 -9
  148. package/scripts/ci/config-v2-product-acceptance.mjs +639 -0
  149. package/scripts/ci/gates/acceptance-artifact.mjs +66 -0
  150. package/scripts/ci/gates/core-contract.mjs +27 -0
  151. package/scripts/ci/gates/platform-tests.mjs +38 -0
  152. package/scripts/ci/gates/prepare-windows-product.mjs +57 -0
  153. package/scripts/ci/gates/test-shard.mjs +71 -0
  154. package/scripts/ci/powershell51-acceptance.mjs +9 -9
  155. package/scripts/ci/pwsh-acceptance.mjs +9 -9
  156. package/scripts/ci/verify.mjs +2 -0
  157. package/scripts/ci/windows-product-acceptance.mjs +9 -10
  158. package/scripts/config/schema-text.mjs +4 -0
  159. package/scripts/config/schema-text.test.mjs +18 -0
  160. package/scripts/config/schema.mjs +74 -0
  161. package/scripts/lsp/interop.mjs +4 -1
  162. package/scripts/release/pack.mjs +64 -4
  163. package/scripts/release/release-gate.test.mjs +93 -27
  164. package/scripts/release-parity.mjs +19 -2
  165. package/templates/instructions/cmd.md +12 -0
  166. package/templates/instructions/fish.md +11 -0
  167. package/templates/instructions/powershell.md +14 -0
  168. package/templates/instructions/pwsh.md +14 -0
  169. package/templates/instructions/zsh.md +11 -0
  170. package/dist/runtime/instructions/powershell-skill.js +0 -65
@@ -1,90 +1,44 @@
1
- import { access, readFile, realpath } from "node:fs/promises";
1
+ import { access, realpath } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { z } from "zod";
5
+ import { resolveProjectContext } from "../workspaces/state/project-context.js";
6
+ import { effectiveLanguageServerEntries, resolveLanguageServersConfig, } from "../runtime/config/resolution/language-servers.js";
7
+ import { projectExecutionRequirement } from "../runtime/security/project-execution-trust.js";
6
8
  export class LanguageServerConfigurationError extends Error {
7
9
  code;
8
- constructor(code, message) {
10
+ availableDefinitionFingerprints;
11
+ constructor(code, message, availableDefinitionFingerprints) {
9
12
  super(message);
10
13
  this.code = code;
14
+ this.availableDefinitionFingerprints = availableDefinitionFingerprints;
11
15
  this.name = "LanguageServerConfigurationError";
12
16
  }
13
17
  }
14
- const definitionSchema = z.object({
15
- enabled: z.boolean().optional(),
16
- command: z.string().min(1).optional(),
17
- args: z.array(z.string()).optional(),
18
- env: z.record(z.string(), z.string()).optional(),
19
- languages: z.array(z.string().min(1)).min(1).optional(),
20
- extensions: z.array(z.string().regex(/^\./)).min(1).optional(),
21
- languageIdByExtension: z.record(z.string().regex(/^\./), z.string().min(1)).optional(),
22
- projectMarkers: z.array(z.string().min(1)).optional(),
23
- }).strict();
24
- const configSchema = z.record(z.string().min(1), definitionSchema);
25
- const BUILTIN_DEFINITIONS = {
26
- typescript: {
27
- executableCandidates: ["typescript-language-server"],
28
- args: ["--stdio"],
29
- languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"],
30
- extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
31
- languageIdByExtension: {
32
- ".ts": "typescript",
33
- ".tsx": "typescriptreact",
34
- ".js": "javascript",
35
- ".jsx": "javascriptreact",
36
- ".mjs": "javascript",
37
- ".cjs": "javascript",
38
- },
39
- projectMarkers: ["tsconfig.json", "jsconfig.json", "package.json"],
40
- },
41
- pyright: {
42
- executableCandidates: ["pyright-langserver"],
43
- args: ["--stdio"],
44
- languages: ["python"],
45
- extensions: [".py", ".pyi"],
46
- languageIdByExtension: { ".py": "python", ".pyi": "python" },
47
- projectMarkers: ["pyrightconfig.json", "pyproject.toml", "setup.cfg", "setup.py"],
48
- },
49
- "rust-analyzer": {
50
- executableCandidates: ["rust-analyzer"],
51
- languages: ["rust"],
52
- extensions: [".rs"],
53
- languageIdByExtension: { ".rs": "rust" },
54
- projectMarkers: ["Cargo.toml"],
55
- },
56
- gopls: {
57
- executableCandidates: ["gopls"],
58
- languages: ["go"],
59
- extensions: [".go"],
60
- languageIdByExtension: { ".go": "go" },
61
- projectMarkers: ["go.work", "go.mod"],
62
- },
63
- clangd: {
64
- executableCandidates: ["clangd"],
65
- languages: ["c", "cpp", "objective-c", "objective-cpp"],
66
- extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".m", ".mm"],
67
- languageIdByExtension: {
68
- ".c": "c",
69
- ".cc": "cpp",
70
- ".cpp": "cpp",
71
- ".cxx": "cpp",
72
- ".h": "cpp",
73
- ".hh": "cpp",
74
- ".hpp": "cpp",
75
- ".hxx": "cpp",
76
- ".m": "objective-c",
77
- ".mm": "objective-cpp",
78
- },
79
- projectMarkers: ["compile_commands.json", "compile_flags.txt", ".clangd"],
80
- },
18
+ const BUILTIN_EXECUTABLE_CANDIDATES = {
19
+ typescript: ["typescript-language-server"],
20
+ pyright: ["pyright-langserver"],
21
+ "rust-analyzer": ["rust-analyzer"],
22
+ gopls: ["gopls"],
23
+ clangd: ["clangd"],
81
24
  };
82
25
  export async function resolveLanguageProject(input) {
83
26
  const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
84
27
  const sourcePath = await resolveWorkspaceSourcePath(workspaceRoot, input.sourcePath);
85
- const projectConfig = await loadProjectLanguageServerConfig(workspaceRoot);
86
- const globalConfig = parseLanguageServerConfig(input.globalConfig ?? {}, "global ForgeRelay config");
87
- const definitions = await effectiveDefinitions(globalConfig, projectConfig, input.env ?? process.env);
28
+ const environment = input.env ?? process.env;
29
+ const project = input.configDir
30
+ ? await resolveProjectContext(input.configDir, workspaceRoot)
31
+ : undefined;
32
+ const resolution = await resolveLanguageServersConfig({
33
+ ...(input.configDir ? { configDir: input.configDir } : {}),
34
+ ...(project ? { project } : {}),
35
+ projectSharedConfigDir: join(workspaceRoot, ".forgerelay"),
36
+ environment,
37
+ ...(input.sourceRuntime ? { sourceRuntime: input.sourceRuntime } : {}),
38
+ });
39
+ const errors = resolution.diagnostics.filter((diagnostic) => diagnostic.severity === "error" && diagnostic.usingLastKnownGood !== true);
40
+ const definitions = await materializeResolvedDefinitions(resolution, environment, project?.id);
41
+ const availableDefinitionFingerprints = Object.fromEntries(definitions.map((definition) => [definition.id, definition.configFingerprint]));
88
42
  const extension = extname(sourcePath).toLowerCase();
89
43
  const candidates = [];
90
44
  for (const definition of definitions) {
@@ -93,96 +47,49 @@ export async function resolveLanguageProject(input) {
93
47
  const projectRoot = await findLanguageProjectRoot(workspaceRoot, dirname(sourcePath), definition.projectMarkers);
94
48
  if (!projectRoot)
95
49
  continue;
96
- candidates.push({ definition, projectRoot });
50
+ candidates.push({ definition, projectRoot, availableDefinitionFingerprints });
97
51
  }
98
52
  if (candidates.length === 0) {
99
- throw new LanguageServerConfigurationError("code.language_service_unavailable", `No available Language-server definition matches ${relative(workspaceRoot, sourcePath) || "."}.`);
53
+ if (errors.length > 0) {
54
+ throw new LanguageServerConfigurationError("code.configuration_invalid", `Invalid Language-server configuration: ${errors.map((diagnostic) => `${diagnostic.source.location ?? diagnostic.source.id}: ${diagnostic.message}`).join("; ")}`, availableDefinitionFingerprints);
55
+ }
56
+ throw new LanguageServerConfigurationError("code.language_service_unavailable", `No available Language-server definition matches ${relative(workspaceRoot, sourcePath) || "."}.`, availableDefinitionFingerprints);
100
57
  }
101
- const sourceRank = { builtin: 0, global: 1, project: 2 };
58
+ const sourceRank = { builtin: 0, global: 1, project: 2, "project-local": 3 };
102
59
  const highestRank = Math.max(...candidates.map((candidate) => sourceRank[candidate.definition.source]));
103
60
  const highest = candidates.filter((candidate) => sourceRank[candidate.definition.source] === highestRank);
104
61
  const deepestLength = Math.max(...highest.map((candidate) => candidate.projectRoot.length));
105
62
  const nearest = highest.filter((candidate) => candidate.projectRoot.length === deepestLength);
106
63
  if (nearest.length !== 1) {
107
- throw new LanguageServerConfigurationError("code.configuration_ambiguous", `Multiple Language-server definitions match ${relative(workspaceRoot, sourcePath)} at the same priority: ${nearest.map((candidate) => candidate.definition.id).join(", ")}.`);
64
+ throw new LanguageServerConfigurationError("code.configuration_ambiguous", `Multiple Language-server definitions match ${relative(workspaceRoot, sourcePath)} at the same priority: ${nearest.map((candidate) => candidate.definition.id).join(", ")}.`, availableDefinitionFingerprints);
108
65
  }
109
66
  return nearest[0];
110
67
  }
111
- export async function loadProjectLanguageServerConfig(workspaceRoot) {
112
- const path = join(workspaceRoot, ".forgerelay", "language-servers.json");
113
- try {
114
- return parseLanguageServerConfig(JSON.parse(await readFile(path, "utf8")), path);
115
- }
116
- catch (error) {
117
- if (isMissingFile(error))
118
- return {};
119
- if (error instanceof LanguageServerConfigurationError)
120
- throw error;
121
- const reason = error instanceof Error ? error.message : String(error);
122
- throw new LanguageServerConfigurationError("code.configuration_invalid", `Unable to load Language-server configuration at ${path}: ${reason}`);
123
- }
124
- }
125
- export function parseLanguageServerConfig(value, label) {
126
- const parsed = configSchema.safeParse(value ?? {});
127
- if (!parsed.success) {
128
- const details = parsed.error.issues
129
- .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
130
- .join("; ");
131
- throw new LanguageServerConfigurationError("code.configuration_invalid", `Invalid Language-server configuration in ${label}: ${details}`);
132
- }
133
- return parsed.data;
134
- }
135
- async function effectiveDefinitions(globalConfig, projectConfig, env) {
136
- const ids = new Set([
137
- ...Object.keys(BUILTIN_DEFINITIONS),
138
- ...Object.keys(globalConfig),
139
- ...Object.keys(projectConfig),
140
- ]);
68
+ async function materializeResolvedDefinitions(resolution, env, projectId) {
141
69
  const definitions = [];
142
- for (const id of ids) {
143
- const builtin = BUILTIN_DEFINITIONS[id];
144
- const global = globalConfig[id];
145
- const project = projectConfig[id];
146
- const source = project
147
- ? "project"
148
- : global
149
- ? "global"
150
- : "builtin";
70
+ for (const entry of effectiveLanguageServerEntries(resolution)) {
151
71
  const merged = {
152
- id,
153
- source,
154
- ...(builtin ?? {}),
155
- ...(global ?? {}),
156
- ...(project ?? {}),
157
- env: {
158
- ...(builtin?.env ?? {}),
159
- ...(global?.env ?? {}),
160
- ...(project?.env ?? {}),
161
- },
162
- languageIdByExtension: {
163
- ...(builtin?.languageIdByExtension ?? {}),
164
- ...(global?.languageIdByExtension ?? {}),
165
- ...(project?.languageIdByExtension ?? {}),
166
- },
72
+ id: entry.id,
73
+ source: sourceForScope(entry.scope),
74
+ ...entry.value,
167
75
  };
168
- if (merged.enabled === false)
169
- continue;
170
76
  const languages = merged.languages ?? [];
171
- const extensions = merged.extensions?.map((entry) => entry.toLowerCase()) ?? [];
77
+ const extensions = merged.extensions?.map((value) => value.toLowerCase()) ?? [];
172
78
  if (languages.length === 0 || extensions.length === 0)
173
79
  continue;
174
- const languageIdByExtension = normalizeLanguageIds(id, merged, languages, extensions);
80
+ const languageIdByExtension = normalizeLanguageIds(entry.id, merged, languages, extensions);
175
81
  let command = merged.command;
176
- if (!command && builtin?.executableCandidates) {
177
- command = await findExecutable(builtin.executableCandidates, env);
82
+ const executableCandidates = BUILTIN_EXECUTABLE_CANDIDATES[entry.id];
83
+ if (!command && executableCandidates) {
84
+ command = await findExecutable(executableCandidates, env);
178
85
  if (!command)
179
86
  continue;
180
87
  }
181
88
  if (!command) {
182
- throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} requires a command.`);
89
+ throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${entry.id} requires a command.`);
183
90
  }
184
91
  const normalized = {
185
- id,
92
+ id: entry.id,
186
93
  command,
187
94
  args: merged.args ?? [],
188
95
  env: merged.env ?? {},
@@ -190,15 +97,33 @@ async function effectiveDefinitions(globalConfig, projectConfig, env) {
190
97
  extensions,
191
98
  languageIdByExtension,
192
99
  projectMarkers: merged.projectMarkers ?? [],
193
- source,
100
+ source: merged.source,
194
101
  };
102
+ const configFingerprint = createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
103
+ const executionRequirement = projectId ? projectExecutionRequirement({
104
+ projectId,
105
+ resolution,
106
+ entryKey: `servers.${entry.id}`,
107
+ display: { kind: "language-server", name: entry.id },
108
+ }) : undefined;
195
109
  definitions.push({
196
110
  ...normalized,
197
- fingerprint: createHash("sha256").update(JSON.stringify(normalized)).digest("hex"),
111
+ ...(executionRequirement ? { executionRequirement } : {}),
112
+ configFingerprint,
113
+ fingerprint: configFingerprint,
198
114
  });
199
115
  }
200
116
  return definitions;
201
117
  }
118
+ function sourceForScope(scope) {
119
+ if (scope === "built-in")
120
+ return "builtin";
121
+ if (scope === "user")
122
+ return "global";
123
+ if (scope === "project" || scope === "project-local")
124
+ return scope;
125
+ throw new Error(`Unsupported Language Server config scope: ${scope}`);
126
+ }
202
127
  function normalizeLanguageIds(id, definition, languages, extensions) {
203
128
  const mapping = Object.fromEntries(Object.entries(definition.languageIdByExtension ?? {})
204
129
  .map(([extension, languageId]) => [extension.toLowerCase(), languageId]));
@@ -308,6 +233,3 @@ function isWithin(root, candidate) {
308
233
  const rel = relative(root, candidate);
309
234
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
310
235
  }
311
- function isMissingFile(error) {
312
- return error instanceof Error && "code" in error && error.code === "ENOENT";
313
- }
@@ -4,6 +4,7 @@ import { resolve, sep } from "node:path";
4
4
  import { CodeIntelligenceError, LanguageService, languageServiceKey, } from "../code-intelligence.js";
5
5
  import { LanguageServerConfigurationError, resolveLanguageProject, } from "../language-server-config.js";
6
6
  import { managedLanguageServerIdForCommand, managedLanguageServerRuntimeIdentity, managedTypeScriptTsserverPath, withManagedLanguageServerPath, } from "./managed-language-servers.js";
7
+ import { compatibilityAllowProjectExecutionTrustPolicy, } from "../../runtime/security/project-execution-trust.js";
7
8
  const LANGUAGE_SERVICE_IDLE_MS = 10 * 60 * 1_000;
8
9
  const LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS = 60 * 1_000;
9
10
  const MAX_LANGUAGE_SERVICES = 16;
@@ -26,8 +27,10 @@ export class CodeIntelligenceManager {
26
27
  cleanupTimer;
27
28
  policy;
28
29
  crashCooldownMs;
30
+ projectExecutionTrustPolicy;
29
31
  constructor(config, options = {}) {
30
32
  this.config = config;
33
+ this.projectExecutionTrustPolicy = options.projectExecutionTrustPolicy ?? compatibilityAllowProjectExecutionTrustPolicy;
31
34
  this.crashCooldownMs = positiveInteger(options.crashCooldownMs, LANGUAGE_SERVICE_CRASH_COOLDOWN_MS, "crashCooldownMs");
32
35
  this.policy = {
33
36
  idleMs: positiveInteger(options.idleMs, LANGUAGE_SERVICE_IDLE_MS, "idleMs"),
@@ -47,24 +50,31 @@ export class CodeIntelligenceManager {
47
50
  this.cleanupTimer.unref();
48
51
  }
49
52
  async run(workspaceRoot, input, options = {}) {
53
+ const canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
54
+ this.assertWorkspaceRootAvailable(canonicalWorkspaceRoot);
50
55
  let project;
51
- let canonicalWorkspaceRoot;
52
56
  try {
53
- canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
54
- this.assertWorkspaceRootAvailable(canonicalWorkspaceRoot);
55
57
  project = withManagedLanguageServerRuntime(await resolveLanguageProject({
56
58
  workspaceRoot: canonicalWorkspaceRoot,
57
59
  sourcePath: input.path,
58
- globalConfig: this.config.languageServers,
60
+ configDir: this.config.configDir,
59
61
  env: withManagedLanguageServerPath(process.env, this.config.configDir),
62
+ sourceRuntime: this.config.configRuntime.sources,
60
63
  }), this.config.configDir);
61
64
  }
62
65
  catch (error) {
63
66
  if (error instanceof LanguageServerConfigurationError) {
67
+ if (error.availableDefinitionFingerprints) {
68
+ await this.invalidateUnavailableServices(canonicalWorkspaceRoot, error.availableDefinitionFingerprints);
69
+ }
64
70
  throw new CodeIntelligenceError(error.code, error.message);
65
71
  }
66
72
  throw error;
67
73
  }
74
+ if (project.definition.executionRequirement) {
75
+ await this.projectExecutionTrustPolicy.authorize(project.definition.executionRequirement);
76
+ }
77
+ await this.invalidateUnavailableServices(canonicalWorkspaceRoot, project.availableDefinitionFingerprints);
68
78
  await this.invalidateChangedServices(project);
69
79
  const identity = languageServiceKey(project);
70
80
  this.assertNotCoolingDown(identity, project);
@@ -217,6 +227,27 @@ export class CodeIntelligenceManager {
217
227
  }
218
228
  throw new CodeIntelligenceError("code.language_service_cooldown", `Language server ${project.definition.id} is cooling down after repeated crashes; retry in ${remaining}ms.`);
219
229
  }
230
+ async invalidateUnavailableServices(workspaceRoot, availableDefinitionFingerprints) {
231
+ const root = resolve(workspaceRoot);
232
+ const invalidated = [];
233
+ for (const [key, service] of this.services) {
234
+ if (resolve(service.workspaceRoot) !== root)
235
+ continue;
236
+ const currentFingerprint = availableDefinitionFingerprints[service.project.definition.id];
237
+ if (currentFingerprint === service.project.definition.configFingerprint)
238
+ continue;
239
+ this.invalidatedServiceKeys.add(key);
240
+ this.crashStates.delete(key);
241
+ if (service.isIdle)
242
+ invalidated.push([key, service]);
243
+ }
244
+ for (const [key, service] of invalidated) {
245
+ if (this.services.get(key) === service)
246
+ this.services.delete(key);
247
+ this.invalidatedServiceKeys.delete(key);
248
+ await service.shutdown();
249
+ }
250
+ }
220
251
  async invalidateChangedServices(project) {
221
252
  const root = resolve(project.projectRoot);
222
253
  const invalidated = [];
@@ -0,0 +1,141 @@
1
+ import * as z from "zod/v4";
2
+ import { defineConfigDomain } from "../../runtime/config/definition/definition.js";
3
+ export const HOOK_EVENTS = [
4
+ "WorkspaceOpen",
5
+ "BeforeTool",
6
+ "AfterTool",
7
+ "AfterToolFailure",
8
+ "ExternalMcpBeforeForward",
9
+ "ExternalMcpAfterForward",
10
+ "AfterFileChange",
11
+ "BeforeWorktreeClose",
12
+ "AfterWorktreeClose",
13
+ "SubagentStart",
14
+ "SubagentStop",
15
+ ];
16
+ const schemaMetadata = {
17
+ $schema: z.string().optional().describe("Editor-only JSON Schema URL. Ignored by ForgeRelay resolution."),
18
+ };
19
+ const nonEmptyString = z.string().trim().min(1);
20
+ const regexString = z.string().min(1).refine((value) => {
21
+ try {
22
+ new RegExp(value);
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }, "Must be a valid regular expression.");
29
+ export const hookMatcherSchema = z.object({
30
+ tool: nonEmptyString.optional(),
31
+ commandRegex: regexString.optional(),
32
+ pathRegex: regexString.optional(),
33
+ provider: nonEmptyString.optional(),
34
+ workspaceMode: z.enum(["checkout", "worktree"]).optional(),
35
+ capability: nonEmptyString.optional(),
36
+ externalServer: nonEmptyString.optional(),
37
+ externalTool: nonEmptyString.optional(),
38
+ }).strict();
39
+ export const hookEntrySchema = z.object({
40
+ event: z.enum(HOOK_EVENTS),
41
+ matcher: hookMatcherSchema.optional(),
42
+ command: nonEmptyString,
43
+ timeoutSeconds: z.number().int().min(1).max(300).optional(),
44
+ report: z.boolean().optional(),
45
+ }).strict();
46
+ const resolvedHookEntrySchema = hookEntrySchema.extend({
47
+ name: nonEmptyString.optional(),
48
+ order: z.number().int().nonnegative().optional(),
49
+ });
50
+ export const hookDisabledEntrySchema = z.object({ disabled: z.literal(true) }).strict();
51
+ export const hookCanonicalFileSchema = z.union([
52
+ hookEntrySchema.extend(schemaMetadata),
53
+ hookDisabledEntrySchema.extend(schemaMetadata),
54
+ ]);
55
+ export const hookEntriesSchema = z.record(z.string().min(1), z.union([
56
+ hookDisabledEntrySchema,
57
+ z.array(resolvedHookEntrySchema).min(1),
58
+ ]));
59
+ const legacyHookHandlerSchema = z.object({
60
+ name: nonEmptyString.optional(),
61
+ command: nonEmptyString,
62
+ timeoutSeconds: z.number().int().min(1).max(300).optional(),
63
+ report: z.boolean().optional(),
64
+ }).strict();
65
+ const legacyDirectHookRuleSchema = legacyHookHandlerSchema.extend({
66
+ matcher: hookMatcherSchema.optional(),
67
+ });
68
+ const legacyHookRuleSchema = z.union([
69
+ legacyDirectHookRuleSchema,
70
+ z.object({
71
+ matcher: hookMatcherSchema.optional(),
72
+ handlers: z.array(legacyHookHandlerSchema).min(1),
73
+ }).strict(),
74
+ ]);
75
+ const legacyHookConfigSchema = z.object(Object.fromEntries(HOOK_EVENTS.map((event) => [event, z.array(legacyHookRuleSchema).optional()]))).strict();
76
+ export function normalizeLegacyHookEntries(value) {
77
+ const parsed = legacyHookConfigSchema.parse(value ?? {});
78
+ const hooks = {};
79
+ let order = 0;
80
+ for (const event of HOOK_EVENTS) {
81
+ for (const [ruleIndex, rule] of (parsed[event] ?? []).entries()) {
82
+ const matcher = rule.matcher;
83
+ const handlers = "handlers" in rule ? rule.handlers : [rule];
84
+ for (const [handlerIndex, handler] of handlers.entries()) {
85
+ const key = handler.name ?? legacyHookKey(event, ruleIndex, handlerIndex);
86
+ const entry = {
87
+ event,
88
+ ...(matcher ? { matcher } : {}),
89
+ command: handler.command,
90
+ ...(handler.name ? { name: handler.name } : {}),
91
+ order,
92
+ ...(handler.timeoutSeconds === undefined ? {} : { timeoutSeconds: handler.timeoutSeconds }),
93
+ ...(handler.report === undefined ? {} : { report: handler.report }),
94
+ };
95
+ order += 1;
96
+ const current = hooks[key];
97
+ if (current && !Array.isArray(current)) {
98
+ throw new Error(`Legacy Hook ${key} conflicts with a disabled Hook entry.`);
99
+ }
100
+ hooks[key] = [...(current ?? []), entry];
101
+ }
102
+ }
103
+ }
104
+ return hooks;
105
+ }
106
+ function legacyHookKey(event, ruleIndex, handlerIndex) {
107
+ return `@legacy/${event}/${String(ruleIndex + 1).padStart(4, "0")}/${String(handlerIndex + 1).padStart(4, "0")}`;
108
+ }
109
+ const HOOK_SCOPES = ["project-local", "project", "user"];
110
+ export const hooksConfigDefinition = defineConfigDomain({
111
+ domain: "hooks",
112
+ title: "ForgeRelay Lifecycle Hook configuration",
113
+ description: "Lifecycle Hook files composed by stable file name across user, project, and Project Local scopes.",
114
+ fileShape: {
115
+ kind: "keyed-entry",
116
+ field: "hooks",
117
+ fileSchema: hookCanonicalFileSchema,
118
+ normalizeEntry: (value) => {
119
+ if (isRecord(value) && value.disabled === true)
120
+ return { disabled: true };
121
+ return [value];
122
+ },
123
+ },
124
+ fields: {
125
+ hooks: {
126
+ schema: hookEntriesSchema,
127
+ description: "Lifecycle Hooks keyed by stable Hook name.",
128
+ required: true,
129
+ legalScopes: HOOK_SCOPES,
130
+ merge: "keyed",
131
+ reload: "hot",
132
+ sensitivity: "sensitive",
133
+ interpolation: "none",
134
+ builtIn: { kind: "none" },
135
+ executionEffect: (value) => isRecord(value) && value.disabled === true ? "none" : "process",
136
+ },
137
+ },
138
+ });
139
+ function isRecord(value) {
140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
141
+ }
@@ -1,9 +1,11 @@
1
1
  import { performance } from "node:perf_hooks";
2
+ import { ConfigSourceRuntime } from "../../runtime/config/runtime/source-refresh.js";
2
3
  import { commandPreview, logEvent, workspaceLogLabel } from "../../runtime/logging/logger.js";
3
4
  import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime, } from "../../runtime/shell/command-shell-runtime.js";
5
+ import { compatibilityAllowProjectExecutionTrustPolicy, } from "../../runtime/security/project-execution-trust.js";
4
6
  import { resolveShellCommandForRuntime } from "../process/process-platform.js";
5
7
  import { executeHookCommand } from "./command-runner.js";
6
- import { loadProjectHookConfig, matchHookRule, } from "./hooks.js";
8
+ import { resolveHookExecutionPlan, } from "./hooks.js";
7
9
  export class ExternalMcpTransformError extends Error {
8
10
  server;
9
11
  tool;
@@ -23,12 +25,18 @@ export class ExternalMcpTransformRunner {
23
25
  hooks;
24
26
  logging;
25
27
  baseEnv;
28
+ configDir;
29
+ sourceRuntime;
30
+ projectExecutionTrustPolicy;
26
31
  commandShellRuntime;
27
32
  maxCaptureBytes;
28
- constructor(hooks, logging, baseEnv = process.env, commandShellRuntime, mediaMaxBytes = 20 * 1024 * 1024) {
33
+ constructor(hooks, logging, baseEnv = process.env, commandShellRuntime, mediaMaxBytes = 20 * 1024 * 1024, configDir, sourceRuntime = new ConfigSourceRuntime(), projectExecutionTrustPolicy = compatibilityAllowProjectExecutionTrustPolicy) {
29
34
  this.hooks = hooks;
30
35
  this.logging = logging;
31
36
  this.baseEnv = baseEnv;
37
+ this.configDir = configDir;
38
+ this.sourceRuntime = sourceRuntime;
39
+ this.projectExecutionTrustPolicy = projectExecutionTrustPolicy;
32
40
  this.commandShellRuntime = snapshotCommandShellRuntime(commandShellRuntime ?? resolveCompatibilityCommandShellRuntime(process.platform, baseEnv));
33
41
  this.maxCaptureBytes = Math.max(MIN_TRANSFORM_CAPTURE_BYTES, Math.ceil((mediaMaxBytes * 4) / 3) + TRANSFORM_CAPTURE_OVERHEAD_BYTES);
34
42
  }
@@ -49,21 +57,20 @@ export class ExternalMcpTransformRunner {
49
57
  workspaceMode: context.workspaceMode,
50
58
  payload: transformMetadata(context, phase),
51
59
  };
52
- const project = await loadProjectHookConfig(context.workspaceRoot);
53
- const handlers = [
54
- ...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
55
- ...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
56
- ].flatMap(({ scope, rule }) => {
57
- const matched = matchHookRule(rule.matcher, invocation);
58
- if (!matched)
59
- return [];
60
- return rule.handlers.map((handler) => ({ scope, handler }));
60
+ const plan = await resolveHookExecutionPlan({
61
+ event,
62
+ invocation,
63
+ legacyUser: this.hooks,
64
+ ...(this.configDir ? { configDir: this.configDir } : {}),
65
+ ...(context.project ? { project: context.project } : {}),
66
+ sourceRuntime: this.sourceRuntime,
61
67
  });
68
+ const handlers = plan.handlers;
62
69
  let value = initialValue;
63
70
  const transforms = [];
64
71
  for (const [index, entry] of handlers.entries()) {
65
72
  signal?.throwIfAborted();
66
- const execution = await this.runHandler(event, phase, context, entry.handler, entry.scope, index, value, signal);
73
+ const execution = await this.runHandler(event, phase, context, entry.handler, entry.scope, index, value, entry.executionRequirement, signal);
67
74
  value = execution.value;
68
75
  transforms.push({
69
76
  phase,
@@ -74,7 +81,7 @@ export class ExternalMcpTransformRunner {
74
81
  }
75
82
  return { value, transforms };
76
83
  }
77
- async runHandler(event, phase, context, handler, scope, index, currentValue, signal) {
84
+ async runHandler(event, phase, context, handler, scope, index, currentValue, executionRequirement, signal) {
78
85
  const startedAt = performance.now();
79
86
  const name = handler.name ?? `${event} handler ${index + 1}`;
80
87
  const shell = resolveShellCommandForRuntime(handler.command, this.commandShellRuntime);
@@ -95,6 +102,8 @@ export class ExternalMcpTransformRunner {
95
102
  };
96
103
  const stdin = JSON.stringify(transformInput(phase, context, currentValue));
97
104
  try {
105
+ if (executionRequirement)
106
+ await this.projectExecutionTrustPolicy.authorize(executionRequirement);
98
107
  const result = await executeHookCommand({
99
108
  executable: shell.executable,
100
109
  args: shell.args,