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/cli.mjs +10 -10
- package/bin/lib/childEnv.mjs +128 -9
- package/bin/lib/config.mjs +28 -0
- package/bin/lib/deploy.mjs +92 -45
- package/bin/lib/env.mjs +595 -852
- package/bin/lib/fleet.mjs +319 -77
- package/bin/lib/git.mjs +63 -0
- package/bin/lib/machines.mjs +39 -13
- package/bin/lib/mcp-cli.mjs +8 -7
- package/bin/lib/vaultArtefacts.mjs +170 -0
- package/bin/lib/work.mjs +34 -43
- package/package.json +1 -1
- package/bin/lib/env-cli.mjs +0 -216
package/bin/lib/env.mjs
CHANGED
|
@@ -1,73 +1,224 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* machine. Regenerating it on a truncated file or an
|
|
105
|
-
* OVERWRITES that identity: the
|
|
106
|
-
*
|
|
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
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* every
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
/**
|
|
563
|
-
*
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
/**
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
rmSync(abs, { force: true });
|
|
590
|
-
}
|
|
602
|
+
names = readdirSync(repoRoot).filter(isEnvFileName).sort();
|
|
591
603
|
} catch {
|
|
592
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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(
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
701
|
-
} catch
|
|
702
|
-
|
|
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.
|
|
745
|
-
*
|
|
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
|
-
}
|