flowviant 0.102.2 → 0.104.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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
package/bin/lib/env.mjs CHANGED
@@ -1,39 +1,41 @@
1
1
  /**
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.
2
+ * THE ENV COMPARISON SCAN — and nothing this box holds for anybody.
3
+ *
4
+ * `scanEnvFiles()` reads the checkout's own `.env*` files and reports variable
5
+ * NAMES plus a short VALUE FINGERPRINT SALTED WITH THE PROJECT ID, so the app
6
+ * can answer "why does it work on that box and not this one?" without an
7
+ * eight-character hash over `production` becoming a dictionary lookup. THE
8
+ * VALUE NEVER LEAVES THE BOX. The values it read are handed to the uplink
9
+ * scrubber (`uplinkScrub.mjs`, through `learnSecrets`) and go no further — a
10
+ * report is a statement about the CHECKOUT'S FILES, and neither the values nor
11
+ * this process's environment are part of one.
12
+ *
13
+ * ── ONE OF THREE (2026-09-26, the SOLID pass) ──
14
+ *
15
+ * This file used to hold three things: the BOX KEYPAIR (now `boxIdentity.mjs`
16
+ * — `envpub`, holdership, the registry row), the UPLINK SCRUBBER (now
17
+ * `uplinkScrub.mjs` — what every posted line is redacted against), and this
18
+ * scan. They shared a file because all three were what the vault below left
19
+ * standing, not because they change together: an identity rule, a redaction
20
+ * policy and a dotenv parser have three different reasons to be edited. The
21
+ * scan keeps the file's name because it is the one that is about the env.
22
+ *
23
+ * WHERE THE SCRUBBER IS FED — settled, not a leftover: `scanEnvForScrub` calls
24
+ * `learnSecrets` itself instead of each poll site doing it. There are two such
25
+ * sites (the 60s report beat in `fleetReports.mjs`, the pre-first-poll feed in
26
+ * `fleet.mjs`), and a scan whose values a caller forgets to hand over is a
27
+ * secret that stops being redacted — the same "no call site has to remember" argument that put the
28
+ * process-env join inside `learnSecrets`. The dependency runs one way (this
29
+ * file knows the scrubber's one door; the scrubber knows nothing of `.env`),
30
+ * and `scanEnvFiles` stays the pure half every parser test drives.
30
31
  *
31
32
  * ── THE VAULT IS DELETED (2026-09-21) ──
32
33
  *
33
34
  * This module used to be the CRYPTO ANCHOR of an end-to-end-encrypted team
34
35
  * 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
36
+ * box keypair (`boxIdentity.mjs` now), the daemon opened every value, cached
37
+ * them encrypted on disk, MATERIALIZED `.env` files into every session
38
+ * worktree, executed wrap jobs for
37
39
  * newly registered boxes, executed rotations when a machine was revoked, and
38
40
  * minted a one-time RECOVERY PASSPHRASE parked beside the private key. About
39
41
  * 700 lines, a `libsodium-wrappers-sumo` Argon2 dependency, four `/fleet/env/*`
@@ -59,378 +61,25 @@
59
61
  * `envv` / `envskip` poll params the materializer reported through.
60
62
  *
61
63
  * 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.
64
+ * correctness rule — all of it in `boxIdentity.mjs` now, which carries the
65
+ * argument.
66
66
  *
67
67
  * `excludeInWorktree` moved to `git.mjs`: it is a generic `git info/exclude`
68
68
  * helper that only ever lived here because the materializer was its first
69
69
  * caller.
70
70
  */
71
71
 
72
- import {
73
- readdirSync,
74
- readFileSync,
75
- mkdirSync,
76
- lstatSync,
77
- openSync,
78
- writeSync,
79
- fsyncSync,
80
- closeSync,
81
- linkSync,
82
- unlinkSync,
83
- } from 'node:fs';
84
- import { createHash, randomBytes } from 'node:crypto';
85
- import { homedir } from 'node:os';
86
- import { join, dirname } from 'node:path';
87
- // STILL SUMO, and deliberately not narrowed to the standard build in the same
88
- // pass that deleted the vault. `crypto_box_keypair` exists in both, but the
89
- // sumo package is what is installed, what every other daemon on npm resolves,
90
- // and swapping the dependency is a separate change with its own install-time
91
- // failure modes. Nothing here needs Argon2 any more; nothing here forbids the
92
- // narrowing either.
93
- import sodium from 'libsodium-wrappers-sumo';
72
+ import { readdirSync, readFileSync, lstatSync } from 'node:fs';
73
+ import { createHash } from 'node:crypto';
74
+ import { join } from 'node:path';
94
75
  // THE PROJECT ID, as a live binding. It salts every fingerprint (see
95
- // `fingerprint`), and it is a `let` in config.mjs because cli.mjs's ambiguity
76
+ // `fingerprint`), and it is a `let` in config.mjs because startProject.mjs's ambiguity
96
77
  // picker chooses a project AFTER import — reading a frozen copy here would salt
97
78
  // a picked project's report with `null` and report nothing at all.
98
79
  import { PROJECT_ID } from './config.mjs';
99
- // THE DEPLOY LANE'S OWN ALLOWLIST, borrowed for REDACTION ONLY. Deriving the
100
- // scrub list from the set a deploy command is actually handed is what stops the
101
- // two drifting; `processEnvSecrets`'s docblock carries the whole argument,
102
- // including why none of it ever reaches the wire.
103
- import { processEnvSecrets } from './childEnv.mjs';
104
-
105
- const B64 = () => sodium.base64_variants.ORIGINAL;
106
- const KEYPAIR_PATH = join(homedir(), '.flowviant', 'env-keypair.json');
107
-
108
- // ── What `scrub` will redact ───────────────────────────────────────────────
109
- //
110
- // THE PREDICATE LIVES ABOVE THE LIST ON PURPOSE (2026-09-21): the list is
111
- // FILTERED THROUGH IT ONCE, when a scan rebuilds it, rather than tested per
112
- // call. `scrub` runs on every posted line of every turn stream, tool event and
113
- // deploy log, and it does a whole-string `split`/`join` per value — so a value
114
- // that will never match must not survive into the loop at all. Pre-filtering
115
- // also makes the list smaller than it was before this pass on a typical
116
- // checkout, because half of a real `.env` is ordinary configuration.
117
-
118
- /**
119
- * THE LENGTH FLOOR. A value of one or two characters — `1`, `ab`, `on` — occurs
120
- * in ordinary prose constantly, so redacting one replaces half of every stream
121
- * this daemon posts with `[REDACTED:NAME]` and the trace stops being readable
122
- * at all. Six is the point below which a value is far likelier to be a flag
123
- * than a secret, and nothing this short is worth making the product illegible
124
- * for.
125
- *
126
- * (It used to carry the comment "mirrors shared ENV_SCRUB_MIN_LENGTH". That
127
- * constant does not exist anywhere in the monorepo any more — it went with the
128
- * vault's server half on 2026-09-21 — and a cross-reference to a deleted
129
- * identifier reads as "do not change this, the other end depends on it", which
130
- * is a claim about a coupling nobody can check. The floor's own argument is
131
- * above and it stands on its own.)
132
- */
133
- const SCRUB_MIN_LENGTH = 6;
134
-
135
- /**
136
- * A VALUE THAT LOOKS LIKE AN ORDINARY WORD IS NOT REDACTED (2026-09-21, the
137
- * review) — and this is a correctness fix, not a tuning knob.
138
- *
139
- * `scrub` now reads the CHECKOUT'S `.env*` files, and a real `.env` is full of
140
- * configuration that is not secret at all: `NODE_ENV=development`,
141
- * `HOST=localhost`, `APP_NAME=flowviant`, `LOG_LEVEL=verbose`,
142
- * `AWS_REGION=us-east-1`. With those in the list, every occurrence of the word
143
- * "development" in the CLI's own narration came back as
144
- * `[REDACTED:NODE_ENV]` — so a turn trace, which exists to relay what the CLI
145
- * said, was relaying something else. A relay does not swap the words it
146
- * carries. That is a worse failure than under-redaction, because it is silent,
147
- * universal, and destroys the readout the trace was built for.
148
- *
149
- * TWO SHAPE TESTS, applied per value, both cheap and both about SHAPE rather
150
- * than meaning (nothing here classifies, guesses, or asks a model):
151
- *
152
- * · A PLAIN IDENTIFIER — starts with a letter, runs at most sixteen
153
- * characters, and is made only of letters, digits and `.` `/` `-`. That is
154
- * what a word, a hostname, a short version string and an enum value all look
155
- * like. `_` is deliberately NOT in the set: an underscore is rare in prose
156
- * and common in key material (`sk-live_…`), so its presence is the cheapest
157
- * honest signal that a string was minted rather than written.
158
- * · A SHORT VALUE WITH LITTLE VARIETY — under twelve characters and drawing on
159
- * fewer than three of {lower, upper, digit, symbol}. A real credential short
160
- * enough to be under twelve characters is mixed; `us-east-1` and `verbose`
161
- * are not.
162
- *
163
- * THE ACCEPTED COST, STATED RATHER THAN HIDDEN: a genuinely secret value that
164
- * is short and lowercase — `hunter2secret`, a thirteen-character all-letters
165
- * password — is NOT redacted. Under-redaction is chosen here over making the
166
- * product illegible, because the alternative is a trace nobody can read and a
167
- * product that silently rewrites its own CLI's output. An operator whose secret
168
- * is a dictionary word has a problem this function cannot fix. Anything with a
169
- * symbol, a case mix, twelve-plus characters, or an underscore — which is every
170
- * token, key, URL, JWT and hex digest anybody actually pastes into a `.env` —
171
- * is redacted.
172
- */
173
- const SCRUB_PLAIN_RE = /^[A-Za-z][A-Za-z0-9._/-]{0,15}$/;
174
- function characterClasses(value) {
175
- let n = 0;
176
- if (/[a-z]/.test(value)) n += 1;
177
- if (/[A-Z]/.test(value)) n += 1;
178
- if (/[0-9]/.test(value)) n += 1;
179
- if (/[^A-Za-z0-9]/.test(value)) n += 1;
180
- return n;
181
- }
182
- function worthRedacting(value) {
183
- if (typeof value !== 'string' || value.length < SCRUB_MIN_LENGTH) return false;
184
- // THE PLAIN-IDENTIFIER EXEMPTION DOES NOT COVER A CASE-AND-DIGIT MIX
185
- // (2026-09-24, the audit). It was checked first and alone, so a generated
186
- // password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
187
- // character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
188
- // [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
189
- // this block's own promise that "a case mix" is redacted. A word, a hostname,
190
- // a region or a version string does not draw on upper, lower AND digits at
191
- // once; a minted secret almost always does.
192
- if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
193
- return false;
194
- }
195
- if (value.length < 12 && characterClasses(value) < 3) return false;
196
- return true;
197
- }
198
-
199
- // ── Module state ───────────────────────────────────────────────────────────
200
- let keypair = null; // { publicKey: Uint8Array, privateKey: Uint8Array }
201
-
202
- /**
203
- * WHAT `scrub` REDACTS — `[{ name, value }]`, refilled by every env scan.
204
- *
205
- * THIS ARRAY CHANGED HANDS (2026-09-21), and the change is an IMPROVEMENT
206
- * rather than a salvage. It used to hold the vault's decrypted bundle: only
207
- * the values Flowviant itself had delivered to this box. With the vault gone
208
- * that array would be permanently empty and `scrub` would become a silent
209
- * no-op in roughly ten call sites — the worst possible way for a redactor to
210
- * stop working, because every one of those sites keeps calling it and nothing
211
- * looks different until a secret is already in the server's database.
212
- *
213
- * So it is filled from the CHECKOUT'S OWN `.env*` FILES, which are the box's
214
- * REAL secrets: the ones the operator pasted in from their providers, the ones
215
- * a dev server actually loads, and the ones a CLI turn is overwhelmingly
216
- * likeliest to echo into a log. The vault could only ever redact what it had
217
- * delivered; this redacts what is there.
218
- *
219
- * ── TWO SOURCES, KEPT IN TWO VARIABLES (2026-09-21, the review) ──
220
- *
221
- * `values` is what `scrub` reads and is rebuilt on every scan as
222
- * `processEnvSecrets()` ++ `fileValues`. The split exists because the two halves
223
- * have opposite failure modes. The CHECKOUT half can come back empty for two
224
- * different reasons — the operator deleted `.env`, or this pass could not read
225
- * the directory — so an empty read must NOT replace it (see `scanEnvForScrub`).
226
- * The PROCESS half cannot blink: `process.env` is in memory and reading it is
227
- * infallible, so it is replaced unconditionally, and folding it into one array
228
- * with the file half would make a single unreadable-directory blip drop the
229
- * operator's deploy credential out of the redactor too.
230
- *
231
- * SEEDED AT IMPORT, deliberately. `scrub` is reachable from the deploy lane and
232
- * from a turn before the first scan has run, and the process half needs no
233
- * checkout, no credential and no I/O to be correct — so there is no moment at
234
- * which this module knows a deploy token and is not hiding it.
235
- *
236
- * AND IT IS ALREADY FILTERED. Every entry in `values` has passed
237
- * `worthRedacting` — see that predicate's block above — so `scrub` itself makes
238
- * no decisions and simply replaces. `fileValues` keeps the UNFILTERED read,
239
- * because the filter is about redaction and the read is about what is on disk.
240
- */
241
- let fileValues = [];
242
- let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
243
-
244
- export async function sodiumReady() {
245
- await sodium.ready;
246
- }
247
-
248
- /*
249
- * `pubkeyEmoji` IS DELETED (2026-09-20), and so is its byte-identical twin in
250
- * the web app.
251
- *
252
- * It existed for exactly one gesture: the terminal printed eight glyphs, the
253
- * browser's approve card printed the same eight, and a human compared them
254
- * before pressing Approve. Registering became enrolling, and then the whole
255
- * enrolment went with the vault — so there is no approve card, no comparison,
256
- * and nobody to make it. A fingerprint kept byte-identical across two repos
257
- * for nobody to look at is worse than none: it reads like a live MITM defence
258
- * and defends nothing.
259
- */
260
-
261
- /** This machine's persistent keypair (created on first use, 0600). */
262
- export async function ensureKeypair() {
263
- await sodium.ready;
264
- if (keypair) return keypair;
265
- let raw = null;
266
- try {
267
- raw = readFileSync(KEYPAIR_PATH, 'utf8');
268
- } catch (e) {
269
- /**
270
- * ONLY "THERE IS NO FILE" IS A FIRST RUN, and the bare catch that used to
271
- * stand here said every failure was one.
272
- *
273
- * This keypair is the box's DURABLE IDENTITY — since 2026-09-14 it is what
274
- * tells two computers apart when the server decides which of them is this
275
- * project's machine, and since 0.91.0 it is the key of this box's row in
276
- * the machine registry. Regenerating it on a truncated file or an
277
- * unreadable one OVERWRITES that identity: the box arrives at the roster as
278
- * a stranger and stands itself down as a standby of itself.
279
- *
280
- * (It was ALSO what the project's secret vault was sealed to, until the
281
- * vault was deleted 2026-09-21. That was the loudest consequence and it is
282
- * gone; the identity consequence is the one that survives, and it is on its
283
- * own sufficient.)
284
- *
285
- * A file we cannot read is not a file we may replace. Rethrown, the caller
286
- * that can survive it does: `envQueryParams` is wrapped, so the poll simply
287
- * carries no `envpub`, and a poll with no envpub is EXEMPT from arbitration
288
- * — the documented fail-open arm, reached honestly instead of by minting a
289
- * new box every restart.
290
- */
291
- if (e?.code !== 'ENOENT') throw e;
292
- }
293
- if (raw !== null) {
294
- const stored = JSON.parse(raw);
295
- keypair = {
296
- publicKey: sodium.from_base64(stored.pub, B64()),
297
- privateKey: sodium.from_base64(stored.priv, B64()),
298
- };
299
- return keypair;
300
- }
301
- const minted = sodium.crypto_box_keypair();
302
- const winner = mintKeypairFile(
303
- KEYPAIR_PATH,
304
- JSON.stringify({
305
- pub: sodium.to_base64(minted.publicKey, B64()),
306
- priv: sodium.to_base64(minted.privateKey, B64()),
307
- })
308
- );
309
- keypair = {
310
- publicKey: sodium.from_base64(winner.pub, B64()),
311
- privateKey: sodium.from_base64(winner.priv, B64()),
312
- };
313
- return keypair;
314
- }
315
-
316
- /**
317
- * WRITE THE BOX'S IDENTITY ONCE, ATOMICALLY, AND LET THE FIRST WRITER WIN
318
- * (2026-09-24, the audit). Returns the `{ pub, priv }` that is ON DISK after
319
- * the call — ours, or the one a concurrent first start got there with.
320
- *
321
- * It was a plain `writeFileSync` reached by every process that saw ENOENT, so
322
- * two daemons starting together on a fresh box (two projects, two units at
323
- * boot) each minted a key and the LAST writer won: the first kept a key in
324
- * memory the disk no longer held and arrived, after its next re-exec, as a
325
- * different box. And a crash inside the write left a torn file that every later
326
- * start refuses to read — the only-ENOENT-mints rule doing its job over a file
327
- * nothing can fix.
328
- *
329
- * So the key goes to a private temp file, is fsync'd, and is LINKED into place:
330
- * `link` fails with EEXIST instead of replacing, so exactly one identity is ever
331
- * published and every loser reads the winner's. The file at the real path is
332
- * either absent or complete — never half of one.
333
- */
334
- export function mintKeypairFile(path, body) {
335
- mkdirSync(dirname(path), { recursive: true });
336
- const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
337
- try {
338
- const fd = openSync(tmp, 'wx', 0o600);
339
- try {
340
- writeSync(fd, body);
341
- fsyncSync(fd);
342
- } finally {
343
- closeSync(fd);
344
- }
345
- try {
346
- linkSync(tmp, path);
347
- return JSON.parse(body);
348
- } catch (e) {
349
- if (e?.code === 'EEXIST') {
350
- // Somebody else published first. Theirs is the identity; ours is dropped.
351
- return JSON.parse(readFileSync(path, 'utf8'));
352
- }
353
- // A filesystem with no hard links (some network and FUSE mounts answer
354
- // EPERM/ENOTSUP) must not cost the box its identity — the plain write
355
- // this replaced worked there. Fall back to an EXCLUSIVE create: still
356
- // first-writer-wins, only without the torn-write guarantee.
357
- if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV'].includes(e?.code)) throw e;
358
- let fd2;
359
- try {
360
- fd2 = openSync(path, 'wx', 0o600);
361
- } catch (e2) {
362
- if (e2?.code !== 'EEXIST') throw e2;
363
- return JSON.parse(readFileSync(path, 'utf8'));
364
- }
365
- try {
366
- writeSync(fd2, body);
367
- fsyncSync(fd2);
368
- } finally {
369
- closeSync(fd2);
370
- }
371
- return JSON.parse(body);
372
- }
373
- } finally {
374
- try {
375
- unlinkSync(tmp);
376
- } catch {
377
- /* already gone */
378
- }
379
- }
380
- }
381
-
382
- export function myPubB64() {
383
- return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
384
- }
385
-
386
- /**
387
- * THIS BOX'S PUBLIC KEY, READ AND NEVER WRITTEN (2026-09-19, the review).
388
- *
389
- * `flowviant machines` is a VIEW-ONLY command — that narrowness is the whole
390
- * reason a third terminal command was allowed to exist at all — and it was
391
- * calling `ensureKeypair`, which on a box with no keypair CREATES ONE: a
392
- * filesystem write, 0600, minting this machine's durable identity, from a
393
- * command whose entire job is to print a list. Listing must not enrol.
394
- *
395
- * It reads the stored file and nothing else. No sodium, no parse of the private
396
- * half, no creation. NULL for every failure — absent, unreadable, malformed —
397
- * and null simply means no row gets the "← this box" mark, which is honest: a
398
- * box that has never run a daemon has never polled, so it is not in the listing
399
- * to be marked. Claiming a row is you on a guess would be worse than the mark
400
- * being absent.
401
- */
402
- export function readStoredPubB64() {
403
- try {
404
- const stored = JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
405
- return typeof stored?.pub === 'string' && stored.pub ? stored.pub : null;
406
- } catch {
407
- return null;
408
- }
409
- }
410
-
411
- /**
412
- * THE IDENTITY PARAMS THE ROSTER POLL CARRIES — one key, and it is the box.
413
- *
414
- * It used to carry two more: `envv` (the materialized bundle version, which fed
415
- * the Settings "env vN" chip) and `envskip` (the target files the materializer
416
- * REFUSED to write, with the empty string as a real "measured, refused nothing"
417
- * report). Both were facts about the vault and both died with it; the server
418
- * stopped reading them in the same change.
419
- *
420
- * WHAT DID NOT CHANGE IS `envpub`: the same base64 of the same public key out
421
- * of the same file. Holdership arbitration, the displaced signal and the box
422
- * registry all key on it, so a box that upgrades to this release must be the
423
- * SAME box to the server it was before — no new row, no re-claim, no standby
424
- * of itself. That continuity is the point of this function still existing at
425
- * all.
426
- *
427
- * Absence keeps its reserved meaning: a poll with no `envpub` is EXEMPT from
428
- * arbitration rather than treated as an unknown box.
429
- */
430
- export async function envQueryParams() {
431
- await ensureKeypair();
432
- return { envpub: myPubB64() };
433
- }
80
+ // THE SCRUBBER'S ONE DOOR. The scan reads the checkout; `uplinkScrub.mjs`
81
+ // decides what of it to hide. They meet at this call and nowhere else.
82
+ import { learnSecrets } from './uplinkScrub.mjs';
434
83
 
435
84
  // ── The env comparison scan ────────────────────────────────────────────────
436
85
 
@@ -756,106 +405,22 @@ export function scanEnvFiles(repoRoot, salt) {
756
405
  * `{ files, filesTotal, varsTotal }`, the body `/fleet/env-report` posts.
757
406
  *
758
407
  * Separated from `scanEnvFiles` so the scan itself stays pure and testable: the
759
- * only impure things about this whole lane are one module-level assignment and
760
- * one read of `process.env`, and both live here where they can be read in four
761
- * lines.
408
+ * only impure things about this whole lane are the scrubber's rebuild and one
409
+ * read of `process.env`, and both live behind `learnSecrets`
410
+ * (`uplinkScrub.mjs`), which carries why the process environment is merged in
411
+ * and why an empty read does not clear what the scrubber already knows.
762
412
  *
763
413
  * THE SCRUBBER IS FED WHETHER OR NOT THE REPORT IS SENT. The report is deduped
764
414
  * against its own hash and posted at most once per change; redaction has no
765
415
  * such economy and must reflect the newest read every time, because a secret
766
416
  * added to `.env` five minutes ago is exactly the one a turn is about to echo.
767
417
  *
768
- * THE PROCESS ENVIRONMENT'S INFRA CREDENTIALS ARE MERGED IN HERE, and here is
769
- * the point: this is the one function every caller already goes through, so the
770
- * two lists cannot drift and nobody has to remember. They are derived from
771
- * `childEnv`'s own `DEPLOY_KEEP`, which is the set a deploy command is actually
772
- * handed — see `processEnvSecrets` for why they are redacted and never
773
- * reported. They are re-read on every scan rather than frozen at import,
774
- * because that costs nothing and an environment read once is a list that can
775
- * only be wrong.
776
- *
777
- * AN EMPTY READ DOES NOT CLEAR WHAT WE ALREADY KNOW, and this is the one place
778
- * the two halves deliberately disagree. A scan that comes back with nothing is
779
- * two different facts wearing one shape — the operator deleted `.env`, or this
780
- * pass could not read the directory (EACCES, a mid-sweep rename, a network
781
- * mount that blinked) — and `scanEnvFiles` swallows per-file errors by design
782
- * so the poll cannot throw. Replacing the list on an empty read means a blip
783
- * silently turns the redactor OFF, which is the exact failure mode the whole
784
- * repoint above exists to avoid, and it is invisible until a secret is already
785
- * in the server's database. Hence the separate `fileValues`: the process half
786
- * is refreshed unconditionally (process.env does not blink) while the file half
787
- * is only ever replaced by a read that found something.
788
- *
789
- * So an empty read leaves the previous list standing. The cost is
790
- * OVER-redaction — a value the operator has since removed or rotated keeps
791
- * being replaced with `[REDACTED:NAME]` for the life of this process — and
792
- * over-redacting is the safe direction, the same one every three-state readout
793
- * in this daemon takes. The REPORT half is unaffected: it returns the real
794
- * (empty) read, so the app is told what was actually measured.
418
+ * THE REPORT HALF IS THE REAL READ: an empty scan returns an empty report even
419
+ * while the scrubber keeps what it knew, so the app is told what was actually
420
+ * measured.
795
421
  */
796
422
  export function scanEnvForScrub(repoRoot, salt = PROJECT_ID) {
797
423
  const scanned = scanEnvFiles(repoRoot, salt);
798
- if (scanned.values.length) fileValues = scanned.values;
799
- // FILTERED ONCE, HERE — see the predicate's own block above: `scrub` is a hot
800
- // path and a value that can never match must not survive into its loop.
801
- values = [...processEnvSecrets(), ...fileValues].filter((v) => worthRedacting(v.value));
424
+ learnSecrets(scanned.values);
802
425
  return { files: scanned.files, filesTotal: scanned.filesTotal, varsTotal: scanned.varsTotal };
803
426
  }
804
-
805
- // ── Uplink scrubbing ───────────────────────────────────────────────────────
806
-
807
- /** Redact every known secret value from daemon-posted text. The list has
808
- * already been through `worthRedacting`, so this makes no decisions: an
809
- * ordinary word, a short flag and anything under the length floor are simply
810
- * not in it. */
811
- export function scrub(text) {
812
- if (typeof text !== 'string' || !text) return text;
813
- let out = text;
814
- for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
815
- return out.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
816
- }
817
-
818
- /**
819
- * EVERY FLOWVIANT CREDENTIAL, CAUGHT BY SHAPE (2026-09-24, the audit).
820
- *
821
- * The value lists above are the CHECKOUT'S secrets and the process
822
- * environment's, and neither holds the one secret that costs the project
823
- * itself: the MACHINE CREDENTIAL. It sits in `~/.flowviant/credentials.json` —
824
- * for EVERY project connected on this box, 0600 and readable by every turn,
825
- * since a turn runs as this uid — and the server mints more of the same shape
826
- * per turn (the work and capture tokens a tab's MCP server carries). A turn
827
- * that read the store and wrote it into an artifact, a trace or an answer
828
- * shipped it verbatim, because `scrub` had no value to match; and a design
829
- * artifact runs scripts in a frame that may navigate itself, so the page that
830
- * held it could carry it anywhere.
831
- *
832
- * Every one of them is `fva_` + 40 characters of nanoid (the server's
833
- * `AGENT_TOKEN_PREFIX`), so a SHAPE catches them all, including ones this
834
- * process never saw, with no store read and nothing to keep in sync. Twenty is
835
- * the floor so the 12-character prefix the app shows for identification
836
- * (`fva_` + 8) still reads as itself.
837
- */
838
- const FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
839
- const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
840
-
841
- /**
842
- * The NAME of the first known secret whose value appears in `bytes` as an
843
- * exact byte substring, or null. For the payloads `scrub` cannot touch: a
844
- * binary artifact (a PNG's text chunk, a PDF's uncompressed stream, a zip's
845
- * stored entry) is not text to rewrite — replacing bytes inside one corrupts
846
- * it — so the caller WITHHOLDS a hit rather than redacting it. The same list,
847
- * already filtered by `worthRedacting`, so the two can never disagree about
848
- * what counts as a secret. It sees only what is stored plainly: a value inside
849
- * a deflated stream is not a substring of the file, and that is stated where
850
- * it is used (artifacts.mjs).
851
- */
852
- export function secretIn(bytes) {
853
- if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
854
- for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
855
- // The token SHAPE too (see `scrub`). `latin1` maps every byte to one
856
- // character, so the ASCII pattern matches exactly where the bytes do.
857
- if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
858
- return FLOWVIANT_TOKEN_NAME;
859
- }
860
- return null;
861
- }