flowviant 0.116.0 → 0.124.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 +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -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 +90 -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 +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- 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/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- 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/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -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 +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- 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/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- 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 +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -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 +113 -31
- 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 +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -22,9 +22,12 @@ import {
|
|
|
22
22
|
symlinkSync,
|
|
23
23
|
unlinkSync,
|
|
24
24
|
} from 'node:fs';
|
|
25
|
-
import {
|
|
26
|
-
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
25
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
27
26
|
import { git, gitNetAsync } from './git.mjs';
|
|
27
|
+
import { worktreeHomeDir } from './statePaths.mjs';
|
|
28
|
+
// WHICH FILES ARE ENV FILES has one home, beside the report's own narrower
|
|
29
|
+
// rule; that block says why the two differ (2026-10-10, the audit's F085).
|
|
30
|
+
import { isCheckoutEnvFile } from './env.mjs';
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* A DEPLOY BUILDS THE BASE COMMIT, NEVER THE OPERATOR'S WORKING TREE
|
|
@@ -60,7 +63,6 @@ import { git, gitNetAsync } from './git.mjs';
|
|
|
60
63
|
* a workspace package's own CLI still runs from the checkout. Submodules are
|
|
61
64
|
* not initialised. The code and the commands no longer come from the checkout.
|
|
62
65
|
*/
|
|
63
|
-
const ENV_FILE_RE = /^(?:\.env(?:\..+)?|\.dev\.vars)$/;
|
|
64
66
|
const lexists = (p) => {
|
|
65
67
|
try {
|
|
66
68
|
lstatSync(p);
|
|
@@ -71,11 +73,15 @@ const lexists = (p) => {
|
|
|
71
73
|
};
|
|
72
74
|
|
|
73
75
|
/** Bring the checkout's untracked environment into a deploy worktree. Returns
|
|
74
|
-
* every path it created, so the cleanup can
|
|
75
|
-
* is removed (a recursive delete must never
|
|
76
|
-
* operator's own node_modules)
|
|
76
|
+
* `{ made, envFiles }`: `made` is every path it created, so the cleanup can
|
|
77
|
+
* unlink them BEFORE the worktree is removed (a recursive delete must never
|
|
78
|
+
* be handed a symlink into the operator's own node_modules); `envFiles` is
|
|
79
|
+
* every env file it COPIED (2026-10-10, the audit's F085), so the deploy lane
|
|
80
|
+
* can teach the scrubber the values its own build is about to be able to
|
|
81
|
+
* print — nested files the root-only env scan never reads. */
|
|
77
82
|
export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
78
83
|
const made = [];
|
|
84
|
+
const envFiles = [];
|
|
79
85
|
let realRoot = repoRoot;
|
|
80
86
|
try {
|
|
81
87
|
realRoot = realpathSync(repoRoot);
|
|
@@ -149,11 +155,12 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
|
149
155
|
if (e.isDirectory() && !lexists(dst)) linkNodeModules(src, dst, 0);
|
|
150
156
|
continue;
|
|
151
157
|
}
|
|
152
|
-
if (e.isFile() &&
|
|
158
|
+
if (e.isFile() && isCheckoutEnvFile(e.name)) {
|
|
153
159
|
if (!lexists(dst)) {
|
|
154
160
|
try {
|
|
155
161
|
copyFileSync(src, dst);
|
|
156
162
|
made.push(dst);
|
|
163
|
+
envFiles.push(dst);
|
|
157
164
|
} catch {
|
|
158
165
|
/* unreadable — the build says so if it needed it */
|
|
159
166
|
}
|
|
@@ -168,15 +175,18 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
|
168
175
|
}
|
|
169
176
|
};
|
|
170
177
|
walk('', 0);
|
|
171
|
-
return made;
|
|
178
|
+
return { made, envFiles };
|
|
172
179
|
}
|
|
173
180
|
|
|
174
181
|
/**
|
|
175
|
-
* Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup
|
|
182
|
+
* Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup,
|
|
183
|
+
* envFiles }` (`envFiles`: the env files copied in — see
|
|
184
|
+
* `linkCheckoutEnvironment`), or
|
|
176
185
|
* throws with a sentence when base does not resolve. `worktreeDir` defaults to
|
|
177
|
-
* the daemon's own worktree home for this repo
|
|
178
|
-
*
|
|
179
|
-
*
|
|
186
|
+
* the daemon's own worktree home for this repo — statePaths.mjs's one
|
|
187
|
+
* derivation, the same one fleetStartup.worktreeHome makes and reaps — and the
|
|
188
|
+
* deploy lane relies on that default rather than passing it; the job id is
|
|
189
|
+
* hashed into the path, never joined raw — it is a server-named string.
|
|
180
190
|
*/
|
|
181
191
|
export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir }) {
|
|
182
192
|
if (!baseRef) throw new Error('no base branch resolved, so there is nothing to deploy from');
|
|
@@ -195,14 +205,7 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
|
|
|
195
205
|
} catch {
|
|
196
206
|
throw new Error(`the base branch ${baseRef} does not resolve on this machine`);
|
|
197
207
|
}
|
|
198
|
-
const home =
|
|
199
|
-
worktreeDir ??
|
|
200
|
-
join(
|
|
201
|
-
homedir(),
|
|
202
|
-
'.flowviant',
|
|
203
|
-
'worktrees',
|
|
204
|
-
`${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`
|
|
205
|
-
);
|
|
208
|
+
const home = worktreeDir ?? worktreeHomeDir(repoRoot);
|
|
206
209
|
const dir = join(home, 'deploy', createHash('sha256').update(String(jobId)).digest('hex').slice(0, 16));
|
|
207
210
|
mkdirSync(join(home, 'deploy'), { recursive: true });
|
|
208
211
|
const drop = (made = [], target = dir) => {
|
|
@@ -256,10 +259,11 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
|
|
|
256
259
|
}
|
|
257
260
|
git(['worktree', 'add', '--detach', dir, sha], repoRoot);
|
|
258
261
|
let made = [];
|
|
262
|
+
let envFiles = [];
|
|
259
263
|
try {
|
|
260
|
-
made = linkCheckoutEnvironment(repoRoot, dir);
|
|
264
|
+
({ made, envFiles } = linkCheckoutEnvironment(repoRoot, dir));
|
|
261
265
|
} catch {
|
|
262
266
|
/* the build says so if it needed any of it */
|
|
263
267
|
}
|
|
264
|
-
return { dir, sha, cleanup: () => drop(made) };
|
|
268
|
+
return { dir, sha, envFiles, cleanup: () => drop(made) };
|
|
265
269
|
}
|
package/bin/lib/deployRunner.mjs
CHANGED
|
@@ -61,19 +61,35 @@
|
|
|
61
61
|
* files, and this box's shell is not one). Deriving the redaction list from the
|
|
62
62
|
* admission list is what stops the two drifting the next time a name is added.
|
|
63
63
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* AND THE NESTED ENV FILES THIS DAEMON COPIES IN ITSELF (2026-10-10, the
|
|
65
|
+
* audit's F085). The root scan reads only the checkout root's `.env*`
|
|
66
|
+
* (env.mjs states that limit), while `openDeployCheckout` copies every
|
|
67
|
+
* `.env*` and `.dev.vars` from tracked directories three levels deep into the
|
|
68
|
+
* worktree the build runs in — `apps/api/.dev.vars` among them — so a build
|
|
69
|
+
* that printed its config relayed values the scrubber had never been told.
|
|
70
|
+
* The checkout now reports which files it copied, and `runDeploy` teaches the
|
|
71
|
+
* scrubber their values for that job's life only (`learnExtraSecrets`), then
|
|
72
|
+
* forgets them with the worktree. The root-only report scan is unchanged.
|
|
73
|
+
* Well-known vendor token SHAPES are redacted too (uplinkScrub.mjs).
|
|
74
|
+
*
|
|
75
|
+
* The honest residue, stated: a credential that is in none of those — one a
|
|
76
|
+
* `build` script fetches for itself, say, in a shape nobody listed — is not
|
|
66
77
|
* redacted, because this daemon has never seen it.
|
|
67
78
|
*/
|
|
68
79
|
|
|
69
80
|
import { spawn } from 'node:child_process';
|
|
70
|
-
import {
|
|
81
|
+
import { forgetExtraSecrets, learnExtraSecrets, scrubTail } from './uplinkScrub.mjs';
|
|
71
82
|
import { childEnv } from './childEnv.mjs';
|
|
72
83
|
import { readDeployConfig } from './deployConfig.mjs';
|
|
73
84
|
import { openDeployCheckout } from './deployCheckout.mjs';
|
|
85
|
+
import { readEnvFileValues } from './env.mjs';
|
|
86
|
+
import { killGroup } from './processGroup.mjs';
|
|
74
87
|
|
|
75
88
|
/** How long one deploy command may run before it is stopped. */
|
|
76
89
|
const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
90
|
+
/** How much of a command's output is kept — a memory bound; only the tail is
|
|
91
|
+
* ever read (`tailLines`, the version id). */
|
|
92
|
+
const DEPLOY_OUTPUT_KEEP = 512 * 1024;
|
|
77
93
|
|
|
78
94
|
/** Run a shell command ASYNC (never blocks the daemon's event loop — the
|
|
79
95
|
* reconcile poll + the deploy heartbeat must keep firing during a long
|
|
@@ -88,7 +104,8 @@ const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
|
88
104
|
* `deploysInFlight` holding off every self-update). Measured: `sleep 30 &
|
|
89
105
|
* wait` held the job past its timeout. So the command runs as its own process
|
|
90
106
|
* group (the shape `gitNetAsync` in git.mjs and the project check in
|
|
91
|
-
* workAgentCheck.mjs already use), the timer kills the group
|
|
107
|
+
* workAgentCheck.mjs already use), the timer kills the group (`killGroup`,
|
|
108
|
+
* processGroup.mjs — one home since 2026-10-10), and the timer
|
|
92
109
|
* itself resolves rather than waiting on a pipe a survivor could still hold.
|
|
93
110
|
* The output gains one line saying so, which reaches the server in the
|
|
94
111
|
* report's `message` — a relayed fact, not a new wire field.
|
|
@@ -131,27 +148,40 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
|
|
|
131
148
|
clearTimeout(timer);
|
|
132
149
|
resolve(v);
|
|
133
150
|
};
|
|
151
|
+
/**
|
|
152
|
+
* SCRUBBED BEFORE IT IS CUT (2026-10-10, the audit's F143) — workAgentCheck's
|
|
153
|
+
* order. This used to keep the last 512 KB per chunk and scrub only at
|
|
154
|
+
* settle, so a secret straddling the window's edge was cut first and its
|
|
155
|
+
* surviving half matched nothing. Now the window is cut only from text
|
|
156
|
+
* that has been scrubbed whole (`scrubTail`); a secret still arriving at
|
|
157
|
+
* the END of the buffer is not cut, and the next scrub sees it whole.
|
|
158
|
+
*
|
|
159
|
+
* Amortised rather than per chunk, unlike the check's 4000-character tail:
|
|
160
|
+
* re-scrubbing a 512 KB buffer on every small write of a chatty build is
|
|
161
|
+
* quadratic, so the buffer may grow to twice the window before it is
|
|
162
|
+
* scrubbed and cut. The settle-time scrub stays as the second pass over
|
|
163
|
+
* what ships.
|
|
164
|
+
*
|
|
165
|
+
* A DECODER PER STREAM: `setEncoding` hands whole characters, where
|
|
166
|
+
* `d.toString()` split a multi-byte character across two chunks into
|
|
167
|
+
* replacement characters — and a secret holding one could then never
|
|
168
|
+
* match.
|
|
169
|
+
*/
|
|
134
170
|
const cap = (d) => {
|
|
135
|
-
buf += d
|
|
136
|
-
if (buf.length >
|
|
171
|
+
buf += d;
|
|
172
|
+
if (buf.length > 2 * DEPLOY_OUTPUT_KEEP) buf = scrubTail(buf, DEPLOY_OUTPUT_KEEP);
|
|
137
173
|
};
|
|
174
|
+
child.stdout.setEncoding('utf8');
|
|
175
|
+
child.stderr.setEncoding('utf8');
|
|
138
176
|
child.stdout.on('data', cap);
|
|
139
177
|
child.stderr.on('data', cap);
|
|
140
178
|
const timer = setTimeout(() => {
|
|
141
|
-
|
|
142
|
-
process.kill(-child.pid, 'SIGKILL'); // the whole group
|
|
143
|
-
} catch {
|
|
144
|
-
try {
|
|
145
|
-
child.kill('SIGKILL');
|
|
146
|
-
} catch {
|
|
147
|
-
/* already gone */
|
|
148
|
-
}
|
|
149
|
-
}
|
|
179
|
+
killGroup(child, 'SIGKILL'); // the whole group
|
|
150
180
|
const limit = timeoutMs >= 60_000 ? `${Math.round(timeoutMs / 60_000)} minutes` : `${timeoutMs}ms`;
|
|
151
181
|
settle({
|
|
152
182
|
ok: false,
|
|
153
183
|
code: -1,
|
|
154
|
-
out:
|
|
184
|
+
out: scrubTail(`${buf}\n[flowviant] the command ran past ${limit} and was stopped`, DEPLOY_OUTPUT_KEEP),
|
|
155
185
|
});
|
|
156
186
|
}, timeoutMs);
|
|
157
187
|
// A broken pipe (a child that exits before its stdin is closed) surfaces as
|
|
@@ -166,10 +196,10 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
|
|
|
166
196
|
child.stdin.on('error', () => {});
|
|
167
197
|
child.stdin.end();
|
|
168
198
|
child.on('close', (code) => {
|
|
169
|
-
settle({ ok: code === 0, code: code ?? -1, out:
|
|
199
|
+
settle({ ok: code === 0, code: code ?? -1, out: scrubTail(buf, DEPLOY_OUTPUT_KEEP) });
|
|
170
200
|
});
|
|
171
201
|
child.on('error', (e) => {
|
|
172
|
-
settle({ ok: false, code: -1, out:
|
|
202
|
+
settle({ ok: false, code: -1, out: scrubTail(`${buf}\n${e.message}`, DEPLOY_OUTPUT_KEEP) });
|
|
173
203
|
});
|
|
174
204
|
});
|
|
175
205
|
}
|
|
@@ -203,7 +233,12 @@ export async function runDeploy(job, ctx) {
|
|
|
203
233
|
} catch (e) {
|
|
204
234
|
return { ok: false, message: `deploy not run — ${e.message}`, logs: [] };
|
|
205
235
|
}
|
|
236
|
+
// THE FILES THIS JOB COPIED IN, TAUGHT TO THE SCRUBBER FOR ITS LIFE ONLY
|
|
237
|
+
// (2026-10-10, the audit's F085) — see the header. Keyed per job, so two
|
|
238
|
+
// deploys never forget each other's.
|
|
239
|
+
const secretsKey = `deploy:${job.id}`;
|
|
206
240
|
try {
|
|
241
|
+
learnExtraSecrets(secretsKey, (checkout.envFiles ?? []).flatMap((f) => readEnvFileValues(f)));
|
|
207
242
|
const targets = readDeployConfig(ctx.repoRoot, checkout.sha);
|
|
208
243
|
const target = targets.find((t) => t.id === job.targetId);
|
|
209
244
|
if (!target) {
|
|
@@ -213,6 +248,7 @@ export async function runDeploy(job, ctx) {
|
|
|
213
248
|
const short = checkout.sha.slice(0, 12);
|
|
214
249
|
return { ...outcome, sha: checkout.sha, message: `${outcome.message} (${short})` };
|
|
215
250
|
} finally {
|
|
251
|
+
forgetExtraSecrets(secretsKey); // first: it cannot throw, and the cleanup can
|
|
216
252
|
checkout.cleanup();
|
|
217
253
|
}
|
|
218
254
|
}
|
package/bin/lib/deployWire.mjs
CHANGED
|
@@ -1,41 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE DEPLOY LANE'S
|
|
2
|
+
* THE DEPLOY LANE'S WIRE — `/fleet/deploy-*`, with the machine credential.
|
|
3
3
|
*
|
|
4
4
|
* Split out of deploy.mjs (SOLID F060) because two halves of the lane speak
|
|
5
5
|
* it: the config report (deployConfig.mjs) and the job lease (deploy.mjs —
|
|
6
6
|
* claim, heartbeat, outcome). A leaf, so neither has to import the other to
|
|
7
|
-
* reach the wire
|
|
8
|
-
*
|
|
7
|
+
* reach the wire.
|
|
8
|
+
*
|
|
9
|
+
* The request itself is fleetPost.mjs's (audit 2026-10-10, F082): this was a
|
|
10
|
+
* byte-identical copy of `postToFleet`'s skeleton that differed only in what it
|
|
11
|
+
* returned. What stays here is the lane's own CONTRACT over that request — a
|
|
12
|
+
* throw on anything but an accepted answer, carrying whether it was the API's
|
|
13
|
+
* own refusal of the credential — and the lane's 30-second ceiling.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
|
-
import { FLEET_URL
|
|
16
|
+
import { FLEET_URL } from './config.mjs';
|
|
12
17
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
13
|
-
import {
|
|
18
|
+
import { claimFleetLease, fleetRequest } from './fleetPost.mjs';
|
|
14
19
|
|
|
15
20
|
const deployUrl = (tail) => fleetEndpoint(tail, FLEET_URL);
|
|
21
|
+
const DEPLOY_TIMEOUT_MS = 30_000;
|
|
16
22
|
|
|
23
|
+
/** POST to `/fleet/<tail>` and return the reply's `data`. A network error or
|
|
24
|
+
* timeout is rethrown as `fetch` threw it; a refusal (a non-2xx, or a 2xx
|
|
25
|
+
* saying `success: false`) throws an Error carrying `status` and
|
|
26
|
+
* `credentialRejected`. */
|
|
17
27
|
export async function post(tail, body) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'User-Agent': USER_AGENT,
|
|
23
|
-
'Content-Type': 'application/json',
|
|
24
|
-
},
|
|
25
|
-
signal: AbortSignal.timeout(30_000),
|
|
26
|
-
body: JSON.stringify(body),
|
|
27
|
-
});
|
|
28
|
-
const json = await res.json().catch(() => ({}));
|
|
29
|
-
if (!res.ok || json?.success === false) {
|
|
28
|
+
const r = await fleetRequest(deployUrl(tail), { body, timeoutMs: DEPLOY_TIMEOUT_MS });
|
|
29
|
+
if (r.error) throw r.error;
|
|
30
|
+
const json = r.json;
|
|
31
|
+
if (!r.ok || json?.success === false) {
|
|
30
32
|
// The sentence is unchanged. What rides beside it is whether this was the
|
|
31
33
|
// API's OWN refusal of the credential (a Disconnect rotates it, even
|
|
32
|
-
// mid-deploy) — authReject.mjs's one rule,
|
|
33
|
-
// 401/403 (Cloudflare's bot checks, which this
|
|
34
|
-
// blip and is retried like any other failed post.
|
|
35
|
-
const e = new Error(`${tail} failed (${
|
|
36
|
-
e.status =
|
|
37
|
-
e.credentialRejected =
|
|
34
|
+
// mid-deploy) — authReject.mjs's one rule, asked by fleetRequest, never
|
|
35
|
+
// the bare status: an edge 401/403 (Cloudflare's bot checks, which this
|
|
36
|
+
// client class trips) is a blip and is retried like any other failed post.
|
|
37
|
+
const e = new Error(`${tail} failed (${r.status}${json?.error ? `: ${json.error}` : ''})`);
|
|
38
|
+
e.status = r.status;
|
|
39
|
+
e.credentialRejected = r.credentialRejected;
|
|
38
40
|
throw e;
|
|
39
41
|
}
|
|
40
42
|
return json?.data;
|
|
41
43
|
}
|
|
44
|
+
|
|
45
|
+
/** Claim a deploy job — the shared lease rule (fleetPost.mjs's
|
|
46
|
+
* `claimFleetLease`: this process's `instance` stamped, the winner read from
|
|
47
|
+
* `data.claimed`, any failure "not claimed"), on this lane's ceiling. */
|
|
48
|
+
export function claim(tail, fields) {
|
|
49
|
+
return claimFleetLease(deployUrl(tail), fields, { timeoutMs: DEPLOY_TIMEOUT_MS });
|
|
50
|
+
}
|
|
@@ -8,12 +8,11 @@ import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
|
|
|
8
8
|
import { listStoredProjects } from './credentials.mjs';
|
|
9
9
|
import { readStoredPubB64 } from './boxIdentity.mjs';
|
|
10
10
|
import { daemonRunningFor, drainingFor } from './instance.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { installChannel } from './launchCommand.mjs';
|
|
12
12
|
import { boxesUrlFrom, classifyMachineResponse, fetchBoxesFor } from './machines.mjs';
|
|
13
13
|
import { daemonLockPid, daemonLogPath, readDaemonState } from './daemonState.mjs';
|
|
14
14
|
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
15
15
|
import { readClaudeAuthStatus } from './runtimeAuth.mjs';
|
|
16
|
-
import { runningCompiledBinary } from './update.mjs';
|
|
17
16
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
18
17
|
|
|
19
18
|
/**
|
|
@@ -40,9 +39,8 @@ export function busyOf(liveState, now = Date.now()) {
|
|
|
40
39
|
return liveState.busy;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
42
|
+
// One home for the channel (launchCommand.mjs); re-exported for views.mjs.
|
|
43
|
+
export { installChannel };
|
|
46
44
|
|
|
47
45
|
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, refreshAuth = false } = {}) {
|
|
48
46
|
const runtimeRows = runtimes.map((rt) => ({
|
|
@@ -66,6 +64,9 @@ export function desktopStatus({ entries = listStoredProjects(), runtimes = detec
|
|
|
66
64
|
holder: liveState?.holder ?? null,
|
|
67
65
|
// Measured by the daemon; null = unknown, never idle (see `busyOf`).
|
|
68
66
|
busy: busyOf(liveState, nowMs),
|
|
67
|
+
maintenance: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenance === 'string' ? liveState.maintenance : null,
|
|
68
|
+
maintenanceEpoch: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenanceEpoch === 'string' ? liveState.maintenanceEpoch : null,
|
|
69
|
+
liveAgents: busyOf(liveState, nowMs) !== null && Number.isInteger(liveState?.liveAgents) && liveState.liveAgents >= 0 ? liveState.liveAgents : null,
|
|
69
70
|
// What a standing-down daemon is finishing (`web → prod`), from its
|
|
70
71
|
// lock's draining mark; null when it is not draining or unknown. A
|
|
71
72
|
// daemon→desktop report field: an older tray ignores it.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** A discovery contract for the person's CLI, never a server-side diagnosis. */
|
|
2
|
+
export const REGRESSION_INVESTIGATION = `REGRESSION INVESTIGATION — for "this existed before and now is gone", restoration requests, and bugs that persist after Done:
|
|
3
|
+
1. Keep the REQUESTED OUTCOME separate from a proposed cause. The runtime baseline names cwd, HEAD, execution base and dirty state; it does not prove you inspected relevant code. Reconcile differing or dirty baselines before relying on them.
|
|
4
|
+
2. Before naming a cause, formula or restoration location, trace current rendering and data flow: locate the UI label/symbol, its render conditions and formatting, the calculation, its data provider, missing/loading inputs, and relevant tests. Reading tests is not running them; never claim tests passed merely because you read them. Do not run write-producing tests or builds under this read-only profile.
|
|
5
|
+
3. Inspect available local Git history with supported plain readers: git log --oneline -- <relative-path>, git log -p --follow -- <relative-path>, git log -S delta -- <relative-path>, git show <commit>:<relative-path>, and relevant diffs. Compare the previous implementation with current code. Missing/shallow history or an unavailable deployed version limits the comparison; say exactly which comparison remains unverified.
|
|
6
|
+
4. Explain only supported evidence. A surviving hidden display needs its render condition investigated, not an invented replacement formula. A missing input needs its provider/comparison data traced, not a claim that the display was deleted. A removed implementation needs a verified historical file/commit and a compatibility check against current code before proposing restoration.
|
|
7
|
+
5. A board card marked Done is history, not proof that an implementation shipped or that the reported behavior works in the deployed version. In capture, dedupe with list_cards and list_staged; use find_cards/read_card for held or completed matches. Refine a pending/staged match in place. A recurrence after Done is new work with followUpOf, never proof of its cause. Scratch planning instead compares the supplied cards and live-agent context; it has no staging MCP.
|
|
8
|
+
6. A failed read requires supported recovery before claiming repository discovery is unavailable. Preserve the actual failure and each attempted alternative; distinguish syntax refusal, permission denial, missing path/history and absent tool. Runtime outcomes are observations; model-authored path/line/commit citations and causal explanations remain separate claims. A request alone proves no successful read.
|
|
9
|
+
7. In capture, still STAGE FIRST: if discovery remains unresolved, stage an investigation-first fix for the requested outcome, requiring reproduction and cause establishment before implementation. State unverified causes in assumed, explicit discovery limits and failures/attempts in the brief, and do not invent a formula or restoration path. Use optional grounding when the served staging tool advertises it; otherwise put requested outcome, observed code/history, discovery limits and recovery attempts in clearly separate description sections. Stage before asking the one remaining person-only question, and edit_staged after its answer rather than creating a twin. In scratch planning, retain that investigation-first ordering in the proposal; do not turn an unverified explanation into implementation instructions.`;
|
|
10
|
+
|
|
11
|
+
export function discoveryGuide(runtime, lane) {
|
|
12
|
+
const readers = runtime === 'claude'
|
|
13
|
+
? `Claude: scoped Read and Glob, in-workspace Grep, and approved plain shell readers (cat/head/ls/wc and git log/show/diff/rev-parse). The read guard rejects shell composition, expansions/globs and unsafe paths; quoted pipes and bracket Git paths can still be refused. Quotes/escaping do not bypass the guard. Prefer native Read/Grep/Glob after such a refusal. Replace git log -G 'delta|change' with separate plain git log -S delta and git log -S change calls. For a bracket working-tree path use native Read with the literal path; for history use a supported parent-relative Git path or a scoped commit diff and inspect the relevant entry. Never disable the guard or request unrestricted Bash.`
|
|
14
|
+
: runtime === 'codex'
|
|
15
|
+
? `Codex: this profile uses a read-only sandbox with shell discovery such as cat, head, rg and plain Git readers; Claude's dedicated Read/Grep/Glob and read guard are not guaranteed here. Use only tools advertised this turn. No native web, subagents, browser/apps or unrestricted shell are granted. Read only the repository and explicitly supplied project knowledge.`
|
|
16
|
+
: `This runtime: use the native file/list/search readers actually advertised this turn. Shell availability is unverified and may be denied; do not claim all reading is blocked after one shell refusal. Keep the read-only profile.`;
|
|
17
|
+
return `DISCOVERY CAPABILITIES (${runtime}, ${lane}):\n${readers}\n` +
|
|
18
|
+
`Recovery order: use a cwd-relative path; use one plain reader rather than a composed command; use native Read/Grep/Glob where supported, or shell cat/head/rg where permitted; use git show <commit>:<relative-path> when a working-tree file is missing and history is available. If a utility is absent, switch to an advertised reader. Do not try to evade a secret/path denial. Re-read the actual result after each attempt. Preserve the first failure and subsequent recovery attempts, including an unresolved result; then state the remaining limit rather than asking the person to disable protections. ${lane === 'capture' ? 'Only the scoped Flowviant staging tools write records; never change repository files.' : 'Scratch planning has no Flowviant MCP; return a proposal only.'}`;
|
|
19
|
+
}
|
package/bin/lib/env.mjs
CHANGED
|
@@ -92,7 +92,18 @@ import { learnSecrets } from './uplinkScrub.mjs';
|
|
|
92
92
|
* monorepo's shared types. The daemon holds its own line for the same reason
|
|
93
93
|
* every other boundary in this file does — a report that the server will
|
|
94
94
|
* reject is a report nobody sees, and clamping here is what makes the
|
|
95
|
-
* difference visible as a smaller list
|
|
95
|
+
* difference visible as a smaller list.
|
|
96
|
+
*
|
|
97
|
+
* ── THE RELEASE GATE HOLDS THEM (2026-10-10, audit F093) ──
|
|
98
|
+
*
|
|
99
|
+
* `ENV_REPORT_BOUNDS` and `ENV_NAME_RE` are exported for
|
|
100
|
+
* `scripts/check-app-parity.mjs`, which compares them with the shared
|
|
101
|
+
* `MACHINE_ENV_MAX_FILES` / `_MAX_VARS` / `_MAX_NAME` / `_FP_LENGTH` and
|
|
102
|
+
* `machineEnvVarSchema`'s name pattern. Drift would be silent: POST
|
|
103
|
+
* /fleet/env-report answers a body its schema refuses with 200 `stored:false`
|
|
104
|
+
* (and its reader drops a bad file alone), so this lane would see `ok`, dedup,
|
|
105
|
+
* and the comparison sheet would simply stop hearing from this box. `vars` is
|
|
106
|
+
* a TOTAL on both ends (the server's reader spends one budget across files).
|
|
96
107
|
*
|
|
97
108
|
* ── THE REPORT'S CAPS ARE NOT THE SCRUBBER'S, AND THAT ASYMMETRY IS
|
|
98
109
|
* DELIBERATE (2026-09-21, the review) ──
|
|
@@ -112,23 +123,41 @@ import { learnSecrets } from './uplinkScrub.mjs';
|
|
|
112
123
|
* actually read, so the totals are honest about a directory somebody has let
|
|
113
124
|
* grow without this scan walking an unbounded list of files on the poll's beat.
|
|
114
125
|
*/
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
126
|
+
export const ENV_REPORT_BOUNDS = Object.freeze({ files: 8, vars: 200, name: 64, fp: 8 });
|
|
127
|
+
const MAX_ENV_FILES = ENV_REPORT_BOUNDS.files;
|
|
128
|
+
const MAX_ENV_VARS = ENV_REPORT_BOUNDS.vars; // TOTAL across every file, not per file — REPORT only
|
|
129
|
+
const MAX_ENV_NAME_CHARS = ENV_REPORT_BOUNDS.name;
|
|
118
130
|
const MAX_ENV_FILE_BYTES = 256 * 1024;
|
|
119
131
|
const MAX_SCAN_FILES = 64;
|
|
120
132
|
const MAX_SCRUB_VALUES = 2000;
|
|
121
|
-
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
133
|
+
export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
122
134
|
|
|
123
135
|
/** `.env`, `.env.local`, `.env.production` … and never a template. A file whose
|
|
124
136
|
* name ends `.example` / `.sample` / `.template` is somebody's DOCUMENTATION
|
|
125
137
|
* of the shape, committed on purpose, and its "values" are placeholders — so
|
|
126
138
|
* it would fingerprint `your-key-here` on every box and report them as
|
|
127
139
|
* agreeing, which is the exact opposite of what a comparison is for. */
|
|
128
|
-
const isEnvFileName = (name) =>
|
|
140
|
+
export const isEnvFileName = (name) =>
|
|
129
141
|
(name === '.env' || name.startsWith('.env.')) &&
|
|
130
142
|
!/\.(example|sample|template)$/i.test(name);
|
|
131
143
|
|
|
144
|
+
/**
|
|
145
|
+
* THE OTHER RULE FOR "AN ENV FILE", AND WHY THE TWO DIFFER (2026-10-10, the
|
|
146
|
+
* audit's F085; it moved here from deployCheckout.mjs so both live in one
|
|
147
|
+
* place). `isEnvFileName` decides what the COMPARISON REPORT reads at the
|
|
148
|
+
* checkout root: `.env*` minus templates, because a template's placeholders
|
|
149
|
+
* would fingerprint as agreement. This one decides what a DEPLOY COPIES into
|
|
150
|
+
* its throwaway worktree, at every tracked directory three levels deep: every
|
|
151
|
+
* `.env*` and `.dev.vars` a build might load, templates included, because a
|
|
152
|
+
* build that reads one must find it. Copying is about making the build work;
|
|
153
|
+
* reporting is about a screen a person reads. The deploy lane teaches the
|
|
154
|
+
* scrubber the files it actually copied (deployRunner.mjs), so the wider rule
|
|
155
|
+
* never widens what goes unredacted. (vaultArtefacts.mjs keeps a third, wider
|
|
156
|
+
* net on purpose: it looks only for files the deleted vault wrote, which it
|
|
157
|
+
* recognises by their header, never by their name alone.)
|
|
158
|
+
*/
|
|
159
|
+
export const isCheckoutEnvFile = (name) => /^(?:\.env(?:\..+)?|\.dev\.vars)$/.test(name);
|
|
160
|
+
|
|
132
161
|
/**
|
|
133
162
|
* ONE LINE OF DOTENV. Blank lines and `#` comments are skipped, an optional
|
|
134
163
|
* `export ` prefix is allowed, the line is split on the FIRST `=` (a value may
|
|
@@ -230,6 +259,55 @@ function closingQuoteLine(lines, start, quote) {
|
|
|
230
259
|
return lines.length;
|
|
231
260
|
}
|
|
232
261
|
|
|
262
|
+
/**
|
|
263
|
+
* ONE DOTENV FILE'S TEXT → `[{ name, value }]`, the variables in force — pure
|
|
264
|
+
* (2026-10-10, extracted from `scanEnvFiles` so the deploy lane parses the
|
|
265
|
+
* nested files it copies with the SAME rules the report uses).
|
|
266
|
+
*
|
|
267
|
+
* A MAP, because LAST WRITE WINS inside one file — which is how a dotenv
|
|
268
|
+
* loader resolves a repeated key, so the fingerprint reported is the one
|
|
269
|
+
* actually in force. It also makes the totals honest: a file that sets the
|
|
270
|
+
* same key forty times counts once, not forty times.
|
|
271
|
+
*/
|
|
272
|
+
export function parseEnvText(text) {
|
|
273
|
+
const byName = new Map();
|
|
274
|
+
const lines = String(text ?? '').split('\n');
|
|
275
|
+
for (let i = 0; i < lines.length; i++) {
|
|
276
|
+
const parsed = parseEnvLine(lines[i]);
|
|
277
|
+
if (!parsed) continue;
|
|
278
|
+
// A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
|
|
279
|
+
// dropped from BOTH halves and the scan steps over its continuation
|
|
280
|
+
// lines, so a PEM body can never be read as variables of its own.
|
|
281
|
+
if (parsed.unterminated) {
|
|
282
|
+
i = closingQuoteLine(lines, i, parsed.unterminated);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
|
|
286
|
+
// is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
|
|
287
|
+
// truncated names as the same row is worse than rendering neither.
|
|
288
|
+
if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
|
|
289
|
+
if (!ENV_NAME_RE.test(parsed.name)) continue;
|
|
290
|
+
if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
|
|
291
|
+
byName.set(parsed.name, parsed.value);
|
|
292
|
+
}
|
|
293
|
+
return [...byName].map(([name, value]) => ({ name, value }));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The values in ONE env file, for the scrubber — `[]` for anything that is not
|
|
298
|
+
* a regular file under the scan's size cap (the same `lstat` refusal of a
|
|
299
|
+
* symlink, for the same reason; see `scanEnvFiles`). Never throws.
|
|
300
|
+
*/
|
|
301
|
+
export function readEnvFileValues(path) {
|
|
302
|
+
try {
|
|
303
|
+
const st = lstatSync(path);
|
|
304
|
+
if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) return [];
|
|
305
|
+
return parseEnvText(readFileSync(path, 'utf8'));
|
|
306
|
+
} catch {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
233
311
|
/**
|
|
234
312
|
* A VALUE FINGERPRINT — sha256 over `<projectId>\n<value>`, first 8 hex
|
|
235
313
|
* characters.
|
|
@@ -249,8 +327,22 @@ function closingQuoteLine(lines, start, quote) {
|
|
|
249
327
|
* `development`, `production`, `postgres`, `localhost`, `info` — so anybody who
|
|
250
328
|
* could read a stored report could recover those values with one precomputed
|
|
251
329
|
* table, and the eight characters that were chosen to be uninformative became
|
|
252
|
-
* a lookup key instead. Salting with the project id
|
|
253
|
-
*
|
|
330
|
+
* a lookup key instead. Salting with the project id stops ONE table serving
|
|
331
|
+
* every project, and that is all it stops.
|
|
332
|
+
*
|
|
333
|
+
* ── WHAT THE SALT DOES NOT STOP (2026-10-10, audit F094) ──
|
|
334
|
+
*
|
|
335
|
+
* The project id is no secret: every member of the project has it in the URL,
|
|
336
|
+
* and it is stored beside the report. To anyone who knows it, this digest
|
|
337
|
+
* still CONFIRMS A GUESS — hash `postgres`, `changeme` or every six-digit PIN
|
|
338
|
+
* with the id and compare eight hex — so for a weak value it is a per-project
|
|
339
|
+
* dictionary oracle, not "useless for recovering it". The server therefore
|
|
340
|
+
* never serves it back: the app's comparison receives an opaque equality group
|
|
341
|
+
* per (file, name) instead (`envComparisonView` in the app's
|
|
342
|
+
* `machineEnvReports.ts`). Keying this digest with a secret no member holds —
|
|
343
|
+
* an HMAC under the machine credential, with a scheme marker so mixed daemons
|
|
344
|
+
* read as "not comparable" rather than `differs` — is the at-rest half, and it
|
|
345
|
+
* is still an open item, not done here.
|
|
254
346
|
*
|
|
255
347
|
* IT COSTS THE FEATURE NOTHING, and that is what makes the trade obvious: the
|
|
256
348
|
* screen compares the BOXES OF ONE PROJECT. Two projects agreeing on a value is
|
|
@@ -263,7 +355,7 @@ function closingQuoteLine(lines, start, quote) {
|
|
|
263
355
|
* deliberately SHORT so nobody mistakes it for a commitment to the value.
|
|
264
356
|
*/
|
|
265
357
|
const fingerprint = (salt, value) =>
|
|
266
|
-
createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0,
|
|
358
|
+
createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0, ENV_REPORT_BOUNDS.fp);
|
|
267
359
|
|
|
268
360
|
/**
|
|
269
361
|
* READ THE CHECKOUT'S `.env*` FILES AND REPORT WHAT IS IN THEM BY NAME.
|
|
@@ -353,36 +445,13 @@ export function scanEnvFiles(repoRoot, salt) {
|
|
|
353
445
|
// file and contributes nothing, and neither is a SYMLINK: see above.
|
|
354
446
|
const st = lstatSync(abs);
|
|
355
447
|
if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) continue;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// actually in force. It also makes the totals honest: a file that sets
|
|
359
|
-
// the same key forty times counts once, not forty times.
|
|
360
|
-
const byName = new Map();
|
|
361
|
-
const lines = readFileSync(abs, 'utf8').split('\n');
|
|
362
|
-
for (let i = 0; i < lines.length; i++) {
|
|
363
|
-
const parsed = parseEnvLine(lines[i]);
|
|
364
|
-
if (!parsed) continue;
|
|
365
|
-
// A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
|
|
366
|
-
// dropped from BOTH halves and the scan steps over its continuation
|
|
367
|
-
// lines, so a PEM body can never be read as variables of its own.
|
|
368
|
-
if (parsed.unterminated) {
|
|
369
|
-
i = closingQuoteLine(lines, i, parsed.unterminated);
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
// A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
|
|
373
|
-
// is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
|
|
374
|
-
// truncated names as the same row is worse than rendering neither.
|
|
375
|
-
if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
|
|
376
|
-
if (!ENV_NAME_RE.test(parsed.name)) continue;
|
|
377
|
-
if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
|
|
378
|
-
byName.set(parsed.name, parsed.value);
|
|
379
|
-
}
|
|
380
|
-
out.varsTotal += byName.size;
|
|
448
|
+
const parsed = parseEnvText(readFileSync(abs, 'utf8'));
|
|
449
|
+
out.varsTotal += parsed.length;
|
|
381
450
|
// Decided BEFORE this file spends any budget, so `budget` reaching zero
|
|
382
451
|
// inside a file still lets that file's own entry carry what it got.
|
|
383
452
|
const reporting = canReport && budget > 0 && out.files.length < MAX_ENV_FILES;
|
|
384
453
|
const vars = [];
|
|
385
|
-
for (const
|
|
454
|
+
for (const { name: n, value } of parsed) {
|
|
386
455
|
// THE SCRUB LIST IS NOT BOUNDED BY THE REPORT'S CAPS — see the bounds
|
|
387
456
|
// block above. A secret dropped for being the 201st is a secret in the
|
|
388
457
|
// server's database.
|