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.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/env.mjs
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
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
|
-
*
|
|
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
|
|
36
|
-
* MATERIALIZED `.env` files into every session
|
|
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
|
|
63
|
-
*
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
100
|
-
//
|
|
101
|
-
|
|
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
|
|
760
|
-
*
|
|
761
|
-
*
|
|
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
|
|
769
|
-
* the
|
|
770
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|