@akira-tl/forgerelay 0.10.1 → 0.10.3
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 +21 -0
- package/README.md +3 -2
- package/capabilities/shell-processes/GUIDE.md +1 -1
- package/dist/mcp/process/process-platform.js +60 -1
- package/dist/mcp/process/process-sessions.js +15 -21
- package/dist/runtime/shell/command-shell-runtime.js +55 -22
- package/dist/server.js +1 -1
- package/dist/subagents/sessions/execution.js +1 -1
- package/docs/gotchas.md +1 -4
- package/package.json +4 -2
- package/scripts/ci/cmd-acceptance.mjs +295 -0
- 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
|
@@ -0,0 +1,295 @@
|
|
|
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("cmd.exe packaged acceptance skipped outside Windows.");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const npmCli = process.env.npm_execpath;
|
|
16
|
+
if (!npmCli) throw new Error("cmd acceptance must run through npm so npm_execpath is available");
|
|
17
|
+
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "forgerelay-cmd-acceptance-"));
|
|
19
|
+
try {
|
|
20
|
+
const cmd = resolveCmd();
|
|
21
|
+
const runtime = {
|
|
22
|
+
family: "cmd",
|
|
23
|
+
executable: cmd,
|
|
24
|
+
source: "explicit",
|
|
25
|
+
capabilities: ["cmd-command-language"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
await exercisePtyLifecycle(runtime);
|
|
29
|
+
await exercisePackagedCmdShim(cmd);
|
|
30
|
+
|
|
31
|
+
console.log(`cmd.exe acceptance passed with ${cmd}.`);
|
|
32
|
+
} finally {
|
|
33
|
+
await rm(root, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveCmd() {
|
|
37
|
+
const executable = process.env.ComSpec ?? process.env.COMSPEC;
|
|
38
|
+
if (!executable || !existsSync(executable)) {
|
|
39
|
+
throw new Error(`Windows cmd acceptance requires a valid ComSpec; got ${executable ?? "unset"}.`);
|
|
40
|
+
}
|
|
41
|
+
return executable;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function exercisePtyLifecycle(runtime) {
|
|
45
|
+
const [{ ProcessManager }, { BashOutputStore }] = await Promise.all([
|
|
46
|
+
import("../../dist/mcp/process/process-sessions.js"),
|
|
47
|
+
import("../../dist/activity/history/bash-output-store.js"),
|
|
48
|
+
]);
|
|
49
|
+
const durableStateDir = join(root, "durable-output-state");
|
|
50
|
+
const outputStore = new BashOutputStore(durableStateDir, {
|
|
51
|
+
outputId: () => "out_cmd_pty_acceptance",
|
|
52
|
+
flushBytes: 1,
|
|
53
|
+
});
|
|
54
|
+
const manager = new ProcessManager({
|
|
55
|
+
commandShellRuntime: runtime,
|
|
56
|
+
outputAudit: outputStore,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const node = quoteCmdArg(process.execPath);
|
|
61
|
+
const ptyScriptDir = join(root, "cmd pty script");
|
|
62
|
+
const ptyScript = join(ptyScriptDir, "interactive lifecycle.cmd");
|
|
63
|
+
await mkdir(ptyScriptDir, { recursive: true });
|
|
64
|
+
await writeFile(
|
|
65
|
+
ptyScript,
|
|
66
|
+
[
|
|
67
|
+
"@echo off",
|
|
68
|
+
"chcp 65001 >nul",
|
|
69
|
+
"echo cmd-pty-ready-雪",
|
|
70
|
+
"set /p FR_CMD_LINE=",
|
|
71
|
+
"echo stdin=%FR_CMD_LINE%",
|
|
72
|
+
`${node} -e "console.log('cols=' + process.stdout.columns + ';rows=' + process.stdout.rows)"`,
|
|
73
|
+
`${node} -e "console.log('cmd-pty-unicode-🙂')"`,
|
|
74
|
+
"exit /b 23",
|
|
75
|
+
"",
|
|
76
|
+
].join("\r\n"),
|
|
77
|
+
"utf8",
|
|
78
|
+
);
|
|
79
|
+
const pty = await manager.start({
|
|
80
|
+
workspaceId: "cmd-agent",
|
|
81
|
+
workspaceRoot: process.cwd(),
|
|
82
|
+
audit: {
|
|
83
|
+
activityId: "act-cmd-pty",
|
|
84
|
+
turnId: "turn-cmd-pty",
|
|
85
|
+
conversationScopeId: "conversation-cmd-pty",
|
|
86
|
+
},
|
|
87
|
+
cwd: process.cwd(),
|
|
88
|
+
command: quoteCmdArg(ptyScript),
|
|
89
|
+
tty: true,
|
|
90
|
+
columns: 80,
|
|
91
|
+
rows: 24,
|
|
92
|
+
yieldTimeMs: 5,
|
|
93
|
+
});
|
|
94
|
+
assert.equal(pty.running, true);
|
|
95
|
+
assert.ok(pty.processId);
|
|
96
|
+
assert.equal(pty.outputId, "out_cmd_pty_acceptance");
|
|
97
|
+
|
|
98
|
+
const interacted = await manager.write({
|
|
99
|
+
workspaceId: "cmd-agent",
|
|
100
|
+
processId: pty.processId,
|
|
101
|
+
columns: 120,
|
|
102
|
+
rows: 30,
|
|
103
|
+
chars: "input-plain\r",
|
|
104
|
+
yieldTimeMs: 5_000,
|
|
105
|
+
});
|
|
106
|
+
assert.equal(interacted.running, false);
|
|
107
|
+
assert.equal(interacted.exitCode, 23);
|
|
108
|
+
const ptyOutput = `${pty.output}${interacted.output}`;
|
|
109
|
+
assert.match(ptyOutput, /cmd-pty-ready-雪/);
|
|
110
|
+
assert.match(ptyOutput, /stdin=input-plain/);
|
|
111
|
+
assert.match(ptyOutput, /cols=120;rows=30/);
|
|
112
|
+
assert.match(ptyOutput, /cmd-pty-unicode-🙂/);
|
|
113
|
+
|
|
114
|
+
const durable = outputStore.read(pty.outputId);
|
|
115
|
+
assert.ok(durable);
|
|
116
|
+
assert.equal(durable.tty, true);
|
|
117
|
+
assert.equal(durable.exitCode, 23);
|
|
118
|
+
assert.equal(durable.status, "failed");
|
|
119
|
+
assert.match(durable.output, /cmd-pty-ready-雪/);
|
|
120
|
+
assert.match(durable.output, /stdin=input-plain/);
|
|
121
|
+
assert.match(durable.output, /cmd-pty-unicode-🙂/);
|
|
122
|
+
|
|
123
|
+
const background = await manager.start({
|
|
124
|
+
workspaceId: "cmd-agent",
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
command: `${node} -e "console.log('cmd-pty-background-start'); setTimeout(() => console.log('cmd-pty-background-done'), 250)"`,
|
|
127
|
+
tty: true,
|
|
128
|
+
yieldTimeMs: 5,
|
|
129
|
+
});
|
|
130
|
+
assert.equal(background.running, true);
|
|
131
|
+
assert.ok(background.processId);
|
|
132
|
+
const backgroundDone = await manager.write({
|
|
133
|
+
workspaceId: "cmd-agent",
|
|
134
|
+
processId: background.processId,
|
|
135
|
+
yieldTimeMs: 5_000,
|
|
136
|
+
});
|
|
137
|
+
assert.equal(backgroundDone.running, false);
|
|
138
|
+
assert.equal(backgroundDone.exitCode, 0);
|
|
139
|
+
assert.match(`${background.output}${backgroundDone.output}`, /cmd-pty-background-done/);
|
|
140
|
+
|
|
141
|
+
const timedOut = await manager.start({
|
|
142
|
+
workspaceId: "cmd-agent",
|
|
143
|
+
cwd: process.cwd(),
|
|
144
|
+
command: `${node} -e "setTimeout(() => {}, 30000)"`,
|
|
145
|
+
tty: true,
|
|
146
|
+
yieldTimeMs: 5_000,
|
|
147
|
+
timeoutMs: 100,
|
|
148
|
+
});
|
|
149
|
+
assert.equal(timedOut.running, false);
|
|
150
|
+
assert.equal(timedOut.timedOut, true);
|
|
151
|
+
|
|
152
|
+
const pidPath = join(root, "cmd-pty-child.pid");
|
|
153
|
+
const childScript = "require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)";
|
|
154
|
+
const interruptible = await manager.start({
|
|
155
|
+
workspaceId: "cmd-agent",
|
|
156
|
+
cwd: process.cwd(),
|
|
157
|
+
command: `${node} -e ${quoteCmdArg(childScript)} ${quoteCmdArg(pidPath)}`,
|
|
158
|
+
tty: true,
|
|
159
|
+
yieldTimeMs: 5,
|
|
160
|
+
});
|
|
161
|
+
assert.equal(interruptible.running, true);
|
|
162
|
+
assert.ok(interruptible.processId);
|
|
163
|
+
const childPid = Number.parseInt(await waitForFile(pidPath), 10);
|
|
164
|
+
assert.ok(Number.isInteger(childPid) && childPid > 0, `invalid PTY child pid: ${childPid}`);
|
|
165
|
+
assert.equal(windowsProcessExists(childPid), true);
|
|
166
|
+
|
|
167
|
+
const interrupted = await manager.write({
|
|
168
|
+
workspaceId: "cmd-agent",
|
|
169
|
+
processId: interruptible.processId,
|
|
170
|
+
chars: "\u0003",
|
|
171
|
+
yieldTimeMs: 5_000,
|
|
172
|
+
});
|
|
173
|
+
assert.equal(interrupted.running, false);
|
|
174
|
+
await waitForWindowsProcessExit(childPid);
|
|
175
|
+
assert.equal(windowsProcessExists(childPid), false, `PTY child process ${childPid} leaked after interrupt`);
|
|
176
|
+
} finally {
|
|
177
|
+
manager.shutdown();
|
|
178
|
+
outputStore.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function exercisePackagedCmdShim(cmd) {
|
|
183
|
+
const artifactDir = join(root, "artifact");
|
|
184
|
+
const prefix = join(root, "prefix");
|
|
185
|
+
const configDir = join(root, "config");
|
|
186
|
+
const stateDir = join(root, "state");
|
|
187
|
+
await Promise.all([
|
|
188
|
+
mkdir(artifactDir, { recursive: true }),
|
|
189
|
+
mkdir(prefix, { recursive: true }),
|
|
190
|
+
mkdir(configDir, { recursive: true }),
|
|
191
|
+
mkdir(stateDir, { recursive: true }),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const packed = runNpm(["pack", "--json", "--pack-destination", artifactDir]);
|
|
195
|
+
const packResult = JSON.parse(packed.stdout);
|
|
196
|
+
const filename = packResult?.[0]?.filename;
|
|
197
|
+
if (!filename) throw new Error(`npm pack did not report a package filename: ${packed.stdout}`);
|
|
198
|
+
const tarball = join(artifactDir, filename);
|
|
199
|
+
assert.ok(existsSync(tarball), `packed artifact is missing: ${tarball}`);
|
|
200
|
+
|
|
201
|
+
runNpm(["install", "--global", "--prefix", prefix, tarball]);
|
|
202
|
+
const shim = join(prefix, "forgerelay.cmd");
|
|
203
|
+
assert.ok(existsSync(shim), `npm did not create the cmd launcher shim: ${shim}`);
|
|
204
|
+
|
|
205
|
+
await writeFile(
|
|
206
|
+
join(configDir, "config.json"),
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
host: "127.0.0.1",
|
|
209
|
+
port: 7678,
|
|
210
|
+
allowedRoots: [process.cwd()],
|
|
211
|
+
stateDir,
|
|
212
|
+
worktreeRoot: join(root, "worktrees"),
|
|
213
|
+
commandShell: {
|
|
214
|
+
mode: "follow-launcher",
|
|
215
|
+
family: "cmd",
|
|
216
|
+
executable: cmd,
|
|
217
|
+
},
|
|
218
|
+
shellInstructions: false,
|
|
219
|
+
}, null, 2),
|
|
220
|
+
"utf8",
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const launcherEnv = {
|
|
224
|
+
...process.env,
|
|
225
|
+
FORGERELAY_CONFIG_DIR: configDir,
|
|
226
|
+
FORGERELAY_OAUTH_OWNER_TOKEN: "cmd-acceptance-owner-token-that-is-long-enough",
|
|
227
|
+
};
|
|
228
|
+
delete launcherEnv.npm_lifecycle_event;
|
|
229
|
+
delete launcherEnv.FORGERELAY_COMMAND_SHELL;
|
|
230
|
+
|
|
231
|
+
const result = spawnSync(
|
|
232
|
+
cmd,
|
|
233
|
+
["/d", "/s", "/c", `"\"${shim}\" doctor"`],
|
|
234
|
+
{
|
|
235
|
+
cwd: process.cwd(),
|
|
236
|
+
env: launcherEnv,
|
|
237
|
+
encoding: "utf8",
|
|
238
|
+
windowsHide: true,
|
|
239
|
+
windowsVerbatimArguments: true,
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
if (result.error || result.status !== 0) {
|
|
243
|
+
throw new Error(`Packaged cmd launcher failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
244
|
+
}
|
|
245
|
+
assert.match(result.stdout ?? "", /Command shell: cmd \(.+; launcher\)/);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function runNpm(args) {
|
|
249
|
+
const result = spawnSync(process.execPath, [npmCli, ...args], {
|
|
250
|
+
cwd: process.cwd(),
|
|
251
|
+
env: process.env,
|
|
252
|
+
encoding: "utf8",
|
|
253
|
+
windowsHide: true,
|
|
254
|
+
});
|
|
255
|
+
if (result.error || result.status !== 0) {
|
|
256
|
+
throw new Error(`npm ${args.join(" ")} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function waitForFile(path, timeoutMs = 5_000) {
|
|
262
|
+
const deadline = Date.now() + timeoutMs;
|
|
263
|
+
while (Date.now() < deadline) {
|
|
264
|
+
try {
|
|
265
|
+
return await readFile(path, "utf8");
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
268
|
+
}
|
|
269
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
270
|
+
}
|
|
271
|
+
throw new Error(`Timed out waiting for file: ${path}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function windowsProcessExists(pid) {
|
|
275
|
+
const result = spawnSync(
|
|
276
|
+
"tasklist.exe",
|
|
277
|
+
["/fi", `PID eq ${pid}`, "/fo", "csv", "/nh"],
|
|
278
|
+
{ encoding: "utf8", windowsHide: true },
|
|
279
|
+
);
|
|
280
|
+
if (result.error) throw result.error;
|
|
281
|
+
if (result.status !== 0) throw new Error(`tasklist.exe failed with exit ${result.status ?? "unknown"}`);
|
|
282
|
+
return new RegExp(`"${pid}"(?:,|$)`).test(result.stdout ?? "");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function waitForWindowsProcessExit(pid, timeoutMs = 5_000) {
|
|
286
|
+
const deadline = Date.now() + timeoutMs;
|
|
287
|
+
while (Date.now() < deadline) {
|
|
288
|
+
if (!windowsProcessExists(pid)) return;
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function quoteCmdArg(value) {
|
|
294
|
+
return `"${String(value).replaceAll('"', '""')}"`;
|
|
295
|
+
}
|