@ctrl-spc/cs 0.5.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 +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +501 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +11571 -910
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +677 -0
- package/dist/panel3/run.js +1988 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/show.js +996 -0
- package/dist/panel3/spawn.js +503 -0
- package/dist/panel3/tools.js +1601 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/package.json +3 -2
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
|
-
|
|
32
|
-
|
|
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
|