@akira-tl/forgerelay 0.10.1 → 0.10.2
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 +10 -0
- package/dist/mcp/process/process-platform.js +56 -1
- package/dist/mcp/process/process-sessions.js +15 -21
- package/dist/runtime/shell/command-shell-runtime.js +53 -22
- package/dist/server.js +1 -1
- package/dist/subagents/sessions/execution.js +1 -1
- package/package.json +2 -1
- package/scripts/ci/powershell51-acceptance.mjs +483 -0
- package/scripts/ci/pwsh-acceptance.mjs +160 -13
- package/scripts/release/release-gate.test.mjs +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.2] - 2026-09-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added first-class Windows PowerShell 5.1 (`powershell.exe`) execution for Agent commands, Hooks, pipe/PTY sessions, shell identity guidance, and packaged Windows acceptance while preserving PowerShell 5.1-specific syntax and quoting semantics.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Hardened the Windows ConPTY lifecycle used by PowerShell runtimes: process-tree interruption no longer passes unsupported Windows signals, leaked ConPTY input/output resources are released after exit, and Windows PowerShell text I/O is normalized to UTF-8 so PTY completion, Unicode output, durable output, and child cleanup complete reliably.
|
|
16
|
+
|
|
7
17
|
## [0.10.1] - 2026-09-05
|
|
8
18
|
|
|
9
19
|
### Added
|
|
@@ -46,11 +46,29 @@ export function resolveShellCommandForRuntime(command, runtime, options = {}) {
|
|
|
46
46
|
command,
|
|
47
47
|
],
|
|
48
48
|
};
|
|
49
|
-
case "fish":
|
|
50
49
|
case "powershell":
|
|
50
|
+
return {
|
|
51
|
+
executable: runtime.executable,
|
|
52
|
+
args: [
|
|
53
|
+
"-NoLogo",
|
|
54
|
+
"-NoProfile",
|
|
55
|
+
...(options.interactive ? [] : ["-NonInteractive"]),
|
|
56
|
+
"-Command",
|
|
57
|
+
windowsPowerShellUtf8Command(command),
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
case "fish":
|
|
51
61
|
throw new Error(`Command shell runtime ${runtime.family} is identified but native execution support is not enabled in this release stage. ForgeRelay will not silently execute the command through another shell.`);
|
|
52
62
|
}
|
|
53
63
|
}
|
|
64
|
+
function windowsPowerShellUtf8Command(command) {
|
|
65
|
+
return [
|
|
66
|
+
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)",
|
|
67
|
+
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
|
|
68
|
+
"$OutputEncoding = [Console]::OutputEncoding",
|
|
69
|
+
command,
|
|
70
|
+
].join("; ");
|
|
71
|
+
}
|
|
54
72
|
export function terminateProcessTree(child, signal, detached, runtime = defaultProcessTreeRuntime) {
|
|
55
73
|
if (runtime.platform === "win32" && child.pid) {
|
|
56
74
|
if (runtime.killWindowsTree(child.pid))
|
|
@@ -68,3 +86,40 @@ export function terminateProcessTree(child, signal, detached, runtime = defaultP
|
|
|
68
86
|
}
|
|
69
87
|
child.kill(signal);
|
|
70
88
|
}
|
|
89
|
+
export function releasePtyProcessResources(pty, platform = process.platform) {
|
|
90
|
+
if (platform !== "win32")
|
|
91
|
+
return;
|
|
92
|
+
// node-pty 1.1.0 leaves ConPTY resources referenced after exit: the input
|
|
93
|
+
// PipeWrap is not destroyed (microsoft/node-pty#947), and the system-ConPTY
|
|
94
|
+
// natural-exit path does not dispose its conout worker. Keep both workarounds
|
|
95
|
+
// at the PTY boundary so the shared ProcessManager stays platform-neutral.
|
|
96
|
+
const agent = pty._agent;
|
|
97
|
+
try {
|
|
98
|
+
agent?._conoutSocketWorker?.dispose?.();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// The worker may already be disposing after an explicit PTY kill.
|
|
102
|
+
}
|
|
103
|
+
const inputSocket = agent?.inSocket;
|
|
104
|
+
if (!inputSocket || inputSocket.destroyed || typeof inputSocket.destroy !== "function")
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
inputSocket.destroy();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Completion must remain observable even if node-pty already released it.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function terminatePtyProcessTree(pty, signal, runtime = defaultProcessTreeRuntime) {
|
|
114
|
+
if (runtime.platform === "win32") {
|
|
115
|
+
runtime.killWindowsTree(pty.pid);
|
|
116
|
+
try {
|
|
117
|
+
pty.kill();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// The PTY may already have completed after tree termination.
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
pty.kill(signal);
|
|
125
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { resolveShellCommandForRuntime, terminateProcessTree } from "./process-platform.js";
|
|
2
|
+
import { releasePtyProcessResources, resolveShellCommandForRuntime, terminateProcessTree, terminatePtyProcessTree } from "./process-platform.js";
|
|
3
3
|
import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime } from "../../runtime/shell/command-shell-runtime.js";
|
|
4
4
|
const DEFAULT_EXEC_YIELD_MS = 10_000;
|
|
5
5
|
const DEFAULT_INTERACTIVE_YIELD_MS = 250;
|
|
@@ -240,7 +240,7 @@ export class ProcessManager {
|
|
|
240
240
|
const processEntry = this.createProcess(input);
|
|
241
241
|
this.processes.set(processEntry.id, processEntry);
|
|
242
242
|
try {
|
|
243
|
-
if (input.tty
|
|
243
|
+
if (input.tty)
|
|
244
244
|
await this.startPty(processEntry, input);
|
|
245
245
|
else
|
|
246
246
|
this.startPipe(processEntry, input);
|
|
@@ -455,7 +455,6 @@ export class ProcessManager {
|
|
|
455
455
|
processEntry.process = {
|
|
456
456
|
write: (data) => child.stdin.write(data),
|
|
457
457
|
kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached),
|
|
458
|
-
resize: input.tty ? () => undefined : undefined,
|
|
459
458
|
};
|
|
460
459
|
child.stdout.on("data", (data) => this.append(processEntry, "stdout", data));
|
|
461
460
|
child.stderr.on("data", (data) => this.append(processEntry, "stderr", data));
|
|
@@ -471,30 +470,25 @@ export class ProcessManager {
|
|
|
471
470
|
throw new Error("PTY support requires the optional node-pty dependency.");
|
|
472
471
|
}
|
|
473
472
|
const shell = resolveShellCommandForRuntime(input.command, this.commandShellRuntime, { interactive: true });
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
rows: processEntry.rows,
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
catch (error) {
|
|
489
|
-
throw error;
|
|
490
|
-
}
|
|
473
|
+
const pty = nodePty.spawn(shell.executable, shell.args, {
|
|
474
|
+
cwd: input.cwd,
|
|
475
|
+
env: processEnvironment({
|
|
476
|
+
workspaceId: input.workspaceId,
|
|
477
|
+
workspaceRoot: input.workspaceRoot,
|
|
478
|
+
codexCi: input.codexCi,
|
|
479
|
+
}),
|
|
480
|
+
name: "xterm-256color",
|
|
481
|
+
cols: processEntry.columns,
|
|
482
|
+
rows: processEntry.rows,
|
|
483
|
+
});
|
|
491
484
|
processEntry.process = {
|
|
492
485
|
write: (data) => pty.write(data),
|
|
493
|
-
kill: (signal) => pty
|
|
486
|
+
kill: (signal = "SIGTERM") => terminatePtyProcessTree(pty, signal),
|
|
494
487
|
resize: (columns, rows) => pty.resize(columns, rows),
|
|
495
488
|
};
|
|
496
489
|
pty.onData((data) => this.append(processEntry, "pty", data));
|
|
497
490
|
pty.onExit(({ exitCode, signal }) => {
|
|
491
|
+
releasePtyProcessResources(pty);
|
|
498
492
|
this.finish(processEntry, exitCode, signal === 0 ? undefined : String(signal));
|
|
499
493
|
});
|
|
500
494
|
}
|
|
@@ -45,7 +45,7 @@ export function resolveConfiguredCommandShellRuntime(preference, platform = proc
|
|
|
45
45
|
runtime = resolveCommandShellRuntime({ recordedFallback });
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
return enrichConfiguredCommandShellRuntime(runtime, metadata.probePowerShell7Version ?? probePowerShell7Version);
|
|
48
|
+
return enrichConfiguredCommandShellRuntime(runtime, metadata.probePowerShell7Version ?? probePowerShell7Version, metadata.probeWindowsPowerShellVersion ?? probeWindowsPowerShellVersion);
|
|
49
49
|
}
|
|
50
50
|
export function resolveCompatibilityCommandShellRuntime(platform = process.platform, environment = process.env) {
|
|
51
51
|
if (platform === "win32") {
|
|
@@ -129,39 +129,67 @@ export function formatCommandShellRuntime(runtime) {
|
|
|
129
129
|
export function snapshotCommandShellRuntime(runtime) {
|
|
130
130
|
return { ...runtime, capabilities: [...runtime.capabilities] };
|
|
131
131
|
}
|
|
132
|
-
function enrichConfiguredCommandShellRuntime(runtime, powerShell7VersionProbe) {
|
|
133
|
-
if (runtime.family
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
function enrichConfiguredCommandShellRuntime(runtime, powerShell7VersionProbe, windowsPowerShellVersionProbe) {
|
|
133
|
+
if (runtime.family === "pwsh") {
|
|
134
|
+
const version = powerShell7VersionProbe(runtime.executable).trim();
|
|
135
|
+
const major = Number.parseInt(version.split(".", 1)[0] ?? "", 10);
|
|
136
|
+
if (!version || !Number.isInteger(major)) {
|
|
137
|
+
throw new Error(`Unable to determine PowerShell 7 version from ${runtime.executable}.`);
|
|
138
|
+
}
|
|
139
|
+
if (major < 7) {
|
|
140
|
+
throw new Error(`Configured pwsh runtime must be PowerShell 7 or newer; detected ${version} at ${runtime.executable}.`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
...runtime,
|
|
144
|
+
version,
|
|
145
|
+
capabilities: Array.from(new Set([
|
|
146
|
+
...runtime.capabilities,
|
|
147
|
+
"profile-isolation",
|
|
148
|
+
"pipeline-chain-operators",
|
|
149
|
+
])),
|
|
150
|
+
};
|
|
139
151
|
}
|
|
140
|
-
if (
|
|
141
|
-
|
|
152
|
+
if (runtime.family === "powershell") {
|
|
153
|
+
const version = windowsPowerShellVersionProbe(runtime.executable).trim();
|
|
154
|
+
const [majorText, minorText] = version.split(".", 3);
|
|
155
|
+
const major = Number.parseInt(majorText ?? "", 10);
|
|
156
|
+
const minor = Number.parseInt(minorText ?? "", 10);
|
|
157
|
+
if (!version || !Number.isInteger(major) || !Number.isInteger(minor)) {
|
|
158
|
+
throw new Error(`Unable to determine Windows PowerShell version from ${runtime.executable}.`);
|
|
159
|
+
}
|
|
160
|
+
if (major !== 5 || minor !== 1) {
|
|
161
|
+
throw new Error(`Configured powershell runtime must be Windows PowerShell 5.1; detected ${version} at ${runtime.executable}. ForgeRelay will not substitute pwsh for the selected runtime.`);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
...runtime,
|
|
165
|
+
version,
|
|
166
|
+
capabilities: Array.from(new Set([
|
|
167
|
+
...runtime.capabilities,
|
|
168
|
+
"profile-isolation",
|
|
169
|
+
"powershell-5.1",
|
|
170
|
+
])),
|
|
171
|
+
};
|
|
142
172
|
}
|
|
143
|
-
return
|
|
144
|
-
...runtime,
|
|
145
|
-
version,
|
|
146
|
-
capabilities: Array.from(new Set([
|
|
147
|
-
...runtime.capabilities,
|
|
148
|
-
"profile-isolation",
|
|
149
|
-
"pipeline-chain-operators",
|
|
150
|
-
])),
|
|
151
|
-
};
|
|
173
|
+
return runtime;
|
|
152
174
|
}
|
|
153
175
|
function probePowerShell7Version(executable) {
|
|
176
|
+
return probePowerShellVersion(executable, "PowerShell 7");
|
|
177
|
+
}
|
|
178
|
+
function probeWindowsPowerShellVersion(executable) {
|
|
179
|
+
return probePowerShellVersion(executable, "Windows PowerShell");
|
|
180
|
+
}
|
|
181
|
+
function probePowerShellVersion(executable, label) {
|
|
154
182
|
const result = spawnSync(executable, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.ToString()"], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
|
|
155
183
|
if (result.error) {
|
|
156
|
-
throw new Error(`Unable to query
|
|
184
|
+
throw new Error(`Unable to query ${label} version from ${executable}: ${result.error.message}`);
|
|
157
185
|
}
|
|
158
186
|
if (result.status !== 0) {
|
|
159
187
|
const detail = result.stderr?.trim();
|
|
160
|
-
throw new Error(`Unable to query
|
|
188
|
+
throw new Error(`Unable to query ${label} version from ${executable}: exited with code ${result.status ?? "unknown"}${detail ? `: ${detail}` : ""}.`);
|
|
161
189
|
}
|
|
162
190
|
const version = result.stdout?.trim();
|
|
163
191
|
if (!version)
|
|
164
|
-
throw new Error(`Unable to query
|
|
192
|
+
throw new Error(`Unable to query ${label} version from ${executable}: no version was reported.`);
|
|
165
193
|
return version;
|
|
166
194
|
}
|
|
167
195
|
export function commandShellAgentInstruction(runtime) {
|
|
@@ -173,6 +201,9 @@ export function commandShellAgentInstruction(runtime) {
|
|
|
173
201
|
`Executable: ${runtime.executable}.`,
|
|
174
202
|
`Selection source: ${runtime.source}.`,
|
|
175
203
|
"Write shell commands for this runtime rather than assuming Bash syntax.",
|
|
204
|
+
...(runtime.family === "powershell"
|
|
205
|
+
? ["Windows PowerShell 5.1 does not support PowerShell 7 pipeline-chain operators && or ||."]
|
|
206
|
+
: []),
|
|
176
207
|
].join(" ");
|
|
177
208
|
}
|
|
178
209
|
function runtimeFromSelection(selection, source) {
|
package/dist/server.js
CHANGED
|
@@ -124,7 +124,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
124
124
|
};
|
|
125
125
|
};
|
|
126
126
|
const toolDescriptions = buildToolDescriptions(config);
|
|
127
|
-
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result, (snapshot) => recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId)));
|
|
127
|
+
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result, (snapshot) => recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId)), config.commandShellRuntime);
|
|
128
128
|
const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
129
129
|
const artifactDownloadAvailable = config.artifactsEnabled && isArtifactDownloadSupportedPlatform();
|
|
130
130
|
const reviewChangesAvailable = config.widgets === "changes";
|
|
@@ -13,7 +13,7 @@ export async function executeSubagentRun(config, input, providerRunner = runSuba
|
|
|
13
13
|
if (record.activeRun?.id !== input.runId) {
|
|
14
14
|
throw new Error(`Subagent Run ${input.runId} is not active for Session ${record.id}.`);
|
|
15
15
|
}
|
|
16
|
-
const hooks = new HookRunner(config.hooks, config.logging);
|
|
16
|
+
const hooks = new HookRunner(config.hooks, config.logging, process.env, undefined, config.commandShellRuntime);
|
|
17
17
|
const hookInvocation = {
|
|
18
18
|
workspaceId: record.workspaceId,
|
|
19
19
|
workspaceRoot: record.workspaceRoot,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"traffic:audit": "node --import tsx scripts/debug/traffic/traffic-audit.mjs",
|
|
44
44
|
"lsp:interop": "node scripts/lsp/interop.mjs",
|
|
45
45
|
"pwsh:accept": "node scripts/ci/pwsh-acceptance.mjs",
|
|
46
|
+
"powershell51:accept": "node scripts/ci/powershell51-acceptance.mjs",
|
|
46
47
|
"wiki:check": "node scripts/wiki/sync.mjs check",
|
|
47
48
|
"wiki:publish": "node scripts/wiki/sync.mjs publish",
|
|
48
49
|
"architecture:check": "node scripts/ci/architecture.mjs",
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
if (process.platform !== "win32") {
|
|
11
|
+
console.log("Windows PowerShell 5.1 packaged acceptance skipped outside Windows.");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const npmCli = process.env.npm_execpath;
|
|
16
|
+
if (!npmCli) throw new Error("Windows PowerShell acceptance must run through npm so npm_execpath is available");
|
|
17
|
+
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "forgerelay-powershell51-acceptance-"));
|
|
19
|
+
try {
|
|
20
|
+
const powershell = resolveWindowsPowerShell();
|
|
21
|
+
const version = powerShellVersion(powershell);
|
|
22
|
+
const [majorText, minorText] = version.split(".", 3);
|
|
23
|
+
const major = Number.parseInt(majorText ?? "", 10);
|
|
24
|
+
const minor = Number.parseInt(minorText ?? "", 10);
|
|
25
|
+
assert.equal(major, 5, `Windows PowerShell acceptance requires major version 5; got ${version}`);
|
|
26
|
+
assert.equal(minor, 1, `Windows PowerShell acceptance requires version 5.1; got ${version}`);
|
|
27
|
+
|
|
28
|
+
const { resolveConfiguredCommandShellRuntime } = await import("../../dist/runtime/shell/command-shell-runtime.js");
|
|
29
|
+
const resolvedRuntime = resolveConfiguredCommandShellRuntime({
|
|
30
|
+
mode: "pinned",
|
|
31
|
+
family: "powershell",
|
|
32
|
+
executable: powershell,
|
|
33
|
+
}, "win32", process.env);
|
|
34
|
+
assert.equal(resolvedRuntime.family, "powershell");
|
|
35
|
+
assert.equal(resolvedRuntime.executable, powershell);
|
|
36
|
+
assert.equal(resolvedRuntime.version, version);
|
|
37
|
+
assert.ok(resolvedRuntime.capabilities.includes("windows-powershell"));
|
|
38
|
+
assert.ok(resolvedRuntime.capabilities.includes("profile-isolation"));
|
|
39
|
+
assert.ok(!resolvedRuntime.capabilities.includes("pipeline-chain-operators"));
|
|
40
|
+
|
|
41
|
+
await exerciseAgentRuntime(resolvedRuntime);
|
|
42
|
+
await exerciseHookRuntime(resolvedRuntime);
|
|
43
|
+
await exercisePackagedPowerShellShim(powershell, version);
|
|
44
|
+
|
|
45
|
+
console.log(`Windows PowerShell 5.1 acceptance passed with ${powershell} (${version}).`);
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(root, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveWindowsPowerShell() {
|
|
51
|
+
const result = spawnSync("where.exe", ["powershell.exe"], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
if (result.error || result.status !== 0) {
|
|
56
|
+
throw new Error("Windows release acceptance requires Windows PowerShell 5.1 (powershell.exe) on PATH.");
|
|
57
|
+
}
|
|
58
|
+
const executable = result.stdout?.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
59
|
+
if (!executable) throw new Error("where.exe reported no powershell.exe path.");
|
|
60
|
+
return executable;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function powerShellVersion(executable) {
|
|
64
|
+
const result = spawnSync(
|
|
65
|
+
executable,
|
|
66
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.ToString()"],
|
|
67
|
+
{ encoding: "utf8", windowsHide: true },
|
|
68
|
+
);
|
|
69
|
+
if (result.error || result.status !== 0) {
|
|
70
|
+
throw new Error(`Unable to query ${executable} version: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
71
|
+
}
|
|
72
|
+
const version = result.stdout?.trim();
|
|
73
|
+
if (!version) throw new Error(`${executable} did not report a version.`);
|
|
74
|
+
return version;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function exerciseAgentRuntime(runtime) {
|
|
78
|
+
const [{ ProcessManager }, { BashOutputStore }] = await Promise.all([
|
|
79
|
+
import("../../dist/mcp/process/process-sessions.js"),
|
|
80
|
+
import("../../dist/activity/history/bash-output-store.js"),
|
|
81
|
+
]);
|
|
82
|
+
const durableStateDir = join(root, "durable-output-state");
|
|
83
|
+
const outputStore = new BashOutputStore(durableStateDir, {
|
|
84
|
+
outputId: () => "out_powershell51_pty_acceptance",
|
|
85
|
+
flushBytes: 1,
|
|
86
|
+
});
|
|
87
|
+
const manager = new ProcessManager({
|
|
88
|
+
commandShellRuntime: runtime,
|
|
89
|
+
outputAudit: outputStore,
|
|
90
|
+
});
|
|
91
|
+
const originalMarker = process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE;
|
|
92
|
+
process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE = "inherited environment";
|
|
93
|
+
try {
|
|
94
|
+
const node = powerShellLiteral(process.execPath);
|
|
95
|
+
const semantics = await manager.start({
|
|
96
|
+
workspaceId: "powershell51-agent",
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
command: [
|
|
99
|
+
'Write-Output "edition=$($PSVersionTable.PSEdition)"',
|
|
100
|
+
'Write-Output "version=$($PSVersionTable.PSVersion.ToString())"',
|
|
101
|
+
'Write-Output "env=$env:FORGERELAY_POWERSHELL51_ACCEPTANCE"',
|
|
102
|
+
"Write-Output 'pipe-unicode-雪'",
|
|
103
|
+
'1,2,3 | Measure-Object -Sum | ForEach-Object { Write-Output "sum=$($_.Sum)" }',
|
|
104
|
+
`$exe = ${node}`,
|
|
105
|
+
"& $exe -e 'console.log(JSON.stringify(process.argv.slice(1)))' 'native arg with spaces' 'plain-arg'",
|
|
106
|
+
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
|
|
107
|
+
"$redirect = Join-Path $env:TEMP 'forgerelay-powershell51-redirection.txt'",
|
|
108
|
+
"'redirected-through-windows-powershell' > $redirect",
|
|
109
|
+
"Get-Content $redirect",
|
|
110
|
+
"Remove-Item $redirect -Force",
|
|
111
|
+
].join("; "),
|
|
112
|
+
yieldTimeMs: 10_000,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(semantics.running, false);
|
|
115
|
+
assert.equal(semantics.exitCode, 0);
|
|
116
|
+
assert.match(semantics.output, /edition=Desktop/);
|
|
117
|
+
assert.match(semantics.output, /version=5\.1\./);
|
|
118
|
+
assert.match(semantics.output, /env=inherited environment/);
|
|
119
|
+
assert.match(semantics.output, /pipe-unicode-雪/);
|
|
120
|
+
assert.match(semantics.output, /sum=6/);
|
|
121
|
+
assert.match(semantics.output, /\["native arg with spaces","plain-arg"\]/);
|
|
122
|
+
assert.match(semantics.output, /redirected-through-windows-powershell/);
|
|
123
|
+
|
|
124
|
+
const errorSemantics = await manager.start({
|
|
125
|
+
workspaceId: "powershell51-agent",
|
|
126
|
+
cwd: process.cwd(),
|
|
127
|
+
command: [
|
|
128
|
+
"Write-Error 'nonterminating-windows-powershell-error'",
|
|
129
|
+
"Write-Output 'continued-after-nonterminating-error'",
|
|
130
|
+
"try { Get-Item 'forgerelay-definitely-missing-item' -ErrorAction Stop; exit 31 } catch { Write-Output 'terminating-error-caught' }",
|
|
131
|
+
].join("; "),
|
|
132
|
+
yieldTimeMs: 10_000,
|
|
133
|
+
});
|
|
134
|
+
assert.equal(errorSemantics.exitCode, 0);
|
|
135
|
+
assert.match(errorSemantics.output, /nonterminating-windows-powershell-error/);
|
|
136
|
+
assert.match(errorSemantics.output, /continued-after-nonterminating-error/);
|
|
137
|
+
assert.match(errorSemantics.output, /terminating-error-caught/);
|
|
138
|
+
|
|
139
|
+
const nativeExit = await manager.start({
|
|
140
|
+
workspaceId: "powershell51-agent",
|
|
141
|
+
cwd: process.cwd(),
|
|
142
|
+
command: `$exe = ${node}; & $exe -e 'process.exit(7)'; exit $LASTEXITCODE`,
|
|
143
|
+
yieldTimeMs: 10_000,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(nativeExit.exitCode, 7);
|
|
146
|
+
|
|
147
|
+
const chainMarker = join(root, "powershell7-chain-operator-marker.txt");
|
|
148
|
+
const powerShell7OnlySyntax = await manager.start({
|
|
149
|
+
workspaceId: "powershell51-agent",
|
|
150
|
+
cwd: process.cwd(),
|
|
151
|
+
command: `Write-Output 'left-side' && Set-Content -LiteralPath ${powerShellLiteral(chainMarker)} -Value 'ran'`,
|
|
152
|
+
yieldTimeMs: 10_000,
|
|
153
|
+
});
|
|
154
|
+
assert.notEqual(powerShell7OnlySyntax.exitCode, 0);
|
|
155
|
+
assert.equal(existsSync(chainMarker), false, "PowerShell 7 pipeline-chain syntax unexpectedly executed under Windows PowerShell 5.1");
|
|
156
|
+
|
|
157
|
+
const background = await manager.start({
|
|
158
|
+
workspaceId: "powershell51-agent",
|
|
159
|
+
cwd: process.cwd(),
|
|
160
|
+
command: "Write-Output 'powershell51-background-start'; Start-Sleep -Milliseconds 250; Write-Output 'powershell51-background-done'",
|
|
161
|
+
yieldTimeMs: 5,
|
|
162
|
+
});
|
|
163
|
+
assert.equal(background.running, true);
|
|
164
|
+
assert.ok(background.processId);
|
|
165
|
+
const completed = await manager.write({
|
|
166
|
+
workspaceId: "powershell51-agent",
|
|
167
|
+
processId: background.processId,
|
|
168
|
+
yieldTimeMs: 5_000,
|
|
169
|
+
});
|
|
170
|
+
assert.equal(completed.running, false);
|
|
171
|
+
assert.equal(completed.exitCode, 0);
|
|
172
|
+
assert.match(completed.output, /powershell51-background-done/);
|
|
173
|
+
|
|
174
|
+
const timedOut = await manager.start({
|
|
175
|
+
workspaceId: "powershell51-agent",
|
|
176
|
+
cwd: process.cwd(),
|
|
177
|
+
command: "Start-Sleep -Seconds 30",
|
|
178
|
+
yieldTimeMs: 5_000,
|
|
179
|
+
timeoutMs: 100,
|
|
180
|
+
});
|
|
181
|
+
assert.equal(timedOut.running, false);
|
|
182
|
+
assert.equal(timedOut.timedOut, true);
|
|
183
|
+
|
|
184
|
+
const interruptible = await manager.start({
|
|
185
|
+
workspaceId: "powershell51-agent",
|
|
186
|
+
cwd: process.cwd(),
|
|
187
|
+
command: "Write-Output 'powershell51-interrupt-ready'; Start-Sleep -Seconds 30",
|
|
188
|
+
yieldTimeMs: 5,
|
|
189
|
+
});
|
|
190
|
+
assert.equal(interruptible.running, true);
|
|
191
|
+
assert.ok(interruptible.processId);
|
|
192
|
+
const interrupted = await manager.write({
|
|
193
|
+
workspaceId: "powershell51-agent",
|
|
194
|
+
processId: interruptible.processId,
|
|
195
|
+
chars: "\u0003",
|
|
196
|
+
yieldTimeMs: 5_000,
|
|
197
|
+
});
|
|
198
|
+
assert.equal(interrupted.running, false);
|
|
199
|
+
|
|
200
|
+
await exercisePtyLifecycle(manager, outputStore, node);
|
|
201
|
+
} finally {
|
|
202
|
+
if (originalMarker === undefined) delete process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE;
|
|
203
|
+
else process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE = originalMarker;
|
|
204
|
+
manager.shutdown();
|
|
205
|
+
outputStore.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function exercisePtyLifecycle(manager, outputStore, node) {
|
|
210
|
+
const pty = await manager.start({
|
|
211
|
+
workspaceId: "powershell51-agent",
|
|
212
|
+
workspaceRoot: process.cwd(),
|
|
213
|
+
audit: {
|
|
214
|
+
activityId: "act-powershell51-pty",
|
|
215
|
+
turnId: "turn-powershell51-pty",
|
|
216
|
+
conversationScopeId: "conversation-powershell51-pty",
|
|
217
|
+
},
|
|
218
|
+
cwd: process.cwd(),
|
|
219
|
+
command: [
|
|
220
|
+
"Write-Output 'powershell51-pty-ready-雪'",
|
|
221
|
+
"$line = [Console]::In.ReadLine()",
|
|
222
|
+
"Start-Sleep -Milliseconds 100",
|
|
223
|
+
'Write-Output "stdin=$line"',
|
|
224
|
+
'Write-Output "cols=$([Console]::WindowWidth);rows=$([Console]::WindowHeight)"',
|
|
225
|
+
"Write-Output 'powershell51-pty-unicode-雪'",
|
|
226
|
+
"exit 23",
|
|
227
|
+
].join("; "),
|
|
228
|
+
tty: true,
|
|
229
|
+
columns: 80,
|
|
230
|
+
rows: 24,
|
|
231
|
+
yieldTimeMs: 5,
|
|
232
|
+
});
|
|
233
|
+
assert.equal(pty.running, true);
|
|
234
|
+
assert.ok(pty.processId);
|
|
235
|
+
assert.equal(pty.outputId, "out_powershell51_pty_acceptance");
|
|
236
|
+
|
|
237
|
+
const interacted = await manager.write({
|
|
238
|
+
workspaceId: "powershell51-agent",
|
|
239
|
+
processId: pty.processId,
|
|
240
|
+
columns: 120,
|
|
241
|
+
rows: 30,
|
|
242
|
+
chars: "input-plain\r",
|
|
243
|
+
yieldTimeMs: 5_000,
|
|
244
|
+
});
|
|
245
|
+
assert.equal(interacted.running, false);
|
|
246
|
+
assert.equal(interacted.exitCode, 23);
|
|
247
|
+
const ptyOutput = `${pty.output}${interacted.output}`;
|
|
248
|
+
assert.match(ptyOutput, /powershell51-pty-ready-雪/);
|
|
249
|
+
assert.match(ptyOutput, /stdin=input-plain/);
|
|
250
|
+
assert.match(ptyOutput, /cols=120;rows=30/);
|
|
251
|
+
assert.match(ptyOutput, /powershell51-pty-unicode-雪/);
|
|
252
|
+
|
|
253
|
+
const durable = outputStore.read(pty.outputId);
|
|
254
|
+
assert.ok(durable);
|
|
255
|
+
assert.equal(durable.tty, true);
|
|
256
|
+
assert.equal(durable.exitCode, 23);
|
|
257
|
+
assert.equal(durable.status, "failed");
|
|
258
|
+
assert.match(durable.output, /powershell51-pty-ready-雪/);
|
|
259
|
+
assert.match(durable.output, /stdin=input-plain/);
|
|
260
|
+
assert.match(durable.output, /powershell51-pty-unicode-雪/);
|
|
261
|
+
|
|
262
|
+
const background = await manager.start({
|
|
263
|
+
workspaceId: "powershell51-agent",
|
|
264
|
+
cwd: process.cwd(),
|
|
265
|
+
command: "Write-Output 'powershell51-pty-background-start'; Start-Sleep -Milliseconds 250; Write-Output 'powershell51-pty-background-done'",
|
|
266
|
+
tty: true,
|
|
267
|
+
yieldTimeMs: 5,
|
|
268
|
+
});
|
|
269
|
+
assert.equal(background.running, true);
|
|
270
|
+
assert.ok(background.processId);
|
|
271
|
+
const backgroundDone = await manager.write({
|
|
272
|
+
workspaceId: "powershell51-agent",
|
|
273
|
+
processId: background.processId,
|
|
274
|
+
yieldTimeMs: 5_000,
|
|
275
|
+
});
|
|
276
|
+
assert.equal(backgroundDone.running, false);
|
|
277
|
+
assert.equal(backgroundDone.exitCode, 0);
|
|
278
|
+
assert.match(`${background.output}${backgroundDone.output}`, /powershell51-pty-background-done/);
|
|
279
|
+
|
|
280
|
+
const timedOut = await manager.start({
|
|
281
|
+
workspaceId: "powershell51-agent",
|
|
282
|
+
cwd: process.cwd(),
|
|
283
|
+
command: "Start-Sleep -Seconds 30",
|
|
284
|
+
tty: true,
|
|
285
|
+
yieldTimeMs: 5_000,
|
|
286
|
+
timeoutMs: 100,
|
|
287
|
+
});
|
|
288
|
+
assert.equal(timedOut.running, false);
|
|
289
|
+
assert.equal(timedOut.timedOut, true);
|
|
290
|
+
|
|
291
|
+
const pidPath = join(root, "powershell51-pty-child.pid");
|
|
292
|
+
const childScript = "require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)";
|
|
293
|
+
const interruptible = await manager.start({
|
|
294
|
+
workspaceId: "powershell51-agent",
|
|
295
|
+
cwd: process.cwd(),
|
|
296
|
+
command: [
|
|
297
|
+
"Write-Output 'powershell51-pty-interrupt-ready'",
|
|
298
|
+
`$exe = ${node}`,
|
|
299
|
+
`& $exe -e ${powerShellLiteral(childScript)} ${powerShellLiteral(pidPath)}`,
|
|
300
|
+
].join("; "),
|
|
301
|
+
tty: true,
|
|
302
|
+
yieldTimeMs: 5,
|
|
303
|
+
});
|
|
304
|
+
assert.equal(interruptible.running, true);
|
|
305
|
+
assert.ok(interruptible.processId);
|
|
306
|
+
const childPid = Number.parseInt(await waitForFile(pidPath), 10);
|
|
307
|
+
assert.ok(Number.isInteger(childPid) && childPid > 0, `invalid PTY child pid: ${childPid}`);
|
|
308
|
+
assert.equal(windowsProcessExists(childPid), true);
|
|
309
|
+
|
|
310
|
+
const interrupted = await manager.write({
|
|
311
|
+
workspaceId: "powershell51-agent",
|
|
312
|
+
processId: interruptible.processId,
|
|
313
|
+
chars: "\u0003",
|
|
314
|
+
yieldTimeMs: 5_000,
|
|
315
|
+
});
|
|
316
|
+
assert.equal(interrupted.running, false);
|
|
317
|
+
await waitForWindowsProcessExit(childPid);
|
|
318
|
+
assert.equal(windowsProcessExists(childPid), false, `PTY child process ${childPid} leaked after interrupt`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function exerciseHookRuntime(runtime) {
|
|
322
|
+
const { HookRunner, parseHookConfig } = await import("../../dist/mcp/hooks/hooks.js");
|
|
323
|
+
const logging = {
|
|
324
|
+
level: "silent",
|
|
325
|
+
format: "json",
|
|
326
|
+
requests: false,
|
|
327
|
+
assets: false,
|
|
328
|
+
toolCalls: false,
|
|
329
|
+
shellCommands: false,
|
|
330
|
+
trustProxy: false,
|
|
331
|
+
};
|
|
332
|
+
const runner = new HookRunner(
|
|
333
|
+
parseHookConfig({
|
|
334
|
+
BeforeTool: [{
|
|
335
|
+
handlers: [{
|
|
336
|
+
name: "Windows PowerShell policy",
|
|
337
|
+
command: [
|
|
338
|
+
"if ($PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1) { exit 17 }",
|
|
339
|
+
"if ($env:FORGERELAY_WORKSPACE_ID -ne 'powershell51-hook') { exit 19 }",
|
|
340
|
+
"Write-Error 'windows powershell policy denied'",
|
|
341
|
+
"exit 13",
|
|
342
|
+
].join("; "),
|
|
343
|
+
}],
|
|
344
|
+
}],
|
|
345
|
+
}),
|
|
346
|
+
logging,
|
|
347
|
+
process.env,
|
|
348
|
+
undefined,
|
|
349
|
+
runtime,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
await assert.rejects(
|
|
353
|
+
() => runner.run("BeforeTool", {
|
|
354
|
+
workspaceId: "powershell51-hook",
|
|
355
|
+
workspaceRoot: process.cwd(),
|
|
356
|
+
workspaceMode: "checkout",
|
|
357
|
+
payload: { tool: "bash", command: "Write-Output 'agent command'" },
|
|
358
|
+
}),
|
|
359
|
+
/Windows PowerShell policy exited with code 13: .*windows powershell policy denied/i,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function exercisePackagedPowerShellShim(powershell, expectedVersion) {
|
|
364
|
+
const artifactDir = join(root, "artifact");
|
|
365
|
+
const prefix = join(root, "prefix");
|
|
366
|
+
const configDir = join(root, "config");
|
|
367
|
+
const stateDir = join(root, "state");
|
|
368
|
+
await Promise.all([
|
|
369
|
+
mkdir(artifactDir, { recursive: true }),
|
|
370
|
+
mkdir(prefix, { recursive: true }),
|
|
371
|
+
mkdir(configDir, { recursive: true }),
|
|
372
|
+
mkdir(stateDir, { recursive: true }),
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
const packed = runNpm(["pack", "--json", "--pack-destination", artifactDir]);
|
|
376
|
+
const packResult = JSON.parse(packed.stdout);
|
|
377
|
+
const filename = packResult?.[0]?.filename;
|
|
378
|
+
if (!filename) throw new Error(`npm pack did not report a package filename: ${packed.stdout}`);
|
|
379
|
+
const tarball = join(artifactDir, filename);
|
|
380
|
+
assert.ok(existsSync(tarball), `packed artifact is missing: ${tarball}`);
|
|
381
|
+
|
|
382
|
+
runNpm(["install", "--global", "--prefix", prefix, tarball]);
|
|
383
|
+
const shim = join(prefix, "forgerelay.ps1");
|
|
384
|
+
assert.ok(existsSync(shim), `npm did not create the PowerShell launcher shim: ${shim}`);
|
|
385
|
+
|
|
386
|
+
await writeFile(
|
|
387
|
+
join(configDir, "config.json"),
|
|
388
|
+
JSON.stringify({
|
|
389
|
+
host: "127.0.0.1",
|
|
390
|
+
port: 7678,
|
|
391
|
+
allowedRoots: [process.cwd()],
|
|
392
|
+
stateDir,
|
|
393
|
+
worktreeRoot: join(root, "worktrees"),
|
|
394
|
+
commandShell: {
|
|
395
|
+
mode: "follow-launcher",
|
|
396
|
+
family: "powershell",
|
|
397
|
+
executable: powershell,
|
|
398
|
+
},
|
|
399
|
+
shellInstructions: false,
|
|
400
|
+
}, null, 2),
|
|
401
|
+
"utf8",
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const launcherEnv = {
|
|
405
|
+
...process.env,
|
|
406
|
+
FORGERELAY_CONFIG_DIR: configDir,
|
|
407
|
+
FORGERELAY_OAUTH_OWNER_TOKEN: "powershell51-acceptance-owner-token-long-enough",
|
|
408
|
+
};
|
|
409
|
+
delete launcherEnv.npm_lifecycle_event;
|
|
410
|
+
delete launcherEnv.FORGERELAY_COMMAND_SHELL;
|
|
411
|
+
|
|
412
|
+
const result = spawnSync(
|
|
413
|
+
powershell,
|
|
414
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-File", shim, "doctor"],
|
|
415
|
+
{
|
|
416
|
+
cwd: process.cwd(),
|
|
417
|
+
env: launcherEnv,
|
|
418
|
+
encoding: "utf8",
|
|
419
|
+
windowsHide: true,
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
if (result.error || result.status !== 0) {
|
|
423
|
+
throw new Error(`Packaged Windows PowerShell launcher failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
424
|
+
}
|
|
425
|
+
assert.match(
|
|
426
|
+
result.stdout ?? "",
|
|
427
|
+
new RegExp(`Command shell: powershell ${escapeRegExp(expectedVersion)} \\(.+; launcher\\)`),
|
|
428
|
+
);
|
|
429
|
+
assert.doesNotMatch(result.stdout ?? "", /Command shell: pwsh/);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function runNpm(args) {
|
|
433
|
+
const result = spawnSync(process.execPath, [npmCli, ...args], {
|
|
434
|
+
cwd: process.cwd(),
|
|
435
|
+
env: process.env,
|
|
436
|
+
encoding: "utf8",
|
|
437
|
+
windowsHide: true,
|
|
438
|
+
});
|
|
439
|
+
if (result.error || result.status !== 0) {
|
|
440
|
+
throw new Error(`npm ${args.join(" ")} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function waitForFile(path, timeoutMs = 5_000) {
|
|
446
|
+
const deadline = Date.now() + timeoutMs;
|
|
447
|
+
while (Date.now() < deadline) {
|
|
448
|
+
try {
|
|
449
|
+
return await readFile(path, "utf8");
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
452
|
+
}
|
|
453
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
454
|
+
}
|
|
455
|
+
throw new Error(`Timed out waiting for file: ${path}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function windowsProcessExists(pid) {
|
|
459
|
+
const result = spawnSync(
|
|
460
|
+
"tasklist.exe",
|
|
461
|
+
["/fi", `PID eq ${pid}`, "/fo", "csv", "/nh"],
|
|
462
|
+
{ encoding: "utf8", windowsHide: true },
|
|
463
|
+
);
|
|
464
|
+
if (result.error) throw result.error;
|
|
465
|
+
if (result.status !== 0) throw new Error(`tasklist.exe failed with exit ${result.status ?? "unknown"}`);
|
|
466
|
+
return new RegExp(`"${pid}"(?:,|$)`).test(result.stdout ?? "");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function waitForWindowsProcessExit(pid, timeoutMs = 5_000) {
|
|
470
|
+
const deadline = Date.now() + timeoutMs;
|
|
471
|
+
while (Date.now() < deadline) {
|
|
472
|
+
if (!windowsProcessExists(pid)) return;
|
|
473
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function powerShellLiteral(value) {
|
|
478
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function escapeRegExp(value) {
|
|
482
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
483
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
@@ -72,8 +72,19 @@ function powerShellVersion(executable) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
async function exerciseAgentRuntime(runtime) {
|
|
75
|
-
const { ProcessManager } = await
|
|
76
|
-
|
|
75
|
+
const [{ ProcessManager }, { BashOutputStore }] = await Promise.all([
|
|
76
|
+
import("../../dist/mcp/process/process-sessions.js"),
|
|
77
|
+
import("../../dist/activity/history/bash-output-store.js"),
|
|
78
|
+
]);
|
|
79
|
+
const durableStateDir = join(root, "durable-output-state");
|
|
80
|
+
const outputStore = new BashOutputStore(durableStateDir, {
|
|
81
|
+
outputId: () => "out_pwsh_pty_acceptance",
|
|
82
|
+
flushBytes: 1,
|
|
83
|
+
});
|
|
84
|
+
const manager = new ProcessManager({
|
|
85
|
+
commandShellRuntime: runtime,
|
|
86
|
+
outputAudit: outputStore,
|
|
87
|
+
});
|
|
77
88
|
const originalMarker = process.env.FORGERELAY_PWSH_ACCEPTANCE;
|
|
78
89
|
process.env.FORGERELAY_PWSH_ACCEPTANCE = "inherited environment";
|
|
79
90
|
try {
|
|
@@ -167,23 +178,127 @@ async function exerciseAgentRuntime(runtime) {
|
|
|
167
178
|
});
|
|
168
179
|
assert.equal(interrupted.running, false);
|
|
169
180
|
|
|
170
|
-
|
|
171
|
-
workspaceId: "pwsh-agent",
|
|
172
|
-
cwd: process.cwd(),
|
|
173
|
-
command: "Write-Output 'pwsh-pty-ok'",
|
|
174
|
-
tty: true,
|
|
175
|
-
yieldTimeMs: 10_000,
|
|
176
|
-
});
|
|
177
|
-
assert.equal(pty.running, false);
|
|
178
|
-
assert.equal(pty.exitCode, 0);
|
|
179
|
-
assert.match(pty.output, /pwsh-pty-ok/);
|
|
181
|
+
await exercisePtyLifecycle(manager, outputStore, node);
|
|
180
182
|
} finally {
|
|
181
183
|
if (originalMarker === undefined) delete process.env.FORGERELAY_PWSH_ACCEPTANCE;
|
|
182
184
|
else process.env.FORGERELAY_PWSH_ACCEPTANCE = originalMarker;
|
|
183
185
|
manager.shutdown();
|
|
186
|
+
outputStore.close();
|
|
184
187
|
}
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
async function exercisePtyLifecycle(manager, outputStore, node) {
|
|
191
|
+
const pty = await manager.start({
|
|
192
|
+
workspaceId: "pwsh-agent",
|
|
193
|
+
workspaceRoot: process.cwd(),
|
|
194
|
+
audit: {
|
|
195
|
+
activityId: "act-pwsh-pty",
|
|
196
|
+
turnId: "turn-pwsh-pty",
|
|
197
|
+
conversationScopeId: "conversation-pwsh-pty",
|
|
198
|
+
},
|
|
199
|
+
cwd: process.cwd(),
|
|
200
|
+
command: [
|
|
201
|
+
"Write-Output 'pwsh-pty-ready-雪'",
|
|
202
|
+
"$line = [Console]::In.ReadLine()",
|
|
203
|
+
"Start-Sleep -Milliseconds 100",
|
|
204
|
+
'Write-Output "stdin=$line"',
|
|
205
|
+
'Write-Output "cols=$([Console]::WindowWidth);rows=$([Console]::WindowHeight)"',
|
|
206
|
+
"Write-Output 'pwsh-pty-unicode-🙂'",
|
|
207
|
+
"exit 23",
|
|
208
|
+
].join("; "),
|
|
209
|
+
tty: true,
|
|
210
|
+
columns: 80,
|
|
211
|
+
rows: 24,
|
|
212
|
+
yieldTimeMs: 5,
|
|
213
|
+
});
|
|
214
|
+
assert.equal(pty.running, true);
|
|
215
|
+
assert.ok(pty.processId);
|
|
216
|
+
assert.equal(pty.outputId, "out_pwsh_pty_acceptance");
|
|
217
|
+
|
|
218
|
+
const interacted = await manager.write({
|
|
219
|
+
workspaceId: "pwsh-agent",
|
|
220
|
+
processId: pty.processId,
|
|
221
|
+
columns: 120,
|
|
222
|
+
rows: 30,
|
|
223
|
+
chars: "input-plain\r",
|
|
224
|
+
yieldTimeMs: 5_000,
|
|
225
|
+
});
|
|
226
|
+
assert.equal(interacted.running, false);
|
|
227
|
+
assert.equal(interacted.exitCode, 23);
|
|
228
|
+
const ptyOutput = `${pty.output}${interacted.output}`;
|
|
229
|
+
assert.match(ptyOutput, /pwsh-pty-ready-雪/);
|
|
230
|
+
assert.match(ptyOutput, /stdin=input-plain/);
|
|
231
|
+
assert.match(ptyOutput, /cols=120;rows=30/);
|
|
232
|
+
assert.match(ptyOutput, /pwsh-pty-unicode-🙂/);
|
|
233
|
+
|
|
234
|
+
const durable = outputStore.read(pty.outputId);
|
|
235
|
+
assert.ok(durable);
|
|
236
|
+
assert.equal(durable.tty, true);
|
|
237
|
+
assert.equal(durable.exitCode, 23);
|
|
238
|
+
assert.equal(durable.status, "failed");
|
|
239
|
+
assert.match(durable.output, /pwsh-pty-ready-雪/);
|
|
240
|
+
assert.match(durable.output, /stdin=input-plain/);
|
|
241
|
+
assert.match(durable.output, /pwsh-pty-unicode-🙂/);
|
|
242
|
+
|
|
243
|
+
const background = await manager.start({
|
|
244
|
+
workspaceId: "pwsh-agent",
|
|
245
|
+
cwd: process.cwd(),
|
|
246
|
+
command: "Write-Output 'pwsh-pty-background-start'; Start-Sleep -Milliseconds 250; Write-Output 'pwsh-pty-background-done'",
|
|
247
|
+
tty: true,
|
|
248
|
+
yieldTimeMs: 5,
|
|
249
|
+
});
|
|
250
|
+
assert.equal(background.running, true);
|
|
251
|
+
assert.ok(background.processId);
|
|
252
|
+
const backgroundDone = await manager.write({
|
|
253
|
+
workspaceId: "pwsh-agent",
|
|
254
|
+
processId: background.processId,
|
|
255
|
+
yieldTimeMs: 5_000,
|
|
256
|
+
});
|
|
257
|
+
assert.equal(backgroundDone.running, false);
|
|
258
|
+
assert.equal(backgroundDone.exitCode, 0);
|
|
259
|
+
assert.match(`${background.output}${backgroundDone.output}`, /pwsh-pty-background-done/);
|
|
260
|
+
|
|
261
|
+
const timedOut = await manager.start({
|
|
262
|
+
workspaceId: "pwsh-agent",
|
|
263
|
+
cwd: process.cwd(),
|
|
264
|
+
command: "Start-Sleep -Seconds 30",
|
|
265
|
+
tty: true,
|
|
266
|
+
yieldTimeMs: 5_000,
|
|
267
|
+
timeoutMs: 100,
|
|
268
|
+
});
|
|
269
|
+
assert.equal(timedOut.running, false);
|
|
270
|
+
assert.equal(timedOut.timedOut, true);
|
|
271
|
+
|
|
272
|
+
const pidPath = join(root, "pwsh-pty-child.pid");
|
|
273
|
+
const childScript = "require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)";
|
|
274
|
+
const interruptible = await manager.start({
|
|
275
|
+
workspaceId: "pwsh-agent",
|
|
276
|
+
cwd: process.cwd(),
|
|
277
|
+
command: [
|
|
278
|
+
"Write-Output 'pwsh-pty-interrupt-ready'",
|
|
279
|
+
`$exe = ${node}`,
|
|
280
|
+
`& $exe -e ${powerShellLiteral(childScript)} ${powerShellLiteral(pidPath)}`,
|
|
281
|
+
].join("; "),
|
|
282
|
+
tty: true,
|
|
283
|
+
yieldTimeMs: 5,
|
|
284
|
+
});
|
|
285
|
+
assert.equal(interruptible.running, true);
|
|
286
|
+
assert.ok(interruptible.processId);
|
|
287
|
+
const childPid = Number.parseInt(await waitForFile(pidPath), 10);
|
|
288
|
+
assert.ok(Number.isInteger(childPid) && childPid > 0, `invalid PTY child pid: ${childPid}`);
|
|
289
|
+
assert.equal(windowsProcessExists(childPid), true);
|
|
290
|
+
|
|
291
|
+
const interrupted = await manager.write({
|
|
292
|
+
workspaceId: "pwsh-agent",
|
|
293
|
+
processId: interruptible.processId,
|
|
294
|
+
chars: "\u0003",
|
|
295
|
+
yieldTimeMs: 5_000,
|
|
296
|
+
});
|
|
297
|
+
assert.equal(interrupted.running, false);
|
|
298
|
+
await waitForWindowsProcessExit(childPid);
|
|
299
|
+
assert.equal(windowsProcessExists(childPid), false, `PTY child process ${childPid} leaked after interrupt`);
|
|
300
|
+
}
|
|
301
|
+
|
|
187
302
|
async function exerciseHookRuntime(runtime) {
|
|
188
303
|
const { HookRunner, parseHookConfig } = await import("../../dist/mcp/hooks/hooks.js");
|
|
189
304
|
const logging = {
|
|
@@ -299,6 +414,38 @@ function runNpm(args) {
|
|
|
299
414
|
return result;
|
|
300
415
|
}
|
|
301
416
|
|
|
417
|
+
async function waitForFile(path, timeoutMs = 5_000) {
|
|
418
|
+
const deadline = Date.now() + timeoutMs;
|
|
419
|
+
while (Date.now() < deadline) {
|
|
420
|
+
try {
|
|
421
|
+
return await readFile(path, "utf8");
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
424
|
+
}
|
|
425
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
426
|
+
}
|
|
427
|
+
throw new Error(`Timed out waiting for file: ${path}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function windowsProcessExists(pid) {
|
|
431
|
+
const result = spawnSync(
|
|
432
|
+
"tasklist.exe",
|
|
433
|
+
["/fi", `PID eq ${pid}`, "/fo", "csv", "/nh"],
|
|
434
|
+
{ encoding: "utf8", windowsHide: true },
|
|
435
|
+
);
|
|
436
|
+
if (result.error) throw result.error;
|
|
437
|
+
if (result.status !== 0) throw new Error(`tasklist.exe failed with exit ${result.status ?? "unknown"}`);
|
|
438
|
+
return new RegExp(`"${pid}"(?:,|$)`).test(result.stdout ?? "");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function waitForWindowsProcessExit(pid, timeoutMs = 5_000) {
|
|
442
|
+
const deadline = Date.now() + timeoutMs;
|
|
443
|
+
while (Date.now() < deadline) {
|
|
444
|
+
if (!windowsProcessExists(pid)) return;
|
|
445
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
302
449
|
function powerShellLiteral(value) {
|
|
303
450
|
return `'${String(value).replaceAll("'", "''")}'`;
|
|
304
451
|
}
|
|
@@ -108,3 +108,17 @@ test("release workflow is tag-only and promotes the verified npm artifact withou
|
|
|
108
108
|
assert.doesNotMatch(workflow, /run:\s*\|/);
|
|
109
109
|
assert.doesNotMatch(workflow, /shell:\s*bash/);
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
test("manual Windows shell acceptance can never publish a release", async () => {
|
|
113
|
+
const workflow = await readFile(
|
|
114
|
+
resolve(repoRoot, ".github/workflows/windows-shell-acceptance.yml"),
|
|
115
|
+
"utf8",
|
|
116
|
+
);
|
|
117
|
+
assert.match(workflow, /workflow_dispatch:/);
|
|
118
|
+
assert.match(workflow, /runs-on:\s*windows-2022/);
|
|
119
|
+
assert.match(workflow, /run:\s*npm run pwsh:accept/);
|
|
120
|
+
assert.doesNotMatch(workflow, /release:publish/);
|
|
121
|
+
assert.doesNotMatch(workflow, /npm publish/);
|
|
122
|
+
assert.doesNotMatch(workflow, /contents:\s*write/);
|
|
123
|
+
assert.doesNotMatch(workflow, /id-token:\s*write/);
|
|
124
|
+
});
|