@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.
- package/CHANGELOG.md +38 -0
- package/README.md +14 -0
- package/dist/activity/runtime/mcp-query-tools.js +15 -5
- package/dist/cli/config/inspect.js +331 -0
- package/dist/cli/config/migrate.js +351 -0
- package/dist/cli/init/setup-config.js +41 -0
- package/dist/cli/init.js +128 -130
- package/dist/cli/maintenance.js +15 -5
- package/dist/cli/mcp/external-mcp.js +14 -10
- package/dist/cli/mcp/status.js +11 -6
- package/dist/cli.js +40 -7
- package/dist/lsp/language-server-config.js +66 -144
- package/dist/lsp/runtime/manager.js +35 -4
- package/dist/mcp/hooks/config.js +141 -0
- package/dist/mcp/hooks/external-mcp-transform.js +22 -13
- package/dist/mcp/hooks/hook-cli.js +62 -52
- package/dist/mcp/hooks/hooks.js +111 -85
- package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +8 -5
- package/dist/mcp/operations/external-mcp/external-mcp.js +9 -2
- package/dist/mcp/server/core/capability-support.js +7 -0
- package/dist/runtime/config/config.js +142 -64
- package/dist/runtime/config/definition/catalog.js +12 -0
- package/dist/runtime/config/definition/definition.js +115 -0
- package/dist/runtime/config/definition/external-mcp.js +58 -0
- package/dist/runtime/config/definition/general-config.js +258 -0
- package/dist/runtime/config/definition/language-servers.js +116 -0
- package/dist/runtime/config/definition/schema.js +113 -0
- package/dist/runtime/config/definition/types.js +2 -0
- package/dist/runtime/config/external-mcp-auth-store.js +105 -29
- package/dist/runtime/config/external-mcp-config.js +39 -0
- package/dist/runtime/config/external-mcp-registry.js +284 -106
- package/dist/runtime/config/inspection/live.js +77 -0
- package/dist/runtime/config/resolution/general.js +93 -0
- package/dist/runtime/config/resolution/hooks.js +315 -0
- package/dist/runtime/config/resolution/language-servers.js +237 -0
- package/dist/runtime/config/resolution/project-sources.js +159 -0
- package/dist/runtime/config/resolution/resolver.js +485 -0
- package/dist/runtime/config/resolution/types.js +1 -0
- package/dist/runtime/config/runtime/config-runtime.js +74 -0
- package/dist/runtime/config/runtime/source-refresh.js +263 -0
- package/dist/runtime/config/user-config.js +38 -31
- package/dist/runtime/security/project-execution-trust.js +34 -0
- package/dist/runtime/testing/server-fixture.js +9 -8
- package/dist/server.js +10 -6
- package/dist/subagents/profiles.js +336 -53
- package/dist/subagents/sessions/execution.js +6 -35
- package/dist/subagents/sessions/manager.js +14 -2
- package/dist/ui/.vite/manifest.json +275 -275
- package/dist/ui/activity-panel-app.html +1 -1
- package/dist/ui/assets/{activity-panel-app-Bw7ZFX2g.js → activity-panel-app-BeDiwd7D.js} +3 -3
- package/dist/ui/assets/{angular-html-DeUNP12X.js → angular-html-DuoCE7nn.js} +1 -1
- package/dist/ui/assets/{angular-ts-B3v8FKi_.js → angular-ts-D5tGk0hL.js} +1 -1
- package/dist/ui/assets/{apl-Bs3iQm4P.js → apl-lciOTPbw.js} +1 -1
- package/dist/ui/assets/{astro-D6GHecBY.js → astro-vJbqfNnJ.js} +1 -1
- package/dist/ui/assets/{blade-BMNbVOHv.js → blade-C7655Q4K.js} +1 -1
- package/dist/ui/assets/{c-BquuA6bv.js → c-DdHp1Jrw.js} +1 -1
- package/dist/ui/assets/{cobol-BuZ6kfps.js → cobol-DgvzQxZP.js} +1 -1
- package/dist/ui/assets/{coffee-DgC5uBzL.js → coffee-BIjVUOUd.js} +1 -1
- package/dist/ui/assets/{cpp-BdvWqCaB.js → cpp-CU8tGzvI.js} +1 -1
- package/dist/ui/assets/{crystal-0Q5qBvNI.js → crystal-CUQAwsg9.js} +1 -1
- package/dist/ui/assets/{css-Be_T8idh.js → css-DtMTqD3F.js} +1 -1
- package/dist/ui/assets/{edge-DaGLdgh_.js → edge-DoCX9RnL.js} +1 -1
- package/dist/ui/assets/{elixir-CMgiFIzB.js → elixir-DJxV9_U6.js} +1 -1
- package/dist/ui/assets/{elm-B-ufMlMC.js → elm-DIgWqK4r.js} +1 -1
- package/dist/ui/assets/{erb-CaGiOZM-.js → erb-BHxP0c_e.js} +1 -1
- package/dist/ui/assets/{git-rebase-CpwUWCzK.js → git-rebase-HbI_Vo8d.js} +1 -1
- package/dist/ui/assets/{glimmer-js-BRxccvnE.js → glimmer-js-CthY4Ty_.js} +1 -1
- package/dist/ui/assets/{glimmer-ts-CgQbPzlT.js → glimmer-ts-B2-_A1Xz.js} +1 -1
- package/dist/ui/assets/{glsl-D7JmHFr0.js → glsl-D9V9gq98.js} +1 -1
- package/dist/ui/assets/{graphql-C8n0eUBK.js → graphql-CddeAaS3.js} +1 -1
- package/dist/ui/assets/{hack-lVN7KVQv.js → hack-D_loGkOT.js} +1 -1
- package/dist/ui/assets/{haml-h18lG0pS.js → haml-BoWDczd0.js} +1 -1
- package/dist/ui/assets/{handlebars-lRFjRPfj.js → handlebars-ZoSOvw9y.js} +1 -1
- package/dist/ui/assets/{heavy-payload-Cqx9MOkH.js → heavy-payload-DO34LwoZ.js} +3 -3
- package/dist/ui/assets/{html-DtILqTE1.js → html-CE-obXVo.js} +1 -1
- package/dist/ui/assets/{html-derivative-CSTSmPup.js → html-derivative-Cd-smAbW.js} +1 -1
- package/dist/ui/assets/{http-DNI8fsbY.js → http-CZrkbrSl.js} +1 -1
- package/dist/ui/assets/{hurl-DgRi60JG.js → hurl-CxTuxBt1.js} +1 -1
- package/dist/ui/assets/{java-CW9pZ5bh.js → java-BshLjCat.js} +1 -1
- package/dist/ui/assets/{javascript-Cvg2UrWL.js → javascript-D2pxQTiX.js} +1 -1
- package/dist/ui/assets/{jinja-CZw_S1FB.js → jinja-kbfPP7-F.js} +1 -1
- package/dist/ui/assets/{jison-C56u_FZv.js → jison-C2mt9l1M.js} +1 -1
- package/dist/ui/assets/{json-DW2UTv1q.js → json-DU_rg_AU.js} +1 -1
- package/dist/ui/assets/{jsx-Zj5gobdD.js → jsx-D_8aQMlx.js} +1 -1
- package/dist/ui/assets/{julia-Cvcrre-L.js → julia-BmefTaVV.js} +1 -1
- package/dist/ui/assets/{just-8CZ2u6Tj.js → just-DXV8vk1S.js} +1 -1
- package/dist/ui/assets/{latex-D289AXWG.js → latex-Cjm2MvGW.js} +1 -1
- package/dist/ui/assets/{liquid-CFEX3SkZ.js → liquid-DyQDlo0u.js} +1 -1
- package/dist/ui/assets/{lua-BKpJ0ybO.js → lua-B2A1vzzy.js} +1 -1
- package/dist/ui/assets/{marko-D9maZ0a6.js → marko-B9vOkXOU.js} +1 -1
- package/dist/ui/assets/{mdc-rM9l0qWR.js → mdc-D42m6spU.js} +1 -1
- package/dist/ui/assets/{nginx-C9w6p2C9.js → nginx-TpfVxSUY.js} +1 -1
- package/dist/ui/assets/{nim-BRePHk0J.js → nim-CJr5T1FK.js} +1 -1
- package/dist/ui/assets/{perl-CskTmiPQ.js → perl-cJZcsdm9.js} +1 -1
- package/dist/ui/assets/{php-DCU_FvNI.js → php-CSZL5KRT.js} +1 -1
- package/dist/ui/assets/{pug-XLaVxTvv.js → pug-B1eMKzuA.js} +1 -1
- package/dist/ui/assets/{qml-CaONKSl-.js → qml-Dl2_7zc9.js} +1 -1
- package/dist/ui/assets/{r-BCVR0ZE3.js → r-F0aaO9gG.js} +1 -1
- package/dist/ui/assets/{razor-DDnGXxnU.js → razor-BPnkSMCw.js} +1 -1
- package/dist/ui/assets/{regexp-DWGTYYVx.js → regexp-BnGHMFbv.js} +1 -1
- package/dist/ui/assets/{rst-Dz3GWvQm.js → rst-B3CO7WYO.js} +1 -1
- package/dist/ui/assets/{ruby-DC3jGtMY.js → ruby-B6VMzT1T.js} +1 -1
- package/dist/ui/assets/{sas-7mNpvsjZ.js → sas-Df2lHVuU.js} +1 -1
- package/dist/ui/assets/{scss-uBsoCJVy.js → scss-4XxI6sgu.js} +1 -1
- package/dist/ui/assets/{shellscript-BCN9N_Jf.js → shellscript-DYASCN4r.js} +1 -1
- package/dist/ui/assets/{shellsession-Hsw46VXV.js → shellsession-Br6AGJO_.js} +1 -1
- package/dist/ui/assets/{soy-D6KV69HU.js → soy-p3ZNq1We.js} +1 -1
- package/dist/ui/assets/{sql-CVzVQkJh.js → sql-CAdv2OXL.js} +1 -1
- package/dist/ui/assets/{stata-BwUjXNhI.js → stata-Dx7yUbG_.js} +1 -1
- package/dist/ui/assets/{surrealql-CgloWqRa.js → surrealql-6YsE8HQa.js} +1 -1
- package/dist/ui/assets/{svelte-DcC3n6IC.js → svelte-YgzedwpD.js} +1 -1
- package/dist/ui/assets/{templ-DOFfnWTJ.js → templ-Bahqx3uD.js} +1 -1
- package/dist/ui/assets/{tex-ln89VPaX.js → tex-o6fTQjkH.js} +1 -1
- package/dist/ui/assets/{ts-tags-CWMzCstH.js → ts-tags-BO-Co0Ju.js} +1 -1
- package/dist/ui/assets/{tsx-DgzfiGeK.js → tsx-DtQe5AoH.js} +1 -1
- package/dist/ui/assets/{twig-Bi2itZL9.js → twig-BcUs6Kqc.js} +1 -1
- package/dist/ui/assets/{typescript-CQ24f4zW.js → typescript-BPtM8h4u.js} +1 -1
- package/dist/ui/assets/{vue-BS8jE5u6.js → vue-5KkGUzJJ.js} +1 -1
- package/dist/ui/assets/{vue-html-o38xqhyB.js → vue-html-Df_Xfsz9.js} +1 -1
- package/dist/ui/assets/{vue-vine-D2NvKv50.js → vue-vine-hUIe_u_Y.js} +1 -1
- package/dist/ui/assets/{xml-_44rx9_2.js → xml-C3Jy7as5.js} +1 -1
- package/dist/ui/assets/{xsl-B5QWQPoi.js → xsl-BnF9OuFo.js} +1 -1
- package/dist/ui/assets/{yaml-CKL2pEWe.js → yaml-DyrFgCou.js} +1 -1
- package/dist/workspaces/conversation-test-support.js +3 -11
- package/dist/workspaces/sessions.js +2 -0
- package/dist/workspaces/state/project-context.js +221 -0
- package/dist/workspaces.js +3 -1
- package/docs/chatgpt-coding-workflow.md +8 -3
- package/docs/configuration.md +99 -41
- package/docs/gotchas.md +24 -5
- package/docs/security.md +14 -3
- package/docs/setup.md +12 -7
- package/package.json +12 -3
- package/schemas/v1/config.project-local.schema.json +14 -0
- package/schemas/v1/config.project.schema.json +14 -0
- package/schemas/v1/config.user.schema.json +538 -0
- package/schemas/v1/hooks.project-local.schema.json +118 -0
- package/schemas/v1/hooks.project.schema.json +118 -0
- package/schemas/v1/hooks.user.schema.json +118 -0
- package/schemas/v1/language-servers.project-local.schema.json +230 -0
- package/schemas/v1/language-servers.project.schema.json +230 -0
- package/schemas/v1/language-servers.user.schema.json +230 -0
- package/schemas/v1/mcp.project-local.schema.json +143 -0
- package/schemas/v1/mcp.project.schema.json +143 -0
- package/schemas/v1/mcp.user.schema.json +143 -0
- package/scripts/ci/architecture.mjs +54 -4
- package/scripts/ci/cmd-acceptance.mjs +9 -9
- package/scripts/ci/config-v2-product-acceptance.mjs +639 -0
- package/scripts/ci/gates/acceptance-artifact.mjs +66 -0
- package/scripts/ci/gates/core-contract.mjs +27 -0
- package/scripts/ci/gates/platform-tests.mjs +38 -0
- package/scripts/ci/gates/prepare-windows-product.mjs +57 -0
- package/scripts/ci/gates/test-shard.mjs +71 -0
- package/scripts/ci/powershell51-acceptance.mjs +9 -9
- package/scripts/ci/pwsh-acceptance.mjs +9 -9
- package/scripts/ci/verify.mjs +2 -0
- package/scripts/ci/windows-product-acceptance.mjs +9 -10
- package/scripts/config/schema-text.mjs +4 -0
- package/scripts/config/schema-text.test.mjs +18 -0
- package/scripts/config/schema.mjs +74 -0
- package/scripts/lsp/interop.mjs +4 -1
- package/scripts/release/pack.mjs +64 -4
- package/scripts/release/release-gate.test.mjs +93 -27
- package/scripts/release-parity.mjs +19 -2
- package/templates/instructions/cmd.md +12 -0
- package/templates/instructions/fish.md +11 -0
- package/templates/instructions/powershell.md +14 -0
- package/templates/instructions/pwsh.md +14 -0
- package/templates/instructions/zsh.md +11 -0
- package/dist/runtime/instructions/powershell-skill.js +0 -65
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
const projectEntries = flattenHooks(project.hooks, "project");
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { resolveProjectContext } from "../../workspaces/state/project-context.js";
|
|
3
|
+
import { effectiveHookConfigEntries, resolveHooksConfig, } from "../../runtime/config/resolution/hooks.js";
|
|
4
|
+
import { forgerelayConfigDir } from "../../runtime/config/user-config.js";
|
|
5
|
+
export async function checkHookConfiguration(projectRoot, globalHooks = {}, configDir, sourceRuntime) {
|
|
6
|
+
const resolution = await resolveHookConfiguration(projectRoot, globalHooks, configDir, sourceRuntime);
|
|
7
|
+
assertHookResolutionValid(resolution);
|
|
8
|
+
const entries = flattenResolvedHooks(resolution);
|
|
11
9
|
return {
|
|
12
|
-
globalHooks:
|
|
13
|
-
projectHooks:
|
|
10
|
+
globalHooks: entries.filter((entry) => entry.scope === "global").length,
|
|
11
|
+
projectHooks: entries.filter((entry) => entry.scope === "project").length,
|
|
14
12
|
};
|
|
15
13
|
}
|
|
16
14
|
export async function runHooksCommand(args) {
|
|
@@ -20,42 +18,75 @@ export async function runHooksCommand(args) {
|
|
|
20
18
|
return;
|
|
21
19
|
}
|
|
22
20
|
const projectRoot = parseProjectRoot(rest);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const projectEntries = flattenHooks(project.hooks, "project");
|
|
21
|
+
const configDir = forgerelayConfigDir();
|
|
22
|
+
const resolution = await resolveHookConfiguration(projectRoot, {}, configDir);
|
|
23
|
+
const entries = flattenResolvedHooks(resolution);
|
|
27
24
|
if (subcommand === "list") {
|
|
28
|
-
for (const entry of
|
|
25
|
+
for (const entry of entries)
|
|
29
26
|
console.log(formatHookEntry(entry));
|
|
30
|
-
|
|
31
|
-
if (globalEntries.length === 0 && projectEntries.length === 0) {
|
|
27
|
+
if (entries.length === 0)
|
|
32
28
|
console.log("No hooks configured.");
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.error(`Project hooks diagnostic: ${project.diagnostic}`);
|
|
29
|
+
for (const diagnostic of hookErrors(resolution)) {
|
|
30
|
+
console.error(`Hooks diagnostic: ${formatDiagnostic(diagnostic)}`);
|
|
36
31
|
}
|
|
37
32
|
return;
|
|
38
33
|
}
|
|
39
34
|
if (subcommand === "check") {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
console.log(`Hooks OK: ${
|
|
35
|
+
assertHookResolutionValid(resolution);
|
|
36
|
+
const globalHooksCount = entries.filter((entry) => entry.scope === "global").length;
|
|
37
|
+
const projectHooksCount = entries.filter((entry) => entry.scope === "project").length;
|
|
38
|
+
console.log(`Hooks OK: ${globalHooksCount} global, ${projectHooksCount} project`);
|
|
44
39
|
return;
|
|
45
40
|
}
|
|
46
41
|
throw new Error(`Unknown hooks command: ${subcommand}`);
|
|
47
42
|
}
|
|
48
|
-
function
|
|
49
|
-
const
|
|
50
|
-
return
|
|
43
|
+
async function resolveHookConfiguration(projectRoot, legacyUser, configDir, sourceRuntime) {
|
|
44
|
+
const project = configDir ? await resolveProjectContext(configDir, projectRoot) : undefined;
|
|
45
|
+
return resolveHooksConfig({
|
|
46
|
+
...(configDir ? { configDir } : {}),
|
|
47
|
+
...(project ? { project } : {}),
|
|
48
|
+
projectSharedConfigDir: join(projectRoot, ".forgerelay"),
|
|
49
|
+
legacyUser,
|
|
50
|
+
...(sourceRuntime ? { sourceRuntime } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function flattenResolvedHooks(resolution) {
|
|
54
|
+
const counters = new Map();
|
|
55
|
+
return effectiveHookConfigEntries(resolution).flatMap((entry) => entry.entries.map((hook) => {
|
|
56
|
+
const scope = entry.scope === "user" ? "global" : "project";
|
|
57
|
+
const counterKey = `${scope}:${hook.event}`;
|
|
58
|
+
const index = (counters.get(counterKey) ?? 0) + 1;
|
|
59
|
+
counters.set(counterKey, index);
|
|
60
|
+
const synthetic = entry.name.startsWith("@legacy/");
|
|
61
|
+
return {
|
|
62
|
+
scope,
|
|
63
|
+
event: hook.event,
|
|
64
|
+
name: hook.name ?? (synthetic ? `${hook.event} handler ${index}` : entry.name),
|
|
65
|
+
...(hook.matcher ? { matcher: hook.matcher } : {}),
|
|
66
|
+
command: hook.command,
|
|
67
|
+
timeoutSeconds: hook.timeoutSeconds ?? 30,
|
|
68
|
+
report: hook.report ?? true,
|
|
69
|
+
};
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
function hookErrors(resolution) {
|
|
73
|
+
return resolution.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
74
|
+
}
|
|
75
|
+
function assertHookResolutionValid(resolution) {
|
|
76
|
+
const errors = hookErrors(resolution);
|
|
77
|
+
if (errors.length === 0)
|
|
78
|
+
return;
|
|
79
|
+
throw new Error(`Hook check failed: ${errors.map(formatDiagnostic).join(" | ")}`);
|
|
80
|
+
}
|
|
81
|
+
function formatDiagnostic(diagnostic) {
|
|
82
|
+
return `${diagnostic.source.location ?? diagnostic.source.id}: ${diagnostic.message}`;
|
|
51
83
|
}
|
|
52
84
|
function parseProjectRoot(args) {
|
|
53
85
|
let projectRoot = process.cwd();
|
|
54
86
|
for (let index = 0; index < args.length; index += 1) {
|
|
55
87
|
const arg = args[index];
|
|
56
|
-
if (arg !== "--project")
|
|
88
|
+
if (arg !== "--project")
|
|
57
89
|
throw new Error(`Unknown hooks option: ${arg}`);
|
|
58
|
-
}
|
|
59
90
|
const value = args[index + 1];
|
|
60
91
|
if (!value)
|
|
61
92
|
throw new Error("Usage: forgerelay hooks <list|check> [--project <path>]");
|
|
@@ -64,27 +95,6 @@ function parseProjectRoot(args) {
|
|
|
64
95
|
}
|
|
65
96
|
return projectRoot;
|
|
66
97
|
}
|
|
67
|
-
function flattenHooks(config, scope) {
|
|
68
|
-
const entries = [];
|
|
69
|
-
for (const event of HOOK_EVENTS) {
|
|
70
|
-
let handlerIndex = 0;
|
|
71
|
-
for (const rule of config[event] ?? []) {
|
|
72
|
-
for (const handler of rule.handlers) {
|
|
73
|
-
handlerIndex += 1;
|
|
74
|
-
entries.push({
|
|
75
|
-
scope,
|
|
76
|
-
event,
|
|
77
|
-
name: handler.name ?? `${event} handler ${handlerIndex}`,
|
|
78
|
-
matcher: rule.matcher,
|
|
79
|
-
command: handler.command,
|
|
80
|
-
timeoutSeconds: handler.timeoutSeconds,
|
|
81
|
-
report: handler.report,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return entries;
|
|
87
|
-
}
|
|
88
98
|
function formatHookEntry(entry) {
|
|
89
99
|
const matcher = formatMatcher(entry.matcher);
|
|
90
100
|
return `${entry.scope} ${entry.name} ${entry.event} ${matcher} timeout=${entry.timeoutSeconds}s report=${entry.report} :: ${entry.command}`;
|
package/dist/mcp/hooks/hooks.js
CHANGED
|
@@ -1,27 +1,17 @@
|
|
|
1
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
2
1
|
import { join } from "node:path";
|
|
3
2
|
import { performance } from "node:perf_hooks";
|
|
4
3
|
import { commandPreview, logEvent, workspaceLogLabel } from "../../runtime/logging/logger.js";
|
|
5
|
-
import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime
|
|
4
|
+
import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime } from "../../runtime/shell/command-shell-runtime.js";
|
|
6
5
|
import { resolveShellCommandForRuntime } from "../process/process-platform.js";
|
|
7
6
|
import { executeHookCommand, hookFailureOutput } from "./command-runner.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"ExternalMcpAfterForward",
|
|
15
|
-
"AfterFileChange",
|
|
16
|
-
"BeforeWorktreeClose",
|
|
17
|
-
"AfterWorktreeClose",
|
|
18
|
-
"SubagentStart",
|
|
19
|
-
"SubagentStop",
|
|
20
|
-
];
|
|
7
|
+
import { resolveProjectContext } from "../../workspaces/state/project-context.js";
|
|
8
|
+
import { effectiveHookConfigEntries, resolveHooksConfig } from "../../runtime/config/resolution/hooks.js";
|
|
9
|
+
import { compatibilityAllowProjectExecutionTrustPolicy, projectExecutionRequirement } from "../../runtime/security/project-execution-trust.js";
|
|
10
|
+
import { ConfigSourceRuntime } from "../../runtime/config/runtime/source-refresh.js";
|
|
11
|
+
import { HOOK_EVENTS } from "./config.js";
|
|
12
|
+
export { HOOK_EVENTS } from "./config.js";
|
|
21
13
|
const DEFAULT_HOOK_TIMEOUT_SECONDS = 30;
|
|
22
14
|
const MAX_HOOK_TIMEOUT_SECONDS = 300;
|
|
23
|
-
const PROJECT_HOOKS_PATH = join(".forgerelay", "hooks.json");
|
|
24
|
-
const PROJECT_HOOKS_DIR = join(".forgerelay", "hooks");
|
|
25
15
|
const BLOCKING_EVENTS = new Set([
|
|
26
16
|
"BeforeTool",
|
|
27
17
|
"ExternalMcpBeforeForward",
|
|
@@ -215,17 +205,64 @@ function formatHookReports(executions) {
|
|
|
215
205
|
}),
|
|
216
206
|
].join("\n");
|
|
217
207
|
}
|
|
208
|
+
export async function resolveHookExecutionPlan(input) {
|
|
209
|
+
let projectRoot = input.event === "AfterWorktreeClose" && input.invocation.sourceRoot
|
|
210
|
+
? input.invocation.sourceRoot
|
|
211
|
+
: input.invocation.workspaceRoot;
|
|
212
|
+
let project = input.project;
|
|
213
|
+
if (!project && input.configDir) {
|
|
214
|
+
try {
|
|
215
|
+
project = await resolveProjectContext(input.configDir, projectRoot);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
const sourceRoot = input.invocation.sourceRoot;
|
|
219
|
+
if (!sourceRoot || projectRoot === sourceRoot || !isMissingPath(error))
|
|
220
|
+
throw error;
|
|
221
|
+
projectRoot = sourceRoot;
|
|
222
|
+
project = await resolveProjectContext(input.configDir, projectRoot);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const resolution = await resolveHooksConfig({
|
|
226
|
+
...(input.configDir ? { configDir: input.configDir } : {}),
|
|
227
|
+
...(project ? { project } : {}),
|
|
228
|
+
projectSharedConfigDir: join(projectRoot, ".forgerelay"),
|
|
229
|
+
legacyUser: input.legacyUser, ...(input.sourceRuntime ? { sourceRuntime: input.sourceRuntime } : {}),
|
|
230
|
+
});
|
|
231
|
+
const handlers = effectiveHookConfigEntries(resolution).flatMap((entry) => entry.entries.flatMap((hook) => {
|
|
232
|
+
if (hook.event !== input.event)
|
|
233
|
+
return [];
|
|
234
|
+
const matchedInvocation = matchHookRule(hook.matcher, input.invocation);
|
|
235
|
+
if (!matchedInvocation)
|
|
236
|
+
return [];
|
|
237
|
+
const requirement = project ? projectExecutionRequirement({
|
|
238
|
+
projectId: project.id, resolution, entryKey: `hooks.${entry.name}`,
|
|
239
|
+
display: { kind: "hook", name: entry.name },
|
|
240
|
+
}) : undefined;
|
|
241
|
+
return [{
|
|
242
|
+
scope: entry.scope === "user" ? "global" : "project",
|
|
243
|
+
handler: resolvedHookHandler(entry.name, hook), invocation: matchedInvocation,
|
|
244
|
+
...(requirement ? { executionRequirement: requirement } : {}),
|
|
245
|
+
}];
|
|
246
|
+
}));
|
|
247
|
+
return { handlers, resolution };
|
|
248
|
+
}
|
|
218
249
|
export class HookRunner {
|
|
219
250
|
hooks;
|
|
220
251
|
logging;
|
|
221
252
|
baseEnv;
|
|
222
253
|
resultDecorator;
|
|
254
|
+
configDir;
|
|
255
|
+
sourceRuntime;
|
|
256
|
+
projectExecutionTrustPolicy;
|
|
223
257
|
commandShellRuntime;
|
|
224
|
-
constructor(hooks, logging, baseEnv = process.env, resultDecorator, commandShellRuntime) {
|
|
258
|
+
constructor(hooks, logging, baseEnv = process.env, resultDecorator, commandShellRuntime, configDir, sourceRuntime = new ConfigSourceRuntime(), projectExecutionTrustPolicy = compatibilityAllowProjectExecutionTrustPolicy) {
|
|
225
259
|
this.hooks = hooks;
|
|
226
260
|
this.logging = logging;
|
|
227
261
|
this.baseEnv = baseEnv;
|
|
228
262
|
this.resultDecorator = resultDecorator;
|
|
263
|
+
this.configDir = configDir;
|
|
264
|
+
this.sourceRuntime = sourceRuntime;
|
|
265
|
+
this.projectExecutionTrustPolicy = projectExecutionTrustPolicy;
|
|
229
266
|
this.commandShellRuntime = snapshotCommandShellRuntime(commandShellRuntime ?? resolveCompatibilityCommandShellRuntime(process.platform, baseEnv));
|
|
230
267
|
}
|
|
231
268
|
decorateResult(workspaceId, result) {
|
|
@@ -233,34 +270,18 @@ export class HookRunner {
|
|
|
233
270
|
}
|
|
234
271
|
async run(event, invocation, signal) {
|
|
235
272
|
signal?.throwIfAborted();
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
|
|
242
|
-
...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
|
|
243
|
-
].flatMap(({ scope, rule }) => {
|
|
244
|
-
const matchedInvocation = matchHookRule(rule.matcher, invocation);
|
|
245
|
-
if (!matchedInvocation)
|
|
246
|
-
return [];
|
|
247
|
-
return rule.handlers.map((handler) => ({ scope, handler, invocation: matchedInvocation }));
|
|
273
|
+
const plan = await resolveHookExecutionPlan({
|
|
274
|
+
event,
|
|
275
|
+
invocation,
|
|
276
|
+
legacyUser: this.hooks,
|
|
277
|
+
...(this.configDir ? { configDir: this.configDir } : {}), sourceRuntime: this.sourceRuntime,
|
|
248
278
|
});
|
|
249
279
|
const blocking = BLOCKING_EVENTS.has(event);
|
|
250
|
-
const executions =
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
name: "Project hooks config",
|
|
254
|
-
scope: "project",
|
|
255
|
-
status: "failed",
|
|
256
|
-
durationMs: 0,
|
|
257
|
-
report: true,
|
|
258
|
-
error: project.diagnostic,
|
|
259
|
-
}]
|
|
260
|
-
: [];
|
|
261
|
-
for (const [index, { scope, handler, invocation: matchedInvocation }] of handlers.entries()) {
|
|
280
|
+
const executions = hookResolutionReports(plan.resolution, event);
|
|
281
|
+
const handlers = plan.handlers;
|
|
282
|
+
for (const [index, { scope, handler, invocation: matchedInvocation, executionRequirement }] of handlers.entries()) {
|
|
262
283
|
signal?.throwIfAborted();
|
|
263
|
-
const execution = await this.runHandler(event, handler, index, matchedInvocation, scope, signal);
|
|
284
|
+
const execution = await this.runHandler(event, handler, index, matchedInvocation, scope, executionRequirement, signal);
|
|
264
285
|
executions.push(execution);
|
|
265
286
|
logEvent(this.logging, execution.status === "passed" ? "info" : "warn", "hook_call", {
|
|
266
287
|
hookEvent: event,
|
|
@@ -281,13 +302,15 @@ export class HookRunner {
|
|
|
281
302
|
}
|
|
282
303
|
return executions;
|
|
283
304
|
}
|
|
284
|
-
async runHandler(event, handler, index, invocation, scope, signal) {
|
|
305
|
+
async runHandler(event, handler, index, invocation, scope, executionRequirement, signal) {
|
|
285
306
|
const startedAt = performance.now();
|
|
286
307
|
const name = handler.name ?? `${event} handler ${index + 1}`;
|
|
287
308
|
const shell = resolveShellCommandForRuntime(handler.command, this.commandShellRuntime);
|
|
288
309
|
const detached = process.platform !== "win32";
|
|
289
310
|
const env = hookEnvironment(this.baseEnv, event, invocation);
|
|
290
311
|
try {
|
|
312
|
+
if (executionRequirement)
|
|
313
|
+
await this.projectExecutionTrustPolicy.authorize(executionRequirement);
|
|
291
314
|
const result = await executeHookCommand({
|
|
292
315
|
executable: shell.executable,
|
|
293
316
|
args: shell.args,
|
|
@@ -340,12 +363,54 @@ export class HookRunner {
|
|
|
340
363
|
}
|
|
341
364
|
}
|
|
342
365
|
}
|
|
366
|
+
function resolvedHookHandler(name, hook) {
|
|
367
|
+
const legacySyntheticName = name.startsWith("@legacy/");
|
|
368
|
+
return {
|
|
369
|
+
...((hook.name ?? (!legacySyntheticName ? name : undefined))
|
|
370
|
+
? { name: hook.name ?? name }
|
|
371
|
+
: {}),
|
|
372
|
+
command: hook.command,
|
|
373
|
+
timeoutSeconds: hook.timeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_SECONDS,
|
|
374
|
+
report: hook.report ?? true,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function hookResolutionReports(resolution, event) {
|
|
378
|
+
const grouped = new Map();
|
|
379
|
+
for (const diagnostic of resolution.diagnostics) {
|
|
380
|
+
if (diagnostic.severity !== "error" || diagnostic.diagnosticChanged === false)
|
|
381
|
+
continue;
|
|
382
|
+
const scope = diagnostic.source.scope === "user"
|
|
383
|
+
? "global"
|
|
384
|
+
: diagnostic.source.scope === "project" || diagnostic.source.scope === "project-local"
|
|
385
|
+
? "project"
|
|
386
|
+
: undefined;
|
|
387
|
+
if (!scope)
|
|
388
|
+
continue;
|
|
389
|
+
const location = diagnostic.source.location ?? diagnostic.source.id;
|
|
390
|
+
grouped.set(scope, [...(grouped.get(scope) ?? []), `${location}: ${diagnostic.message}`]);
|
|
391
|
+
}
|
|
392
|
+
return ["global", "project"].flatMap((scope) => {
|
|
393
|
+
const errors = grouped.get(scope);
|
|
394
|
+
if (!errors?.length)
|
|
395
|
+
return [];
|
|
396
|
+
return [{
|
|
397
|
+
event,
|
|
398
|
+
name: scope === "global" ? "Global hooks config" : "Project hooks config",
|
|
399
|
+
scope,
|
|
400
|
+
status: "failed",
|
|
401
|
+
durationMs: 0,
|
|
402
|
+
report: true,
|
|
403
|
+
error: errors.join(" | "),
|
|
404
|
+
}];
|
|
405
|
+
});
|
|
406
|
+
}
|
|
343
407
|
function parseHookRule(event, value, index) {
|
|
344
408
|
if (!isRecord(value)) {
|
|
345
409
|
throw new Error(`Hook ${event} entry ${index + 1} must be an object`);
|
|
346
410
|
}
|
|
347
411
|
if (!("handlers" in value)) {
|
|
348
412
|
return {
|
|
413
|
+
matcher: parseHookMatcher(event, value.matcher, index),
|
|
349
414
|
handlers: [parseHookHandler(event, value, index)],
|
|
350
415
|
};
|
|
351
416
|
}
|
|
@@ -467,45 +532,6 @@ function assertValidRegex(event, field, pattern) {
|
|
|
467
532
|
throw new Error(`Hook ${event} matcher ${field} must be a valid regular expression`);
|
|
468
533
|
}
|
|
469
534
|
}
|
|
470
|
-
export async function loadProjectHookConfig(workspaceRoot) {
|
|
471
|
-
let hooks = {};
|
|
472
|
-
const diagnostics = [];
|
|
473
|
-
const aggregatePath = join(workspaceRoot, PROJECT_HOOKS_PATH);
|
|
474
|
-
try {
|
|
475
|
-
const content = await readFile(aggregatePath, "utf8");
|
|
476
|
-
hooks = mergeHookConfigs(hooks, parseHookConfig(JSON.parse(content)));
|
|
477
|
-
}
|
|
478
|
-
catch (error) {
|
|
479
|
-
if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
|
|
480
|
-
diagnostics.push(`Could not load project hooks at ${aggregatePath}: ${errorMessage(error)}`);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
const directory = join(workspaceRoot, PROJECT_HOOKS_DIR);
|
|
484
|
-
try {
|
|
485
|
-
const entries = (await readdir(directory, { withFileTypes: true }))
|
|
486
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
487
|
-
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
488
|
-
for (const entry of entries) {
|
|
489
|
-
const path = join(directory, entry.name);
|
|
490
|
-
try {
|
|
491
|
-
const value = JSON.parse(await readFile(path, "utf8"));
|
|
492
|
-
hooks = mergeHookConfigs(hooks, parseHookFile(value, entry.name.slice(0, -5)));
|
|
493
|
-
}
|
|
494
|
-
catch (error) {
|
|
495
|
-
diagnostics.push(`Could not load project hook at ${path}: ${errorMessage(error)}`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
catch (error) {
|
|
500
|
-
if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
|
|
501
|
-
diagnostics.push(`Could not read project hook directory at ${directory}: ${errorMessage(error)}`);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
return {
|
|
505
|
-
hooks,
|
|
506
|
-
...(diagnostics.length > 0 ? { diagnostic: diagnostics.join(" | ") } : {}),
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
535
|
export function matchHookRule(matcher, invocation) {
|
|
510
536
|
if (!matcher)
|
|
511
537
|
return invocation;
|
|
@@ -580,8 +606,8 @@ function hookEnvironment(baseEnv, event, invocation) {
|
|
|
580
606
|
function errorMessage(error) {
|
|
581
607
|
return error instanceof Error ? error.message : String(error);
|
|
582
608
|
}
|
|
583
|
-
function
|
|
584
|
-
return error instanceof Error && "code" in error;
|
|
609
|
+
function isMissingPath(error) {
|
|
610
|
+
return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
585
611
|
}
|
|
586
612
|
function isRecord(value) {
|
|
587
613
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -2,7 +2,7 @@ import { ExternalMcpConfigRegistry } from "../../../runtime/config/external-mcp-
|
|
|
2
2
|
import { ExternalMcpCredentialStore } from "../../../runtime/config/external-mcp-auth-store.js";
|
|
3
3
|
import { logEvent } from "../../../runtime/logging/logger.js";
|
|
4
4
|
import { CapabilityError } from "../../server/core/capability-registry.js";
|
|
5
|
-
import { requireCapabilityWorkspaceRoot } from "../../server/core/capability-support.js";
|
|
5
|
+
import { requireCapabilityProject, requireCapabilityWorkspaceRoot, } from "../../server/core/capability-support.js";
|
|
6
6
|
import { ExternalMcpTransformError, ExternalMcpTransformRunner, } from "../../hooks/external-mcp-transform.js";
|
|
7
7
|
import { ExternalMcpError, ExternalMcpGateway } from "./external-mcp.js";
|
|
8
8
|
export function createExternalMcpCapabilityRuntime(config) {
|
|
@@ -10,24 +10,26 @@ export function createExternalMcpCapabilityRuntime(config) {
|
|
|
10
10
|
const externalMcp = new ExternalMcpGateway(config.mediaMaxBytes, credentialStore);
|
|
11
11
|
const registry = new ExternalMcpConfigRegistry({
|
|
12
12
|
configDir: config.configDir,
|
|
13
|
-
|
|
13
|
+
sourceRuntime: config.configRuntime.sources,
|
|
14
14
|
onDiagnostic: (diagnostic) => logEvent(config.logging, "warn", "external_mcp_config_invalid", {
|
|
15
15
|
source: diagnostic.source,
|
|
16
16
|
path: diagnostic.path,
|
|
17
17
|
reason: diagnostic.message,
|
|
18
18
|
}),
|
|
19
19
|
});
|
|
20
|
-
const transforms = new ExternalMcpTransformRunner(
|
|
20
|
+
const transforms = new ExternalMcpTransformRunner({}, config.logging, process.env, config.commandShellRuntime, config.mediaMaxBytes, config.configDir, config.configRuntime.sources);
|
|
21
21
|
return {
|
|
22
22
|
available: true,
|
|
23
23
|
run: async (input, context, runOptions) => {
|
|
24
24
|
try {
|
|
25
25
|
const workspaceRoot = requireCapabilityWorkspaceRoot(context);
|
|
26
|
-
const
|
|
26
|
+
const project = requireCapabilityProject(context);
|
|
27
|
+
const snapshot = registry.resolve(project);
|
|
27
28
|
const transformContext = (server, tool) => ({
|
|
28
29
|
workspaceId: context.workspaceId,
|
|
29
30
|
workspaceRoot,
|
|
30
31
|
workspaceMode: context.workspaceMode,
|
|
32
|
+
project,
|
|
31
33
|
server,
|
|
32
34
|
tool,
|
|
33
35
|
});
|
|
@@ -37,8 +39,9 @@ export function createExternalMcpCapabilityRuntime(config) {
|
|
|
37
39
|
result: (server, tool, externalResult) => transforms.transformResult(transformContext(server, tool), externalResult, runOptions.signal),
|
|
38
40
|
}
|
|
39
41
|
: undefined, {
|
|
40
|
-
|
|
42
|
+
project: { id: project.id, projectRoot: project.projectRoot },
|
|
41
43
|
origins: snapshot.origins,
|
|
44
|
+
executionRequirements: snapshot.executionRequirements,
|
|
42
45
|
});
|
|
43
46
|
return {
|
|
44
47
|
value: result.value,
|
|
@@ -3,6 +3,7 @@ import { AuthorizationServerMismatchError, Client, InsufficientScopeError, SdkHt
|
|
|
3
3
|
import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/client/stdio";
|
|
4
4
|
import { CallToolResultSchema } from "@modelcontextprotocol/core";
|
|
5
5
|
import { externalMcpCredentialIdentity, } from "../../../runtime/config/external-mcp-auth-store.js";
|
|
6
|
+
import { compatibilityAllowProjectExecutionTrustPolicy, } from "../../../runtime/security/project-execution-trust.js";
|
|
6
7
|
import { claimMediaBytes, createMediaBudget, isSupportedImageMimeType, strictBase64ByteLength, } from "../media-content.js";
|
|
7
8
|
import { ExternalMcpTransformError, } from "../../hooks/external-mcp-transform.js";
|
|
8
9
|
import { ExternalMcpOAuthError, createExternalMcpRuntimeAuth, externalMcpAuthError, markExternalMcpAuthorizationRequired, markExternalMcpReauthorization, } from "./external-mcp-oauth.js";
|
|
@@ -24,10 +25,12 @@ export class ExternalMcpError extends Error {
|
|
|
24
25
|
export class ExternalMcpGateway {
|
|
25
26
|
mediaMaxBytes;
|
|
26
27
|
credentialStore;
|
|
28
|
+
projectExecutionTrustPolicy;
|
|
27
29
|
negotiationCache = new Map();
|
|
28
|
-
constructor(mediaMaxBytes, credentialStore) {
|
|
30
|
+
constructor(mediaMaxBytes, credentialStore, projectExecutionTrustPolicy = compatibilityAllowProjectExecutionTrustPolicy) {
|
|
29
31
|
this.mediaMaxBytes = mediaMaxBytes;
|
|
30
32
|
this.credentialStore = credentialStore;
|
|
33
|
+
this.projectExecutionTrustPolicy = projectExecutionTrustPolicy;
|
|
31
34
|
}
|
|
32
35
|
async run(servers, input, signal, transforms, authContext) {
|
|
33
36
|
signal?.throwIfAborted();
|
|
@@ -121,6 +124,10 @@ export class ExternalMcpGateway {
|
|
|
121
124
|
throw new ExternalMcpError("unknown_server", `Unknown configured external MCP server: ${name}.`);
|
|
122
125
|
const fingerprint = externalMcpServerFingerprint(config);
|
|
123
126
|
const runtimeAuth = this.resolveRuntimeAuth(name, config, authContext);
|
|
127
|
+
const requirement = authContext?.executionRequirements?.[name];
|
|
128
|
+
if (config.transport === "stdio" && requirement) {
|
|
129
|
+
await this.projectExecutionTrustPolicy.authorize(requirement);
|
|
130
|
+
}
|
|
124
131
|
const client = new Client({ name: "forgerelay-external-mcp", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } });
|
|
125
132
|
const transport = createTransport(config, runtimeAuth?.authProvider);
|
|
126
133
|
const prior = this.cachedPrior(name, fingerprint);
|
|
@@ -169,7 +176,7 @@ export class ExternalMcpGateway {
|
|
|
169
176
|
const source = context.origins[name];
|
|
170
177
|
if (!source)
|
|
171
178
|
return undefined;
|
|
172
|
-
const identity = externalMcpCredentialIdentity(source, name, context.
|
|
179
|
+
const identity = externalMcpCredentialIdentity(source, name, context.project);
|
|
173
180
|
return createExternalMcpRuntimeAuth(this.credentialStore, identity, config.url);
|
|
174
181
|
}
|
|
175
182
|
cachedPrior(name, fingerprint) {
|
|
@@ -16,6 +16,7 @@ export function capabilityContextFor(workspace) {
|
|
|
16
16
|
workspaceRoot: workspace.root,
|
|
17
17
|
workspaceMode: workspace.mode,
|
|
18
18
|
workspaceManaged: workspace.worktree?.managed ?? false,
|
|
19
|
+
...(workspace.project ? { project: workspace.project } : {}),
|
|
19
20
|
guides: workspace.capabilityGuides.map((guide) => ({
|
|
20
21
|
name: guide.name,
|
|
21
22
|
description: guide.description,
|
|
@@ -56,6 +57,12 @@ export function requireCapabilityWorkspaceRoot(context) {
|
|
|
56
57
|
}
|
|
57
58
|
return context.workspaceRoot;
|
|
58
59
|
}
|
|
60
|
+
export function requireCapabilityProject(context) {
|
|
61
|
+
if (!context.project) {
|
|
62
|
+
throw new CapabilityError("capability_unavailable", `Capability execution requires Project identity for Workspace ${context.workspaceId}.`);
|
|
63
|
+
}
|
|
64
|
+
return context.project;
|
|
65
|
+
}
|
|
59
66
|
export function runWorkspaceTasksCapability(store, workspaceId, input) {
|
|
60
67
|
switch (input.operation) {
|
|
61
68
|
case "get":
|