@akira-tl/forgerelay 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/cli/init.js +72 -1
  4. package/dist/cli/shell/setup.js +99 -0
  5. package/dist/cli.js +43 -10
  6. package/dist/mcp/hooks/hooks.js +6 -3
  7. package/dist/mcp/process/process-platform.js +24 -26
  8. package/dist/mcp/process/process-sessions.js +6 -3
  9. package/dist/mcp/server/transport/http-server.js +4 -1
  10. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  11. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  12. package/dist/mcp/server-instructions.js +10 -1
  13. package/dist/runtime/config/config.js +6 -0
  14. package/dist/runtime/instructions/shell-instructions.js +100 -0
  15. package/dist/runtime/security/runtime-privilege.js +120 -0
  16. package/dist/runtime/shell/command-shell-runtime.js +235 -0
  17. package/dist/ui/.vite/manifest.json +276 -276
  18. package/dist/ui/activity-panel-app.html +2 -2
  19. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  20. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  21. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  22. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  23. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  24. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  25. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  26. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  27. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  28. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  29. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  30. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  31. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  32. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  33. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  34. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  35. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  36. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  37. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  38. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  39. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  40. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  41. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  42. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  43. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  44. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  45. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  46. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  47. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  48. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  49. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  50. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  51. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  52. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  53. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  54. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  55. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  56. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  57. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  58. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  59. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  60. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  61. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  62. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  63. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  64. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  65. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  66. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  67. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  68. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  69. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  70. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  71. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  72. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  73. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  74. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  75. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  76. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  77. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  78. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  79. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  80. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  81. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  82. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  83. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  84. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  85. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  86. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  87. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  88. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  89. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  90. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  91. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  92. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  93. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  94. package/dist/workspaces/context.js +32 -5
  95. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  96. package/dist/workspaces/sessions.js +2 -0
  97. package/dist/workspaces.js +2 -1
  98. package/docs/configuration.md +1 -1
  99. package/package.json +1 -1
  100. package/scripts/release/release-gate.test.mjs +1 -0
  101. package/scripts/release-proof.mjs +85 -9
  102. package/scripts/release-proof.test.mjs +79 -1
@@ -0,0 +1,100 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ const TEMPLATE_REPOSITORY_RAW = "https://raw.githubusercontent.com/Akira-TL/forgerelay";
5
+ const TEMPLATE_DIRECTORY = "templates/instructions";
6
+ export function shellInstructionFamily(family) {
7
+ if (family === "pwsh" || family === "powershell" || family === "cmd" || family === "zsh" || family === "fish") {
8
+ return family;
9
+ }
10
+ return undefined;
11
+ }
12
+ export function shellInstructionPath(configDir, family) {
13
+ const instructionFamily = shellInstructionFamily(family);
14
+ return instructionFamily ? join(resolve(configDir), "instructions", `${instructionFamily}.md`) : undefined;
15
+ }
16
+ export function shellInstructionFamiliesToSeed(platform, selectedFamily) {
17
+ const selectedPosixGuidance = selectedFamily === "zsh" || selectedFamily === "fish"
18
+ ? [selectedFamily]
19
+ : [];
20
+ if (platform === "win32")
21
+ return ["pwsh", "powershell", "cmd", ...selectedPosixGuidance];
22
+ return selectedPosixGuidance;
23
+ }
24
+ export function shellInstructionTemplateUrl(version, family) {
25
+ const normalizedVersion = version.trim().replace(/^v/, "");
26
+ if (!normalizedVersion)
27
+ throw new Error("ForgeRelay version is required to fetch shell Instructions.");
28
+ return `${TEMPLATE_REPOSITORY_RAW}/v${encodeURIComponent(normalizedVersion)}/${TEMPLATE_DIRECTORY}/${family}.md`;
29
+ }
30
+ export async function seedShellInstructionFiles(options) {
31
+ const instructionsDir = join(resolve(options.configDir), "instructions");
32
+ await mkdir(instructionsDir, { recursive: true, mode: 0o700 });
33
+ const fetchText = options.fetchText ?? fetchInstructionText;
34
+ const uniqueFamilies = [...new Set(options.families)];
35
+ const results = [];
36
+ for (const family of uniqueFamilies) {
37
+ const path = join(instructionsDir, `${family}.md`);
38
+ const sourceUrl = shellInstructionTemplateUrl(options.version, family);
39
+ if (!options.reset && await fileExists(path)) {
40
+ results.push({ family, path, status: "preserved", sourceUrl });
41
+ continue;
42
+ }
43
+ try {
44
+ const content = await fetchText(sourceUrl);
45
+ if (!content.trim())
46
+ throw new Error("template response was empty");
47
+ await writeFile(path, content.endsWith("\n") ? content : `${content}\n`, {
48
+ encoding: "utf8",
49
+ mode: 0o600,
50
+ flag: options.reset ? "w" : "wx",
51
+ });
52
+ results.push({ family, path, status: "created", sourceUrl });
53
+ }
54
+ catch (error) {
55
+ if (!options.reset && await fileExists(path)) {
56
+ results.push({ family, path, status: "preserved", sourceUrl });
57
+ continue;
58
+ }
59
+ results.push({
60
+ family,
61
+ path,
62
+ status: "failed",
63
+ sourceUrl,
64
+ error: error instanceof Error ? error.message : String(error),
65
+ });
66
+ }
67
+ }
68
+ return results;
69
+ }
70
+ export async function readShellInstruction(path) {
71
+ try {
72
+ return await readFile(path, "utf8");
73
+ }
74
+ catch (error) {
75
+ if (isMissingFile(error))
76
+ return undefined;
77
+ throw error;
78
+ }
79
+ }
80
+ async function fetchInstructionText(url) {
81
+ const response = await fetch(url, {
82
+ headers: { "user-agent": "forgerelay-shell-instructions" },
83
+ });
84
+ if (!response.ok) {
85
+ throw new Error(`HTTP ${response.status} while fetching ${url}`);
86
+ }
87
+ return response.text();
88
+ }
89
+ async function fileExists(path) {
90
+ try {
91
+ await access(path, fsConstants.F_OK);
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ function isMissingFile(error) {
99
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
100
+ }
@@ -0,0 +1,120 @@
1
+ import { spawnSync } from "node:child_process";
2
+ const WINDOWS_ELEVATED_INTEGRITY_SIDS = [
3
+ "S-1-16-12288", // High Mandatory Level
4
+ "S-1-16-16384", // System Mandatory Level
5
+ "S-1-16-20480", // Protected Process Mandatory Level
6
+ ];
7
+ const WINDOWS_LOCAL_SYSTEM_SID = "S-1-5-18";
8
+ export function detectRuntimePrivilege(dependencies = {}) {
9
+ const platform = dependencies.platform ?? process.platform;
10
+ if (platform === "win32") {
11
+ return detectWindowsRuntimePrivilege(dependencies.runWindowsWhoami ?? runWindowsWhoami);
12
+ }
13
+ if (platform === "linux" || platform === "darwin") {
14
+ const geteuid = dependencies.geteuid ?? process.geteuid?.bind(process);
15
+ if (!geteuid) {
16
+ return {
17
+ level: "unknown",
18
+ platform,
19
+ source: "unsupported",
20
+ detail: "effective user id is unavailable",
21
+ };
22
+ }
23
+ return {
24
+ level: geteuid() === 0 ? "elevated" : "standard",
25
+ platform,
26
+ source: "posix-euid",
27
+ };
28
+ }
29
+ return {
30
+ level: "unknown",
31
+ platform,
32
+ source: "unsupported",
33
+ detail: `privilege detection is not implemented for ${platform}`,
34
+ };
35
+ }
36
+ export function assertRuntimePrivilegeAllowed(state, allowElevated) {
37
+ if (state.level === "standard" || allowElevated)
38
+ return;
39
+ const detected = state.level === "elevated"
40
+ ? "ForgeRelay detected elevated operating-system privileges."
41
+ : `ForgeRelay could not safely determine whether this process is elevated${state.detail ? ` (${state.detail})` : ""}.`;
42
+ throw new Error([
43
+ detected,
44
+ "ForgeRelay refuses to start its Agent/Hook runtime with system-level or unknown privilege by default because commands may make system-wide or irreversible changes.",
45
+ "",
46
+ "Run ForgeRelay as a normal user, or if elevated execution is intentional, acknowledge the risk for this invocation:",
47
+ " forgerelay serve --allow-elevated",
48
+ "",
49
+ "The --allow-elevated acknowledgement is invocation-scoped and is not a persistent configuration setting.",
50
+ ].join("\n"));
51
+ }
52
+ export function formatRuntimePrivilege(state) {
53
+ const detail = state.detail ? ` (${state.detail})` : "";
54
+ return `${state.level}${detail}`;
55
+ }
56
+ export function elevatedRuntimeWarning(state) {
57
+ const status = state.level === "elevated"
58
+ ? "elevated operating-system privileges"
59
+ : state.level === "unknown"
60
+ ? "an unverified operating-system privilege level"
61
+ : "standard operating-system privileges";
62
+ return [
63
+ `WARNING: --allow-elevated was supplied and ForgeRelay is starting with ${status}.`,
64
+ "Agent and Hook commands run with the ForgeRelay process authority and may make system-wide or irreversible changes.",
65
+ "Continue only when this level of operating-system access is intentional.",
66
+ ].join("\n");
67
+ }
68
+ function detectWindowsRuntimePrivilege(runWhoami) {
69
+ const user = runWhoami(["/user", "/fo", "csv", "/nh"]);
70
+ const groups = runWhoami(["/groups", "/fo", "csv", "/nh"]);
71
+ if (user.status !== 0 || groups.status !== 0) {
72
+ const reason = [user.stderr, groups.stderr]
73
+ .map((value) => value.trim())
74
+ .filter(Boolean)
75
+ .join("; ");
76
+ return {
77
+ level: "unknown",
78
+ platform: "win32",
79
+ source: "windows-token",
80
+ detail: reason || "whoami.exe could not inspect the current process token",
81
+ };
82
+ }
83
+ if (containsSid(user.stdout, WINDOWS_LOCAL_SYSTEM_SID)) {
84
+ return {
85
+ level: "elevated",
86
+ platform: "win32",
87
+ source: "windows-token",
88
+ detail: "LocalSystem token",
89
+ };
90
+ }
91
+ const elevatedIntegritySid = WINDOWS_ELEVATED_INTEGRITY_SIDS.find((sid) => containsSid(groups.stdout, sid));
92
+ if (elevatedIntegritySid) {
93
+ return {
94
+ level: "elevated",
95
+ platform: "win32",
96
+ source: "windows-token",
97
+ detail: `integrity ${elevatedIntegritySid}`,
98
+ };
99
+ }
100
+ return {
101
+ level: "standard",
102
+ platform: "win32",
103
+ source: "windows-token",
104
+ };
105
+ }
106
+ function containsSid(output, sid) {
107
+ const escaped = sid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ return new RegExp(`(?:^|[^0-9-])${escaped}(?:$|[^0-9-])`, "i").test(output);
109
+ }
110
+ function runWindowsWhoami(args) {
111
+ const result = spawnSync("whoami.exe", args, {
112
+ encoding: "utf8",
113
+ windowsHide: true,
114
+ });
115
+ return {
116
+ status: result.status,
117
+ stdout: result.stdout ?? "",
118
+ stderr: result.stderr ?? result.error?.message ?? "",
119
+ };
120
+ }
@@ -0,0 +1,235 @@
1
+ import { existsSync, readlinkSync } from "node:fs";
2
+ import { isAbsolute, resolve, win32 } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ const POSIX_SH_NAMES = new Set(["ash", "dash", "ksh", "sh"]);
5
+ export function resolveCommandShellRuntime(input) {
6
+ const selected = input.explicit
7
+ ? { source: "explicit", value: input.explicit }
8
+ : input.launcher
9
+ ? { source: "launcher", value: input.launcher }
10
+ : input.recordedFallback
11
+ ? { source: "recorded", value: input.recordedFallback }
12
+ : undefined;
13
+ if (!selected) {
14
+ throw new Error("Unable to determine ForgeRelay command shell. Configure a shell explicitly or run `forgerelay init` to record a fallback for non-interactive launches.");
15
+ }
16
+ return runtimeFromSelection(selected.value, selected.source);
17
+ }
18
+ /**
19
+ * Preserve the pre-P3 execution contract while the owner-facing init/config
20
+ * surface is introduced incrementally. This is deliberately separate from the
21
+ * strict precedence resolver above: new configuration should call
22
+ * resolveCommandShellRuntime instead of inventing another fallback.
23
+ */
24
+ export function resolveConfiguredCommandShellRuntime(preference, platform = process.platform, environment = process.env) {
25
+ const legacyExplicit = environment.FORGERELAY_COMMAND_SHELL?.trim();
26
+ if (legacyExplicit) {
27
+ const explicit = normalizeCommandShellSelection({ executable: legacyExplicit }, platform, environment, true);
28
+ return resolveCommandShellRuntime({ explicit });
29
+ }
30
+ if (!preference)
31
+ return resolveCompatibilityCommandShellRuntime(platform, environment);
32
+ if (preference.mode === "pinned") {
33
+ const explicit = normalizeCommandShellSelection(preference, platform, environment, true);
34
+ return resolveCommandShellRuntime({ explicit });
35
+ }
36
+ const launcher = detectLauncherCommandShell({ platform, environment });
37
+ if (launcher)
38
+ return resolveCommandShellRuntime({ launcher });
39
+ const recordedFallback = normalizeCommandShellSelection(preference, platform, environment, true);
40
+ return resolveCommandShellRuntime({ recordedFallback });
41
+ }
42
+ export function resolveCompatibilityCommandShellRuntime(platform = process.platform, environment = process.env) {
43
+ if (platform === "win32") {
44
+ return runtimeFromSelection({
45
+ family: "cmd",
46
+ executable: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
47
+ }, "compatibility-default");
48
+ }
49
+ const configured = environment.FORGERELAY_COMMAND_SHELL?.trim();
50
+ if (configured) {
51
+ return runtimeFromSelection({ executable: configured }, "explicit");
52
+ }
53
+ if (platform === "linux" || platform === "darwin") {
54
+ return runtimeFromSelection({ family: "bash", executable: "/bin/bash" }, "compatibility-default");
55
+ }
56
+ return runtimeFromSelection({ family: "sh", executable: "/bin/sh" }, "compatibility-default");
57
+ }
58
+ export function detectLauncherCommandShell(dependencies = {}) {
59
+ const platform = dependencies.platform ?? process.platform;
60
+ const environment = dependencies.environment ?? process.env;
61
+ // npm lifecycle scripts insert their own /bin/sh or cmd.exe. Treating that
62
+ // wrapper as the user's launcher would recreate the npm-script-shell bug P3
63
+ // is specifically intended to avoid. The recorded fallback is safer here.
64
+ if (environment.npm_lifecycle_event)
65
+ return undefined;
66
+ const ppid = dependencies.ppid ?? process.ppid;
67
+ if (!Number.isInteger(ppid) || ppid < 1)
68
+ return undefined;
69
+ const parentExecutable = (dependencies.parentExecutable ?? defaultParentExecutable)(platform, ppid);
70
+ if (!parentExecutable)
71
+ return undefined;
72
+ const family = inferCommandShellFamily(parentExecutable);
73
+ if (!family)
74
+ return undefined;
75
+ return {
76
+ family,
77
+ executable: resolveExecutablePath(parentExecutable, platform, environment),
78
+ };
79
+ }
80
+ export function normalizeCommandShellSelection(selection, platform = process.platform, environment = process.env, requireAvailable = false) {
81
+ const executable = resolveExecutablePath(selection.executable, platform, environment);
82
+ if (requireAvailable && !executableExists(executable, platform, environment)) {
83
+ throw new Error(`Configured command-shell executable is unavailable: ${selection.executable}. Choose an installed shell or update the explicit executable path.`);
84
+ }
85
+ return { ...selection, executable };
86
+ }
87
+ export function defaultCommandShellSelection(family, platform = process.platform, environment = process.env) {
88
+ const candidate = family === "bash" && platform !== "win32"
89
+ ? "/bin/bash"
90
+ : family === "sh" && platform !== "win32"
91
+ ? "/bin/sh"
92
+ : family === "cmd"
93
+ ? environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe"
94
+ : family === "powershell"
95
+ ? "powershell.exe"
96
+ : family;
97
+ return normalizeCommandShellSelection({ family, executable: candidate }, platform, environment);
98
+ }
99
+ export function inferCommandShellFamily(executable) {
100
+ const name = executableBasename(executable).toLowerCase().replace(/\.exe$/i, "");
101
+ if (name === "bash")
102
+ return "bash";
103
+ if (name === "zsh")
104
+ return "zsh";
105
+ if (name === "fish")
106
+ return "fish";
107
+ if (POSIX_SH_NAMES.has(name))
108
+ return "sh";
109
+ if (name === "pwsh")
110
+ return "pwsh";
111
+ if (name === "powershell")
112
+ return "powershell";
113
+ if (name === "cmd")
114
+ return "cmd";
115
+ return undefined;
116
+ }
117
+ export function formatCommandShellRuntime(runtime) {
118
+ const version = runtime.version ? ` ${runtime.version}` : "";
119
+ return `${runtime.family}${version} (${runtime.executable}; ${runtime.source})`;
120
+ }
121
+ export function snapshotCommandShellRuntime(runtime) {
122
+ return { ...runtime, capabilities: [...runtime.capabilities] };
123
+ }
124
+ export function commandShellAgentInstruction(runtime) {
125
+ if (runtime.family === "bash")
126
+ return "";
127
+ const version = runtime.version ? ` ${runtime.version}` : "";
128
+ return [
129
+ `Command shell runtime: ${runtime.family}${version}.`,
130
+ `Executable: ${runtime.executable}.`,
131
+ `Selection source: ${runtime.source}.`,
132
+ "Write shell commands for this runtime rather than assuming Bash syntax.",
133
+ ].join(" ");
134
+ }
135
+ function runtimeFromSelection(selection, source) {
136
+ const executable = selection.executable.trim();
137
+ if (!executable)
138
+ throw new Error(`Command shell ${source} selection is missing an executable.`);
139
+ const family = selection.family ?? inferCommandShellFamily(executable);
140
+ if (!family) {
141
+ throw new Error(`Unable to identify the command-shell family for ${executable}. Configure the shell family explicitly instead of allowing ForgeRelay to guess the command language.`);
142
+ }
143
+ return {
144
+ family,
145
+ executable,
146
+ source,
147
+ ...(selection.version?.trim() ? { version: selection.version.trim() } : {}),
148
+ capabilities: Array.from(new Set(selection.capabilities ?? defaultCapabilities(family))),
149
+ };
150
+ }
151
+ function defaultCapabilities(family) {
152
+ switch (family) {
153
+ case "bash":
154
+ return ["bash", "profile-isolation", "posix-command-language"];
155
+ case "zsh":
156
+ return ["zsh", "profile-isolation", "posix-command-language"];
157
+ case "sh":
158
+ return ["posix-sh", "posix-command-language"];
159
+ case "fish":
160
+ return ["fish-command-language"];
161
+ case "pwsh":
162
+ return ["powershell-command-language", "powershell-core"];
163
+ case "powershell":
164
+ return ["powershell-command-language", "windows-powershell"];
165
+ case "cmd":
166
+ return ["cmd-command-language"];
167
+ }
168
+ }
169
+ function executableBasename(executable) {
170
+ const normalized = executable.trim().replace(/^"|"$/g, "");
171
+ return win32.basename(normalized.replaceAll("/", "\\"));
172
+ }
173
+ function executableExists(executable, platform, environment) {
174
+ if (existsSync(executable))
175
+ return true;
176
+ return resolveExecutablePath(executable, platform, environment) !== executable;
177
+ }
178
+ function resolveExecutablePath(executable, platform, environment) {
179
+ const trimmed = executable.trim().replace(/^"|"$/g, "");
180
+ if (isAbsolute(trimmed) || win32.isAbsolute(trimmed))
181
+ return trimmed;
182
+ if (trimmed.includes("/") || trimmed.includes("\\"))
183
+ return resolve(trimmed);
184
+ const pathValue = environment.PATH ?? environment.Path ?? environment.path;
185
+ if (!pathValue)
186
+ return trimmed;
187
+ const delimiter = platform === "win32" ? ";" : ":";
188
+ const extensions = platform === "win32"
189
+ ? (environment.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
190
+ : [""];
191
+ const hasExtension = /\.[A-Za-z0-9]+$/.test(trimmed);
192
+ for (const directory of pathValue.split(delimiter).filter(Boolean)) {
193
+ const candidates = platform === "win32" && !hasExtension
194
+ ? extensions.map((extension) => win32.join(directory, `${trimmed}${extension}`))
195
+ : [platform === "win32" ? win32.join(directory, trimmed) : resolve(directory, trimmed)];
196
+ for (const candidate of candidates) {
197
+ if (existsSync(candidate))
198
+ return candidate;
199
+ }
200
+ }
201
+ return trimmed;
202
+ }
203
+ function defaultParentExecutable(platform, ppid) {
204
+ if (platform === "linux") {
205
+ try {
206
+ return readlinkSync(`/proc/${ppid}/exe`);
207
+ }
208
+ catch {
209
+ return psParentExecutable(ppid);
210
+ }
211
+ }
212
+ if (platform === "darwin")
213
+ return psParentExecutable(ppid);
214
+ if (platform === "win32")
215
+ return windowsParentExecutable(ppid);
216
+ return undefined;
217
+ }
218
+ function psParentExecutable(ppid) {
219
+ const result = spawnSync("ps", ["-p", String(ppid), "-o", "comm="], {
220
+ encoding: "utf8",
221
+ });
222
+ if (result.status !== 0)
223
+ return undefined;
224
+ return result.stdout?.trim() || undefined;
225
+ }
226
+ function windowsParentExecutable(ppid) {
227
+ const result = spawnSync("tasklist.exe", ["/fi", `PID eq ${ppid}`, "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
228
+ if (result.status !== 0)
229
+ return undefined;
230
+ const output = result.stdout?.trim();
231
+ if (!output || output.startsWith("INFO:"))
232
+ return undefined;
233
+ const quoted = /^"([^"]+)"/.exec(output);
234
+ return quoted?.[1] ?? (output.split(",", 1)[0]?.replace(/^"|"$/g, "").trim() || undefined);
235
+ }