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/prWorkflow.mjs
CHANGED
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
* - ADOPT ONLY AN OPEN PR. gh's branch finder falls back to the most recent
|
|
18
18
|
* MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
|
|
19
19
|
* every later delivery on a long-lived branch into a silent black hole.
|
|
20
|
+
* - NEVER A BARE BRANCH AS A PR SELECTOR (audit 2026-10-10, F166). gh reads a
|
|
21
|
+
* selector that parses as a number (`42`, `#42`) as PR #42, whatever its
|
|
22
|
+
* head — so `gh pr view <branch>` / `gh pr merge <branch>` on a branch
|
|
23
|
+
* named after a ticket adopted, and then MERGED, somebody else's pull
|
|
24
|
+
* request under the operator's login. The PR is found by `gh pr list
|
|
25
|
+
* --head`, kept only when its head is exactly this branch in this
|
|
26
|
+
* repository (a fork's same-named branch is not ours), and merged by that
|
|
27
|
+
* verified URL. No verified PR, no merge.
|
|
20
28
|
* - …AND ONLY ONE THAT TARGETS THE PROJECT'S BASE. A PR somebody opened by
|
|
21
29
|
* hand into another branch would be merged INTO that branch. Refused only
|
|
22
30
|
* on a MEASURED mismatch: an absent or empty `baseRefName` adopts as
|
|
@@ -44,13 +52,28 @@
|
|
|
44
52
|
*/
|
|
45
53
|
|
|
46
54
|
import { execFileSync } from 'node:child_process';
|
|
47
|
-
import { git } from './git.mjs';
|
|
55
|
+
import { git, stripUrlUserinfo } from './git.mjs';
|
|
56
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
48
57
|
|
|
49
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* gh's (or git's) own first line of complaint, capped — the words a failure
|
|
60
|
+
* relays. USERINFO STRIPPED (audit 2026-10-10, F153): a push writes the remote
|
|
61
|
+
* URL to stderr and a remote can carry a token as `//user:token@host`, and
|
|
62
|
+
* this line is stored where the whole project reads it.
|
|
63
|
+
*/
|
|
50
64
|
export const ghFirstLine = (e) =>
|
|
51
|
-
(
|
|
52
|
-
'failed')
|
|
53
|
-
|
|
65
|
+
stripUrlUserinfo(
|
|
66
|
+
(e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) || 'failed'
|
|
67
|
+
).slice(0, 400);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A FAILURE LINE FIT TO LEAVE THIS MACHINE — userinfo stripped and every
|
|
71
|
+
* known secret scrubbed. Every git or gh failure relayed to a team-visible
|
|
72
|
+
* record (a card's activity, the tab chat, an agent's mergeError) goes
|
|
73
|
+
* through here: the session PR lane relayed git's push stderr unscrubbed
|
|
74
|
+
* while the agent lane scrubbed the same line (audit 2026-10-10, F153/F167).
|
|
75
|
+
*/
|
|
76
|
+
export const relayedFailure = (e) => scrub(ghFirstLine(e));
|
|
54
77
|
|
|
55
78
|
/** A pull request URL worth storing. Anything else gh printed is not one. */
|
|
56
79
|
export const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
|
|
@@ -67,19 +90,53 @@ export function ghReady({ gh = runGh } = {}) {
|
|
|
67
90
|
gh(['auth', 'status'], { timeout: 20_000 });
|
|
68
91
|
return null;
|
|
69
92
|
} catch (e) {
|
|
70
|
-
return { missing: e?.code === 'ENOENT', error:
|
|
93
|
+
return { missing: e?.code === 'ENOENT', error: relayedFailure(e) };
|
|
71
94
|
}
|
|
72
95
|
}
|
|
73
96
|
|
|
74
|
-
/**
|
|
75
|
-
*
|
|
97
|
+
/** A PR URL gh may be handed as a selector: a web address, never a bare
|
|
98
|
+
* number or a leading-dash value. Host-agnostic, so GitHub Enterprise merges. */
|
|
99
|
+
const SELECTOR_URL_RE = /^https?:\/\/\S+$/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The OPEN pull request whose head is exactly `head` in THIS repository, as
|
|
103
|
+
* `{ url, base, number }` — or null when gh lists none. THROWS when gh itself
|
|
104
|
+
* fails, so a caller can tell "GitHub says there is none" from "we could not
|
|
105
|
+
* ask"; `findOpenPr` folds the two for the callers that only adopt.
|
|
106
|
+
*/
|
|
107
|
+
function verifiedOpenPr(head, { cwd, gh }) {
|
|
108
|
+
const rows = JSON.parse(
|
|
109
|
+
gh(
|
|
110
|
+
[
|
|
111
|
+
'pr', 'list', '--head', head, '--state', 'open',
|
|
112
|
+
'--json', 'number,url,baseRefName,headRefName,isCrossRepository',
|
|
113
|
+
'--limit', '30',
|
|
114
|
+
],
|
|
115
|
+
{ cwd, timeout: 30_000 }
|
|
116
|
+
).toString()
|
|
117
|
+
);
|
|
118
|
+
const pr = (Array.isArray(rows) ? rows : []).find(
|
|
119
|
+
(r) =>
|
|
120
|
+
r?.headRefName === head &&
|
|
121
|
+
r?.isCrossRepository === false &&
|
|
122
|
+
typeof r?.url === 'string' &&
|
|
123
|
+
SELECTOR_URL_RE.test(r.url.trim())
|
|
124
|
+
);
|
|
125
|
+
if (!pr) return null;
|
|
126
|
+
return {
|
|
127
|
+
url: pr.url.trim(),
|
|
128
|
+
base: typeof pr.baseRefName === 'string' ? pr.baseRefName : null,
|
|
129
|
+
number: Number.isInteger(pr.number) ? pr.number : null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The OPEN pull request for `head`, as `{ url, base, number }` — or null when
|
|
134
|
+
* there is none (or gh could not say). `base` is null when gh did not say. */
|
|
76
135
|
export function findOpenPr(head, { cwd, gh = runGh } = {}) {
|
|
77
136
|
try {
|
|
78
|
-
|
|
79
|
-
if (j?.state !== 'OPEN' || typeof j?.url !== 'string') return null;
|
|
80
|
-
return { url: j.url.trim(), base: typeof j?.baseRefName === 'string' ? j.baseRefName : null };
|
|
137
|
+
return verifiedOpenPr(head, { cwd, gh });
|
|
81
138
|
} catch {
|
|
82
|
-
return null; //
|
|
139
|
+
return null; // gh could not list — nothing to adopt
|
|
83
140
|
}
|
|
84
141
|
}
|
|
85
142
|
|
|
@@ -107,7 +164,7 @@ export function ensureOpenPr(head, base, { cwd, gh = runGh } = {}) {
|
|
|
107
164
|
.trim();
|
|
108
165
|
return { url: out.split('\n').filter(Boolean).pop() ?? null, adopted: false };
|
|
109
166
|
} catch (e) {
|
|
110
|
-
return { error:
|
|
167
|
+
return { error: relayedFailure(e) };
|
|
111
168
|
}
|
|
112
169
|
}
|
|
113
170
|
|
|
@@ -141,15 +198,35 @@ export async function tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, slee
|
|
|
141
198
|
/**
|
|
142
199
|
* MERGE `head`'s PR with a merge commit and VERIFY the tip reached base.
|
|
143
200
|
* Answers `{ ok: true }`, `{ error }` (gh refused, in its own words — an
|
|
144
|
-
* "already merged" is not a refusal),
|
|
145
|
-
*
|
|
201
|
+
* "already merged" is not a refusal), `{ noPr: true }` (GitHub lists no open
|
|
202
|
+
* PR from `head` in this repository, so nothing was merged — the caller says
|
|
203
|
+
* so in its own words), or `{ notOnBase: true }` (gh said yes and base does
|
|
204
|
+
* not have the tip: an older PR merged, or a queue holds it).
|
|
205
|
+
*
|
|
206
|
+
* The PR is looked up HERE, by its head, and merged by its URL — never by
|
|
207
|
+
* `head` itself, which gh would read as a PR number if it looks like one. So
|
|
208
|
+
* no caller can hand the merge an unverified selector.
|
|
209
|
+
*
|
|
146
210
|
* The push that must precede it is the caller's: GitHub merges the REMOTE tip.
|
|
147
211
|
*/
|
|
148
212
|
export async function mergeAndVerifyTip(head, { cwd, tip, baseRef, repoRoot, fetchOrigin, gh = runGh, sleep, gitImpl }) {
|
|
213
|
+
let pr;
|
|
214
|
+
try {
|
|
215
|
+
pr = verifiedOpenPr(head, { cwd, gh });
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return { error: relayedFailure(e) };
|
|
218
|
+
}
|
|
219
|
+
if (!pr) {
|
|
220
|
+
// Nothing open to merge — and a PR that already MERGED leaves nothing
|
|
221
|
+
// open either (a re-offered job whose report was lost). "Already merged"
|
|
222
|
+
// stays a success only when it is measured: the tip on base.
|
|
223
|
+
const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
|
|
224
|
+
return landed ? { ok: true } : { noPr: true };
|
|
225
|
+
}
|
|
149
226
|
try {
|
|
150
|
-
gh(['pr', 'merge',
|
|
227
|
+
gh(['pr', 'merge', pr.url, '--merge'], { cwd, timeout: 120_000 });
|
|
151
228
|
} catch (e) {
|
|
152
|
-
const line =
|
|
229
|
+
const line = relayedFailure(e);
|
|
153
230
|
if (!/already merged/i.test(line)) return { error: line };
|
|
154
231
|
}
|
|
155
232
|
const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
|
package/bin/lib/preflight.mjs
CHANGED
|
@@ -4,31 +4,13 @@
|
|
|
4
4
|
* what's missing, rather than failing cryptically mid-run.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
7
|
import { ok, warn, info, c } from './ui.mjs';
|
|
9
|
-
import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
|
|
10
|
-
import {
|
|
8
|
+
import { addLocalBinToPath, commandPresent as present, promptYesNo, installClaude, installGh } from './install.mjs';
|
|
9
|
+
import { ghReady } from './prWorkflow.mjs';
|
|
10
|
+
import { RUNTIMES, mediatedSafeGap, runtimeById } from './runtimes.mjs';
|
|
11
11
|
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
12
12
|
import { terminalCommand } from './launchCommand.mjs';
|
|
13
13
|
|
|
14
|
-
function present(cmd) {
|
|
15
|
-
try {
|
|
16
|
-
execFileSync(cmd, ['--version'], { stdio: 'ignore' });
|
|
17
|
-
return true;
|
|
18
|
-
} catch {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function ghAuthed() {
|
|
24
|
-
try {
|
|
25
|
-
execFileSync('gh', ['auth', 'status'], { stdio: 'ignore', timeout: 20_000 });
|
|
26
|
-
return true;
|
|
27
|
-
} catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
14
|
/** Prints a checklist and, when a missing prereq is auto-installable, OFFERS to
|
|
33
15
|
* install it (consent-based, never silent). Returns false only if a *fatal*
|
|
34
16
|
* prereq (SOME drivable runtime, or git when worktrees are used) is still
|
|
@@ -53,7 +35,7 @@ export async function preflight({ needGit = true } = {}) {
|
|
|
53
35
|
let detected = detectRuntimes({ refresh: true });
|
|
54
36
|
const shown = detected.filter((r) => r.installed || r.id === 'claude');
|
|
55
37
|
for (const r of shown) {
|
|
56
|
-
const rt =
|
|
38
|
+
const rt = runtimeById(r.id);
|
|
57
39
|
if (r.dispatchable) {
|
|
58
40
|
ok(
|
|
59
41
|
`${rt.bin} installed ${c.dim(`· ${rt.label} · must be signed in — run \`${rt.login}\` once if you haven’t`)}`
|
|
@@ -77,9 +59,10 @@ export async function preflight({ needGit = true } = {}) {
|
|
|
77
59
|
// permission control is all-or-nothing. Say so at startup rather than letting
|
|
78
60
|
// an operator believe a setting is in force that isn't.
|
|
79
61
|
for (const r of drivable) {
|
|
80
|
-
|
|
62
|
+
const rt = runtimeById(r.id);
|
|
63
|
+
if (mediatedSafeGap(rt)) {
|
|
81
64
|
warn(
|
|
82
|
-
`${
|
|
65
|
+
`${rt.bin}: FLOWVIANT_SAFE cannot narrow this runtime — it has no per-invocation permission control, so its builds run unrestricted`
|
|
83
66
|
);
|
|
84
67
|
}
|
|
85
68
|
}
|
|
@@ -98,7 +81,7 @@ export async function preflight({ needGit = true } = {}) {
|
|
|
98
81
|
// agent, not a requirement. Hence the offer no longer defaults to yes: an
|
|
99
82
|
// install nobody asked for, defaulted on, is how a preflight installs
|
|
100
83
|
// software on a machine whose operator only wanted a status line.
|
|
101
|
-
if (gh &&
|
|
84
|
+
if (gh && ghReady() === null) {
|
|
102
85
|
ok('gh authenticated');
|
|
103
86
|
} else if (gh) {
|
|
104
87
|
warn('gh not signed in — run: gh auth login');
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -32,10 +32,12 @@ import { spawn, execFileSync } from 'node:child_process';
|
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
33
33
|
import { writeFileSync, renameSync, existsSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
|
|
34
34
|
import { join } from 'node:path';
|
|
35
|
-
import {
|
|
35
|
+
import { platform, arch } from 'node:os';
|
|
36
36
|
import { startAuthProxy } from './authproxy.mjs';
|
|
37
|
+
import { LOCAL_BIN, commandPresent } from './install.mjs';
|
|
37
38
|
import { forgetInfraPid, isListening, noteInfraPid, originFor } from './listeners.mjs';
|
|
38
39
|
import { forgetPreviewPid, recordPreviewPid } from './previewRegistry.mjs';
|
|
40
|
+
import { killGroup } from './processGroup.mjs';
|
|
39
41
|
|
|
40
42
|
// ── cloudflared: pinned, verified, or not fetched at all ───────────────────
|
|
41
43
|
|
|
@@ -53,15 +55,6 @@ const CF_SHA256 = {
|
|
|
53
55
|
'darwin-arm64': '9042c2c5d8b2de78e60f313d5fb31b6c5c1cebde787a3caf1f2c9588084ac442',
|
|
54
56
|
};
|
|
55
57
|
|
|
56
|
-
function onPath() {
|
|
57
|
-
try {
|
|
58
|
-
execFileSync('cloudflared', ['--version'], { stdio: 'ignore' });
|
|
59
|
-
return true;
|
|
60
|
-
} catch {
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
58
|
/**
|
|
66
59
|
* Resolve a cloudflared binary: PATH → previously fetched → download.
|
|
67
60
|
*
|
|
@@ -74,12 +67,12 @@ function onPath() {
|
|
|
74
67
|
* to be relayed verbatim rather than replaced with a Flowviant-authored one.
|
|
75
68
|
*/
|
|
76
69
|
async function ensureCloudflared(log) {
|
|
77
|
-
if (
|
|
70
|
+
if (commandPresent('cloudflared')) return { bin: 'cloudflared' };
|
|
78
71
|
|
|
79
72
|
const os = platform();
|
|
80
73
|
const a = arch() === 'arm64' ? 'arm64' : 'amd64';
|
|
81
74
|
const key = `${os === 'darwin' ? 'darwin' : 'linux'}-${a}`;
|
|
82
|
-
const dir =
|
|
75
|
+
const dir = LOCAL_BIN;
|
|
83
76
|
// Version-stamped, so a pin bump fetches rather than reusing the old binary.
|
|
84
77
|
const bin = join(dir, `cloudflared-${CF_VERSION}${os === 'win32' ? '.exe' : ''}`);
|
|
85
78
|
if (existsSync(bin)) return { bin };
|
|
@@ -162,7 +155,7 @@ const TAIL_BYTES = 2000;
|
|
|
162
155
|
* Flowviant asserting a state it never measured.
|
|
163
156
|
*
|
|
164
157
|
* `stillServing` (optional, async → boolean) is the ATTRIBUTION check — the
|
|
165
|
-
* same `
|
|
158
|
+
* same `originFor(worktree, port)` attribution the caller ran at the boundary —
|
|
166
159
|
* and it guards EVERY gate in here, not just the probe: the open-time
|
|
167
160
|
* re-validation, one more look immediately before cloudflared spawns, and the
|
|
168
161
|
* recurring probe. Ports are global to a box and a worktree is not: when the
|
|
@@ -245,15 +238,7 @@ export async function openTunnel({
|
|
|
245
238
|
/* best-effort */
|
|
246
239
|
}
|
|
247
240
|
if (tunnel?.pid) {
|
|
248
|
-
|
|
249
|
-
process.kill(-tunnel.pid, 'SIGKILL'); // the whole detached group
|
|
250
|
-
} catch {
|
|
251
|
-
try {
|
|
252
|
-
tunnel.kill('SIGKILL');
|
|
253
|
-
} catch {
|
|
254
|
-
/* already gone */
|
|
255
|
-
}
|
|
256
|
-
}
|
|
241
|
+
killGroup(tunnel, 'SIGKILL'); // the whole detached group
|
|
257
242
|
forgetPreviewPid(tunnel.pid);
|
|
258
243
|
forgetInfraPid(tunnel.pid);
|
|
259
244
|
}
|
|
@@ -301,7 +286,15 @@ export async function openTunnel({
|
|
|
301
286
|
return { error: `nothing is listening on port ${port} in this worktree any more.` };
|
|
302
287
|
}
|
|
303
288
|
|
|
304
|
-
|
|
289
|
+
// DIAL THE ADDRESS THE GATE BOUND (audit 2026-10-10, F757). The gate
|
|
290
|
+
// listens on 127.0.0.1 only; `localhost` resolves IPv6-first on macOS and
|
|
291
|
+
// on some Linux setups, and Go's dialer tries [::1]:<port> first — where
|
|
292
|
+
// any other local process could bind the same port number (it is readable
|
|
293
|
+
// from /proc/net/tcp and from this argv) and receive every public request,
|
|
294
|
+
// grant cookies and the automation password included. The same
|
|
295
|
+
// two-loopback-families hazard `originFor` closes on the origin side.
|
|
296
|
+
const gateUrl = `http://127.0.0.1:${gate.port}`;
|
|
297
|
+
const args = ['tunnel', '--url', gateUrl];
|
|
305
298
|
// Send the origin the Host it expects. Vite and Next reject a Host they do
|
|
306
299
|
// not recognise, so without this the tunnel resolves and then 403s.
|
|
307
300
|
args.push('--http-host-header', 'localhost');
|
|
@@ -310,8 +303,9 @@ export async function openTunnel({
|
|
|
310
303
|
// The signature names THIS tunnel's gate port, not the bare word
|
|
311
304
|
// 'cloudflared': the reap matches cmdline.includes(sig), and the generic
|
|
312
305
|
// word would let a recycled pid land on an operator's own unrelated
|
|
313
|
-
// cloudflared and group-SIGKILL it.
|
|
314
|
-
|
|
306
|
+
// cloudflared and group-SIGKILL it. Entries written before 2026-10-10 keep
|
|
307
|
+
// their own `--url http://localhost:<port>` signature and still reap.
|
|
308
|
+
recordPreviewPid(tunnel.pid, `--url ${gateUrl}`);
|
|
315
309
|
// Keep our own plumbing out of the listeners measurement (see listeners.mjs:
|
|
316
310
|
// cloudflared's metrics socket lives in the checkout's cwd).
|
|
317
311
|
noteInfraPid(tunnel.pid);
|
|
@@ -63,10 +63,21 @@ export const sourceOf = (req) =>
|
|
|
63
63
|
* says is cross-site is refused unless it is a plain navigation GET/HEAD —
|
|
64
64
|
* the two shapes this product legitimately serves cross-site (the Open
|
|
65
65
|
* link, the Workbench frame's own src; a rendered attacker frame is then
|
|
66
|
-
* stopped by the frame-ancestors rewrite).
|
|
67
|
-
* auth
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* stopped by the frame-ancestors rewrite). Applied in full to grant-cookie
|
|
67
|
+
* auth; clients that send no Sec-Fetch headers never held a None cookie, so
|
|
68
|
+
* absence stays permitted.
|
|
69
|
+
*
|
|
70
|
+
* The PASSWORD is not exempt from CSRF (corrected 2026-10-10, F116). It was
|
|
71
|
+
* held that "a password in an Authorization header is never attached
|
|
72
|
+
* cross-site by a browser" — false for HTTP Basic: the gate's own 401 makes
|
|
73
|
+
* the browser prompt, and the browser then replays the cached credentials on
|
|
74
|
+
* later requests to the origin, a cross-site auto-submitted form POST
|
|
75
|
+
* included (the classic router CSRF). So a password-authenticated request —
|
|
76
|
+
* and every request on a password-only share — is refused when it is
|
|
77
|
+
* cross-site AND not a safe method (`crossSiteWrite`). Cross-site
|
|
78
|
+
* GET/HEAD/OPTIONS still ride (a frontend preview calling an API preview on
|
|
79
|
+
* another tunnel), and so does anything without Sec-Fetch headers (curl,
|
|
80
|
+
* native tools, the automation the password exists for).
|
|
70
81
|
*
|
|
71
82
|
* Why a backstop at all: Chromium 76–113 (and same-vintage webviews/forks)
|
|
72
83
|
* sends Sec-Fetch-Dest — so it takes the framed-cookie branch — while
|
|
@@ -85,6 +96,16 @@ export const crossSiteAbuse = (req) => {
|
|
|
85
96
|
return !(req.method === 'GET' || req.method === 'HEAD');
|
|
86
97
|
};
|
|
87
98
|
|
|
99
|
+
/** Methods a cross-site page may send with a cached Basic credential: none
|
|
100
|
+
* of them changes state on a well-behaved origin. */
|
|
101
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
102
|
+
|
|
103
|
+
/** A cross-site request that can change state — the shape Basic-auth CSRF
|
|
104
|
+
* rides (see `crossSiteAbuse`'s second paragraph). */
|
|
105
|
+
export const crossSiteWrite = (req) =>
|
|
106
|
+
String(req.headers['sec-fetch-site'] || '').toLowerCase() === 'cross-site' &&
|
|
107
|
+
!SAFE_METHODS.has(String(req.method || '').toUpperCase());
|
|
108
|
+
|
|
88
109
|
/** A top-level browser navigation, and nothing else. A 302 is re-issued as
|
|
89
110
|
* GET and drops the body, so bouncing a POST would turn an unauthenticated
|
|
90
111
|
* write into a mystery GET; and this is what keeps curl and native clients
|
|
@@ -207,15 +228,18 @@ export function createGatePolicy({ grants, grantSecret, shareId, expected, log,
|
|
|
207
228
|
};
|
|
208
229
|
|
|
209
230
|
/** True when THIS request authenticated with the password rather than the
|
|
210
|
-
* cookie
|
|
231
|
+
* cookie. Not a CSRF exemption: a cached Basic credential rides a
|
|
232
|
+
* cross-site navigation too (see `crossSiteAbuse`). */
|
|
211
233
|
const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
|
|
212
234
|
|
|
213
235
|
/**
|
|
214
236
|
* THE ONE ADMISSION ORDER both transports ask: the credential first, then —
|
|
215
|
-
* only for a caller who HAS one — the cross-site
|
|
216
|
-
* cookie, and a cookie can be ridden (header rule 4a's backstop
|
|
217
|
-
*
|
|
218
|
-
*
|
|
237
|
+
* only for a caller who HAS one — the cross-site backstops. The grant is a
|
|
238
|
+
* cookie, and a cookie can be ridden (header rule 4a's backstop: every
|
|
239
|
+
* cross-site shape but a GET/HEAD navigation); a cached Basic password can
|
|
240
|
+
* be ridden too, by a cross-site write (2026-10-10, F116). A refusal there
|
|
241
|
+
* is never a bounce, because the caller has a credential and the request
|
|
242
|
+
* SHAPE is what is refused.
|
|
219
243
|
*
|
|
220
244
|
* Returns the credential verdict ('blocked', 'none', 'expired', 'forged',
|
|
221
245
|
* 'badpass'), 'cross-site', or 'ok'.
|
|
@@ -224,6 +248,7 @@ export function createGatePolicy({ grants, grantSecret, shareId, expected, log,
|
|
|
224
248
|
const verdict = credential(req);
|
|
225
249
|
if (verdict !== 'ok') return verdict;
|
|
226
250
|
if (grants && crossSiteAbuse(req) && !viaPassword(req)) return 'cross-site';
|
|
251
|
+
if (crossSiteWrite(req)) return 'cross-site';
|
|
227
252
|
return 'ok';
|
|
228
253
|
};
|
|
229
254
|
|
|
@@ -56,6 +56,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
56
56
|
import { join } from 'node:path';
|
|
57
57
|
import { homedir, platform } from 'node:os';
|
|
58
58
|
import { mutateRegistry, processAlive, processStartTime, readRegistry } from './procRegistry.mjs';
|
|
59
|
+
import { killGroup } from './processGroup.mjs';
|
|
59
60
|
|
|
60
61
|
const FLOWVIANT_DIR = join(homedir(), '.flowviant');
|
|
61
62
|
const PREVIEW_REGISTRY = join(FLOWVIANT_DIR, 'previews.json');
|
|
@@ -218,17 +219,9 @@ export function reapOrphanPreviews(log) {
|
|
|
218
219
|
if (ours === null) continue;
|
|
219
220
|
handled.add(pid);
|
|
220
221
|
if (!ours) continue;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
} catch {
|
|
225
|
-
try {
|
|
226
|
-
process.kill(pid, 'SIGKILL');
|
|
227
|
-
killed++;
|
|
228
|
-
} catch {
|
|
229
|
-
/* already gone */
|
|
230
|
-
}
|
|
231
|
-
}
|
|
222
|
+
// The whole group, else the pid — by number, as `process.kill` always
|
|
223
|
+
// coerced a row's pid (processGroup.mjs refuses anything that is not one).
|
|
224
|
+
if (killGroup(Number(pid), 'SIGKILL')) killed++;
|
|
232
225
|
}
|
|
233
226
|
if (handled.size) mutatePreviews((cur) => cur.filter((e) => !handled.has(e.pid)));
|
|
234
227
|
if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
|
package/bin/lib/printable.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* the server's own `parseBoxName`/`parseCheckoutPath` did — an escape
|
|
7
7
|
* sequence in a project or box name can clear the screen or, via OSC 52,
|
|
8
8
|
* rewrite the terminal's clipboard — but it stopped at C1 and never scrubbed
|
|
9
|
-
* the BIDI/format controls (U+200E/U+200F, U+202A–U+202E,
|
|
10
|
-
* reorder a line to hide or forge part of it: a box named
|
|
9
|
+
* the BIDI/format controls (U+061C, U+200E/U+200F, U+202A–U+202E,
|
|
10
|
+
* U+2066–U+2069) that reorder a line to hide or forge part of it: a box named
|
|
11
11
|
* `eviltxt.crt` reads backwards on a bidi-aware terminal, so
|
|
12
12
|
* "evil" can visually swap places with an extension that was never there.
|
|
13
13
|
*
|
|
@@ -19,10 +19,17 @@
|
|
|
19
19
|
* with zero imports is the one shape both can depend on without adding an
|
|
20
20
|
* edge neither wants.
|
|
21
21
|
*
|
|
22
|
-
* THE CHARACTER CLASS IS THE SERVER'S OWN (`stripDisplayControl`,
|
|
23
|
-
* machineBoxes.ts
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* THE CHARACTER CLASS IS THE SERVER'S OWN (`stripDisplayControl`, now
|
|
23
|
+
* `packages/shared/src/displayText.ts`; machineBoxes.ts when it was copied,
|
|
24
|
+
* 2026-09-24) — copied rather than re-derived, because the point of a BELT is
|
|
25
|
+
* that the two ends agree about what counts as printable without either one
|
|
26
|
+
* trusting the other to have applied it. The copy fell behind once: the
|
|
27
|
+
* server's set gained U+061C, the Arabic letter mark (the one Bidi_Control
|
|
28
|
+
* code point it lacked), on 2026-10-10 (audit F231), and this copy gained it
|
|
29
|
+
* in the audit's final cleanup the same day. The release gate
|
|
30
|
+
* (`scripts/check-app-parity.mjs`, rule 28) now walks every code point
|
|
31
|
+
* through `printable`, `printableText` and the shared `stripDisplayControl`,
|
|
32
|
+
* so the sets cannot drift apart unnoticed again. The server scrub
|
|
26
33
|
* is the primary fix (it is what a stale stored row, upserted before this
|
|
27
34
|
* belt existed, is read back through); this is defence in depth, because the
|
|
28
35
|
* terminal is where the harm happens and the CLI should not trust that every
|
|
@@ -32,7 +39,7 @@
|
|
|
32
39
|
* `safeName`.
|
|
33
40
|
*/
|
|
34
41
|
// eslint-disable-next-line no-control-regex
|
|
35
|
-
const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f
|
|
42
|
+
const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
36
43
|
|
|
37
44
|
/** Strip the class above. Non-strings pass through unchanged — this is a
|
|
38
45
|
* scrub, not a coercion, and a caller that hands it `undefined` should get
|
|
@@ -40,3 +47,59 @@ const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f--]/g;
|
|
|
40
47
|
export function printable(raw) {
|
|
41
48
|
return typeof raw === 'string' ? raw.replace(DISPLAY_CONTROL, '') : raw;
|
|
42
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ONE LINE OF SOMEBODY ELSE'S PROSE, made safe to print (2026-10-10, the
|
|
53
|
+
* audit's F084) — the CLI twin of the server's `normalizeStopReason`.
|
|
54
|
+
*
|
|
55
|
+
* A SENTENCE, so the class is BLANKED to a space rather than stripped (two
|
|
56
|
+
* words must not run together where a newline or a control sat between them),
|
|
57
|
+
* then whitespace collapses to one line, and the cap counts CODE POINTS: a
|
|
58
|
+
* `slice` on UTF-16 units can end on half a surrogate pair, and half a pair is
|
|
59
|
+
* what would be printed. The commanded stop's reason had its own copy of this
|
|
60
|
+
* that stopped at C0 and DEL — the 8-bit CSI `\u009b` and every bidi override
|
|
61
|
+
* went straight to the operator's terminal whenever the server-side scrub was
|
|
62
|
+
* bypassed (the column is also written by hand, machineStop.ts).
|
|
63
|
+
*
|
|
64
|
+
* Not a string in, '' out: every caller prints the result, and a sentence
|
|
65
|
+
* nobody wrote has no words.
|
|
66
|
+
*/
|
|
67
|
+
export function printableLine(raw, maxCodePoints) {
|
|
68
|
+
if (typeof raw !== 'string') return '';
|
|
69
|
+
const line = raw.replace(DISPLAY_CONTROL, ' ').replace(/\s+/g, ' ').trim();
|
|
70
|
+
return [...line].slice(0, maxCodePoints).join('').trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* OUTPUT A CLI OR A MODEL WROTE, made safe for the operator's terminal and log
|
|
75
|
+
* (2026-10-10, the audit's F133/F097): the same class, minus the two controls
|
|
76
|
+
* multi-line text is made of — `\n` and `\t` survive, so a CLI's stderr and a
|
|
77
|
+
* replayed log keep their shape.
|
|
78
|
+
*
|
|
79
|
+
* The ESC-introduced sequences go WHOLE first — CSI (`ESC [ … final`, which
|
|
80
|
+
* includes a colour) and OSC (`ESC ] …` ended by BEL or ST, which includes the
|
|
81
|
+
* OSC 52 clipboard write and the OSC 8 link) — so their parameter bytes do not
|
|
82
|
+
* survive as litter; any lone or unterminated ESC, every other C0 (a carriage
|
|
83
|
+
* return overwrites the line above it), DEL, C1 (the 8-bit CSI/OSC need no
|
|
84
|
+
* ESC at all) and the bidi controls then go one by one.
|
|
85
|
+
*
|
|
86
|
+
* NEVER APPLIED INSIDE ui.mjs's `line()`. The daemon colours its own words
|
|
87
|
+
* with SGR (`c.*`) around text it has already composed, so a sink-wide scrub
|
|
88
|
+
* would have to keep SGR — and cannot tell the daemon's from an attacker's.
|
|
89
|
+
* Text is cleaned where it ENTERS instead: server strings at their intake
|
|
90
|
+
* (`printable`), and CLI/model output at the one sink that writes it raw
|
|
91
|
+
* (runTurn's `emit`, before the daemon's coloured prefix is added). The one
|
|
92
|
+
* other reader is `flowviant logs` (views.mjs), which replays a log file that
|
|
93
|
+
* an older daemon may have written raw — so a replay loses the log's colours
|
|
94
|
+
* along with anybody else's sequences.
|
|
95
|
+
*/
|
|
96
|
+
// eslint-disable-next-line no-control-regex
|
|
97
|
+
const ESC_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b\u009c]*(?:\x07|\x1b\\|\u009c)/g;
|
|
98
|
+
// eslint-disable-next-line no-control-regex
|
|
99
|
+
const TEXT_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
100
|
+
|
|
101
|
+
/** Strip the class above, keeping `\n` and `\t`. Non-strings pass through
|
|
102
|
+
* unchanged, as `printable` does. */
|
|
103
|
+
export function printableText(raw) {
|
|
104
|
+
return typeof raw === 'string' ? raw.replace(ESC_SEQUENCE, '').replace(TEXT_CONTROL, '') : raw;
|
|
105
|
+
}
|
package/bin/lib/procRegistry.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
unlinkSync,
|
|
31
31
|
writeFileSync,
|
|
32
32
|
} from 'node:fs';
|
|
33
|
+
import { procStat } from './procStat.mjs';
|
|
33
34
|
|
|
34
35
|
const LOCK_STALE_MS = 15_000;
|
|
35
36
|
const MAX_ENTRIES = 32;
|
|
@@ -132,23 +133,21 @@ export function sameBoot(mark, now = bootMark()) {
|
|
|
132
133
|
* WHEN A PROCESS STARTED, as an opaque token — so a lock that remembers a pid
|
|
133
134
|
* can tell the process it meant from one that inherited the number.
|
|
134
135
|
*
|
|
135
|
-
* Linux: field 22 of `/proc/<pid>/stat
|
|
136
|
-
* read
|
|
136
|
+
* Linux: `starttime` (field 22 of `/proc/<pid>/stat`, clock ticks since boot),
|
|
137
|
+
* read through procStat.mjs, the one parser of that line.
|
|
137
138
|
* macOS: `ps -o lstart=`. Null when it cannot be read, which callers treat as
|
|
138
139
|
* "cannot tell" — never as a mismatch.
|
|
140
|
+
*
|
|
141
|
+
* The darwin token keeps the reader's locale (no LC_ALL pin, 2026-10-10): it is
|
|
142
|
+
* compared for EQUALITY against tokens older daemons already wrote into live
|
|
143
|
+
* turn locks, and pinning it would read every one of those holders as a
|
|
144
|
+
* different process for one upgrade.
|
|
139
145
|
*/
|
|
140
146
|
export function processStartTime(pid) {
|
|
141
147
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// `rest[0]` is field 3 (state), so field 22 is rest[19].
|
|
146
|
-
const st = rest[19];
|
|
147
|
-
return st && /^\d+$/.test(st) ? `${bootMark()}:${st}` : null;
|
|
148
|
-
} catch {
|
|
149
|
-
/* not Linux, or the pid is gone */
|
|
150
|
-
}
|
|
151
|
-
if (platform() !== 'darwin') return null;
|
|
148
|
+
const linux = procStat(pid);
|
|
149
|
+
if (linux) return `${bootMark()}:${linux.starttime}`;
|
|
150
|
+
if (platform() !== 'darwin') return null; // not Linux, or the pid is gone
|
|
152
151
|
try {
|
|
153
152
|
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
154
153
|
encoding: 'utf8',
|
|
@@ -161,7 +160,12 @@ export function processStartTime(pid) {
|
|
|
161
160
|
}
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Signal-0 liveness — THE probe (2026-10-10, F050/F090: instance.mjs kept a
|
|
165
|
+
* byte-for-byte copy and uninstall a drifted one). EPERM means alive and not
|
|
166
|
+
* ours, which is still alive: a reader deciding whether something may be
|
|
167
|
+
* removed or replaced from under a process errs toward the process.
|
|
168
|
+
*/
|
|
165
169
|
export function processAlive(pid) {
|
|
166
170
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
167
171
|
try {
|
|
@@ -172,6 +176,22 @@ export function processAlive(pid) {
|
|
|
172
176
|
}
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Alive AND signalable by us — the reading for a pid that can only ever be a
|
|
181
|
+
* process THIS uid spawned (the turn lock's CLI, turnLock.mjs). There EPERM is
|
|
182
|
+
* by definition not ours: a pid we may not signal was recycled to another
|
|
183
|
+
* uid, so the record naming it is stale. Everywhere else, `processAlive`.
|
|
184
|
+
*/
|
|
185
|
+
export function aliveAndOurs(pid) {
|
|
186
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
187
|
+
try {
|
|
188
|
+
process.kill(pid, 0);
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
return false; // ESRCH: gone. EPERM: another uid's process — never ours.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
175
195
|
/**
|
|
176
196
|
* THE DEFAULT PRUNE: a row whose pid is alive, or dead for less than a week,
|
|
177
197
|
* capped at the newest `MAX_ENTRIES`. An entry whose pid has been dead for a
|
package/bin/lib/procScan.mjs
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
37
|
-
import {
|
|
37
|
+
import { isPathInside } from './repoPath.mjs';
|
|
38
38
|
|
|
39
39
|
/** Rows collected per scan before the walk stops early — a bound on the ROWS
|
|
40
40
|
* held (and the per-row work: an fd walk, a stat read) for one pathological
|
|
@@ -75,8 +75,7 @@ export function insideDir(dir) {
|
|
|
75
75
|
} catch {
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
return (p) => p === root || p.startsWith(prefix);
|
|
78
|
+
return (p) => isPathInside(p, root);
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
function cwdOf(raw) {
|