copilot-operator 0.1.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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Runs one step in a Windows shell and captures everything it produced.
3
+ *
4
+ * The hard case this is built for: a step that runs an automated test suite. Such a step
5
+ * can legitimately take an hour, and a naive wall-clock timeout would kill a perfectly
6
+ * healthy run. So there are two independent clocks:
7
+ *
8
+ * hardTimeoutMs absolute ceiling. A run that exceeds it is killed, no matter what.
9
+ * idleTimeoutMs how long the process may produce NO output at all before it is
10
+ * considered hung. This is the clock that actually matters: a test suite
11
+ * printing a line every few seconds resets it forever, while a genuinely
12
+ * stuck process trips it quickly.
13
+ *
14
+ * Output is streamed to disk as it arrives, so a killed step still reports everything it
15
+ * managed to print. Nothing is buffered in memory only.
16
+ *
17
+ * Which shell a step runs in is not decided here. It is decided in `shells.ts`, for steps and
18
+ * post-task checks alike, and this module only carries the answer out again in the result —
19
+ * what was asked for, what it ran in, and the executable that was actually started — because a
20
+ * run that behaved oddly is usually a run that was read by an interpreter nobody looked at.
21
+ */
22
+ import { spawn } from 'node:child_process';
23
+ import { createWriteStream, existsSync } from 'node:fs';
24
+ import { mkdir } from 'node:fs/promises';
25
+ import { dirname } from 'node:path';
26
+ import { effectiveShell, invocationFor, missingShellProblem, resolveShell } from './shells.js';
27
+ import { stopTree } from './processes.js';
28
+ import { stepEnvironment } from './stepEnv.js';
29
+ const DEFAULT_HARD_TIMEOUT_MS = 4 * 60 * 60 * 1000;
30
+ const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
31
+ const HEARTBEAT_EVERY_MS = 30_000;
32
+ /** Cap on what is held in memory and echoed into the report body. */
33
+ const MAX_CAPTURE_CHARS = 200_000;
34
+ /**
35
+ * The result of a step that never started because the machine has no shell for it.
36
+ *
37
+ * Shaped like a spawn error, because that is what it would have been a moment later, and with
38
+ * the problem attached so that whoever is counting attempts can see this one does not count.
39
+ */
40
+ function unrunnable(req, problem) {
41
+ return {
42
+ id: req.id,
43
+ // What it would have run in, which on a machine with nothing installed is only a name.
44
+ shell: effectiveShell(req.shell),
45
+ requestedShell: req.shell ?? null,
46
+ shellPath: '',
47
+ shellProblem: problem,
48
+ command: req.command,
49
+ exitCode: -2,
50
+ outcome: 'spawn-error',
51
+ durationMs: 0,
52
+ stdout: '',
53
+ stderr: `[runner] the command was not run: ${problem.message}\n`,
54
+ truncated: false,
55
+ logPath: '',
56
+ lastOutputAgoMs: 0,
57
+ };
58
+ }
59
+ /**
60
+ * The longest a step that is being stopped may take before its result is given anyway. The stop
61
+ * itself is `taskkill /T`, a grace period, then `/F` (see `processes.ts`), which with reading the
62
+ * process table comes to about ten seconds at worst; this is the ceiling on
63
+ * waiting for it, so that a stop that itself hangs cannot hang the task.
64
+ */
65
+ const STOP_CEILING_MS = 30_000;
66
+ export async function runStep(req,
67
+ /**
68
+ * `tracker` records the shell this step starts, which is what later lets the runner tell the
69
+ * processes the bot started from ones the operator started by hand. See `processes.ts`.
70
+ */
71
+ opts = {}) {
72
+ const hardTimeoutMs = req.hardTimeoutMs ?? DEFAULT_HARD_TIMEOUT_MS;
73
+ const idleTimeoutMs = req.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
74
+ const startedAt = Date.now();
75
+ // Every path into the shell goes through here, which is what makes a step and a post-task
76
+ // check incapable of disagreeing about which interpreter their commands were read by.
77
+ const choice = resolveShell(req.shell);
78
+ if (!choice.ok)
79
+ return unrunnable(req, choice.problem);
80
+ const resolved = choice.resolved;
81
+ await mkdir(dirname(req.logPath), { recursive: true });
82
+ const log = createWriteStream(req.logPath, { flags: 'a' });
83
+ /*
84
+ * A step log that cannot be written must never take the process down with it.
85
+ *
86
+ * Without a listener, a stream error is an unhandled 'error' event, which in Node is an
87
+ * uncaught exception: a full disk, a locked file or a write that lands after the stream has
88
+ * ended would kill the API in the middle of a run. The log is a record of a step, not the
89
+ * step itself, so losing a line of it is the smallest possible failure and is treated as one.
90
+ */
91
+ log.on('error', () => undefined);
92
+ log.write(`# step ${req.id} shell=${resolved.shell} exe=${resolved.path} cwd=${req.cwd}\n# ${req.command}\n`);
93
+ const { file, args } = invocationFor(resolved, req.command, req.scriptArgs);
94
+ return await new Promise((resolve) => {
95
+ let stdout = '';
96
+ let stderr = '';
97
+ let bytesOut = 0;
98
+ let truncated = false;
99
+ let lastLine = '';
100
+ let lastOutputAt = Date.now();
101
+ let settled = false;
102
+ /** Set when the shell itself would not start, which is not a failure of the command. */
103
+ let shellProblem;
104
+ // PowerShell colours its output with ANSI escapes, which then travel to Copilot inside
105
+ // the report as `ESC[32;1m` noise around every value. NO_COLOR is honoured by
106
+ // PowerShell 7 and by most modern tools, and was verified to produce clean output here.
107
+ const child = spawn(file, args, {
108
+ cwd: req.cwd,
109
+ // Named, not inherited: a step must not see the bot's own token or whatever else the
110
+ // operator's shell holds. See `stepEnv.ts`.
111
+ env: stepEnvironment(req.env ?? process.env, req.passEnv),
112
+ windowsHide: true,
113
+ stdio: ['ignore', 'pipe', 'pipe'],
114
+ });
115
+ if (child.pid)
116
+ opts.tracker?.started(child.pid);
117
+ /** Set once the runner has decided to stop the step, so the process exiting reports why. */
118
+ let stopping = null;
119
+ const finish = (outcome, exitCode) => {
120
+ if (settled)
121
+ return;
122
+ settled = true;
123
+ if (child.pid)
124
+ opts.tracker?.ended(child.pid);
125
+ clearInterval(heartbeat);
126
+ clearTimeout(hardTimer);
127
+ clearInterval(idleTimer);
128
+ opts.signal?.removeEventListener('abort', onAbort);
129
+ log.end(`\n# outcome=${outcome} exit=${exitCode} durationMs=${Date.now() - startedAt}\n`);
130
+ resolve({
131
+ id: req.id,
132
+ shell: resolved.shell,
133
+ requestedShell: resolved.requested,
134
+ shellPath: resolved.path,
135
+ shellProblem,
136
+ command: req.command,
137
+ exitCode,
138
+ outcome,
139
+ durationMs: Date.now() - startedAt,
140
+ stdout,
141
+ stderr,
142
+ truncated,
143
+ logPath: req.logPath,
144
+ lastOutputAgoMs: Date.now() - lastOutputAt,
145
+ });
146
+ };
147
+ const capture = (chunk, stream) => {
148
+ const text = chunk.toString('utf8');
149
+ lastOutputAt = Date.now();
150
+ bytesOut += chunk.length;
151
+ // Output can still arrive after the child has closed and the step has been settled: the
152
+ // pipes flush independently of the close event. Writing then would be a write to an
153
+ // ended stream, so the tail goes to the captured text and not to the file.
154
+ if (!settled)
155
+ log.write(stream === 'err' ? text.replace(/^/gm, '[stderr] ') : text);
156
+ const trimmed = text.trimEnd();
157
+ const nl = trimmed.lastIndexOf('\n');
158
+ if (trimmed)
159
+ lastLine = nl >= 0 ? trimmed.slice(nl + 1) : trimmed;
160
+ const target = stream === 'out' ? stdout : stderr;
161
+ if (target.length >= MAX_CAPTURE_CHARS) {
162
+ truncated = true;
163
+ return;
164
+ }
165
+ const room = MAX_CAPTURE_CHARS - target.length;
166
+ const slice = text.length > room ? text.slice(0, room) : text;
167
+ if (slice.length < text.length)
168
+ truncated = true;
169
+ if (stream === 'out')
170
+ stdout += slice;
171
+ else
172
+ stderr += slice;
173
+ };
174
+ child.stdout.on('data', (c) => capture(c, 'out'));
175
+ child.stderr.on('data', (c) => capture(c, 'err'));
176
+ child.on('error', (err) => {
177
+ stderr += `\n[runner] failed to start: ${String(err)}\n`;
178
+ /*
179
+ * ENOENT here is usually the interpreter — `spawn` never got as far as reading what it was
180
+ * given — and that is worth saying plainly, because the one thing that must not happen next
181
+ * is the task treating it as work that failed and trying again.
182
+ *
183
+ * It is not always the interpreter, though, and Windows makes the two indistinguishable
184
+ * from the error alone. A `cwd` that does not exist raises ENOENT as well, and Node writes
185
+ * the *executable's* name into the message while doing it: `spawn cmd.exe ENOENT` for a
186
+ * directory that was simply never created. A step or a check pointed at a folder an earlier
187
+ * step was supposed to make would then close the task with a sentence about PowerShell not
188
+ * being installed — false, and worse than false, because an environment fault is never sent
189
+ * back to the chat and the chat is the only thing that could have created the folder. So
190
+ * the two are told apart by the one fact that separates them: whether the executable is
191
+ * still where detection found it.
192
+ */
193
+ const enoent = err.code === 'ENOENT';
194
+ if (enoent && !existsSync(resolved.path))
195
+ shellProblem = missingShellProblem(resolved);
196
+ finish('spawn-error', -2);
197
+ });
198
+ // While the runner is stopping the step the process exiting is the stop working, not the step
199
+ // completing, so the reason recorded is the runner's.
200
+ child.on('close', (code) => (stopping ? finish(stopping.outcome, stopping.exitCode) : finish('completed', code ?? -1)));
201
+ /*
202
+ * Stops the step's whole tree — `taskkill /T`, then `/F` — and gives the result only
203
+ * once it is down, so the next step does not start while this one still holds a port or a
204
+ * file. It used to be `taskkill /T /F` at once, which is TerminateProcess: no handler runs, and a
205
+ * dev server or a test runner is cut off mid-write.
206
+ */
207
+ const stop = (outcome, exitCode, why) => {
208
+ if (stopping || settled)
209
+ return;
210
+ stopping = { outcome, exitCode };
211
+ stderr += `\n[runner] ${why}, stopping the process tree (asked to close, then forced)\n`;
212
+ clearTimeout(hardTimer);
213
+ clearInterval(idleTimer);
214
+ const ceiling = setTimeout(() => finish(outcome, exitCode), STOP_CEILING_MS);
215
+ const pid = child.pid;
216
+ void (pid ? stopTree(pid, startedAt) : Promise.resolve())
217
+ .catch(() => undefined)
218
+ .finally(() => {
219
+ clearTimeout(ceiling);
220
+ finish(outcome, exitCode);
221
+ });
222
+ };
223
+ const hardTimer = setTimeout(() => {
224
+ stop('hard-timeout', -1, `hard timeout after ${Math.round(hardTimeoutMs / 1000)}s`);
225
+ }, hardTimeoutMs);
226
+ const idleTimer = setInterval(() => {
227
+ if (Date.now() - lastOutputAt < idleTimeoutMs)
228
+ return;
229
+ stop('idle-timeout', -1, `no output for ${Math.round(idleTimeoutMs / 1000)}s, treating as hung`);
230
+ }, Math.min(idleTimeoutMs, 30_000));
231
+ const heartbeat = setInterval(() => {
232
+ opts.onHeartbeat?.({
233
+ id: req.id,
234
+ elapsedMs: Date.now() - startedAt,
235
+ idleMs: Date.now() - lastOutputAt,
236
+ bytesOut,
237
+ lastLine,
238
+ });
239
+ }, HEARTBEAT_EVERY_MS);
240
+ const onAbort = () => {
241
+ stop('aborted', -3, 'aborted by the user');
242
+ };
243
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
244
+ });
245
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The script files a command line runs, read so that what is in them passes the same gate as the
3
+ * line itself.
4
+ *
5
+ * The contract tells the chat to write a long script into the project with `Set-Content` and run
6
+ * it in the next step with `pwsh -File`. The file's text is then screened twice: once as the text
7
+ * of the step that wrote it, and — since 2026-09-27 — again here, as the file on disk, when a step
8
+ * runs it. The second reading is the one that counts: it sees the file as it is, not as it was
9
+ * described.
10
+ *
11
+ * Two rules follow from that:
12
+ *
13
+ * - A step that both writes a script and runs it is refused. The contract already says write in
14
+ * one step, run in the next; only then does the file exist to be read at the gate.
15
+ * - A step that runs a script the gate cannot read — the file is missing, or larger than a step's
16
+ * worth of text — is refused with that reason rather than run on trust.
17
+ *
18
+ * Covered: PowerShell scripts given to `-File`, dot-sourced, or called with `&` or by path. Not
19
+ * covered, and stated so: a `.js`, `.py` or `.cs` file a project's own tools run. Those are the
20
+ * project's code, and this runner has never claimed to read it; the boundary there is isolation.
21
+ */
22
+ import { readFileSync, statSync } from 'node:fs';
23
+ import { isAbsolute, resolve } from 'node:path';
24
+ /** Larger than this is not a step's script; it is not read, and running it is refused. */
25
+ const MAX_SCRIPT_BYTES = 256 * 1024;
26
+ /** Paths of PowerShell scripts this command line runs, as written. */
27
+ export function scriptPathsRun(command) {
28
+ const paths = new Set();
29
+ const patterns = [
30
+ /\b(?:pwsh|powershell)(?:\.exe)?\b[^|;\n]*?\s-(?:File|f)\s+(?:"([^"]+)"|'([^']+)'|(\S+))/gi,
31
+ /(?:^|[|;&(\n{]\s*)(?:[.&]\s+)?(?:"([^"]+\.ps1)"|'([^']+\.ps1)'|(\S+\.ps1))(?=\s|$)/gi,
32
+ ];
33
+ for (const re of patterns) {
34
+ for (const m of command.matchAll(re)) {
35
+ const p = (m[1] ?? m[2] ?? m[3] ?? '').trim();
36
+ if (p && /\.ps1$/i.test(p))
37
+ paths.add(p);
38
+ }
39
+ }
40
+ return [...paths];
41
+ }
42
+ /** Whether the command also writes a PowerShell script — with a cmdlet or a redirection. */
43
+ export function writesScript(command) {
44
+ return /\b(Set-Content|Add-Content|Out-File|New-Item|Copy-Item|Move-Item|Rename-Item|sc|ac|cp|copy|mv|move|ren|ni)\b[^|;\n]*\.ps1\b|>\s*["']?[^\s"']*\.ps1\b/i.test(command);
45
+ }
46
+ /** Reads the scripts a command runs, relative to `cwd`. */
47
+ export function scriptFilesRun(command, cwd) {
48
+ return scriptPathsRun(command).map((p) => {
49
+ const path = isAbsolute(p) ? p : resolve(cwd, p);
50
+ try {
51
+ const size = statSync(path).size;
52
+ if (size > MAX_SCRIPT_BYTES)
53
+ return { path, text: null, problem: `${path} is ${size} bytes, larger than a step's script` };
54
+ return { path, text: readFileSync(path, 'utf8') };
55
+ }
56
+ catch {
57
+ return { path, text: null, problem: `${path} does not exist yet, so its contents cannot be read before it runs` };
58
+ }
59
+ });
60
+ }
61
+ /**
62
+ * Why the scripts this line runs must not run, or null. `screen` is the gate applied to each
63
+ * script's text — the same one the command line went through.
64
+ */
65
+ export function scriptFileRefusal(command, cwd, screen) {
66
+ const paths = scriptPathsRun(command);
67
+ if (paths.length === 0)
68
+ return null;
69
+ if (writesScript(command)) {
70
+ return ('refused: this step writes a script and runs it in the same line. Write the file in one step and run it in the ' +
71
+ 'next, so that what is in the file can be read before it runs.');
72
+ }
73
+ for (const file of scriptFilesRun(command, cwd)) {
74
+ if (file.text === null)
75
+ return `refused: ${file.problem}. Write the file in an earlier step, then run it.`;
76
+ const reason = screen(file.text);
77
+ if (reason)
78
+ return `${reason} (in the script ${file.path})`;
79
+ }
80
+ return null;
81
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `Start-Process npx` does not start npx.
3
+ *
4
+ * PowerShell resolves a bare command name through PATH and, inside a directory, takes a `.ps1`
5
+ * before a `.cmd` of the same name. Node ships `npm.ps1` and `npx.ps1` next to their `.cmd`
6
+ * twins, and every `npm install -g` adds another pair (`pnpm.ps1`, `pnpx.ps1`). Typed inline,
7
+ * `npx tsx src/main.ts`, that is exactly right: the script runs in the shell that found it.
8
+ * Handed to `Start-Process` it is not. Start-Process gives anything that is not an executable
9
+ * to ShellExecute, and the Windows shell then does whatever `.ps1` is associated with on that
10
+ * machine: here "This command cannot be run completely because the system cannot find all the
11
+ * information required", or a "Select an app to open this .ps1 file" dialog that nobody is
12
+ * sitting in front of. Either way no server starts, and the step that then polls the port
13
+ * reports "connection refused" against work that was fine.
14
+ *
15
+ * On 2026-09-20 thirteen review steps started a server this way and one hung on the dialog
16
+ * until the hard timeout (91 s). The reviewer contract's own example had taught the form, and
17
+ * the model reproduced it faithfully, so the contract is fixed too; this file is what makes the
18
+ * refusal mechanical, because a rule that lives only in prose is advice.
19
+ *
20
+ * The rule is exact rather than a list of names: a name is a trap when the PATH search
21
+ * PowerShell itself performs would land on a `.ps1` for it, in the same directories and the
22
+ * same order. `node`, `pwsh`, `cmd.exe` and a name that is not on PATH at all pass through:
23
+ * the first two resolve to an `.exe`, the last fails fast and clearly on its own.
24
+ */
25
+ import { existsSync } from 'node:fs';
26
+ import { extname, join } from 'node:path';
27
+ /** Start-Process parameters that take a value, so the token after them is not the target. */
28
+ const VALUE_PARAMETERS = [
29
+ 'ArgumentList',
30
+ 'Args',
31
+ 'FilePath',
32
+ 'Path',
33
+ 'Credential',
34
+ 'WorkingDirectory',
35
+ 'wd',
36
+ 'Verb',
37
+ 'WindowStyle',
38
+ 'RedirectStandardError',
39
+ 'RedirectStandardInput',
40
+ 'RedirectStandardOutput',
41
+ 'RSE',
42
+ 'RSI',
43
+ 'RSO',
44
+ 'Environment',
45
+ ];
46
+ /** Cached PATH lookups: the search is cheap, but a step can name the same command many times. */
47
+ const shimCache = new Map();
48
+ const SHIM_CACHE_MS = 60_000;
49
+ /**
50
+ * The `.ps1` that PowerShell would run for a bare `name`, or null when it would run an
51
+ * application (or nothing). Walks PATH in order; in each directory a `.ps1` wins over any
52
+ * PATHEXT extension, and the first directory that has the name in any form decides.
53
+ */
54
+ export function scriptShimFor(name, env = process.env) {
55
+ const path = env.PATH ?? env.Path ?? '';
56
+ const key = `${path}\u0000${name.toLowerCase()}`;
57
+ const cached = shimCache.get(key);
58
+ if (cached && Date.now() - cached.at < SHIM_CACHE_MS)
59
+ return cached.hit;
60
+ const pathext = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
61
+ let hit = null;
62
+ for (const dir of path.split(';').map((d) => d.trim()).filter(Boolean)) {
63
+ const script = join(dir, `${name}.ps1`);
64
+ if (existsSync(script)) {
65
+ hit = script;
66
+ break;
67
+ }
68
+ if (pathext.some((ext) => existsSync(join(dir, `${name}${ext}`))))
69
+ break;
70
+ }
71
+ shimCache.set(key, { at: Date.now(), hit });
72
+ return hit;
73
+ }
74
+ /** For tests, which build their own PATH and must not read a stale answer. */
75
+ export function forgetScriptShims() {
76
+ shimCache.clear();
77
+ }
78
+ /**
79
+ * Splits one PowerShell statement into tokens: whitespace-separated, quotes kept together,
80
+ * parentheses and braces kept together at depth, and the statement cut at the first `;`, `|`,
81
+ * newline or closing bracket that belongs to an enclosing construct.
82
+ */
83
+ function tokenize(text) {
84
+ const tokens = [];
85
+ let cur = '';
86
+ let quoted = false;
87
+ let quote = null;
88
+ let depth = 0;
89
+ const push = () => {
90
+ if (cur)
91
+ tokens.push({ text: cur, quoted });
92
+ cur = '';
93
+ quoted = false;
94
+ };
95
+ for (let i = 0; i < text.length; i += 1) {
96
+ const ch = text[i];
97
+ if (ch === '\r' && !quote)
98
+ continue;
99
+ if (quote) {
100
+ cur += ch;
101
+ if (ch === quote)
102
+ quote = null;
103
+ continue;
104
+ }
105
+ if (ch === "'" || ch === '"') {
106
+ quote = ch;
107
+ quoted = true;
108
+ cur += ch;
109
+ continue;
110
+ }
111
+ if (ch === '(' || ch === '{' || ch === '[')
112
+ depth += 1;
113
+ if (ch === ')' || ch === '}' || ch === ']') {
114
+ if (depth === 0)
115
+ break;
116
+ depth -= 1;
117
+ }
118
+ if (depth === 0 && (ch === ';' || ch === '|' || ch === '\n' || ch === '\r')) {
119
+ if (ch === '\n' && cur.endsWith('`')) {
120
+ cur = cur.slice(0, -1);
121
+ push();
122
+ continue;
123
+ }
124
+ break;
125
+ }
126
+ if (depth === 0 && /\s/.test(ch)) {
127
+ push();
128
+ continue;
129
+ }
130
+ cur += ch;
131
+ }
132
+ push();
133
+ return tokens;
134
+ }
135
+ function takesValue(parameter) {
136
+ const p = parameter.toLowerCase();
137
+ return VALUE_PARAMETERS.some((full) => full.toLowerCase().startsWith(p));
138
+ }
139
+ /** The target of one Start-Process invocation whose text starts right after the cmdlet name. */
140
+ function targetOf(rest) {
141
+ const tokens = tokenize(rest);
142
+ for (let i = 0; i < tokens.length; i += 1) {
143
+ const t = tokens[i];
144
+ if (!t.quoted && t.text.startsWith('-')) {
145
+ const [name, inlineValue] = t.text.slice(1).split(':', 2);
146
+ const isFilePath = 'filepath'.startsWith(name.toLowerCase()) || 'path'.startsWith(name.toLowerCase());
147
+ if (inlineValue !== undefined) {
148
+ if (isFilePath)
149
+ return inlineValue;
150
+ continue;
151
+ }
152
+ if (!takesValue(name))
153
+ continue;
154
+ const value = tokens[i + 1];
155
+ if (!value)
156
+ return null;
157
+ if (isFilePath)
158
+ return value.text;
159
+ // A comma-separated list written with spaces (`'a', 'b'`) continues past the first token.
160
+ let j = i + 1;
161
+ while (tokens[j]?.text.endsWith(',') && tokens[j + 1])
162
+ j += 1;
163
+ i = j;
164
+ continue;
165
+ }
166
+ return t.text;
167
+ }
168
+ return null;
169
+ }
170
+ /**
171
+ * Every Start-Process target in a command line. The alias forms `start` and `saps` count only
172
+ * where a command can begin, so the `'start'` inside an argument list is not mistaken for one.
173
+ */
174
+ export function startProcessTargets(command) {
175
+ const out = [];
176
+ const re = /(?:\bStart-Process\b|(?<=^|[;{(|=&]|\n)\s*\b(?:start|saps)\b(?!-))/gi;
177
+ let m;
178
+ while ((m = re.exec(command)) !== null) {
179
+ const target = targetOf(command.slice(m.index + m[0].length));
180
+ if (target)
181
+ out.push(target.replace(/^['"]|['"]$/g, ''));
182
+ }
183
+ return out;
184
+ }
185
+ /** Bare means: no directory, no extension, no expression — the shell has to look it up. */
186
+ function isBareName(target) {
187
+ return /^[A-Za-z0-9_.-]+$/.test(target) && !/[\\/:]/.test(target) && extname(target) === '' && !target.includes('$');
188
+ }
189
+ /**
190
+ * Why a command must not run as written, or null. The full cmdlet name is checked in any shell,
191
+ * because a `cmd` step can wrap `powershell -Command "Start-Process npx"` and fall into the same
192
+ * hole; the short aliases exist only in PowerShell.
193
+ */
194
+ export function findShellExecuteTrap(command, shell = 'pwsh', env = process.env) {
195
+ if (!/start-process|\b(?:start|saps)\b/i.test(command))
196
+ return null;
197
+ const targets = shell === 'cmd'
198
+ ? startProcessTargets(command.replace(/(?<=^|[;{(|=&]|\n)\s*\b(?:start|saps)\b(?!-)/gi, ''))
199
+ : startProcessTargets(command);
200
+ for (const target of targets) {
201
+ if (extname(target).toLowerCase() === '.ps1') {
202
+ return (`\`Start-Process ${target}\` hands a .ps1 to the Windows shell, which fails ("cannot find all the ` +
203
+ `information required") or opens a "Select an app" dialog nobody is there to answer. Run the script ` +
204
+ `with its interpreter: \`Start-Process pwsh -ArgumentList '-NoProfile','-File','${target}'\`, or inline with \`& '${target}'\`.`);
205
+ }
206
+ if (!isBareName(target))
207
+ continue;
208
+ const shim = scriptShimFor(target, env);
209
+ if (!shim)
210
+ continue;
211
+ const twin = shim.replace(/\.ps1$/i, '.cmd');
212
+ const named = existsSync(twin) ? `'${target}.cmd'` : `the .cmd or .exe, with its extension`;
213
+ return (`\`Start-Process ${target}\` would not start ${target}: PowerShell resolves the bare name to ${shim}, ` +
214
+ `and Start-Process hands a .ps1 to the Windows shell, which fails ("cannot find all the information required") ` +
215
+ `or opens a "Select an app" dialog nobody is there to answer, so the step hangs until its timeout and nothing ` +
216
+ `listens on the port. Name the file: \`Start-Process -FilePath ${named} -ArgumentList ...\` — that PID is a ` +
217
+ `wrapper's, stop the tree with \`taskkill /PID $p.Id /T /F\` — or start the program itself ` +
218
+ `(\`Start-Process node -ArgumentList 'dist/main.js'\`). Inline, \`${target} ...\` runs fine in the shell.`);
219
+ }
220
+ return null;
221
+ }