flowviant 0.91.2 → 0.92.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/bin/lib/env.mjs CHANGED
@@ -1,73 +1,224 @@
1
1
  /**
2
- * Team env sync — the daemon is the CRYPTO ANCHOR. This machine holds a
3
- * persistent X25519 keypair (~/.flowviant/env-keypair.json, 0600); the
4
- * project's private key reaches it only sealed to that pubkey. Everything the
5
- * server stores is ciphertext it cannot open.
6
- *
7
- * Duties per roster tick (handleRosterEnv):
8
- * - register this machine's pubkey (once) → that IS the enrolment (server
9
- * 2026-09-20): the box already holds this project's machine credential,
10
- * which a person handed it by typing a device code into the app, so there is
11
- * nothing left for anybody to approve. Nobody clicks; nothing is printed
12
- * except one quiet line.
13
- * - bootstrap the project keypair when none exists (first machine): generate
14
- * it + a standing RECOVERY keypair wrapped under a one-time passphrase —
15
- * rotations re-seal to the same recovery pub, so that passphrase survives
16
- * forever. Bootstrap itself is SILENT since 2026-09-20: the passphrase is
17
- * parked in this box's keypair file and printed the first time this box
18
- * materializes a secret, which is the first moment it is about anything
19
- * (`stashRecoveryCode`).
20
- * - sync: on a bundle version change, unwrap the priv, open every sealed
21
- * value, cache (encrypted under a key derived from our own priv), and
22
- * rematerialize env files into the agent worktrees.
23
- * - execute wrap jobs (another box registered → seal the priv to it).
24
- * - execute rotations (a machine was revoked → new keypair, re-seal all
25
- * values, re-wrap every enrolled machine, re-seal recovery).
26
- *
27
- * Materialization writes per-targetFile KEY=value files into a worktree and
28
- * registers each path in the worktree's git info/exclude — untracked AND
29
- * unstageable, so an agent can never commit them. The WIKI worktree never
30
- * gets env (the cartographer doesn't need secrets).
31
- *
32
- * scrub() redacts every known plaintext value from daemon-posted uplinks
33
- * (turn streams, wiki progress, vault sync). Agent-MCP-direct payloads
34
- * (evidence, progress, complete) never pass through the daemon — those are
35
- * covered by the prompt contract, not here.
2
+ * THREE THINGS THIS BOX KNOWS ABOUT ITSELF — and nothing it holds for anybody.
3
+ *
4
+ * 1. THE BOX KEYPAIR. A persistent X25519 keypair at
5
+ * `~/.flowviant/env-keypair.json` (0600). Its public half rides EVERY roster
6
+ * poll as `envpub`, and that is what tells two computers apart when the
7
+ * server decides which of them is this project's machine (holdership,
8
+ * 2026-09-14) and which rows belong to which box in the registry
9
+ * (`machine_box`, 0.91.0). It is an identity LABEL for arbitration, never
10
+ * an authorization: nothing is GRANTED by it — the credential grants, the
11
+ * pubkey only disambiguates boxes.
12
+ * 2. THE UPLINK SCRUBBER. `scrub()` redacts secret values out of every piece
13
+ * of text this daemon posts — turn streams, tool events, the trace,
14
+ * process command lines, wiki progress, deploy logs. It is fed from TWO
15
+ * places (2026-09-21, the review): the checkout's `.env*` files, and the
16
+ * PRESENT values of `childEnv`'s `DEPLOY_KEEP` names out of this process's
17
+ * own environment, which is where an operator's `CLOUDFLARE_API_TOKEN`
18
+ * actually lives and which the deploy lane hands to a command whose stdout
19
+ * it then streams to the server. It redacts on SHAPE, never on meaning: a
20
+ * value that looks like an ordinary word is left alone, because a relay
21
+ * that swaps the CLI's own words for `[REDACTED:NODE_ENV]` has stopped
22
+ * being a relay.
23
+ * 3. THE ENV COMPARISON SCAN. `scanEnvFiles()` reads the checkout's own
24
+ * `.env*` files and reports variable NAMES plus a short VALUE FINGERPRINT
25
+ * SALTED WITH THE PROJECT ID, so the app can answer "why does it work on
26
+ * that box and not this one?" without an eight-character hash over
27
+ * `production` becoming a dictionary lookup. THE VALUE NEVER LEAVES THE
28
+ * BOX, and neither does anything from (2) — a report is a statement about
29
+ * the CHECKOUT'S FILES, and this process's environment is not one.
30
+ *
31
+ * ── THE VAULT IS DELETED (2026-09-21) ──
32
+ *
33
+ * This module used to be the CRYPTO ANCHOR of an end-to-end-encrypted team
34
+ * secrets vault: the project's private key reached this box sealed to the
35
+ * keypair above, the daemon opened every value, cached them encrypted on disk,
36
+ * MATERIALIZED `.env` files into every session worktree, executed wrap jobs for
37
+ * newly registered boxes, executed rotations when a machine was revoked, and
38
+ * minted a one-time RECOVERY PASSPHRASE parked beside the private key. About
39
+ * 700 lines, a `libsodium-wrappers-sumo` Argon2 dependency, four `/fleet/env/*`
40
+ * endpoints, a `flowviant env` subcommand, and five server tables.
41
+ *
42
+ * The owner, asked directly whether he wanted it:
43
+ *
44
+ * "no i dont want it. unless its needed where i want to show the env of
45
+ * each of the machines (for comparison)."
46
+ *
47
+ * and on the recovery passphrase the whole custody ceremony existed to protect:
48
+ *
49
+ * "no, its fine to repaste from providers."
50
+ *
51
+ * So the vault goes whole and the ONE readout inside it that was genuinely
52
+ * wanted — seeing what each machine's environment looks like — is rebuilt as
53
+ * the scan below, which holds no secrets and therefore needs no custody, no
54
+ * recovery code, no rotation and no wrap jobs. Gone with it: `handleRosterEnv`,
55
+ * `bootstrapProject`, `fetchBundle`, `loadCachedEnv`, the encrypted
56
+ * `~/.flowviant/env-cache`, `materializeInto` / `hasMaterialized`,
57
+ * `deployCreds` / `appSecretsFor`, `stashRecoveryCode` /
58
+ * `printRecoveryCodeOnce`, `removeStaleEnvFile`, `isSafeTarget`, and the
59
+ * `envv` / `envskip` poll params the materializer reported through.
60
+ *
61
+ * WHAT DID NOT GO, AND MUST NOT: the keypair file, its path, and its 2026-09-14
62
+ * correctness rule (only ENOENT mints; every other read failure RETHROWS).
63
+ * Rotating this box's identity would make the same physical machine arrive at
64
+ * the roster as a stranger — a new row in the registry, and a standby of
65
+ * itself for the whole holder-claim window.
66
+ *
67
+ * `excludeInWorktree` moved to `git.mjs`: it is a generic `git info/exclude`
68
+ * helper that only ever lived here because the materializer was its first
69
+ * caller.
36
70
  */
37
71
 
38
- import {
39
- readFileSync,
40
- writeFileSync,
41
- mkdirSync,
42
- existsSync,
43
- appendFileSync,
44
- chmodSync,
45
- lstatSync,
46
- rmSync,
47
- } from 'node:fs';
48
- import { execFileSync } from 'node:child_process';
49
- import { homedir, hostname } from 'node:os';
50
- import { join, dirname, resolve } from 'node:path';
51
- // The SUMO build: the standard `libsodium-wrappers` omits Argon2 (crypto_pwhash),
52
- // which bootstrapProject() needs to derive the recovery-code key — without it
53
- // crypto_pwhash_SALTBYTES is undefined and bootstrap throws every poll.
72
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync, lstatSync } from 'node:fs';
73
+ import { createHash } from 'node:crypto';
74
+ import { homedir } from 'node:os';
75
+ import { join, dirname } from 'node:path';
76
+ // STILL SUMO, and deliberately not narrowed to the standard build in the same
77
+ // pass that deleted the vault. `crypto_box_keypair` exists in both, but the
78
+ // sumo package is what is installed, what every other daemon on npm resolves,
79
+ // and swapping the dependency is a separate change with its own install-time
80
+ // failure modes. Nothing here needs Argon2 any more; nothing here forbids the
81
+ // narrowing either.
54
82
  import sodium from 'libsodium-wrappers-sumo';
55
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
56
- import { c, info, note, ok, warn } from './ui.mjs';
83
+ // THE PROJECT ID, as a live binding. It salts every fingerprint (see
84
+ // `fingerprint`), and it is a `let` in config.mjs because cli.mjs's ambiguity
85
+ // picker chooses a project AFTER import — reading a frozen copy here would salt
86
+ // a picked project's report with `null` and report nothing at all.
87
+ import { PROJECT_ID } from './config.mjs';
88
+ // THE DEPLOY LANE'S OWN ALLOWLIST, borrowed for REDACTION ONLY. Deriving the
89
+ // scrub list from the set a deploy command is actually handed is what stops the
90
+ // two drifting; `processEnvSecrets`'s docblock carries the whole argument,
91
+ // including why none of it ever reaches the wire.
92
+ import { processEnvSecrets } from './childEnv.mjs';
57
93
 
58
94
  const B64 = () => sodium.base64_variants.ORIGINAL;
59
95
  const KEYPAIR_PATH = join(homedir(), '.flowviant', 'env-keypair.json');
60
- const CACHE_DIR = join(homedir(), '.flowviant', 'env-cache');
61
- const SCRUB_MIN_LENGTH = 6; // mirrors shared ENV_SCRUB_MIN_LENGTH
62
- const envUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/env/${tail}`);
63
96
 
64
- // ── Module state (one project per daemon, same as the vault) ───────────────
97
+ // ── What `scrub` will redact ───────────────────────────────────────────────
98
+ //
99
+ // THE PREDICATE LIVES ABOVE THE LIST ON PURPOSE (2026-09-21): the list is
100
+ // FILTERED THROUGH IT ONCE, when a scan rebuilds it, rather than tested per
101
+ // call. `scrub` runs on every posted line of every turn stream, tool event and
102
+ // deploy log, and it does a whole-string `split`/`join` per value — so a value
103
+ // that will never match must not survive into the loop at all. Pre-filtering
104
+ // also makes the list smaller than it was before this pass on a typical
105
+ // checkout, because half of a real `.env` is ordinary configuration.
106
+
107
+ /**
108
+ * THE LENGTH FLOOR. A value of one or two characters — `1`, `ab`, `on` — occurs
109
+ * in ordinary prose constantly, so redacting one replaces half of every stream
110
+ * this daemon posts with `[REDACTED:NAME]` and the trace stops being readable
111
+ * at all. Six is the point below which a value is far likelier to be a flag
112
+ * than a secret, and nothing this short is worth making the product illegible
113
+ * for.
114
+ *
115
+ * (It used to carry the comment "mirrors shared ENV_SCRUB_MIN_LENGTH". That
116
+ * constant does not exist anywhere in the monorepo any more — it went with the
117
+ * vault's server half on 2026-09-21 — and a cross-reference to a deleted
118
+ * identifier reads as "do not change this, the other end depends on it", which
119
+ * is a claim about a coupling nobody can check. The floor's own argument is
120
+ * above and it stands on its own.)
121
+ */
122
+ const SCRUB_MIN_LENGTH = 6;
123
+
124
+ /**
125
+ * A VALUE THAT LOOKS LIKE AN ORDINARY WORD IS NOT REDACTED (2026-09-21, the
126
+ * review) — and this is a correctness fix, not a tuning knob.
127
+ *
128
+ * `scrub` now reads the CHECKOUT'S `.env*` files, and a real `.env` is full of
129
+ * configuration that is not secret at all: `NODE_ENV=development`,
130
+ * `HOST=localhost`, `APP_NAME=flowviant`, `LOG_LEVEL=verbose`,
131
+ * `AWS_REGION=us-east-1`. With those in the list, every occurrence of the word
132
+ * "development" in the CLI's own narration came back as
133
+ * `[REDACTED:NODE_ENV]` — so a turn trace, which exists to relay what the CLI
134
+ * said, was relaying something else. A relay does not swap the words it
135
+ * carries. That is a worse failure than under-redaction, because it is silent,
136
+ * universal, and destroys the readout the trace was built for.
137
+ *
138
+ * TWO SHAPE TESTS, applied per value, both cheap and both about SHAPE rather
139
+ * than meaning (nothing here classifies, guesses, or asks a model):
140
+ *
141
+ * · A PLAIN IDENTIFIER — starts with a letter, runs at most sixteen
142
+ * characters, and is made only of letters, digits and `.` `/` `-`. That is
143
+ * what a word, a hostname, a short version string and an enum value all look
144
+ * like. `_` is deliberately NOT in the set: an underscore is rare in prose
145
+ * and common in key material (`sk-live_…`), so its presence is the cheapest
146
+ * honest signal that a string was minted rather than written.
147
+ * · A SHORT VALUE WITH LITTLE VARIETY — under twelve characters and drawing on
148
+ * fewer than three of {lower, upper, digit, symbol}. A real credential short
149
+ * enough to be under twelve characters is mixed; `us-east-1` and `verbose`
150
+ * are not.
151
+ *
152
+ * THE ACCEPTED COST, STATED RATHER THAN HIDDEN: a genuinely secret value that
153
+ * is short and lowercase — `hunter2secret`, a thirteen-character all-letters
154
+ * password — is NOT redacted. Under-redaction is chosen here over making the
155
+ * product illegible, because the alternative is a trace nobody can read and a
156
+ * product that silently rewrites its own CLI's output. An operator whose secret
157
+ * is a dictionary word has a problem this function cannot fix. Anything with a
158
+ * symbol, a case mix, twelve-plus characters, or an underscore — which is every
159
+ * token, key, URL, JWT and hex digest anybody actually pastes into a `.env` —
160
+ * is redacted.
161
+ */
162
+ const SCRUB_PLAIN_RE = /^[A-Za-z][A-Za-z0-9._/-]{0,15}$/;
163
+ function characterClasses(value) {
164
+ let n = 0;
165
+ if (/[a-z]/.test(value)) n += 1;
166
+ if (/[A-Z]/.test(value)) n += 1;
167
+ if (/[0-9]/.test(value)) n += 1;
168
+ if (/[^A-Za-z0-9]/.test(value)) n += 1;
169
+ return n;
170
+ }
171
+ function worthRedacting(value) {
172
+ if (typeof value !== 'string' || value.length < SCRUB_MIN_LENGTH) return false;
173
+ if (SCRUB_PLAIN_RE.test(value)) return false;
174
+ if (value.length < 12 && characterClasses(value) < 3) return false;
175
+ return true;
176
+ }
177
+
178
+ // ── Module state ───────────────────────────────────────────────────────────
65
179
  let keypair = null; // { publicKey: Uint8Array, privateKey: Uint8Array }
66
- let registeredOnce = false;
67
- let projectPriv = null; // Uint8Array — unwrapped project private key
68
- let bundleVersion = -1; // last materialized bundle version (-1 = never)
69
- let values = []; // [{ name, targetFile, value }]
70
- let cachedProjectId = null;
180
+
181
+ /**
182
+ * WHAT `scrub` REDACTS — `[{ name, value }]`, refilled by every env scan.
183
+ *
184
+ * THIS ARRAY CHANGED HANDS (2026-09-21), and the change is an IMPROVEMENT
185
+ * rather than a salvage. It used to hold the vault's decrypted bundle: only
186
+ * the values Flowviant itself had delivered to this box. With the vault gone
187
+ * that array would be permanently empty and `scrub` would become a silent
188
+ * no-op in roughly ten call sites — the worst possible way for a redactor to
189
+ * stop working, because every one of those sites keeps calling it and nothing
190
+ * looks different until a secret is already in the server's database.
191
+ *
192
+ * So it is filled from the CHECKOUT'S OWN `.env*` FILES, which are the box's
193
+ * REAL secrets: the ones the operator pasted in from their providers, the ones
194
+ * a dev server actually loads, and the ones a CLI turn is overwhelmingly
195
+ * likeliest to echo into a log. The vault could only ever redact what it had
196
+ * delivered; this redacts what is there.
197
+ *
198
+ * ── TWO SOURCES, KEPT IN TWO VARIABLES (2026-09-21, the review) ──
199
+ *
200
+ * `values` is what `scrub` reads and is rebuilt on every scan as
201
+ * `processEnvSecrets()` ++ `fileValues`. The split exists because the two halves
202
+ * have opposite failure modes. The CHECKOUT half can come back empty for two
203
+ * different reasons — the operator deleted `.env`, or this pass could not read
204
+ * the directory — so an empty read must NOT replace it (see `scanEnvForScrub`).
205
+ * The PROCESS half cannot blink: `process.env` is in memory and reading it is
206
+ * infallible, so it is replaced unconditionally, and folding it into one array
207
+ * with the file half would make a single unreadable-directory blip drop the
208
+ * operator's deploy credential out of the redactor too.
209
+ *
210
+ * SEEDED AT IMPORT, deliberately. `scrub` is reachable from the deploy lane and
211
+ * from a turn before the first scan has run, and the process half needs no
212
+ * checkout, no credential and no I/O to be correct — so there is no moment at
213
+ * which this module knows a deploy token and is not hiding it.
214
+ *
215
+ * AND IT IS ALREADY FILTERED. Every entry in `values` has passed
216
+ * `worthRedacting` — see that predicate's block above — so `scrub` itself makes
217
+ * no decisions and simply replaces. `fileValues` keeps the UNFILTERED read,
218
+ * because the filter is about redaction and the read is about what is on disk.
219
+ */
220
+ let fileValues = [];
221
+ let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
71
222
 
72
223
  export async function sodiumReady() {
73
224
  await sodium.ready;
@@ -79,11 +230,11 @@ export async function sodiumReady() {
79
230
  *
80
231
  * It existed for exactly one gesture: the terminal printed eight glyphs, the
81
232
  * browser's approve card printed the same eight, and a human compared them
82
- * before pressing Approve. Registering IS enrolling now — the device code that
83
- * gave this box the project's machine credential was the decision — so there is
84
- * no approve card, no comparison, and nobody to make it. A fingerprint kept
85
- * byte-identical across two repos for nobody to look at is worse than none:
86
- * it reads like a live MITM defence and defends nothing.
233
+ * before pressing Approve. Registering became enrolling, and then the whole
234
+ * enrolment went with the vault — so there is no approve card, no comparison,
235
+ * and nobody to make it. A fingerprint kept byte-identical across two repos
236
+ * for nobody to look at is worse than none: it reads like a live MITM defence
237
+ * and defends nothing.
87
238
  */
88
239
 
89
240
  /** This machine's persistent keypair (created on first use, 0600). */
@@ -98,12 +249,17 @@ export async function ensureKeypair() {
98
249
  * ONLY "THERE IS NO FILE" IS A FIRST RUN, and the bare catch that used to
99
250
  * stand here said every failure was one.
100
251
  *
101
- * This keypair is the box's DURABLE IDENTITY — it is what the project's
102
- * private key is sealed to, and since 2026-09-14 it is also what tells two
103
- * computers apart when the server decides which of them is this project's
104
- * machine. Regenerating it on a truncated file or an unreadable one
105
- * OVERWRITES that identity: the wraps stop opening, and the box arrives at
106
- * the roster as a stranger and stands itself down as a standby of itself.
252
+ * This keypair is the box's DURABLE IDENTITY — since 2026-09-14 it is what
253
+ * tells two computers apart when the server decides which of them is this
254
+ * project's machine, and since 0.91.0 it is the key of this box's row in
255
+ * the machine registry. Regenerating it on a truncated file or an
256
+ * unreadable one OVERWRITES that identity: the box arrives at the roster as
257
+ * a stranger and stands itself down as a standby of itself.
258
+ *
259
+ * (It was ALSO what the project's secret vault was sealed to, until the
260
+ * vault was deleted 2026-09-21. That was the loudest consequence and it is
261
+ * gone; the identity consequence is the one that survives, and it is on its
262
+ * own sufficient.)
107
263
  *
108
264
  * A file we cannot read is not a file we may replace. Rethrown, the caller
109
265
  * that can survive it does: `envQueryParams` is wrapped, so the poll simply
@@ -164,821 +320,408 @@ export function readStoredPubB64() {
164
320
  }
165
321
 
166
322
  /**
167
- * ── THE RECOVERY CODE WAITS UNTIL THERE IS SOMETHING TO RECOVER (2026-09-20) ──
168
- *
169
- * `bootstrapProject` used to end with the loudest artefact this product owns:
170
- * a blank line, RECOVERY CODE in bold, a passphrase in yellow, and "the only
171
- * way back into the secrets". That fired on the FIRST DAEMON OF EVERY PROJECT
172
- * — a keypair is bootstrapped whether or not a single secret exists — so the
173
- * overwhelming majority of the people who saw it were being handed a code for
174
- * an empty vault, in custody vocabulary they had not asked for, having run
175
- * `npx flowviant` to connect a machine. That is the same complaint the approve
176
- * gate died of, printed instead of clicked, and leaving it in would have
177
- * contradicted the change it shipped beside.
178
- *
179
- * So bootstrap is SILENT (one quiet line that the keypair exists) and the code
180
- * is kept HERE, in this box's own keypair file, until the first time this box
181
- * materializes a secret — the moment the person actually has something to
182
- * protect, and the first moment the sentence is true.
183
- *
184
- * THE FILE IS THE RIGHT PLACE AND COSTS NOTHING. It already holds the private
185
- * key the passphrase would recover — anybody who can read one can read the
186
- * other — so storing it there adds no exposure that was not already the whole
187
- * security model of this machine. It is 0600 and stays 0600.
188
- *
189
- * EXTENDING THE SHAPE IS SAFE. `ensureKeypair` reads `pub` and `priv` and
190
- * ignores every other key, and it only WRITES the file when there is none, so
191
- * an older daemon reading a file with a `recovery` field behaves identically
192
- * and cannot clobber it.
193
- *
194
- * THE ONE ACCEPTED COST, stated: a box that bootstraps and never materializes
195
- * anything never prints the code. That is the point — there is nothing to
196
- * recover — and the code is not lost, it is on this disk beside the key.
197
- */
198
- function readKeypairFile() {
199
- try {
200
- return JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
201
- } catch {
202
- return null;
203
- }
204
- }
205
-
206
- /** 0600 on every write, not just at creation: `writeFileSync`'s `mode` applies
207
- * only when the file is created, which is the same trap `materializeInto`
208
- * documents. Returns whether it landed — the caller has to know, because the
209
- * thing being written is unrecoverable if it does not. */
210
- function writeKeypairFile(stored) {
211
- try {
212
- writeFileSync(KEYPAIR_PATH, JSON.stringify(stored), { mode: 0o600 });
213
- try {
214
- chmodSync(KEYPAIR_PATH, 0o600);
215
- } catch {
216
- /* a filesystem without modes is not a reason to refuse */
217
- }
218
- return true;
219
- } catch {
220
- return false;
221
- }
222
- }
223
-
224
- /** Park the bootstrap passphrase beside the key it recovers. False means it did
225
- * NOT land, and the caller's answer to that is to print it immediately —
226
- * losing a recovery code silently is the one outcome worse than printing it
227
- * early. */
228
- export function stashRecoveryCode(code, projectId) {
229
- const stored = readKeypairFile();
230
- if (!stored?.priv) return false;
231
- stored.recovery = { code, projectId: projectId ?? null, shown: false };
232
- return writeKeypairFile(stored);
233
- }
234
-
235
- /** Once per process at most, and once per box for real. */
236
- let recoveryUnprinted = true;
237
-
238
- function printRecoveryBlock(code) {
239
- console.log('');
240
- ok(`${c.cyan('env')} — this machine now holds this project's secrets.`);
241
- console.log(` ${c.bold('RECOVERY CODE')} ${c.dim('(shown ONCE — save it in a password manager):')}`);
242
- console.log(` ${c.bold(c.yellow(code))}`);
243
- note(' If every enrolled machine is ever lost, this code is the only way back into the secrets.');
244
- console.log('');
245
- }
246
-
247
- /**
248
- * Print the parked code the first time this box writes a secret to disk, then
249
- * mark it shown so it never prints again.
250
- *
251
- * The mark is written to the FILE, not just to the module flag, because "once"
252
- * has to survive a restart. If that write fails the flag still stops this
253
- * process from repeating itself and a later process prints again — noisy in
254
- * the safe direction, which is the only direction available when the thing at
255
- * stake is the only way back into somebody's secrets.
256
- *
257
- * The parked `projectId` is checked because the keypair file is per BOX while
258
- * the code is per PROJECT: a daemon serves one project, so a mismatch means
259
- * this code belongs to a different one and printing it here would attribute it
260
- * to the wrong vault. An older stash carries no id and prints regardless.
323
+ * THE IDENTITY PARAMS THE ROSTER POLL CARRIES — one key, and it is the box.
324
+ *
325
+ * It used to carry two more: `envv` (the materialized bundle version, which fed
326
+ * the Settings "env vN" chip) and `envskip` (the target files the materializer
327
+ * REFUSED to write, with the empty string as a real "measured, refused nothing"
328
+ * report). Both were facts about the vault and both died with it; the server
329
+ * stopped reading them in the same change.
330
+ *
331
+ * WHAT DID NOT CHANGE IS `envpub`: the same base64 of the same public key out
332
+ * of the same file. Holdership arbitration, the displaced signal and the box
333
+ * registry all key on it, so a box that upgrades to this release must be the
334
+ * SAME box to the server it was before — no new row, no re-claim, no standby
335
+ * of itself. That continuity is the point of this function still existing at
336
+ * all.
337
+ *
338
+ * Absence keeps its reserved meaning: a poll with no `envpub` is EXEMPT from
339
+ * arbitration rather than treated as an unknown box.
261
340
  */
262
- function printRecoveryCodeOnce() {
263
- if (!recoveryUnprinted) return;
264
- const stored = readKeypairFile();
265
- const rec = stored?.recovery;
266
- if (!rec?.code || rec.shown) {
267
- recoveryUnprinted = false;
268
- return;
269
- }
270
- if (rec.projectId && cachedProjectId && rec.projectId !== cachedProjectId) {
271
- recoveryUnprinted = false; // one project per daemon — this will not change
272
- return;
273
- }
274
- recoveryUnprinted = false;
275
- printRecoveryBlock(rec.code);
276
- stored.recovery = { ...rec, shown: true };
277
- writeKeypairFile(stored);
278
- }
279
-
280
- /** Query params the roster poll carries: identity, materialized version, and
281
- * the target files we REFUSED to write.
282
- *
283
- * `envv` alone was a half-truth and the surface built on it said the wrong
284
- * thing out loud: it is set the moment the bundle DECRYPTS, independent of
285
- * whether a single byte reached a worktree, so a project whose `.env` is
286
- * tracked in git got the green "on the current env" chip while every session
287
- * ran on whatever stale placeholder git had checked out. The daemon knew —
288
- * it warned, to a console nobody reads. `envskip` is that warning routed
289
- * somewhere a human is actually looking.
290
- *
291
- * A daemon→server REPORT, so it needs no version floor: an older daemon
292
- * simply sends no `envskip` key, which reads as "nothing to report" — and
293
- * that is honest, because an older daemon genuinely is not measuring it.
294
- * Bounded hard: a query string is not a log. */
295
341
  export async function envQueryParams() {
296
342
  await ensureKeypair();
297
- const params = { envpub: myPubB64() };
298
- if (bundleVersion >= 0) params.envv = String(bundleVersion);
299
- // THE EMPTY STRING IS A REPORT, and it is the only thing that can ever CLEAR
300
- // the surface's warning. Gating this on truthiness (which is what it did
301
- // first) meant a person who followed the on-screen remedy exactly — gitignore
302
- // the file, restart — sent no `envskip` at all, the server left the column
303
- // alone by design, and the amber line stayed up forever telling them to fix
304
- // something already fixed. Absence must keep meaning IGNORANCE, so the gate
305
- // is "has a pass actually run", never "is there something to say".
306
- // (fleet.mjs's query loop had to stop filtering on truthiness too — one
307
- // check here is useless while a second one downstream drops the same value.)
308
- if (everMaterialized) {
309
- const files = [...new Set([...skippedByWorktree.values()].flat())].sort();
310
- // Each path is percent-encoded BEFORE the join, because `isSafeEnvTargetFile`
311
- // permits a comma in a filename and the server splits on one — unencoded,
312
- // `a,b.env` would arrive as two files that do not exist. Truncation is by
313
- // WHOLE ELEMENTS against a byte budget; a mid-path cut names a file nobody
314
- // has, which is worse than naming fewer.
315
- const parts = [];
316
- let budget = 400;
317
- for (const f of files) {
318
- if (parts.length >= 10) break;
319
- const enc = encodeURIComponent(f);
320
- if (enc.length + 1 > budget) break;
321
- parts.push(enc);
322
- budget -= enc.length + 1;
323
- }
324
- params.envskip = parts.join(',');
325
- }
326
- return params;
343
+ return { envpub: myPubB64() };
327
344
  }
328
345
 
329
- // ── HTTP helpers ───────────────────────────────────────────────────────────
330
- async function post(tail, body) {
331
- const res = await fetch(envUrl(tail), {
332
- method: 'POST',
333
- headers: {
334
- Authorization: `Bearer ${FLEET_TOKEN}`,
335
- 'User-Agent': USER_AGENT,
336
- 'Content-Type': 'application/json',
337
- },
338
- signal: AbortSignal.timeout(30_000),
339
- body: JSON.stringify(body),
340
- });
341
- const json = await res.json().catch(() => ({}));
342
- if (!res.ok || json?.success === false) {
343
- throw new Error(`env ${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
344
- }
345
- return json?.data;
346
- }
347
-
348
- export async function fetchBundle() {
349
- const res = await fetch(`${envUrl('bundle')}?pubkey=${encodeURIComponent(myPubB64())}`, {
350
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
351
- signal: AbortSignal.timeout(30_000),
352
- });
353
- const json = await res.json().catch(() => ({}));
354
- if (!res.ok || !json?.data) throw new Error(`env bundle fetch failed (${res.status})`);
355
- return json.data;
356
- }
357
-
358
- // ── Crypto ─────────────────────────────────────────────────────────────────
359
- const seal = (bytes, pubB64) => sodium.to_base64(sodium.crypto_box_seal(bytes, sodium.from_base64(pubB64, B64())), B64());
360
- const openSealed = (b64, pub, priv) => sodium.crypto_box_seal_open(sodium.from_base64(b64, B64()), pub, priv);
361
-
362
- /** Cache the decrypted bundle at rest, encrypted under a key derived from our
363
- * own priv — the worktrees hold the same plaintext anyway; this just keeps
364
- * the cache from being a SECOND, tidier copy. */
365
- function cacheKey() {
366
- return sodium.crypto_generichash(sodium.crypto_secretbox_KEYBYTES, keypair.privateKey);
367
- }
368
- function writeCache(projectId, payload) {
369
- try {
370
- mkdirSync(CACHE_DIR, { recursive: true });
371
- const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
372
- const box = sodium.crypto_secretbox_easy(sodium.from_string(JSON.stringify(payload)), nonce, cacheKey());
373
- writeFileSync(
374
- join(CACHE_DIR, `${projectId}.json`),
375
- JSON.stringify({ nonce: sodium.to_base64(nonce, B64()), box: sodium.to_base64(box, B64()) }),
376
- { mode: 0o600 }
377
- );
378
- } catch {
379
- /* cache is best-effort */
380
- }
381
- }
382
- function readCache(projectId) {
383
- try {
384
- const { nonce, box } = JSON.parse(readFileSync(join(CACHE_DIR, `${projectId}.json`), 'utf8'));
385
- const plain = sodium.crypto_secretbox_open_easy(
386
- sodium.from_base64(box, B64()),
387
- sodium.from_base64(nonce, B64()),
388
- cacheKey()
389
- );
390
- return JSON.parse(sodium.to_string(plain));
391
- } catch {
392
- return null;
393
- }
394
- }
395
-
396
- /** Offline start: materialize from the encrypted cache before the first poll.
397
- * Also seeds knownTargetFiles so stale-file cleanup survives a restart. */
398
- export async function loadCachedEnv(projectId) {
399
- await ensureKeypair();
400
- const cached = readCache(projectId);
401
- if (!cached) return false;
402
- values = cached.values ?? [];
403
- bundleVersion = cached.bundleVersion ?? -1;
404
- // Filtered even though we wrote the cache: this set feeds removeStaleEnvFile,
405
- // and a cache file predates whatever rules the running daemon enforces.
406
- knownTargetFiles = new Set((cached.knownFiles ?? values.map((v) => v.targetFile)).filter(isSafeTarget));
407
- cachedProjectId = projectId;
408
- return values.length > 0;
409
- }
410
-
411
- // ── Materialization ────────────────────────────────────────────────────────
346
+ // ── The env comparison scan ────────────────────────────────────────────────
412
347
 
413
348
  /**
414
- * Add the materialized paths to the exclude file git ACTUALLY READS.
415
- *
416
- * This used to resolve the worktree's own gitdir (`.git/worktrees/<name>`) and
417
- * write `info/exclude` there, on the belief that it "applies to that worktree
418
- * only and never touches the user's repo". Git does not read that file: it
419
- * resolves `info/exclude` against $GIT_COMMON_DIR — the main `.git` — so in
420
- * every linked worktree the daemon creates, the exclusion did nothing at all.
421
- * The plaintext secret files stayed visible to `git add -A` — the first thing
422
- * an agent runs before committing and pushing the branch it is working on.
423
- *
424
- * `--git-common-dir` is asked of git rather than derived, because that is the
425
- * one answer that cannot drift from what git itself will consult. The file is
426
- * local to the clone and never committed.
427
- *
428
- * This is a CONVENIENCE, not the guarantee. The guarantee is the check-ignore
429
- * verification in materializeInto, which refuses to write a secret that git can
430
- * still see.
349
+ * THE BOUNDS, as local literals.
350
+ *
351
+ * They mirror the server's own report schema (`packages/shared` — the env
352
+ * report's zod parse), and they are DUPLICATED here on purpose rather than
353
+ * imported: this package ships to npm on its own and has no path to the
354
+ * monorepo's shared types. The daemon holds its own line for the same reason
355
+ * every other boundary in this file does — a report that the server will
356
+ * reject is a report nobody sees, and clamping here is what makes the
357
+ * difference visible as a smaller list rather than a 400.
358
+ *
359
+ * ── THE REPORT'S CAPS ARE NOT THE SCRUBBER'S, AND THAT ASYMMETRY IS
360
+ * DELIBERATE (2026-09-21, the review) ──
361
+ *
362
+ * `MAX_ENV_FILES` / `MAX_ENV_VARS` bound what goes ON THE WIRE, because a
363
+ * comparison screen is a thing a person reads and a 4000-row list answers
364
+ * nothing. `MAX_SCRUB_VALUES` bounds what this box KNOWS TO HIDE, and it is an
365
+ * order of magnitude larger because redaction has no wire cost, no render cost
366
+ * and no reader. A secret dropped for being the 201st variable in the checkout
367
+ * is a secret in the server's database — the report's economy must never be
368
+ * allowed to decide that. The scrub list is still bounded, because an
369
+ * unbounded `split`/`join` loop over a stream is a real cost; 2000 is a number
370
+ * no honest checkout reaches.
371
+ *
372
+ * `MAX_SCAN_FILES` is the read bound that makes `filesTotal`/`varsTotal`
373
+ * affordable: every `.env*` name in the root is COUNTED, and the first 64 are
374
+ * actually read, so the totals are honest about a directory somebody has let
375
+ * grow without this scan walking an unbounded list of files on the poll's beat.
431
376
  */
432
- export function excludeInWorktree(wt, relPaths) {
433
- try {
434
- let gitdir;
435
- try {
436
- gitdir = resolve(
437
- wt,
438
- execFileSync('git', ['rev-parse', '--git-common-dir'], {
439
- cwd: wt,
440
- encoding: 'utf8',
441
- stdio: ['ignore', 'pipe', 'ignore'],
442
- }).trim()
443
- );
444
- } catch {
445
- return; // not a repo — materializeInto's check-ignore gate will refuse anyway
446
- }
447
- const excludePath = join(gitdir, 'info', 'exclude');
448
- mkdirSync(dirname(excludePath), { recursive: true });
449
- let existing = '';
450
- try {
451
- existing = readFileSync(excludePath, 'utf8');
452
- } catch {
453
- /* fresh */
454
- }
455
- const missing = relPaths.filter((p) => !existing.split('\n').includes(`/${p}`));
456
- if (missing.length) {
457
- appendFileSync(excludePath, `${existing.endsWith('\n') || !existing ? '' : '\n'}${missing.map((p) => `/${p}`).join('\n')}\n`);
458
- }
459
- } catch {
460
- /* best-effort — the agent prompt still forbids committing secrets */
461
- }
462
- }
463
-
464
- /** MUST match the server's isSafeEnvTargetFile (env.schema.ts) — the server
465
- * validates at intake, but this file also refills paths from the on-disk
466
- * cache and hands them to rmSync, so the daemon holds its own line rather
467
- * than trusting either source. `.git` is refused at ANY depth and
468
- * case-insensitively (git treats `.GIT` the same on case-insensitive
469
- * filesystems): a target of `.git/hooks/pre-commit` would turn a
470
- * materialized value into code git runs on the operator's next commit —
471
- * check-ignore alone must not be the only thing standing there. The control
472
- * range subsumes the old bare `\0` check and keeps a newline out of a PATH,
473
- * where nothing downstream expects one. */
474
- const isSafeTarget = (p) =>
475
- typeof p === 'string' &&
476
- p.length > 0 &&
477
- p.length <= 200 &&
478
- !p.includes('\\') &&
479
- // eslint-disable-next-line no-control-regex
480
- !/[\x00-\x1f\x7f]/.test(p) &&
481
- !p.startsWith('/') &&
482
- p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..' && s.toLowerCase() !== '.git');
483
-
484
- /** Is this path TRACKED in the repo? info/exclude only hides UNTRACKED files —
485
- * materializing secrets into a tracked file would make them stageable and
486
- * committable. We refuse those paths entirely. */
487
- function isTrackedInGit(wt, relPath) {
488
- try {
489
- execFileSync('git', ['ls-files', '--error-unmatch', '--', relPath], {
490
- cwd: wt,
491
- stdio: 'ignore',
492
- });
493
- return true;
494
- } catch {
495
- return false;
496
- }
497
- }
377
+ const MAX_ENV_FILES = 8;
378
+ const MAX_ENV_VARS = 200; // TOTAL across every file, not per file — REPORT only
379
+ const MAX_ENV_NAME_CHARS = 64;
380
+ const MAX_ENV_FILE_BYTES = 256 * 1024;
381
+ const MAX_SCAN_FILES = 64;
382
+ const MAX_SCRUB_VALUES = 2000;
383
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
384
+
385
+ /** `.env`, `.env.local`, `.env.production` … and never a template. A file whose
386
+ * name ends `.example` / `.sample` / `.template` is somebody's DOCUMENTATION
387
+ * of the shape, committed on purpose, and its "values" are placeholders — so
388
+ * it would fingerprint `your-key-here` on every box and report them as
389
+ * agreeing, which is the exact opposite of what a comparison is for. */
390
+ const isEnvFileName = (name) =>
391
+ (name === '.env' || name.startsWith('.env.')) &&
392
+ !/\.(example|sample|template)$/i.test(name);
498
393
 
499
394
  /**
500
- * Will git hide this path? Asked of git, never inferred.
395
+ * ONE LINE OF DOTENV. Blank lines and `#` comments are skipped, an optional
396
+ * `export ` prefix is allowed, the line is split on the FIRST `=` (a value may
397
+ * contain any number of them), key and value are trimmed, and ONE matching pair
398
+ * of surrounding quotes is stripped. A line with no `=` is skipped rather than
399
+ * guessed at; `null` means "this is not a variable".
400
+ *
401
+ * ── WHAT "PARSED THE WAY EVERY DOTENV PARSER DOES" USED TO GLOSS OVER, AND
402
+ * WHAT THE REAL RULES ARE (2026-09-21, the review) ──
403
+ *
404
+ * 1. AN INLINE COMMENT IS NOT PART OF THE VALUE. `PORT=3000 # dev server`
405
+ * loads as `3000` everywhere, and this parser kept ` # dev server`. Two
406
+ * consequences, both wrong in the direction that matters: the FINGERPRINT
407
+ * then depended on somebody's comment, so two boxes running the identical
408
+ * port disagreed on the one screen whose whole job is to say whether they
409
+ * agree; and the scrub list held a value that never occurs in any log, so
410
+ * the real one was not redacted. Inside a QUOTED value a `#` is literal
411
+ * (`PASS="a#b"` is `a#b`), and the quoted branch below never looks for one.
501
412
  *
502
- * This is the gate that makes writing a secret safe, and it is asked AFTER the
503
- * exclude file is updated so it reflects the state the agent will actually run
504
- * under. It fails CLOSED: any error — not a repo, git missing, a weird
505
- * pathspec — reads as "not ignored", so the secret is not written. A wrong
506
- * "yes" here puts plaintext on a remote branch; a wrong "no" costs a warning.
413
+ * DELIBERATELY NARROWER THAN dotenv v16 ON ONE POINT, and it is stated
414
+ * rather than hidden: dotenv's unquoted value group is `[^#\r\n]+`, so
415
+ * `PASS=a#b` loads as `a`. Here a `#` only begins a comment when it is at
416
+ * the start or preceded by WHITESPACE. An unquoted password containing `#`
417
+ * is an ordinary thing for an operator to paste, and cutting it would
418
+ * produce exactly the two failures above — a fingerprint of a value nobody
419
+ * holds, and a redaction that misses the secret. Keeping too much is the
420
+ * safe direction for both halves of this module; inventing a shorter value
421
+ * is not.
422
+ *
423
+ * 2. A QUOTED VALUE MAY NOT END ON THIS LINE, and that was a BLOCKER. An RSA
424
+ * private key pasted into `.env` as
425
+ *
426
+ * KEY="-----BEGIN RSA PRIVATE KEY-----
427
+ * MIIEpAIBAAKCAQEA...
428
+ * -----END RSA PRIVATE KEY-----"
429
+ *
430
+ * was parsed a line at a time, so the variable's "value" was the header
431
+ * line. Every RSA key on every box then fingerprinted to the same eight
432
+ * characters, which made the comparison screen state that two DIFFERENT
433
+ * private keys agree — a readout whose only job is to answer "do these
434
+ * boxes match" answering it backwards. Worse, `scrub` was handed the header
435
+ * instead of the key, so the actual key body was NOT redacted out of turn
436
+ * traces or deploy logs.
437
+ *
438
+ * The fix is the file's own rule applied honestly: a value we cannot read
439
+ * correctly is reported as NOTHING, never as a wrong fingerprint — the same
440
+ * call an over-long name gets ("a cut name is a DIFFERENT VARIABLE"). So an
441
+ * unterminated quote returns `{ name, unterminated }` and the caller DROPS
442
+ * the variable from both halves and consumes forward to the closing quote,
443
+ * so the key's own body lines are never mistaken for variables of their own.
444
+ * That forward skip is not cosmetic: base64 carries `=` padding and a body
445
+ * line can perfectly well contain `A=B`, which this parser would otherwise
446
+ * read as a variable whose value is a slice of somebody's private key.
447
+ *
448
+ * THE COST IS NAMED: a legitimately multi-line value is invisible to the
449
+ * comparison AND unredacted. Reading it properly means a stateful parser
450
+ * that also has to decide what `\n` escaping means per dialect, and getting
451
+ * that subtly wrong reproduces exactly the bug above. Silence is the honest
452
+ * answer until somebody asks for the feature.
507
453
  */
508
- function isIgnoredInGit(wt, relPath) {
509
- try {
510
- execFileSync('git', ['check-ignore', '-q', '--', relPath], {
511
- cwd: wt,
512
- stdio: 'ignore',
513
- });
514
- return true; // exit 0 = ignored
515
- } catch {
516
- return false;
454
+ function parseEnvLine(raw) {
455
+ const line = raw.trim();
456
+ if (!line || line.startsWith('#')) return null;
457
+ const eq = line.indexOf('=');
458
+ if (eq <= 0) return null;
459
+ const name = line.slice(0, eq).trim().replace(/^export\s+/, '');
460
+ const rest = line.slice(eq + 1).trim();
461
+ const quote = rest[0];
462
+ if (quote === '"' || quote === "'") {
463
+ const close = rest.indexOf(quote, 1);
464
+ // No closing quote ON THIS LINE. We know the name and we do not know the
465
+ // value; saying so is the whole contract.
466
+ if (close < 0) return { name, unterminated: quote };
467
+ // Anything after the closing quote is a comment or stray text, and a `#`
468
+ // INSIDE the quotes is part of the value.
469
+ return { name, value: rest.slice(1, close) };
517
470
  }
471
+ const hash = rest.search(/(?:^|\s)#/);
472
+ return { name, value: hash < 0 ? rest : rest.slice(0, hash).trimEnd() };
518
473
  }
519
474
 
520
- // Per-worktree: the target files we last materialized THIS SESSION.
521
- const lastFilesByWorktree = new Map();
522
-
523
- /** Target files refused for a GIT reason, PER WORKTREE — reported to the
524
- * server on the next poll. Per-worktree because the refusal is: both
525
- * predicates (`isTrackedInGit`, `isIgnoredInGit`) run with `cwd: wt`, so
526
- * ".env is refused" is a fact about ONE tree. A process-global set mixed two
527
- * trees' answers together and, worse, could only ever grow.
528
- *
529
- * Names only — a path is not a secret, and the whole point is that a human
530
- * can act on it ("gitignore apps/api/.dev.vars"). Only the two GIT causes go
531
- * in here: they have a remedy the reader can carry out, and the surface names
532
- * that remedy. A transient write failure is a warn, not a standing claim. */
533
- const skippedByWorktree = new Map();
534
-
535
- /** Worktrees whose most recent pass wrote everything it was asked to.
536
- * `hasMaterialized` is built on THIS rather than on "a pass ran", so a pass
537
- * that refused something RETRIES on the next turn — which is what lets
538
- * `echo .env >> .gitignore` actually take effect without waiting for an
539
- * unrelated bundle change. A pass with nothing to write counts as clean. */
540
- const cleanWorktrees = new Set();
541
-
542
- /** True once any materialization pass has completed. Distinguishes "we refused
543
- * nothing" from "we have not looked", which is the whole contract of the
544
- * `envskip` report — see envQueryParams. */
545
- let everMaterialized = false;
546
-
547
- /** Has this process completed a CLEAN materialization pass for this worktree?
548
- * The creation-only rule (work.mjs) needs a second condition or a directory
549
- * that existed before the bundle did is never revisited. */
550
- export function hasMaterialized(wt) {
551
- return cleanWorktrees.has(wt);
475
+ /**
476
+ * WHERE A MULTI-LINE QUOTED VALUE ENDS — the index of the line holding its
477
+ * closing quote, or the end of the file if there is none.
478
+ *
479
+ * Deliberately the cheap shape: the first line containing that quote character
480
+ * closes it. An escaped quote inside the body would end it early, which cuts
481
+ * the skip short and lets a few body lines be looked at again — and those lines
482
+ * are dropped anyway, because `ENV_NAME_RE` refuses every name a base64 or PEM
483
+ * body can produce. Under-skipping degrades to the old behaviour on a line or
484
+ * two; over-skipping would silently eat real variables after the key, which is
485
+ * the failure worth avoiding.
486
+ *
487
+ * An unterminated quote that runs to EOF consumes the rest of the file, which
488
+ * is what a shell and a dotenv loader both effectively do with one.
489
+ */
490
+ function closingQuoteLine(lines, start, quote) {
491
+ for (let i = start + 1; i < lines.length; i++) if (lines[i].includes(quote)) return i;
492
+ return lines.length;
552
493
  }
553
- // Project-global union of every target file we've ever materialized — PERSISTED
554
- // in the cache and seeded on load, so a file whose key was deleted while the
555
- // daemon was down still gets its stale plaintext copy cleaned on the next
556
- // materialize (lastFilesByWorktree alone is empty after a restart, and
557
- // `git clean -fd` never removes an info/exclude'd file).
558
- let knownTargetFiles = new Set();
559
-
560
- const MATERIALIZE_HEADER = '# Materialized by flowviant env sync';
561
494
 
562
- /** Render KEY=value with values that contain newlines/= safely quoted so one
563
- * value can't fabricate another key line. */
564
- function renderEnvFile(list) {
565
- const lines = list.map((v) => {
566
- const needsQuote = /[\n\r"'`$\\ ]/.test(v.value) || v.value === '';
567
- if (!needsQuote) return `${v.name}=${v.value}`;
568
- const esc = v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
569
- return `${v.name}="${esc}"`;
570
- });
571
- return `${MATERIALIZE_HEADER} — DO NOT COMMIT.\n${lines.join('\n')}\n`;
572
- }
495
+ /**
496
+ * A VALUE FINGERPRINT — sha256 over `<projectId>\n<value>`, first 8 hex
497
+ * characters.
498
+ *
499
+ * THE VALUE NEVER LEAVES THE BOX. The whole question the owner asked is
500
+ * comparative — "show the env of each of the machines (for comparison)" — and
501
+ * a comparison needs only to know whether two boxes hold the SAME thing, which
502
+ * a fingerprint answers and a value answers no better. Sending the value would
503
+ * rebuild, on a plainer wire, the exact custody problem the vault was deleted
504
+ * to be rid of.
505
+ *
506
+ * ── IT IS SALTED PER PROJECT (2026-09-21, the review), BECAUSE AN UNSALTED
507
+ * TRUNCATED HASH OVER A LOW-ENTROPY VALUE IS A DICTIONARY ORACLE ──
508
+ *
509
+ * The first cut hashed the value alone. Half of a real `.env` is drawn from a
510
+ * vocabulary of a few hundred strings — `true`, `false`, `3000`, `8080`,
511
+ * `development`, `production`, `postgres`, `localhost`, `info` — so anybody who
512
+ * could read a stored report could recover those values with one precomputed
513
+ * table, and the eight characters that were chosen to be uninformative became
514
+ * a lookup key instead. Salting with the project id makes the table worthless
515
+ * without knowing which project it is for, and makes it per-project even then.
516
+ *
517
+ * IT COSTS THE FEATURE NOTHING, and that is what makes the trade obvious: the
518
+ * screen compares the BOXES OF ONE PROJECT. Two projects agreeing on a value is
519
+ * not a question anybody has asked, is not rendered anywhere, and is stated
520
+ * here as NOT A FEATURE so that a later "why don't these match" has an answer
521
+ * in the file rather than a bug report.
522
+ *
523
+ * Eight hex characters is 32 bits — a birthday collision at a few tens of
524
+ * thousands of distinct values, which no single project's env is — and it stays
525
+ * deliberately SHORT so nobody mistakes it for a commitment to the value.
526
+ */
527
+ const fingerprint = (salt, value) =>
528
+ createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0, 8);
573
529
 
574
- /** Delete a materialized file from a worktree, but ONLY if it's ours (carries
575
- * our header) and not git-tracked — never touch a file we didn't write.
576
- *
577
- * `rel` gets the SAME gate the write path has: it arrives via
578
- * knownTargetFiles, which is refilled from the server bundle and from the
579
- * on-disk cache, and this function joins it to a worktree and calls rmSync.
580
- * The isTrackedInGit check cannot stand in for validation — `ls-files` on a
581
- * path outside the worktree THROWS, the catch reads as "not tracked", and
582
- * the deletion proceeds. A delete primitive validates its own input. */
583
- function removeStaleEnvFile(wt, rel) {
584
- if (!isSafeTarget(rel)) return;
585
- if (isTrackedInGit(wt, rel)) return;
586
- const abs = join(wt, rel);
530
+ /**
531
+ * READ THE CHECKOUT'S `.env*` FILES AND REPORT WHAT IS IN THEM BY NAME.
532
+ *
533
+ * Returns
534
+ * `{ files: [{ file, vars: [{ name, fp }] }], filesTotal, varsTotal, values }`
535
+ * — `files` + the two totals are the REPORT (names and fingerprints, safe to
536
+ * send) and `values` is what stays here and feeds `scrub`. The two are
537
+ * separated at the type level rather than by a filter at the call site, because
538
+ * the one mistake that would matter here is a value riding the report, and a
539
+ * caller that has to remember to strip something will eventually not.
540
+ *
541
+ * ── THE TOTALS RIDE BESIDE THE CAPPED LISTS (2026-09-21, the review) ──
542
+ *
543
+ * `filesTotal` and `varsTotal` are the numbers BEFORE the report's caps, so the
544
+ * app can say "N more not shown" instead of letting a list silently cut at
545
+ * eight files read as the whole directory. That is the rule `repoState` already
546
+ * keeps for branches and worktrees, and the rule the listeners panel learned
547
+ * the hard way when `wrangler dev` opened nine sockets into a cap of eight: a
548
+ * list cut without saying so answers "how much is in here" with a lie.
549
+ *
550
+ * ── THE SALT IS AN ARGUMENT, AND AN ABSENT ONE REPORTS NOTHING ──
551
+ *
552
+ * `salt` is the project id (see `fingerprint`). It is passed in rather than
553
+ * read from config here so this function stays pure and provable from a temp
554
+ * directory; `scanEnvForScrub` is the impure wrapper that defaults it.
555
+ *
556
+ * A box that cannot name its project — a credential handed in through
557
+ * `FLOWVIANT_FLEET` with nothing in the store — reports NO FILES AT ALL rather
558
+ * than fingerprints salted with something else. Incomparable fingerprints would
559
+ * render as a confident `differs` beside a box that holds the identical value,
560
+ * which is the readout asserting the opposite of the truth. Ignorance renders
561
+ * nothing, the three-state rule this daemon keeps everywhere. THE SCRUB HALF IS
562
+ * UNAFFECTED: `values` is filled whatever the salt is, because hiding a secret
563
+ * needs no project id and a box with no report is not a box with no secrets.
564
+ *
565
+ * ── THE CHECKOUT ROOT ONLY, AND THAT BOUND IS DELIBERATE ──
566
+ *
567
+ * Not worktrees (a session's directory is a branch of this one, and reporting N
568
+ * near-identical copies answers a question nobody asked), not subdirectories,
569
+ * and not "every `.env*` git knows about". A monorepo genuinely does keep
570
+ * `apps/api/.dev.vars` and friends, and this scan will not see them — that is
571
+ * an accepted, stated limit rather than an oversight. Walking a tree to find
572
+ * secret files is how a scan ends up reading `node_modules/**` on somebody's
573
+ * laptop, and the root is where the overwhelming majority of the "it works on
574
+ * my machine" difference actually lives. Widening it is a decision with an
575
+ * argument, not a tweak.
576
+ *
577
+ * ── A SYMLINK IS NOT A FILE (2026-09-21, the review) ──
578
+ *
579
+ * `statSync` FOLLOWS a link, so `.env.link -> /proc/self/environ` was stat'd
580
+ * and size-capped at its TARGET and then read: the scan would have reported the
581
+ * daemon's own process environment — the machine credential among it — as this
582
+ * checkout's variables, and `-> /etc/shadow` or a link onto a multi-gigabyte
583
+ * file elsewhere are the same gesture. `lstatSync` + `isFile()` refuses every
584
+ * one of them without needing to reason about where any of them point. The
585
+ * argument is one line: this scan reads THE CHECKOUT, and a link is an
586
+ * instruction to read somewhere else. A checkout whose `.env` is genuinely a
587
+ * symlink into a secrets directory reports nothing, which is the same silence
588
+ * every other unreadable thing here produces.
589
+ *
590
+ * ── IT NEVER THROWS ──
591
+ *
592
+ * This runs on the poll's own beat. A per-file error contributes nothing and is
593
+ * swallowed; an unreadable directory yields an empty report. Ignorance renders
594
+ * nothing — a box that could not look reports no files, and the app's answer to
595
+ * that is silence rather than "this machine has no env".
596
+ */
597
+ export function scanEnvFiles(repoRoot, salt) {
598
+ const out = { files: [], filesTotal: 0, varsTotal: 0, values: [] };
599
+ if (!repoRoot || typeof repoRoot !== 'string') return out;
600
+ let names;
587
601
  try {
588
- if (existsSync(abs) && readFileSync(abs, 'utf8').startsWith(MATERIALIZE_HEADER)) {
589
- rmSync(abs, { force: true });
590
- }
602
+ names = readdirSync(repoRoot).filter(isEnvFileName).sort();
591
603
  } catch {
592
- /* best-effort */
593
- }
594
- }
595
-
596
- /** Deploy-scope credentials (e.g. CLOUDFLARE_API_TOKEN) as a NAME→value map —
597
- * injected into the deploy command's process env, NEVER written to a file. */
598
- export function deployCreds() {
599
- const out = {};
600
- for (const v of values) if (v.scope === 'deploy' && v.value) out[v.name] = v.value;
601
- return out;
602
- }
603
-
604
- /** app-scope secrets for one environment as a NAME→value map — for pushing to
605
- * the provider's secret store on a prod deploy (`wrangler secret put`). */
606
- export function appSecretsFor(env) {
607
- const out = {};
608
- for (const v of values) if (v.scope === 'app' && v.env === env && v.value) out[v.name] = v.value;
609
- return out;
610
- }
611
-
612
- /** Write the decrypted env into ONE worktree. Never call on the wiki worktree.
613
- * v1 materializes the 'dev' app env (agent test runs + local preview); prod
614
- * app secrets go to the provider at deploy, deploy creds are injected only. */
615
- export function materializeInto(wt) {
616
- if (!wt || !existsSync(wt)) return;
617
- // NEVER SYNCED IS NOT "NO SECRETS", and conflating them cost a whole session.
618
- // `values` is empty both before the first bundle lands and for a project that
619
- // genuinely has none; `bundleVersion < 0` is the one that means IGNORANCE.
620
- // Writing nothing here and RECORDING it as materialized let a worktree
621
- // created on the first poll after a restart — before handleRosterEnv had
622
- // warmed the cache — sit secret-less for its entire life, because nothing
623
- // re-materializes a directory that is neither fresh nor covered by a bundle
624
- // CHANGE. Returning without recording is what makes the next turn retry.
625
- if (bundleVersion < 0) return;
626
- const byFile = new Map();
627
- for (const v of values) {
628
- if (v.scope !== 'app' || v.env !== 'dev') continue; // only local dev secrets hit a worktree file
629
- if (!isSafeTarget(v.targetFile)) continue;
630
- const list = byFile.get(v.targetFile) ?? [];
631
- list.push(v);
632
- byFile.set(v.targetFile, list);
604
+ return out; // unreadable checkout — say nothing rather than say "none"
633
605
  }
634
-
635
- // Exclude BEFORE writing, not after. The old order wrote plaintext first and
636
- // tried to hide it afterwards, so every failure mode — and the exclude file
637
- // being the wrong one, which it was — left a readable secret sitting where
638
- // the next `git add -A` in that tree would stage it for a push.
639
- excludeInWorktree(wt, [...byFile.keys()]);
640
-
641
- const written = [];
642
- /** Refused for a GIT reason this pass — reported, and remediable. */
643
- const refusedForGit = [];
644
- /** Anything that did not get written, git reasons and write failures alike.
645
- * Blocks the clean mark so the next turn tries again. */
646
- let anyProblem = false;
647
- for (const [file, list] of byFile) {
648
- if (isTrackedInGit(wt, file)) {
649
- warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
650
- refusedForGit.push(file);
651
- anyProblem = true;
652
- continue;
653
- }
654
- // The load-bearing check. A materialized secret sits in a worktree where an
655
- // agent runs `git add -A` and pushes as a matter of course, so "git cannot
656
- // see this file" is a precondition for writing it, not a nicety.
657
- if (!isIgnoredInGit(wt, file)) {
658
- warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
659
- refusedForGit.push(file);
660
- anyProblem = true;
661
- continue;
662
- }
606
+ out.filesTotal = names.length;
607
+ const canReport = typeof salt === 'string' && salt.length > 0;
608
+ let budget = MAX_ENV_VARS;
609
+ for (const name of names.slice(0, MAX_SCAN_FILES)) {
663
610
  try {
664
- const abs = join(wt, file);
665
- // A SYMLINK AT THE TARGET IS NOT A TARGET. `writeFileSync` follows one,
666
- // so a link committed into the repo (or dropped by an agent) at the
667
- // materialization path would write the project's decrypted secrets
668
- // wherever it points — outside the worktree, and outside everything the
669
- // check-ignore gate can reason about. `lstat`, not `stat`, and refuse.
670
- // Cheap, and the whole exposure is one call away otherwise.
671
- try {
672
- if (lstatSync(abs).isSymbolicLink()) {
673
- warn(`env: "${file}" is a symlink — refusing to write secrets through it.`);
674
- anyProblem = true;
611
+ const abs = join(repoRoot, name);
612
+ // The cap is checked against the LSTAT before the read, so a stray binary
613
+ // named `.env.bin` — or a log somebody redirected into `.env.out` — is
614
+ // never slurped into memory at all. A directory called `.env.d` is not a
615
+ // file and contributes nothing, and neither is a SYMLINK: see above.
616
+ const st = lstatSync(abs);
617
+ if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) continue;
618
+ // A MAP, because LAST WRITE WINS inside one file — which is how a dotenv
619
+ // loader resolves a repeated key, so the fingerprint reported is the one
620
+ // actually in force. It also makes the totals honest: a file that sets
621
+ // the same key forty times counts once, not forty times.
622
+ const byName = new Map();
623
+ const lines = readFileSync(abs, 'utf8').split('\n');
624
+ for (let i = 0; i < lines.length; i++) {
625
+ const parsed = parseEnvLine(lines[i]);
626
+ if (!parsed) continue;
627
+ // A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
628
+ // dropped from BOTH halves and the scan steps over its continuation
629
+ // lines, so a PEM body can never be read as variables of its own.
630
+ if (parsed.unterminated) {
631
+ i = closingQuoteLine(lines, i, parsed.unterminated);
675
632
  continue;
676
633
  }
677
- } catch {
678
- /* does not exist yet — the ordinary case */
679
- }
680
- mkdirSync(dirname(abs), { recursive: true });
681
- const body = renderEnvFile(list);
682
- // Skip an identical rewrite — otherwise every bundle bump touches the
683
- // file mtime and hot-restarts a running preview dev-server mid-review.
684
- let prior = null;
685
- try {
686
- prior = readFileSync(abs, 'utf8');
687
- } catch {
688
- /* new file */
634
+ // A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
635
+ // is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
636
+ // truncated names as the same row is worse than rendering neither.
637
+ if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
638
+ if (!ENV_NAME_RE.test(parsed.name)) continue;
639
+ if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
640
+ byName.set(parsed.name, parsed.value);
689
641
  }
690
- if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
691
- // `mode` on writeFileSync applies at CREATION only — an overwrite of a
692
- // file that already existed keeps whatever mode it had, so a 0644 stub
693
- // committed by a teammate (or left by an older daemon) would hold
694
- // plaintext secrets world-readable on a shared box. chmod every time.
695
- try {
696
- chmodSync(abs, 0o600);
697
- } catch {
698
- /* best-effort: a filesystem without modes is not a reason to refuse */
642
+ out.varsTotal += byName.size;
643
+ // Decided BEFORE this file spends any budget, so `budget` reaching zero
644
+ // inside a file still lets that file's own entry carry what it got.
645
+ const reporting = canReport && budget > 0 && out.files.length < MAX_ENV_FILES;
646
+ const vars = [];
647
+ for (const [n, value] of byName) {
648
+ // THE SCRUB LIST IS NOT BOUNDED BY THE REPORT'S CAPS — see the bounds
649
+ // block above. A secret dropped for being the 201st is a secret in the
650
+ // server's database.
651
+ if (out.values.length < MAX_SCRUB_VALUES) out.values.push({ name: n, value });
652
+ if (reporting && budget > 0) {
653
+ vars.push({ name: n, fp: fingerprint(salt, value) });
654
+ budget -= 1;
655
+ }
699
656
  }
700
- written.push(file);
701
- } catch (e) {
702
- // NOT reported as a refusal: the surface's line names a git cause and a
703
- // git remedy, and a full disk is neither. It still blocks the clean mark,
704
- // so the next turn retries.
705
- warn(`env: could not write ${file} into worktree: ${e.message}`);
706
- anyProblem = true;
657
+ if (reporting) out.files.push({ file: name, vars });
658
+ } catch {
659
+ /* an unreadable file contributes nothing — never a thrown poll */
707
660
  }
708
661
  }
709
-
710
- // Remove any file we ever materialized (this session OR a prior one, via the
711
- // persisted knownTargetFiles) that has no keys now — a deleted secret's
712
- // plaintext file must not linger, even across a daemon restart.
713
- const writtenSet = new Set(written);
714
- const candidates = new Set([...(lastFilesByWorktree.get(wt) ?? []), ...knownTargetFiles]);
715
- for (const stale of candidates) {
716
- if (!writtenSet.has(stale)) removeStaleEnvFile(wt, stale);
717
- }
718
- for (const f of written) knownTargetFiles.add(f);
719
- lastFilesByWorktree.set(wt, written);
720
-
721
- // THE PASS'S VERDICT, recorded whole and REPLACING the previous one — this is
722
- // what lets a refusal clear. `refusedForGit` is recomputed from scratch every
723
- // pass, so a file that gets gitignored simply is not in the next one, and the
724
- // union reported on the poll shrinks. `anyProblem` (which also covers a write
725
- // failure) is what decides whether this worktree gets retried on the next
726
- // turn; a clean pass is remembered so we stop touching a live directory.
727
- if (refusedForGit.length > 0) skippedByWorktree.set(wt, refusedForGit);
728
- else skippedByWorktree.delete(wt);
729
- if (anyProblem) cleanWorktrees.delete(wt);
730
- else cleanWorktrees.add(wt);
731
- everMaterialized = true;
732
-
733
- // THE FIRST SECRET THIS BOX EVER WROTE TO DISK is the moment the recovery
734
- // code stops being a warning about nothing — see `printRecoveryCodeOnce`.
735
- // Gated on `written`, not on reaching this line: a pass that refused every
736
- // file for a git reason materialized nothing, and the sentence would be
737
- // false. Nothing is printed on any later pass.
738
- if (written.length > 0) printRecoveryCodeOnce();
662
+ return out;
739
663
  }
740
664
 
665
+ /**
666
+ * Run a scan and hand its values to the scrubber. Returns the REPORT half —
667
+ * `{ files, filesTotal, varsTotal }`, the body `/fleet/env-report` posts.
668
+ *
669
+ * Separated from `scanEnvFiles` so the scan itself stays pure and testable: the
670
+ * only impure things about this whole lane are one module-level assignment and
671
+ * one read of `process.env`, and both live here where they can be read in four
672
+ * lines.
673
+ *
674
+ * THE SCRUBBER IS FED WHETHER OR NOT THE REPORT IS SENT. The report is deduped
675
+ * against its own hash and posted at most once per change; redaction has no
676
+ * such economy and must reflect the newest read every time, because a secret
677
+ * added to `.env` five minutes ago is exactly the one a turn is about to echo.
678
+ *
679
+ * THE PROCESS ENVIRONMENT'S INFRA CREDENTIALS ARE MERGED IN HERE, and here is
680
+ * the point: this is the one function every caller already goes through, so the
681
+ * two lists cannot drift and nobody has to remember. They are derived from
682
+ * `childEnv`'s own `DEPLOY_KEEP`, which is the set a deploy command is actually
683
+ * handed — see `processEnvSecrets` for why they are redacted and never
684
+ * reported. They are re-read on every scan rather than frozen at import,
685
+ * because that costs nothing and an environment read once is a list that can
686
+ * only be wrong.
687
+ *
688
+ * AN EMPTY READ DOES NOT CLEAR WHAT WE ALREADY KNOW, and this is the one place
689
+ * the two halves deliberately disagree. A scan that comes back with nothing is
690
+ * two different facts wearing one shape — the operator deleted `.env`, or this
691
+ * pass could not read the directory (EACCES, a mid-sweep rename, a network
692
+ * mount that blinked) — and `scanEnvFiles` swallows per-file errors by design
693
+ * so the poll cannot throw. Replacing the list on an empty read means a blip
694
+ * silently turns the redactor OFF, which is the exact failure mode the whole
695
+ * repoint above exists to avoid, and it is invisible until a secret is already
696
+ * in the server's database. Hence the separate `fileValues`: the process half
697
+ * is refreshed unconditionally (process.env does not blink) while the file half
698
+ * is only ever replaced by a read that found something.
699
+ *
700
+ * So an empty read leaves the previous list standing. The cost is
701
+ * OVER-redaction — a value the operator has since removed or rotated keeps
702
+ * being replaced with `[REDACTED:NAME]` for the life of this process — and
703
+ * over-redacting is the safe direction, the same one every three-state readout
704
+ * in this daemon takes. The REPORT half is unaffected: it returns the real
705
+ * (empty) read, so the app is told what was actually measured.
706
+ */
707
+ export function scanEnvForScrub(repoRoot, salt = PROJECT_ID) {
708
+ const scanned = scanEnvFiles(repoRoot, salt);
709
+ if (scanned.values.length) fileValues = scanned.values;
710
+ // FILTERED ONCE, HERE — see the predicate's own block above: `scrub` is a hot
711
+ // path and a value that can never match must not survive into its loop.
712
+ values = [...processEnvSecrets(), ...fileValues].filter((v) => worthRedacting(v.value));
713
+ return { files: scanned.files, filesTotal: scanned.filesTotal, varsTotal: scanned.varsTotal };
714
+ }
741
715
 
742
716
  // ── Uplink scrubbing ───────────────────────────────────────────────────────
743
717
 
744
- /** Redact every known secret value from daemon-posted text. Values shorter
745
- * than the floor ("1", "true") would redact half the stream — skipped. */
718
+ /** Redact every known secret value from daemon-posted text. The list has
719
+ * already been through `worthRedacting`, so this makes no decisions: an
720
+ * ordinary word, a short flag and anything under the length floor are simply
721
+ * not in it. */
746
722
  export function scrub(text) {
747
723
  if (typeof text !== 'string' || !text || !values.length) return text;
748
724
  let out = text;
749
- for (const v of values) {
750
- if (typeof v.value === 'string' && v.value.length >= SCRUB_MIN_LENGTH) {
751
- out = out.split(v.value).join(`[REDACTED:${v.name}]`);
752
- }
753
- }
725
+ for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
754
726
  return out;
755
727
  }
756
-
757
- // ── Roster tick ────────────────────────────────────────────────────────────
758
-
759
- let busy = false; // one env operation at a time — ticks are cheap to skip
760
-
761
- /**
762
- * React to the roster's env block. Returns { changed } — true when the bundle
763
- * was (re)materialized so the caller refreshes its worktrees.
764
- */
765
- export async function handleRosterEnv(env, { projectId } = {}) {
766
- if (!env || busy) return { changed: false };
767
- busy = true;
768
- try {
769
- await ensureKeypair();
770
- if (projectId) cachedProjectId = projectId;
771
- // First tick after a restart: warm from the encrypted cache so worktrees
772
- // can materialize even if the bundle fetch below fails transiently.
773
- if (bundleVersion < 0 && cachedProjectId) await loadCachedEnv(cachedProjectId);
774
-
775
- // 1. Introduce this machine (idempotent server-side). registeredOnce is set
776
- // only AFTER the POST lands — a transient failure must retry next poll, not
777
- // wedge registration until restart.
778
- if (env.status === 'none' && !registeredOnce) {
779
- const label = hostname() || 'daemon';
780
- let registered = null;
781
- try {
782
- registered = await post('register', { pubkey: myPubB64(), label });
783
- } catch (e) {
784
- // A 429 = the project is at its machine cap; retrying every poll would
785
- // just hammer it. Stop for this session (a restart re-tries).
786
- if (/\(429/.test(e.message)) {
787
- registeredOnce = true;
788
- warn('env: this project is at its machine limit — env access not requested. Ask an admin to remove an old machine.');
789
- return { changed: false };
790
- }
791
- throw e; // transient — retry next poll (registeredOnce still false)
792
- }
793
- registeredOnce = true;
794
- /*
795
- * ONE LINE, AND IT RELAYS WHAT THE SERVER ANSWERED.
796
- *
797
- * It used to print a key fingerprint and say "an admin approves it in
798
- * Settings → Environment (compare the emoji)" — an instruction for a
799
- * button that no longer exists, about a comparison nobody was making, on
800
- * top of a credential this box was already trusted with. What replaced it
801
- * asserted the opposite and just as blindly: "enrolled … secrets sync to
802
- * this box automatically", printed whatever the response said. A fresh
803
- * registration comes back `approved`, not `enrolled` — the key is not
804
- * here yet and arrives only once some box that holds it polls — so the
805
- * line was claiming a state this box was one or more ticks away from, and
806
- * on an OLDER server (which still answers `pending`) it was claiming one
807
- * the box would never reach at all.
808
- *
809
- * So the status is read off the response and each state says its own
810
- * true sentence, and an answer we do not recognise — or no body at all —
811
- * says only the part we measured: the registration went out.
812
- */
813
- const registeredAs = `${c.cyan('env')} · registered as ${c.bold(label)}`;
814
- if (registered?.status === 'enrolled') {
815
- info(`${c.cyan('env')} · enrolled as ${c.bold(label)} — secrets sync to this box automatically`);
816
- } else if (registered?.status === 'approved') {
817
- info(`${registeredAs} — secrets sync here as soon as a machine holding the key is online`);
818
- } else if (registered?.status === 'pending') {
819
- // An older server, which still has the approve gate. Say what IT is
820
- // waiting on rather than what we are: this daemon cannot clear it.
821
- info(`${registeredAs} — this server is waiting on an approval in Settings`);
822
- } else {
823
- info(registeredAs);
824
- }
825
- return { changed: false };
826
- }
827
- // A HARMLESS WAIT, and the server no longer produces this: registering IS
828
- // enrolling since 2026-09-20, and a row left `pending` by the old gate is
829
- // promoted on the register above. Kept because an OLDER server still
830
- // answers `pending`, and a daemon must not treat an unrecognised state as a
831
- // reason to act.
832
- if (env.status === 'pending') return { changed: false };
833
- if (env.status === 'revoked') return { changed: false };
834
-
835
- // 2. Bootstrap: no project keypair exists — this machine creates it.
836
- if (env.bootstrapNeeded && (env.status === 'approved' || env.status === 'enrolled' || env.status === 'none')) {
837
- if (env.status === 'none') return { changed: false }; // register first, next tick
838
- await bootstrapProject();
839
- return { changed: false }; // next tick syncs as enrolled
840
- }
841
- if (env.status !== 'enrolled') return { changed: false };
842
-
843
- // 3. Wrap jobs + rotation + sync — all need the bundle.
844
- const needSync = env.bundleVersion !== bundleVersion;
845
- if (!needSync && !env.pendingWraps && !env.rotationPending) return { changed: false };
846
- const bundle = await fetchBundle();
847
- if (!bundle.wrappedPriv || !bundle.projectPub) return { changed: false };
848
- projectPriv = openSealed(bundle.wrappedPriv, keypair.publicKey, keypair.privateKey);
849
- const projectPub = sodium.from_base64(bundle.projectPub, B64());
850
-
851
- // Execute pending enrollments: seal the priv to each newly registered
852
- // machine. Registering is what puts a box on this list now — there is no
853
- // approval step between the two. The
854
- // wrap's epoch rides along — the server rejects (stale) if a rotation moved
855
- // it since we fetched, so nobody enrolls with a dead key.
856
- if (bundle.pendingWraps.length) {
857
- const wraps = bundle.pendingWraps.map((p) => ({
858
- daemonId: p.daemonId,
859
- wrappedPriv: seal(projectPriv, p.pubkey),
860
- }));
861
- const res = await post('wraps', { pubkey: myPubB64(), keyEpoch: bundle.keyEpoch, wraps });
862
- if (res?.stale) note(`${c.cyan('env')} ${c.dim('— wraps raced a rotation; retrying next poll')}`);
863
- else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly registered machine${wraps.length === 1 ? '' : 's'}`)}`);
864
- }
865
-
866
- // Decrypt the values we have — carrying each key's VERSION so a rotation can
867
- // prove it re-sealed the current value (not one a concurrent write moved).
868
- const opened = [];
869
- let allOpened = true;
870
- for (const k of bundle.keys) {
871
- try {
872
- const plain = openSealed(k.ciphertext, projectPub, projectPriv);
873
- opened.push({ name: k.name, env: k.env, scope: k.scope ?? 'app', targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
874
- } catch {
875
- allOpened = false;
876
- warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
877
- }
878
- }
879
-
880
- // Execute a pending rotation: new keypair, full coverage, all wraps. If we
881
- // couldn't open every value, DON'T attempt — a partial rotate would fail
882
- // the server's coverage check; let another enrolled daemon (which can open
883
- // them) do it. Server serializes concurrent executors via a claim lock.
884
- if (bundle.rotationPending) {
885
- if (!allOpened) {
886
- warn(`env: skipping rotation — this machine can't open every value; another daemon will rotate`);
887
- return { changed: false };
888
- }
889
- const next = sodium.crypto_box_keypair();
890
- const nextPubB64 = sodium.to_base64(next.publicKey, B64());
891
- const res = await post('rotate', {
892
- pubkey: myPubB64(),
893
- fromEpoch: bundle.keyEpoch,
894
- projectPub: nextPubB64,
895
- values: opened.map((v) => ({ name: v.name, env: v.env, ciphertext: seal(sodium.from_string(v.value), nextPubB64), version: v.version })),
896
- wraps: bundle.enrolledDaemons.map((d) => ({ daemonId: d.daemonId, wrappedPriv: seal(next.privateKey, d.pubkey) })),
897
- ...(bundle.recoveryPub ? { recoverySealed: seal(next.privateKey, bundle.recoveryPub) } : {}),
898
- }).catch((e) => {
899
- // epoch_stale / value_moved / coverage → a concurrent change; the next
900
- // poll re-fetches and retries. Not fatal.
901
- note(`${c.cyan('env')} ${c.dim(`— rotation deferred (${e.message}); retrying next poll`)}`);
902
- return null;
903
- });
904
- if (res) ok(`${c.cyan('env')} ${c.dim('— project key rotated (a machine was revoked); next poll syncs the new epoch')}`);
905
- return { changed: false }; // resync on the next tick at the new version
906
- }
907
-
908
- if (needSync) {
909
- values = opened;
910
- bundleVersion = bundle.bundleVersion;
911
- // Fold the current target files into the persisted known set so stale
912
- // cleanup survives a restart (a key deleted while down still gets swept).
913
- // Filtered at the fill, not just at the delete: this set is persisted,
914
- // so an unsafe path admitted here would outlive the bundle that sent it.
915
- for (const v of values) if (isSafeTarget(v.targetFile)) knownTargetFiles.add(v.targetFile);
916
- if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion, knownFiles: [...knownTargetFiles] });
917
- ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
918
- return { changed: true };
919
- }
920
- return { changed: false };
921
- } catch (e) {
922
- warn(`env sync: ${e.message} — will retry next poll`);
923
- return { changed: false };
924
- } finally {
925
- busy = false;
926
- }
927
- }
928
-
929
- /**
930
- * First machine creates the project keypair + the standing recovery target.
931
- *
932
- * THIS IS SILENT (2026-09-20). Rotations re-seal to the same recovery pub, so
933
- * the passphrase minted here works forever — which is exactly why it does not
934
- * have to be shouted at somebody who has no secrets yet. It is parked in this
935
- * box's keypair file and printed the first time this box materializes a
936
- * secret; `stashRecoveryCode`'s docblock carries the whole argument.
937
- */
938
- async function bootstrapProject() {
939
- const project = sodium.crypto_box_keypair();
940
- const recovery = sodium.crypto_box_keypair();
941
- // Human-typable passphrase: 6 groups of 4 from an unambiguous alphabet.
942
- const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789';
943
- const raw = sodium.randombytes_buf(24);
944
- const passphrase = Array.from(raw, (b, i) => ALPHA[b % ALPHA.length] + ((i + 1) % 4 === 0 && i < 23 ? '-' : '')).join('');
945
- const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
946
- const kdfKey = sodium.crypto_pwhash(
947
- sodium.crypto_secretbox_KEYBYTES,
948
- passphrase,
949
- salt,
950
- sodium.crypto_pwhash_OPSLIMIT_MODERATE,
951
- sodium.crypto_pwhash_MEMLIMIT_MODERATE,
952
- sodium.crypto_pwhash_ALG_DEFAULT
953
- );
954
- const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
955
- const recoverySecret = JSON.stringify({
956
- pub: sodium.to_base64(recovery.publicKey, B64()),
957
- priv: sodium.to_base64(recovery.privateKey, B64()),
958
- });
959
- const recoveryBlob = [
960
- sodium.to_base64(salt, B64()),
961
- sodium.to_base64(nonce, B64()),
962
- sodium.to_base64(sodium.crypto_secretbox_easy(sodium.from_string(recoverySecret), nonce, kdfKey), B64()),
963
- ].join(':');
964
-
965
- await post('bootstrap', {
966
- pubkey: myPubB64(),
967
- projectPub: sodium.to_base64(project.publicKey, B64()),
968
- selfWrap: seal(project.privateKey, myPubB64()),
969
- recoveryPub: sodium.to_base64(recovery.publicKey, B64()),
970
- recoveryBlob,
971
- recoverySealed: seal(project.privateKey, sodium.to_base64(recovery.publicKey, B64())),
972
- });
973
-
974
- // ONE QUIET LINE. The code goes into the keypair file and waits for the
975
- // first secret. A stash that did NOT land is the one case that prints now:
976
- // the passphrase exists only in this closure, and losing the only way back
977
- // into a project's secrets to keep the terminal tidy is not a trade.
978
- if (stashRecoveryCode(passphrase, cachedProjectId)) {
979
- ok(`${c.cyan('env')} ${c.dim('— created this project\'s env keypair')}`);
980
- } else {
981
- warn('env: could not save the recovery code to this machine — here it is, once:');
982
- printRecoveryBlock(passphrase);
983
- }
984
- }