@ctrl-spc/cs 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agents.js CHANGED
@@ -1,6 +1,6 @@
1
- import { accessSync, constants } from 'node:fs';
1
+ import { accessSync, constants, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { delimiter, join } from 'node:path';
3
+ import { delimiter, join, win32 } from 'node:path';
4
4
  const AGENTS = ['claude', 'codex'];
5
5
  /** Well-known install locations Claude/Codex land in but that a plain
6
6
  * Terminal's PATH often misses (notably the Codex CLI inside ChatGPT.app). */
@@ -9,13 +9,38 @@ function fallbackPaths(agent) {
9
9
  return [join(homedir(), '.local/bin/claude'), '/opt/homebrew/bin/claude', '/usr/local/bin/claude'];
10
10
  }
11
11
  return [
12
- process.env.CODEX_CLI_PATH ?? '',
13
12
  '/Applications/ChatGPT.app/Contents/Resources/codex',
14
13
  join(homedir(), '.local/bin/codex'),
15
14
  '/opt/homebrew/bin/codex',
16
15
  '/usr/local/bin/codex',
17
16
  ].filter(Boolean);
18
17
  }
18
+ /** Explicit and app-managed installs that must beat PATH. On Windows the
19
+ * Desktop app's current runtime and the global npm CLI can be different
20
+ * builds; the app-managed binary is the one paired with the machine's current
21
+ * sandbox helpers. */
22
+ function preferredPaths(agent, platform = process.platform, env = process.env, home = homedir()) {
23
+ const explicit = agent === 'codex' ? (env.CODEX_CLI_PATH ?? '').trim() : '';
24
+ const windowsAppRoot = agent === 'codex' && platform === 'win32'
25
+ ? win32.join(env.LOCALAPPDATA ?? win32.join(home, 'AppData', 'Local'), 'OpenAI', 'Codex', 'bin')
26
+ : '';
27
+ let bundled = [];
28
+ if (windowsAppRoot) {
29
+ try {
30
+ bundled = readdirSync(windowsAppRoot, { withFileTypes: true })
31
+ .filter((entry) => entry.isDirectory())
32
+ .map((entry) => win32.join(windowsAppRoot, entry.name, 'codex.exe'))
33
+ .filter(isExecutable)
34
+ .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs);
35
+ }
36
+ catch {
37
+ bundled = [];
38
+ }
39
+ }
40
+ const windowsAppFallback = windowsAppRoot ? win32.join(windowsAppRoot, 'codex.exe') : '';
41
+ return [explicit, ...bundled, windowsAppFallback].filter(Boolean);
42
+ }
43
+ export const preferredPathsForTest = preferredPaths;
19
44
  function isExecutable(path) {
20
45
  try {
21
46
  accessSync(path, constants.X_OK);
@@ -25,11 +50,51 @@ function isExecutable(path) {
25
50
  return false;
26
51
  }
27
52
  }
53
+ /** The extensions a launcher can carry on this platform, in the order Windows
54
+ * itself prefers them.
55
+ *
56
+ * WINDOWS DOES NOT EXECUTE AN EXTENSIONLESS FILE. npm installs BOTH a shell
57
+ * script named `claude` and a `claude.cmd`; the extensionless one satisfies
58
+ * `accessSync(X_OK)`, so resolution used to "succeed" and hand `spawn` a path
59
+ * it could not run — every dispatch died with ENOENT. Verified on the Windows
60
+ * box: the bare path gives ENOENT, `claude.cmd --version` prints the version.
61
+ *
62
+ * The empty string comes first so a genuine extensionless binary (every POSIX
63
+ * install, and any real .exe found by name) still wins where it exists. */
64
+ function candidateNames(agent, platform = process.platform, pathext = process.env.PATHEXT) {
65
+ if (platform !== 'win32')
66
+ return [agent];
67
+ const exts = (pathext ?? '.COM;.EXE;.BAT;.CMD')
68
+ .split(';')
69
+ .map(e => e.trim().toLowerCase())
70
+ .filter(Boolean);
71
+ /* EXTENSIONS FIRST ON WINDOWS, bare name LAST. npm ships both `claude` (a sh
72
+ script, unrunnable here) and `claude.cmd` in the SAME directory, and
73
+ `accessSync(X_OK)` cannot tell them apart — it is satisfied by any readable
74
+ file. Putting the bare name first therefore resolved to the unrunnable one
75
+ and every dispatch still ENOENTed, which is what the box showed after the
76
+ first attempt at this fix. The bare name is kept as a last resort so a real
77
+ extensionless executable is still found. */
78
+ return [...exts.map(ext => `${agent}${ext}`), agent];
79
+ }
80
+ /** Exported for the cross-platform test: `process.platform` cannot be faked, so
81
+ * the Windows behaviour has to be reachable from a Mac test run. */
82
+ export const candidateNamesForTest = candidateNames;
28
83
  function resolve(agent) {
84
+ const preferred = preferredPaths(agent).find(isExecutable);
85
+ if (preferred)
86
+ return preferred;
29
87
  const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
88
+ const names = candidateNames(agent);
30
89
  for (const dir of dirs) {
31
- if (isExecutable(join(dir, agent)))
32
- return join(dir, agent);
90
+ for (const name of names) {
91
+ const candidate = join(dir, name);
92
+ /* On Windows `accessSync(X_OK)` is satisfied by any readable file, so it
93
+ cannot tell a runnable launcher from the extensionless shell script.
94
+ The extension list is what does the discriminating there. */
95
+ if (isExecutable(candidate))
96
+ return candidate;
97
+ }
33
98
  }
34
99
  return fallbackPaths(agent).find(isExecutable) ?? null;
35
100
  }
package/dist/autostart.js CHANGED
@@ -88,6 +88,20 @@ function macOn() {
88
88
  const uid = process.getuid?.() ?? 0;
89
89
  const node = process.execPath;
90
90
  const entry = entryPath();
91
+ /* ═══ `USER` IS LOAD-BEARING, AND ITS ABSENCE IS SILENT. ═══
92
+ launchd starts an agent with almost no environment: PATH was set here
93
+ because the daemon could not otherwise FIND `claude`, and `USER` is the
94
+ same class of bug one layer down. The agents the daemon spawns inherit
95
+ `process.env`, and the Claude Code CLI resolves its stored credential by
96
+ the current user's name — with `USER` unset it reports "Not logged in ·
97
+ Please run /login" while the very same binary, run from a shell one second
98
+ earlier, authenticates fine.
99
+ THE COST OF MISSING IT: every claude-designated request fails with "Claude
100
+ is not signed in on this machine", which reads as an expired token and
101
+ sends you to re-authenticate something that was never broken. Reproduced
102
+ by hand: `env -i HOME=… PATH=… claude -p` fails, and adding USER alone
103
+ fixes it. HOME is set for the same reason, though launchd does supply it. */
104
+ const userName = process.env.USER ?? process.env.LOGNAME ?? '';
91
105
  const logDir = join(homedir(), '.config', 'ctrl-spc-v2');
92
106
  mkdirSync(logDir, { recursive: true });
93
107
  mkdirSync(dirname(plistPath()), { recursive: true });
@@ -102,7 +116,7 @@ function macOn() {
102
116
  <key>KeepAlive</key><true/>
103
117
  <key>StandardOutPath</key><string>${join(logDir, 'daemon.log')}</string>
104
118
  <key>StandardErrorPath</key><string>${join(logDir, 'daemon.log')}</string>
105
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
119
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string><key>USER</key><string>${userName}</string><key>HOME</key><string>${homedir()}</string></dict>
106
120
  </dict></plist>
107
121
  `;
108
122
  writeFileSync(plistPath(), plist);
package/dist/browser.js CHANGED
@@ -1,4 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ /* 18c Slice 7, gate round 2: the tree kill this repo already proved is the only
7
+ one that works. `child.kill()` ends ONE process, and headless Chrome is a
8
+ parent with six to ten helpers — see `captureUrlScreenshot` for the
9
+ measurement, and win-shell.ts:98 for the Windows case that established it. */
10
+ import { killTree } from './win-shell.js';
2
11
  /** Opens `url` in the default browser. Never fatal — callers print the URL too. */
3
12
  export function openBrowser(url) {
4
13
  const { command, args } = process.platform === 'darwin' ? { command: 'open', args: [url] }
@@ -11,3 +20,380 @@ export function openBrowser(url) {
11
20
  }
12
21
  catch { /* missing opener is non-fatal */ }
13
22
  }
23
+ /* ═══ 18c SLICE 7, CORRECTED — THE PRODUCT TAKES THE PICTURE, NOT THE AGENT. ═══
24
+
25
+ WHAT THE WALK FOUND, on both platforms, in two independent sittings. Story 4's
26
+ scenario ("show me what the recruiter search screen looks like at
27
+ http://localhost:8000/", the word screenshot never typed) finished `done` every
28
+ time with ZERO rows in `cliv2_screenshots` and no `attach_screenshot` on the
29
+ wire. Slice 7's prompt change works — the agent knows the tool is there and
30
+ says so — and the agent still cannot use it.
31
+
32
+ THE REAL CAUSE, and it is NOT the allow list. `--allowedTools` was suspected,
33
+ and measuring it cleared it: on claude 2.1.226 that flag adds permission RULES
34
+ and does not remove built-ins (`--tools ""` is the flag that would), so `Bash`
35
+ is present in the worker's tool list and a read-only command runs fine. What
36
+ fails is the APPROVAL. `--permission-mode acceptEdits` auto-approves file
37
+ edits and nothing else, so any command outside the CLI's built-in read-only
38
+ classifier comes back `This command requires approval` — and a headless worker
39
+ has no human to approve it, so the denial is permanent. Reproduced exactly
40
+ against the product's own argv: `echo` succeeds, `curl` returns
41
+ `permission_denials: [{tool_name: "Bash"}]`. Every capture command is in the
42
+ second class, so the agent could not produce the tool's input.
43
+
44
+ WHY THIS IS THE FIX RATHER THAN A WIDER ALLOW LIST. `attach_screenshot` demands
45
+ an absolute path to an EXISTING PNG, and its own description told the agent
46
+ "Playwright or Maestro captures the file first" — neither of which is installed,
47
+ and both of which would need a shell to drive. So the product was asking the
48
+ agent for an input it had no reachable way to make. Handing it a scoped
49
+ `Bash(...)` rule would have made the capability depend on the agent guessing a
50
+ browser binary, a flag set and a temp path correctly on two operating systems,
51
+ failing silently wherever that guess was wrong, and it would have bought codex
52
+ nothing at all: codex's isolation is a seeded `$CODEX_HOME` with no argv
53
+ equivalent, so a claude-only argv change would leave half the product broken.
54
+ Capturing here fixes both agents at once, through the one channel a worker is
55
+ guaranteed to reach — an MCP tool call.
56
+
57
+ AND IT WEAKENS NOTHING. No permission flag changes. `--allowedTools
58
+ mcp__ctrl-spc__*`, `--strict-mcp-config`, `--setting-sources ''`,
59
+ `--permission-mode acceptEdits` and the whole codex branch Slice 8 hardened are
60
+ all untouched, so a worker still cannot run an arbitrary command. The browser
61
+ is launched by THIS process, the daemon the user installed, with a fixed argv
62
+ the agent cannot influence beyond the URL. That is strictly less authority than
63
+ letting the worker run a shell command of its own composition.
64
+
65
+ HEADLESS CHROME BECAUSE IT IS ALREADY THERE. No dependency is added: this
66
+ product ships no browser engine, and adding Playwright would put a ~300 MB
67
+ download in front of a capability that has to work on a user's machine today.
68
+ Chrome's `--headless --screenshot` writes a standard non-interlaced PNG, which
69
+ is exactly the shape `readPngScreenshot` already validates. */
70
+ /** Where Chrome (or a Chromium build) usually lives, per platform.
71
+ *
72
+ * A LIST RATHER THAN ONE PATH because "installed" is not one location: Edge is
73
+ * Chromium and is present on essentially every Windows box, and a Mac may have
74
+ * Chromium or Brave and no Chrome. Probed in order and the first that exists
75
+ * wins, so the common case costs one `existsSync`.
76
+ *
77
+ * `CTRL_SPC_V2_BROWSER` overrides the whole list. It is here for the user whose
78
+ * browser is somewhere else entirely, and for a test that needs a known binary,
79
+ * and it is read from the DAEMON's environment — never from anything an agent
80
+ * can set, which is the property that keeps this from becoming a way to make
81
+ * the product run an arbitrary executable. */
82
+ export function browserCandidates(platform = process.platform) {
83
+ const override = process.env.CTRL_SPC_V2_BROWSER?.trim();
84
+ if (override)
85
+ return [override];
86
+ if (platform === 'darwin') {
87
+ return [
88
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
89
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
90
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
91
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
92
+ ];
93
+ }
94
+ if (platform === 'win32') {
95
+ const programFiles = process.env['ProgramFiles'] ?? 'C:\\Program Files';
96
+ const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
97
+ const localAppData = process.env['LOCALAPPDATA'] ?? '';
98
+ return [
99
+ `${programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
100
+ `${programFilesX86}\\Google\\Chrome\\Application\\chrome.exe`,
101
+ ...(localAppData ? [`${localAppData}\\Google\\Chrome\\Application\\chrome.exe`] : []),
102
+ `${programFiles}\\Microsoft\\Edge\\Application\\msedge.exe`,
103
+ `${programFilesX86}\\Microsoft\\Edge\\Application\\msedge.exe`,
104
+ ];
105
+ }
106
+ return [
107
+ '/usr/bin/google-chrome',
108
+ '/usr/bin/chromium',
109
+ '/usr/bin/chromium-browser',
110
+ '/usr/bin/microsoft-edge',
111
+ ];
112
+ }
113
+ /** The first candidate that exists, or null when this machine has no Chromium. */
114
+ export function findBrowser(platform = process.platform, exists = existsSync) {
115
+ return browserCandidates(platform).find(exists) ?? null;
116
+ }
117
+ /** Only http(s), and that is a security boundary rather than tidiness.
118
+ *
119
+ * The URL is the ONE part of the capture the agent controls, so it is the one
120
+ * part that has to be constrained. `file://` would turn a screenshot request
121
+ * into "render any file on this disk and upload it to the cloud", which is an
122
+ * exfiltration route out of a machine whose absolute paths this product is
123
+ * otherwise careful never to publish (AGENTS.md's path-privacy invariant).
124
+ * `javascript:` and `data:` are refused for the same reason. An allow list, not
125
+ * a deny list, so a scheme nobody thought of is refused by default.
126
+ *
127
+ * ═══ LOOPBACK AND PRIVATE ADDRESSES ARE DELIBERATELY ALLOWED. DO NOT "FIX"
128
+ * THIS. ═══
129
+ *
130
+ * Read as a generic web fetcher this looks like a textbook SSRF hole: an agent
131
+ * can name `http://localhost:*`, `http://127.0.0.1:*` or a LAN address and the
132
+ * product will render it. Blocking those is the reflex, and it would delete the
133
+ * entire feature. THE WHOLE CAPABILITY IS ABOUT LOCALHOST: story 4's scenario
134
+ * is "show me what the recruiter search screen looks like at
135
+ * http://localhost:8000/", the walk's fixture is a `python3 -m http.server` on
136
+ * the tester's own machine, and a user asking to see their work in progress
137
+ * means the dev server they are running right now. A loopback blocklist turns
138
+ * the answer to every real request into a refusal.
139
+ *
140
+ * WHAT KEEPS THAT ACCEPTABLE, and it is not that the risk is imaginary. The
141
+ * agent already runs ON this machine with the user's own file access, so a
142
+ * localhost HTTP GET grants it nothing it could not already do more directly —
143
+ * this is not a server-side fetcher reaching into a trusted network from
144
+ * outside, which is the shape SSRF advice is written for. The two local
145
+ * services this product itself runs are both token-gated (the MCP tools server
146
+ * checks `mcpToken()`, the companion checks its own token), so an agent
147
+ * pointing a capture at either gets an error page rather than credentials;
148
+ * that was checked rather than assumed. And the scheme allow list above still
149
+ * holds, so the cloud-metadata style of attack that needs a non-HTTP scheme, or
150
+ * a redirect to one, is refused — Chrome itself blocks an http->file redirect
151
+ * with ERR_UNSAFE_REDIRECT, which was measured too.
152
+ *
153
+ * If a future reader wants to narrow this, the thing to narrow is WHO may ask
154
+ * for a capture, not which addresses are reachable from the user's own box. */
155
+ export function isCapturableUrl(url) {
156
+ let parsed;
157
+ try {
158
+ parsed = new URL(url);
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
164
+ }
165
+ /** The bound on a capture, and after gate round 2 it is a REAL bound again
166
+ * rather than the normal exit path. See `captureUrlScreenshot`. */
167
+ export const CAPTURE_TIMEOUT_MS = 45_000;
168
+ /** How often the PNG is checked for. 100ms costs nothing against a capture that
169
+ * takes one to three seconds, and it is what turns a 45-second wait into a
170
+ * ~1-second one. */
171
+ const CAPTURE_POLL_MS = 100;
172
+ /** The PNG must hold the SAME SIZE across two consecutive polls before it is
173
+ * called finished. Chrome writes the file progressively, so a single
174
+ * `existsSync` can catch a half-written image whose IDAT stream is truncated —
175
+ * which `readPngScreenshot` would then reject as a corrupt PNG, turning a good
176
+ * capture into a mysterious validation failure. One quiet interval is enough
177
+ * because the write is a single streamed pass, not a series of edits. */
178
+ const CAPTURE_SETTLE_POLLS = 2;
179
+ /**
180
+ * Render `url` in headless Chrome and write a PNG this process owns.
181
+ *
182
+ * THE FILE IS THIS PROCESS'S, IN A PRIVATE TEMP DIRECTORY, AND IS DELETED. The
183
+ * agent never learns the path and never needed to: it names a URL, the bytes
184
+ * reach `cliv2_screenshots`, and nothing about this machine's filesystem is
185
+ * recorded anywhere. That is the same rule the `path` argument already obeys
186
+ * ("The local path is never uploaded or stored"), kept while removing the reason
187
+ * the agent had to know a path at all.
188
+ *
189
+ * `--disable-gpu` and `--hide-scrollbars` are the two flags that make the output
190
+ * deterministic enough to be worth comparing between runs; the rest of Chrome's
191
+ * defaults are left alone deliberately, because a screenshot the user recognises
192
+ * is the point and a heavily-flagged browser stops looking like their browser.
193
+ *
194
+ * ═══ GATE ROUND 2 — WHY THIS WAITS FOR THE FILE AND NOT FOR THE PROCESS. ═══
195
+ *
196
+ * THE FIRST BUILD USED `execFile` AND WAS WRONG IN TWO WAYS AT ONCE, both
197
+ * measured on this machine against Chrome 151 rather than reasoned about.
198
+ *
199
+ * ONE: CHROME 112+ NEW-HEADLESS DOES NOT EXIT AFTER `--screenshot`. The PNG was
200
+ * complete and byte-stable at about 2.5 seconds and the browser was still alive
201
+ * at three minutes. So every capture ran to `CAPTURE_TIMEOUT_MS` and returned in
202
+ * 45024, 45038, 45039 and 45042 ms — the timeout was not a safety net, it was
203
+ * the normal exit path, and a user asking for one screenshot waited 45 seconds
204
+ * for an image that had been ready for 42 of them.
205
+ *
206
+ * TWO: AND THE KILL DID NOT KILL IT. `execFile`'s `timeout` sends SIGTERM to the
207
+ * DIRECT CHILD ONLY, and headless Chrome is a parent with six to ten helper
208
+ * processes. Measured: after a capture "finished", the Chrome parent and its
209
+ * helpers were still alive and orphaned, holding open handles inside the temp
210
+ * directory this function had just tried to delete. Ten screenshots left ten
211
+ * resident browsers.
212
+ *
213
+ * THIS REPO ALREADY LITIGATED THAT SECOND POINT AND I MISSED IT. `win-shell.ts`
214
+ * exists to say that a single-process kill does not end a process tree: it
215
+ * records the Windows measurement (the descendant survived in `tasklist`) and
216
+ * ships `killTree`, whose `taskkill /T` walks downward and takes every
217
+ * descendant. On Windows the consequence is worse than a stray process, because
218
+ * an open handle BLOCKS directory deletion outright, so `rm(dir)` fails and the
219
+ * `.catch(() => {})` on the cleanup swallows it — the temp-profile leak would
220
+ * have been silent and the Windows walk could not have passed.
221
+ *
222
+ * SO THE COMPLETION SIGNAL IS THE ARTEFACT, NOT THE EXIT. Poll for the PNG,
223
+ * require its size to hold steady across two polls so a half-written file is
224
+ * never handed on, then `killTree` immediately. The process exiting on its own
225
+ * is still honoured (`--headless=old` and non-Chrome Chromium builds do exit),
226
+ * and either way the tree is killed before returning, so nothing is left behind
227
+ * on any platform.
228
+ */
229
+ export async function captureUrlScreenshot(url, { width = 1280, height = 900, timeoutMs = CAPTURE_TIMEOUT_MS, browserPath = findBrowser(),
230
+ /** Injected by the suite. Real captures use `spawn` — see the note in
231
+ * screenshot-capture.test.mjs about what a stubbed browser cannot prove. */
232
+ spawnBrowser = spawn, pollMs = CAPTURE_POLL_MS, } = {}) {
233
+ if (!isCapturableUrl(url)) {
234
+ throw new Error(`url must be an http:// or https:// address, and "${url}" is not`);
235
+ }
236
+ if (!browserPath) {
237
+ throw new Error('no Chrome, Chromium or Edge was found on this machine, so CTRL+SPC could not take the ' +
238
+ 'screenshot. Install one, or set CTRL_SPC_V2_BROWSER to the browser executable.');
239
+ }
240
+ const dir = await mkdtemp(join(tmpdir(), 'ctrl-spc-shot-'));
241
+ const path = join(dir, 'screenshot.png');
242
+ const cleanup = () => rm(dir, { recursive: true, force: true }).catch(() => { });
243
+ /* A THROWAWAY PROFILE, because the alternative is worse in both directions.
244
+ Sharing the user's real profile would put their logged-in cookies into a
245
+ picture the agent asked for, and Chrome refuses to start headless against a
246
+ profile an interactive window already holds — so the capability would fail
247
+ precisely when the user has their browser open, which is always. */
248
+ const profileDir = join(dir, 'profile');
249
+ let child;
250
+ try {
251
+ child = spawnBrowser(browserPath, [
252
+ '--headless',
253
+ '--disable-gpu',
254
+ '--hide-scrollbars',
255
+ `--user-data-dir=${profileDir}`,
256
+ `--window-size=${width},${height}`,
257
+ `--screenshot=${path}`,
258
+ url,
259
+ ], {
260
+ // The repo-wide invariant for ANY child process (MEMORY: "Windows silence
261
+ // decision"): a user must never see a console window flash.
262
+ windowsHide: true,
263
+ stdio: 'ignore',
264
+ /* ITS OWN PROCESS GROUP, ON POSIX, AND THIS IS LOad-BEARING — see `stop`
265
+ below. `detached` here does NOT mean "outlive us": the process is never
266
+ `unref`ed, and `stop` always runs before this function returns. It means
267
+ "become a group leader", which is the only way to signal Chrome's
268
+ helpers as a unit on Mac and Linux. */
269
+ detached: process.platform !== 'win32',
270
+ });
271
+ }
272
+ catch (err) {
273
+ await cleanup();
274
+ throw new Error(`the browser could not open ${url}: ${err.message}`);
275
+ }
276
+ /* A browser that could not be launched at all (a bad CTRL_SPC_V2_BROWSER, a
277
+ binary that vanished between the existence check and here) raises `error`
278
+ rather than exiting, and with `stdio: 'ignore'` there is no stderr to read.
279
+ Recorded rather than thrown, because throwing from an event handler escapes
280
+ this promise entirely. */
281
+ const launchState = { error: null, exited: false };
282
+ child.on('error', (err) => { launchState.error = err; });
283
+ child.on('exit', () => { launchState.exited = true; });
284
+ /* ═══ THE TREE, NOT THE HANDLE — AND ON POSIX THAT MEANS THE PROCESS GROUP.
285
+ ═══
286
+
287
+ `killTree` is `win-shell.ts`'s and it is exactly right on Windows, where it
288
+ is `taskkill /T`. But its POSIX branch is a plain `child.kill('SIGKILL')`,
289
+ which is correct for what that file was written for (a `cmd.exe` wrapper
290
+ around one agent) and NOT enough for Chrome. Measured here rather than
291
+ assumed: a headless Chrome spawned with a temp profile had 6 direct children
292
+ and 8 processes total holding the directory; after a single-process SIGKILL
293
+ one survivor was still alive, and that survivor is what recreated the temp
294
+ directory AFTER `cleanup()` had deleted it. It showed up as one stray
295
+ `ctrl-spc-shot-*` directory per full-suite run and nowhere else, because it
296
+ needs a loaded machine to lose the race.
297
+
298
+ So on Mac and Linux the signal goes to the GROUP (`-pid`), which the
299
+ `detached: true` above created. That reaches every helper in one call, which
300
+ is the same guarantee `taskkill /T` gives on Windows, by the mechanism each
301
+ platform actually offers. `killTree` is still used for the Windows path and
302
+ as the fallback, so nothing about the case that file litigated changes.
303
+
304
+ Safe to call when the process has already gone: ESRCH is swallowed, because
305
+ a caller that wanted it dead is not wrong when it already is. */
306
+ const stop = () => {
307
+ if (process.platform !== 'win32' && typeof child.pid === 'number') {
308
+ try {
309
+ process.kill(-child.pid, 'SIGKILL');
310
+ return;
311
+ }
312
+ catch {
313
+ /* No group (the child died before `setsid`, or this platform declined
314
+ it). Fall through to the single-process kill, which is still better
315
+ than nothing. */
316
+ }
317
+ }
318
+ try {
319
+ killTree(child);
320
+ }
321
+ catch { /* already gone */ }
322
+ };
323
+ const started = Date.now();
324
+ let lastSize = -1;
325
+ let stableFor = 0;
326
+ try {
327
+ for (;;) {
328
+ let size = -1;
329
+ try {
330
+ const info = await stat(path);
331
+ if (info.isFile())
332
+ size = info.size;
333
+ }
334
+ catch { /* not written yet */ }
335
+ /* Size 0 is Chrome having created the file but not yet filled it, which is
336
+ not a finished capture. */
337
+ if (size > 0 && size === lastSize) {
338
+ stableFor += 1;
339
+ if (stableFor >= CAPTURE_SETTLE_POLLS - 1)
340
+ break;
341
+ }
342
+ else {
343
+ stableFor = 0;
344
+ }
345
+ lastSize = size;
346
+ if (launchState.error) {
347
+ throw new Error(`the browser could not open ${url}: ${launchState.error.message}`);
348
+ }
349
+ /* THE PROCESS EXITING IS STILL A VALID END, for `--headless=old` and for
350
+ Chromium builds that do quit. One more look at the file settles whether
351
+ it produced anything, and then the loop is over either way. */
352
+ if (launchState.exited) {
353
+ let finalSize = -1;
354
+ try {
355
+ const info = await stat(path);
356
+ if (info.isFile())
357
+ finalSize = info.size;
358
+ }
359
+ catch { /* nothing was written */ }
360
+ if (finalSize > 0)
361
+ break;
362
+ throw new Error(`the browser opened ${url} but produced no image`);
363
+ }
364
+ /* ═══ A REAL TIMEOUT IS NOW DISTINGUISHABLE FROM SUCCESS (gate round 2,
365
+ BLOCKER 2). ═══
366
+ While the timeout WAS the normal exit path, "timed out but a file
367
+ exists" had to be treated as success, so a page that painted a spinner
368
+ and hung produced a screenshot of the spinner after 45 seconds with
369
+ nothing saying it was truncated. Now that a finished capture returns in
370
+ about a second, reaching here means the page genuinely never settled,
371
+ and the honest answer is to say so and produce NOTHING. A picture of a
372
+ half-loaded page presented as the screen is worse than an error,
373
+ because the user cannot tell. */
374
+ if (Date.now() - started >= timeoutMs) {
375
+ throw new Error(`the browser opened ${url} but the page had not finished rendering after ` +
376
+ `${Math.round(timeoutMs / 1000)}s, so no screenshot was taken. The page may be waiting ` +
377
+ 'on a request that never returns.');
378
+ }
379
+ await new Promise(resolve => setTimeout(resolve, pollMs));
380
+ }
381
+ }
382
+ catch (err) {
383
+ stop();
384
+ await cleanup();
385
+ throw err;
386
+ }
387
+ /* THE BROWSER GOES BEFORE THIS RETURNS, always. The caller gets a path to a
388
+ file nothing is still holding, which is what makes `cleanup()` able to
389
+ succeed on Windows. */
390
+ stop();
391
+ /* Belt and braces against a `stat` that raced the write: the caller is about
392
+ to hand this to the PNG validator, and "no image was produced" is a far
393
+ better message than "not found at <path>". */
394
+ if (!existsSync(path)) {
395
+ await cleanup();
396
+ throw new Error(`the browser opened ${url} but produced no image`);
397
+ }
398
+ return { path, cleanup };
399
+ }
package/dist/codebases.js CHANGED
@@ -32,6 +32,21 @@ export async function listCodebases(client, projectId) {
32
32
  name: row.name,
33
33
  }));
34
34
  }
35
+ /** Path-free availability rows for the signed-in user's machines. */
36
+ export async function listCodebaseLocations(client, gitRemoteUrls) {
37
+ if (gitRemoteUrls.length === 0)
38
+ return [];
39
+ const { data, error } = await client
40
+ .from('cliv2_codebase_locations')
41
+ .select('machine_id, git_remote_url')
42
+ .in('git_remote_url', gitRemoteUrls);
43
+ if (error)
44
+ throw new Error(error.message);
45
+ return (data ?? []).map((row) => ({
46
+ machineId: row.machine_id,
47
+ gitRemoteUrl: row.git_remote_url,
48
+ }));
49
+ }
35
50
  /**
36
51
  * A display name from a canonical `host/path` remote: its last `/` segment.
37
52
  * E.g. `github.com/acme/web` → `web`. Throws rather than ever returning an