flowviant 0.116.0 → 0.117.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/README.md +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +71 -139
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/loginPath.mjs
CHANGED
|
@@ -11,17 +11,27 @@
|
|
|
11
11
|
* measured "not installed" under the tray (2026-09-25).
|
|
12
12
|
*
|
|
13
13
|
* So the daemon asks the person's login shell for its PATH once, and keeps
|
|
14
|
-
* every entry it already had FIRST (a terminal start
|
|
14
|
+
* every entry it already had FIRST (a terminal start keeps its order) with the
|
|
15
15
|
* shell's additions after it; then the usual per-user bin directories that
|
|
16
16
|
* exist. Best-effort and bounded: a shell that hangs or prints nothing costs a
|
|
17
|
-
* few seconds once
|
|
17
|
+
* few seconds once.
|
|
18
|
+
*
|
|
19
|
+
* ONLY ABSOLUTE ENTRIES ARE KEPT, from every input — the current PATH too
|
|
20
|
+
* (2026-10-10, F118). A relative entry (`node_modules/.bin`, `.`, and the empty
|
|
21
|
+
* entry, which also means "here") resolves against whatever directory a child
|
|
22
|
+
* is spawned in, and the daemon spawns `git`, `gh`, `cloudflared` and the CLIs
|
|
23
|
+
* by bare name with `cwd` set to an agent's worktree: a binary an agent plants
|
|
24
|
+
* there would run as the daemon, outside any sandbox, with its credentials. A
|
|
25
|
+
* common ~/.bashrc idiom is enough to open that. `FLOWVIANT_KEEP_PATH=1` is the
|
|
26
|
+
* operator's explicit opt-out and leaves PATH exactly as they set it.
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
|
-
import { execFileSync
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
21
30
|
import { existsSync, readdirSync } from 'node:fs';
|
|
22
31
|
import { homedir, platform } from 'node:os';
|
|
23
32
|
import { join } from 'node:path';
|
|
24
33
|
import { compareVersions } from './versionOrder.mjs';
|
|
34
|
+
import { relaunchSelf, selfArgv } from './launchCommand.mjs';
|
|
25
35
|
|
|
26
36
|
const MARK = '__FLOWVIANT_PATH__=';
|
|
27
37
|
|
|
@@ -60,12 +70,13 @@ export function userBinDirs({ home = homedir(), exists = existsSync, list = read
|
|
|
60
70
|
return dirs.filter((d) => exists(d));
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
/** Current entries first, then any the others add. Order-preserving, deduped
|
|
73
|
+
/** Current entries first, then any the others add. Order-preserving, deduped,
|
|
74
|
+
* absolute only (see the header): an empty or relative entry is dropped. */
|
|
64
75
|
export function mergePath(current, ...more) {
|
|
65
76
|
const seen = new Set();
|
|
66
77
|
const out = [];
|
|
67
78
|
for (const part of [current, ...more].flatMap((p) => (Array.isArray(p) ? p : String(p ?? '').split(':')))) {
|
|
68
|
-
if (!part || seen.has(part)) continue;
|
|
79
|
+
if (!part || !part.startsWith('/') || seen.has(part)) continue;
|
|
69
80
|
seen.add(part);
|
|
70
81
|
out.push(part);
|
|
71
82
|
}
|
|
@@ -91,24 +102,18 @@ export function adoptLoginPath({ shell = true, env = process.env, ...deps } = {}
|
|
|
91
102
|
* processes are looked up and started with the environment the process
|
|
92
103
|
* STARTED with unless a spawn passes `env` — a later `process.env.PATH = …` is
|
|
93
104
|
* invisible to them (measured 2026-09-25: node found ~/.local/bin/claude after
|
|
94
|
-
* the change, the compiled binary did not). So when the PATH
|
|
95
|
-
* binary restarts itself once with it
|
|
96
|
-
*
|
|
97
|
-
* Node needs none of that and adopts in place.
|
|
105
|
+
* the change, the compiled binary did not). So when the PATH changed, the
|
|
106
|
+
* binary restarts itself once with it through the one relaunch proxy
|
|
107
|
+
* (launchCommand.mjs `relaunchSelf`): stdio inherited, exit code and signals
|
|
108
|
+
* passed through. Node needs none of that and adopts in place.
|
|
98
109
|
*/
|
|
99
110
|
export async function adoptLoginPathForProcess(opts = {}) {
|
|
100
111
|
if (process.env.FLOWVIANT_PATH_ADOPTED === '1') return;
|
|
101
112
|
const before = process.env.PATH;
|
|
102
113
|
const after = adoptLoginPath(opts);
|
|
103
114
|
if (!process.versions.bun || after === before) return;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
stdio: 'inherit',
|
|
115
|
+
await relaunchSelf({
|
|
116
|
+
argv: selfArgv(process.argv.slice(2)),
|
|
107
117
|
env: { ...process.env, FLOWVIANT_PATH_ADOPTED: '1' },
|
|
108
118
|
});
|
|
109
|
-
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
110
|
-
process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
|
|
111
|
-
}
|
|
112
|
-
const code = await new Promise((resolve) => child.on('exit', (status, signal) => resolve(status ?? (signal ? 1 : 0))));
|
|
113
|
-
process.exit(code);
|
|
114
119
|
}
|
|
@@ -92,11 +92,14 @@ const JUST_NOW = 'just now';
|
|
|
92
92
|
/**
|
|
93
93
|
* A coarse age. Never "0s": a box heard a moment ago reads as "just now",
|
|
94
94
|
* because a daemon that polled four hundred milliseconds ago has not been
|
|
95
|
-
* silent for no time at all — the same rule `
|
|
96
|
-
* `elapsed` keeps in the web.
|
|
95
|
+
* silent for no time at all — the same rule `elapsed` keeps in the web.
|
|
97
96
|
*
|
|
98
97
|
* NULL for anything unmeasurable, so the caller DROPS the clause rather than
|
|
99
|
-
* printing a duration nobody took.
|
|
98
|
+
* printing a duration nobody took — the one rule this shares with holder.mjs's
|
|
99
|
+
* `agoLabel`. That one is a different formatter on purpose (2026-10-10, F084:
|
|
100
|
+
* this comment used to claim it kept the "never 0s" rule too): it prints a
|
|
101
|
+
* measured zero as "0s" and steps at 60s/60m/48h, and holder.test.mjs pins
|
|
102
|
+
* both.
|
|
100
103
|
*/
|
|
101
104
|
export function agoFrom(iso, now = Date.now()) {
|
|
102
105
|
const t = Date.parse(iso ?? '');
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -17,13 +17,16 @@
|
|
|
17
17
|
* never from a CLI credential sitting on a shared machine.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { FLEET_TOKEN,
|
|
20
|
+
import { FLEET_TOKEN, MCP_URL, FLEET_URL } from './config.mjs';
|
|
21
21
|
import { terminalCommand } from './launchCommand.mjs';
|
|
22
22
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
23
|
+
import { postFleetJson } from './fleetPost.mjs';
|
|
23
24
|
|
|
24
25
|
const CLI_TOKEN_URL = fleetEndpoint('cli-token', FLEET_URL);
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
/** `timeoutMs` is the request's ceiling — injectable only so a test can prove
|
|
28
|
+
* a hung server is abandoned without waiting thirty seconds. */
|
|
29
|
+
export async function runMcpCommand(args = [], { timeoutMs = 30_000 } = {}) {
|
|
27
30
|
if (!FLEET_TOKEN) {
|
|
28
31
|
console.error(
|
|
29
32
|
`error: no credential resolves here. Run \`${terminalCommand('login')}\` first — or, with\n` +
|
|
@@ -33,16 +36,12 @@ export async function runMcpCommand(args = []) {
|
|
|
33
36
|
process.exit(1);
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
// BOUNDED (audit 2026-10-10, F102): this was the one fleet request with no
|
|
40
|
+
// timeout, so a hung server held the command forever. A timeout lands in
|
|
41
|
+
// the catch below and says so like any other unreachable server.
|
|
36
42
|
let res;
|
|
37
43
|
try {
|
|
38
|
-
res = await
|
|
39
|
-
method: 'POST',
|
|
40
|
-
headers: {
|
|
41
|
-
authorization: `Bearer ${FLEET_TOKEN}`,
|
|
42
|
-
'content-type': 'application/json',
|
|
43
|
-
'user-agent': USER_AGENT,
|
|
44
|
-
},
|
|
45
|
-
});
|
|
44
|
+
res = await postFleetJson(CLI_TOKEN_URL, undefined, { timeoutMs });
|
|
46
45
|
} catch (err) {
|
|
47
46
|
console.error(`error: could not reach Flowviant (${err?.message || err})`);
|
|
48
47
|
process.exit(1);
|
package/bin/lib/noWindowEnv.mjs
CHANGED
|
@@ -41,9 +41,11 @@
|
|
|
41
41
|
* purpose through WSL interop (`explorer.exe`, `cmd.exe /c start`); the kind
|
|
42
42
|
* contracts ask it not to (artifactContracts.mjs, `NO_WINDOW`).
|
|
43
43
|
*/
|
|
44
|
-
import {
|
|
44
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
45
45
|
import { tmpdir } from 'node:os';
|
|
46
46
|
import { join } from 'node:path';
|
|
47
|
+
import { withoutMachineCredentials } from './machineEnv.mjs';
|
|
48
|
+
import { ensureOwnDir } from './ownedDirs.mjs';
|
|
47
49
|
|
|
48
50
|
/** Removed outright; WAYLAND_DISPLAY is kept but emptied (see above). */
|
|
49
51
|
export const WINDOW_ENV_REMOVED = Object.freeze(['DISPLAY', 'WAYLAND_SOCKET']);
|
|
@@ -62,6 +64,24 @@ export function withoutWindows(env, browserHome = null) {
|
|
|
62
64
|
return out;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* EVERY CLI CHILD THE DAEMON SPAWNS UNATTENDED, ONE RULE (2026-10-10, the
|
|
69
|
+
* audit): no machine credential (machineEnv.mjs), and no way to a window
|
|
70
|
+
* unless a person at the keyboard asked for one (`display: true`, a Terminal
|
|
71
|
+
* tab's alone). It was composed by hand twice — a turn's `cliEnv`
|
|
72
|
+
* (runTurn.mjs) and the project check's `checkEnv` (workAgentCheck.mjs) — and
|
|
73
|
+
* skipped outright by the two spawns the daemon makes for its own purposes:
|
|
74
|
+
* the startup skills probe (runtimeCapabilities.mjs), whose `claude` started
|
|
75
|
+
* the person's stdio MCP servers and the checkout's SessionStart hooks with
|
|
76
|
+
* the machine credential in their environment, and the Codex offerings reads
|
|
77
|
+
* (runtimeOfferings.mjs), whose `codex mcp list` may start configured servers.
|
|
78
|
+
* Every one of them now asks here. Never mutates its input.
|
|
79
|
+
*/
|
|
80
|
+
export function unattendedEnv(env = process.env, { display = false, browserHome = null } = {}) {
|
|
81
|
+
const out = withoutMachineCredentials(env);
|
|
82
|
+
return display === true ? out : withoutWindows(out, browserHome);
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
/**
|
|
66
86
|
* AN AGENT'S BROWSER HOME is kept beside its Codex home, in its worktree's
|
|
67
87
|
* private git dir: never committed, the same profile turn after turn, and
|
|
@@ -82,8 +102,8 @@ export const agentBrowserHome = (sessionMetaPath, wt, agentId) =>
|
|
|
82
102
|
export function openBrowserHome(kept) {
|
|
83
103
|
if (kept) {
|
|
84
104
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
// ownedDirs.mjs (2026-10-10): the link goes, never its target.
|
|
106
|
+
ensureOwnDir(kept, { mode: 0o700 });
|
|
87
107
|
return { dir: kept, cleanup: () => {} };
|
|
88
108
|
} catch { /* fall through to a turn's own */ }
|
|
89
109
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DIRECTORIES THE DAEMON WRITES INTO THAT A REPOSITORY COULD HAVE PLANTED —
|
|
3
|
+
* one home for the rule "never follow a link a checkout carries under
|
|
4
|
+
* `.flowviant/`" (2026-10-10, the audit's F100 / F080).
|
|
5
|
+
*
|
|
6
|
+
* git commits symlinks. A checkout can arrive carrying `.flowviant -> ~/.ssh`,
|
|
7
|
+
* or `.flowviant/uploads -> ../../../../.ssh` (a worktree's depth under
|
|
8
|
+
* `~/.flowviant/worktrees` is deterministic), and any daemon write through
|
|
9
|
+
* either lands outside the place it was meant for, as the operator. The rule
|
|
10
|
+
* that stops it was hand-written in ten places in four shapes — a boolean, a
|
|
11
|
+
* true/null/false, a 'dir'/'none'/'other', a replace-then-mkdir — and the
|
|
12
|
+
* attachment lane, the one that writes a MEMBER'S bytes under a member's name,
|
|
13
|
+
* had none of them. Each new `.flowviant/<x>` writer had to rediscover it.
|
|
14
|
+
*
|
|
15
|
+
* TWO POLICIES, deliberately two doors:
|
|
16
|
+
*
|
|
17
|
+
* - REFUSED, NEVER REPLACED (`ensureRealSubdir`): `.flowviant/` and the
|
|
18
|
+
* directories under it a repository could hold — the artifacts directory,
|
|
19
|
+
* the uploads directory. `.flowviant/` is not only ours (a repo declares
|
|
20
|
+
* `check.json` and `deploy.json` there), so a link or a file at one of
|
|
21
|
+
* these names is the repository's content: Flowviant deleting it would be
|
|
22
|
+
* destroying what it did not create, and following it is the write outside
|
|
23
|
+
* the worktree. Either answer is "no", and the caller says so in words.
|
|
24
|
+
* - REPLACED, NEVER FOLLOWED (`ensureOwnDir`): a path that is wholly the
|
|
25
|
+
* daemon's — the knowledge library under a parent its caller has already
|
|
26
|
+
* checked, a kept browser home. A link or a file planted there is removed
|
|
27
|
+
* (the link, never its target) and a real directory made in its place.
|
|
28
|
+
*
|
|
29
|
+
* Leaf: node:fs and node:path only.
|
|
30
|
+
*/
|
|
31
|
+
import { lstatSync, mkdirSync, rmSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What stands at `p`, by `lstat` and never followed: `'dir'` for a real
|
|
36
|
+
* directory, `'none'` for nothing at all, `'other'` for anything else — a
|
|
37
|
+
* symlink (to a directory or dangling), a regular file, or a path this cannot
|
|
38
|
+
* look at (a component that is a file, no permission). The unknown case is
|
|
39
|
+
* `'other'`, never `'none'`: an answer of "absent" invites a create.
|
|
40
|
+
*/
|
|
41
|
+
export function realDirState(p) {
|
|
42
|
+
try {
|
|
43
|
+
const st = lstatSync(p);
|
|
44
|
+
return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return e?.code === 'ENOENT' ? 'none' : 'other';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `<placeDir>/.flowviant`, by the same three states. */
|
|
51
|
+
export const flowviantParentState = (placeDir) => realDirState(join(placeDir, '.flowviant'));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `<placeDir>/<rel>` as a real directory, every component of `rel` a real
|
|
55
|
+
* directory too — or FALSE, with nothing replaced.
|
|
56
|
+
*
|
|
57
|
+
* Each component must already be a real directory or be absent; any one that
|
|
58
|
+
* is a link or a non-directory refuses before anything is made. The missing
|
|
59
|
+
* ones are made, and then EVERY component is checked again: a link swapped in
|
|
60
|
+
* between the look and the make is caught here, and the caller writes
|
|
61
|
+
* nothing. `rel` is a relative path of plain names (`.flowviant/uploads`); a
|
|
62
|
+
* `..`, an empty or an absolute component refuses.
|
|
63
|
+
*/
|
|
64
|
+
export function ensureRealSubdir(placeDir, rel) {
|
|
65
|
+
const parts = String(rel).split('/');
|
|
66
|
+
if (parts.some((p) => p === '' || p === '.' || p === '..')) return false;
|
|
67
|
+
const paths = parts.map((_, i) => join(placeDir, ...parts.slice(0, i + 1)));
|
|
68
|
+
if (paths.some((p) => realDirState(p) === 'other')) return false;
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(paths[paths.length - 1], { recursive: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return paths.every((p) => realDirState(p) === 'dir');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A directory that is wholly the daemon's, made real: anything at `path` that
|
|
79
|
+
* is not a real directory is removed — non-recursively, so a link goes and its
|
|
80
|
+
* target never does — and the directory made (`mode` for each directory this
|
|
81
|
+
* creates). Returns `path`; THROWS when a real directory cannot be had there,
|
|
82
|
+
* a link reappearing between the remove and the make included, so a caller
|
|
83
|
+
* never writes through one.
|
|
84
|
+
*
|
|
85
|
+
* Only for paths that are OURS. A path a repository may legitimately hold is
|
|
86
|
+
* `ensureRealSubdir`'s, which refuses rather than removes.
|
|
87
|
+
*/
|
|
88
|
+
export function ensureOwnDir(path, { mode } = {}) {
|
|
89
|
+
if (realDirState(path) !== 'dir') {
|
|
90
|
+
try {
|
|
91
|
+
rmSync(path, { force: true });
|
|
92
|
+
} catch {
|
|
93
|
+
/* the make below says why */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
mkdirSync(path, mode === undefined ? { recursive: true } : { recursive: true, mode });
|
|
97
|
+
if (realDirState(path) !== 'dir') throw new Error(`${path} is not a real directory`);
|
|
98
|
+
return path;
|
|
99
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SCREENSHOT OF ONE PAGE, AND ONLY THAT PAGE (audit 2026-10-10, F657/F756).
|
|
3
|
+
*
|
|
4
|
+
* `captureScreenshot` (shot.mjs) photographs whatever document Chrome ends up
|
|
5
|
+
* on. For a page somebody else wrote that is the wrong question: the design
|
|
6
|
+
* preview used to load the model's HTML as a top-level `file://` document,
|
|
7
|
+
* and a `<meta http-equiv=refresh>` (or `location.replace`) to
|
|
8
|
+
* `file:///home/<you>/.flowviant/credentials.json` made the uploaded PNG a
|
|
9
|
+
* picture of every machine credential on the box — past the read fence the
|
|
10
|
+
* design posture exists for, and past the secret scrub, which reads text, not
|
|
11
|
+
* pixels. CSP has no working control over top-level navigation.
|
|
12
|
+
*
|
|
13
|
+
* This capture drives Chrome over its DevTools pipe (`--remote-debugging-pipe`
|
|
14
|
+
* — no port, nothing another local process can reach) and holds the page to
|
|
15
|
+
* the one URL it was given:
|
|
16
|
+
*
|
|
17
|
+
* - EVERY network request is paused. The first Document request for `url` is
|
|
18
|
+
* the page and goes through; any other Document request — a meta refresh,
|
|
19
|
+
* a script navigation, a frame — is failed AND marks the capture as having
|
|
20
|
+
* left the page. Subresources go through only when `allowSubresource` says
|
|
21
|
+
* so (the page's own CSP hosts); the rest are failed quietly.
|
|
22
|
+
* - A NEW PAGE (window.open, target=_blank) is paused before it runs, closed,
|
|
23
|
+
* and marks the capture as having left the page.
|
|
24
|
+
* - Downloads are denied.
|
|
25
|
+
* - Before the PNG is kept, the committed document must still be `url`
|
|
26
|
+
* (fragment aside) and must not be an error page.
|
|
27
|
+
*
|
|
28
|
+
* Any of those failing is `{ ok: false, reason: 'left-page' }`, and the caller
|
|
29
|
+
* (artifactPreview.mjs) reports `renderState: 'unavailable'` with no preview.
|
|
30
|
+
* Chrome itself refuses a navigation from an http page to `file://`, which is
|
|
31
|
+
* why the caller serves the page over loopback http rather than from disk.
|
|
32
|
+
*
|
|
33
|
+
* A Windows browser reached through WSL interop cannot be driven: interop
|
|
34
|
+
* bridges stdin/stdout/stderr and nothing else, so the DevTools pipe on fds 3
|
|
35
|
+
* and 4 never arrives. That path answers 'unavailable' rather than a
|
|
36
|
+
* screenshot nobody verified.
|
|
37
|
+
*
|
|
38
|
+
* The launch obeys shot.mjs's policy: sandboxed unless `sandboxOff`, one retry
|
|
39
|
+
* without it after a launch that measured a sandbox failure, and the machine
|
|
40
|
+
* credential stripped from Chrome's environment.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { spawn } from 'node:child_process';
|
|
44
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
import { tmpdir, platform } from 'node:os';
|
|
47
|
+
import { SANDBOX_FAILURE_RE, chromeEnv, chromeFlags, resolveBrowser, sandboxOff } from './shot.mjs';
|
|
48
|
+
import { killGroup } from './processGroup.mjs';
|
|
49
|
+
|
|
50
|
+
/** How long a closing browser gets to exit before it is killed. */
|
|
51
|
+
const EXIT_GRACE_MS = 3_000;
|
|
52
|
+
/** How long the page Chrome just made gets to be auto-attached. */
|
|
53
|
+
const ATTACH_WAIT_MS = 2_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A DevTools client over a pipe pair: JSON messages, each ended by a NUL.
|
|
57
|
+
* `send(method, params, sessionId)` resolves the result or rejects with the
|
|
58
|
+
* protocol's error; `on(fn)` receives every event; `close(reason)` rejects
|
|
59
|
+
* whatever is still waiting (the browser is gone).
|
|
60
|
+
*/
|
|
61
|
+
export function createCdpClient(writable, readable) {
|
|
62
|
+
let nextId = 0;
|
|
63
|
+
let closed = null;
|
|
64
|
+
const waiting = new Map();
|
|
65
|
+
const listeners = [];
|
|
66
|
+
let chunks = [];
|
|
67
|
+
readable.on('data', (d) => {
|
|
68
|
+
let start = 0;
|
|
69
|
+
for (let i = 0; i < d.length; i++) {
|
|
70
|
+
if (d[i] !== 0) continue;
|
|
71
|
+
chunks.push(d.subarray(start, i));
|
|
72
|
+
start = i + 1;
|
|
73
|
+
let msg;
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
76
|
+
} catch {
|
|
77
|
+
msg = null;
|
|
78
|
+
}
|
|
79
|
+
chunks = [];
|
|
80
|
+
if (!msg) continue;
|
|
81
|
+
if (msg.id != null && waiting.has(msg.id)) {
|
|
82
|
+
const w = waiting.get(msg.id);
|
|
83
|
+
waiting.delete(msg.id);
|
|
84
|
+
if (msg.error) w.reject(new Error(msg.error.message || 'protocol error'));
|
|
85
|
+
else w.resolve(msg.result ?? {});
|
|
86
|
+
} else if (msg.method) {
|
|
87
|
+
for (const fn of listeners) fn(msg);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (start < d.length) chunks.push(d.subarray(start));
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
send(method, params = {}, sessionId) {
|
|
94
|
+
if (closed) return Promise.reject(new Error(closed));
|
|
95
|
+
const id = ++nextId;
|
|
96
|
+
const msg = { id, method, params, ...(sessionId ? { sessionId } : {}) };
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
waiting.set(id, { resolve, reject });
|
|
99
|
+
try {
|
|
100
|
+
writable.write(JSON.stringify(msg) + '\0');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
waiting.delete(id);
|
|
103
|
+
reject(e);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
on(fn) {
|
|
108
|
+
listeners.push(fn);
|
|
109
|
+
},
|
|
110
|
+
close(reason) {
|
|
111
|
+
if (closed) return;
|
|
112
|
+
closed = reason;
|
|
113
|
+
for (const w of waiting.values()) w.reject(new Error(reason));
|
|
114
|
+
waiting.clear();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `a` and `b` name the same document: equal once the fragment is dropped. */
|
|
120
|
+
export function sameDocument(a, b) {
|
|
121
|
+
try {
|
|
122
|
+
const x = new URL(a);
|
|
123
|
+
const y = new URL(b);
|
|
124
|
+
x.hash = '';
|
|
125
|
+
y.hash = '';
|
|
126
|
+
return x.href === y.href;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One launch, one page, one PNG. Resolves `{ ok: true }` with the PNG written
|
|
134
|
+
* to `out`, or `{ ok: false, reason, message, launchFailed?, sandboxFailed? }`.
|
|
135
|
+
* `launchFailed` means the browser never answered on the pipe — the only
|
|
136
|
+
* failure a different launch (classic headless, or no sandbox) can change.
|
|
137
|
+
*/
|
|
138
|
+
async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env }) {
|
|
139
|
+
const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-pin-'));
|
|
140
|
+
const args = [...chromeFlags({ headlessFlag, noSandbox, userDataDir, width, height }), '--remote-debugging-pipe', 'about:blank'];
|
|
141
|
+
let child;
|
|
142
|
+
try {
|
|
143
|
+
child = spawnImpl(browser.bin, args, {
|
|
144
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
145
|
+
env: chromeEnv(env),
|
|
146
|
+
// Its own process group, so the kill below takes Chrome's helpers too.
|
|
147
|
+
detached: platform() !== 'win32',
|
|
148
|
+
});
|
|
149
|
+
} catch (e) {
|
|
150
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
151
|
+
return { ok: false, reason: 'spawn', message: e.message, launchFailed: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!child.stdio?.[3] || !child.stdio?.[4]) {
|
|
155
|
+
try { child.kill('SIGKILL'); } catch { /* gone */ }
|
|
156
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
157
|
+
return { ok: false, reason: 'spawn', message: 'no DevTools pipe to the browser.', launchFailed: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let err = '';
|
|
161
|
+
child.stderr?.on('data', (d) => { err = (err + d.toString()).slice(-4000); });
|
|
162
|
+
const exited = new Promise((resolve) => {
|
|
163
|
+
if (child.exitCode != null) return resolve();
|
|
164
|
+
child.once('exit', () => resolve());
|
|
165
|
+
child.once('error', () => resolve());
|
|
166
|
+
});
|
|
167
|
+
const cdp = createCdpClient(child.stdio[3], child.stdio[4]);
|
|
168
|
+
child.once('exit', (code) => cdp.close(`Chrome exited (code ${code}).`));
|
|
169
|
+
child.once('error', (e) => cdp.close(e.message));
|
|
170
|
+
child.stdio[3]?.on?.('error', () => {});
|
|
171
|
+
child.stdio[4]?.on?.('error', () => {});
|
|
172
|
+
|
|
173
|
+
// The browser's whole group (processGroup.mjs). On Windows it is not
|
|
174
|
+
// detached, the group signal throws there, and the child itself is killed.
|
|
175
|
+
const kill = () => killGroup(child, 'SIGKILL');
|
|
176
|
+
const shutdown = async () => {
|
|
177
|
+
cdp.send('Browser.close').catch(() => {});
|
|
178
|
+
let grace;
|
|
179
|
+
const gone = await Promise.race([exited.then(() => true), new Promise((r) => { grace = setTimeout(() => r(false), EXIT_GRACE_MS); })]);
|
|
180
|
+
clearTimeout(grace);
|
|
181
|
+
// A browser that closed took its helpers with it; one that did not is
|
|
182
|
+
// killed with its whole group.
|
|
183
|
+
if (!gone) kill();
|
|
184
|
+
cdp.close('closed');
|
|
185
|
+
for (const fd of [3, 4]) {
|
|
186
|
+
try { child.stdio[fd]?.destroy?.(); } catch { /* gone */ }
|
|
187
|
+
}
|
|
188
|
+
try { child.stderr?.destroy?.(); } catch { /* gone */ }
|
|
189
|
+
try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* a dying helper still writing */ }
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
let left = null; // the first sign the page was left, in words
|
|
193
|
+
const leave = (why) => { if (!left) left = why; };
|
|
194
|
+
let deadline;
|
|
195
|
+
const timedOut = new Promise((resolve) => {
|
|
196
|
+
deadline = setTimeout(() => resolve({ ok: false, reason: 'timeout', message: `the page did not finish within ${timeoutMs / 1000}s.` }), timeoutMs);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const run = async () => {
|
|
200
|
+
try {
|
|
201
|
+
await cdp.send('Browser.getVersion');
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Give a dying browser a moment to say why on stderr.
|
|
204
|
+
await Promise.race([exited, new Promise((r) => setTimeout(r, 200))]);
|
|
205
|
+
const tail = err.trim().split('\n').slice(-2).join(' ');
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: 'render',
|
|
209
|
+
message: `Chrome did not start (${e.message})${tail ? ' ' + tail : ''}`,
|
|
210
|
+
launchFailed: true,
|
|
211
|
+
sandboxFailed: !noSandbox && SANDBOX_FAILURE_RE.test(err),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let ours = null; // { targetId, sessionId }
|
|
216
|
+
let ourTargetId = null;
|
|
217
|
+
let documentSeen = false;
|
|
218
|
+
const attached = new Map(); // targetId -> { sessionId, waiting, type }
|
|
219
|
+
let ready;
|
|
220
|
+
const ourSession = new Promise((r) => { ready = r; });
|
|
221
|
+
let budgetDone;
|
|
222
|
+
const budget = new Promise((r) => { budgetDone = r; });
|
|
223
|
+
|
|
224
|
+
let navigating = false;
|
|
225
|
+
// Called only for a target that is paused before it runs. Once the page
|
|
226
|
+
// is navigating, a new page is one IT opened (a popup) and never runs;
|
|
227
|
+
// before that, it can only be the window Chrome starts with.
|
|
228
|
+
const settleForeign = (targetId, info) => {
|
|
229
|
+
if (info.type === 'page') {
|
|
230
|
+
if (navigating) leave('the page opened another window');
|
|
231
|
+
cdp.send('Target.closeTarget', { targetId }).catch(() => {});
|
|
232
|
+
} else {
|
|
233
|
+
cdp.send('Runtime.runIfWaitingForDebugger', {}, info.sessionId).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
cdp.on((msg) => {
|
|
238
|
+
const p = msg.params || {};
|
|
239
|
+
if (msg.method === 'Target.attachedToTarget' && !msg.sessionId) {
|
|
240
|
+
const info = { sessionId: p.sessionId, waiting: !!p.waitingForDebugger, type: p.targetInfo?.type };
|
|
241
|
+
const targetId = p.targetInfo?.targetId;
|
|
242
|
+
attached.set(targetId, info);
|
|
243
|
+
if (ourTargetId) {
|
|
244
|
+
if (targetId === ourTargetId) ready(info.sessionId);
|
|
245
|
+
else if (info.waiting) settleForeign(targetId, info);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!ours || msg.sessionId !== ours.sessionId) return;
|
|
250
|
+
if (msg.method === 'Fetch.requestPaused') {
|
|
251
|
+
const requestId = p.requestId;
|
|
252
|
+
const reqUrl = p.request?.url ?? '';
|
|
253
|
+
if (p.resourceType === 'Document') {
|
|
254
|
+
if (!documentSeen && p.frameId === ours.targetId && reqUrl === url) {
|
|
255
|
+
documentSeen = true;
|
|
256
|
+
cdp.send('Fetch.continueRequest', { requestId }, ours.sessionId).catch(() => {});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
leave(`the page navigated to ${reqUrl.slice(0, 200)}`);
|
|
260
|
+
cdp.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, ours.sessionId).catch(() => {});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let allowed = false;
|
|
264
|
+
try {
|
|
265
|
+
allowed = !!allowSubresource(reqUrl);
|
|
266
|
+
} catch {
|
|
267
|
+
allowed = false;
|
|
268
|
+
}
|
|
269
|
+
cdp.send(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }, ours.sessionId).catch(() => {});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (msg.method === 'Emulation.virtualTimeBudgetExpired') budgetDone();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' }).catch(() => {});
|
|
276
|
+
await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
|
|
277
|
+
const created = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
278
|
+
ourTargetId = created.targetId;
|
|
279
|
+
for (const [targetId, info] of attached) {
|
|
280
|
+
if (targetId === ourTargetId) ready(info.sessionId);
|
|
281
|
+
else if (info.waiting) settleForeign(targetId, info);
|
|
282
|
+
}
|
|
283
|
+
// A Chrome that does not auto-attach the page it just made is attached
|
|
284
|
+
// to it by hand.
|
|
285
|
+
let attachWait;
|
|
286
|
+
const sessionId = await Promise.race([
|
|
287
|
+
ourSession,
|
|
288
|
+
new Promise((r) => { attachWait = setTimeout(r, ATTACH_WAIT_MS, null); }),
|
|
289
|
+
]).then((id) => id ?? cdp.send('Target.attachToTarget', { targetId: ourTargetId, flatten: true }).then((a) => a.sessionId));
|
|
290
|
+
clearTimeout(attachWait);
|
|
291
|
+
ours = { targetId: ourTargetId, sessionId };
|
|
292
|
+
|
|
293
|
+
await cdp.send('Page.enable', {}, sessionId);
|
|
294
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId);
|
|
295
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
|
|
296
|
+
await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
|
|
297
|
+
await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {});
|
|
298
|
+
navigating = true;
|
|
299
|
+
const nav = await cdp.send('Page.navigate', { url }, sessionId);
|
|
300
|
+
if (nav.errorText) {
|
|
301
|
+
return { ok: false, reason: 'render', message: `the page did not load (${nav.errorText}).` };
|
|
302
|
+
}
|
|
303
|
+
// Let fonts and scripts settle, as `--virtual-time-budget` does.
|
|
304
|
+
await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
|
|
305
|
+
// A Chrome that never reports the budget is captured on the wall clock,
|
|
306
|
+
// short of the deadline, rather than never.
|
|
307
|
+
let wall;
|
|
308
|
+
await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
|
|
309
|
+
clearTimeout(wall);
|
|
310
|
+
|
|
311
|
+
const history = await cdp.send('Page.getNavigationHistory', {}, sessionId);
|
|
312
|
+
const current = history.entries?.[history.currentIndex]?.url ?? '';
|
|
313
|
+
const tree = await cdp.send('Page.getFrameTree', {}, sessionId);
|
|
314
|
+
const main = tree.frameTree?.frame ?? {};
|
|
315
|
+
if (!documentSeen) leave('the page was never requested');
|
|
316
|
+
if (!sameDocument(current, url) || !sameDocument(main.url ?? '', url) || main.unreachableUrl) {
|
|
317
|
+
leave(`the page ended on ${String(current || main.url || 'nothing').slice(0, 200)}`);
|
|
318
|
+
}
|
|
319
|
+
if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
|
|
320
|
+
|
|
321
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
322
|
+
// One last look: a navigation that landed during the capture.
|
|
323
|
+
if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
|
|
324
|
+
writeFileSync(out, Buffer.from(shot.data ?? '', 'base64'));
|
|
325
|
+
return { ok: true, path: out };
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
return await Promise.race([
|
|
330
|
+
run().catch((e) => ({ ok: false, reason: 'render', message: e?.message ?? String(e) })),
|
|
331
|
+
timedOut,
|
|
332
|
+
]);
|
|
333
|
+
} finally {
|
|
334
|
+
clearTimeout(deadline);
|
|
335
|
+
await shutdown();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Capture `url` and nothing else to a PNG at `out` (see the header). Resolves
|
|
341
|
+
* `{ ok: true, path }` or `{ ok: false, reason, message }`; a reason of
|
|
342
|
+
* 'left-page' means the page tried to become something else.
|
|
343
|
+
*
|
|
344
|
+
* `browser`, `spawn`, `uid` and `env` are injectable for the tests; nothing in
|
|
345
|
+
* the daemon passes them.
|
|
346
|
+
*/
|
|
347
|
+
export async function capturePinnedPage({
|
|
348
|
+
url,
|
|
349
|
+
out,
|
|
350
|
+
width = 1440,
|
|
351
|
+
height = 900,
|
|
352
|
+
timeoutMs = 60_000,
|
|
353
|
+
budgetMs = 5_000,
|
|
354
|
+
allowSubresource = () => false,
|
|
355
|
+
browser = resolveBrowser(),
|
|
356
|
+
spawn: spawnImpl = spawn,
|
|
357
|
+
uid = process.getuid?.(),
|
|
358
|
+
env = process.env,
|
|
359
|
+
}) {
|
|
360
|
+
if (!browser) {
|
|
361
|
+
return { ok: false, reason: 'no-browser', message: 'No Chrome/Chromium/Edge found.' };
|
|
362
|
+
}
|
|
363
|
+
if (browser.viaWindows) {
|
|
364
|
+
return { ok: false, reason: 'wsl', message: 'a Windows browser reached through WSL cannot be held to one page.' };
|
|
365
|
+
}
|
|
366
|
+
let noSandbox = sandboxOff({ uid, env });
|
|
367
|
+
let last = null;
|
|
368
|
+
for (const headlessFlag of ['--headless=new', '--headless']) {
|
|
369
|
+
const once = () =>
|
|
370
|
+
captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env });
|
|
371
|
+
let r = await once();
|
|
372
|
+
if (!r.ok && r.sandboxFailed) {
|
|
373
|
+
noSandbox = true; // sticky: the one retry this capture gets
|
|
374
|
+
r = await once();
|
|
375
|
+
}
|
|
376
|
+
const { launchFailed, sandboxFailed: _measured, ...result } = r;
|
|
377
|
+
// Only a browser that never answered is worth another launch; a page that
|
|
378
|
+
// left, timed out or failed to load answers the same under any flag.
|
|
379
|
+
if (result.ok || !launchFailed) return result;
|
|
380
|
+
last = result;
|
|
381
|
+
}
|
|
382
|
+
return last;
|
|
383
|
+
}
|