flowviant 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +99 -145
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/update.mjs
CHANGED
|
@@ -33,11 +33,12 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
33
33
|
import { createHash } from 'node:crypto';
|
|
34
34
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
35
35
|
import { chmod, mkdtemp, open, rename, rm } from 'node:fs/promises';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
36
37
|
import { dirname, join } from 'node:path';
|
|
37
38
|
import { platform, arch } from 'node:process';
|
|
38
39
|
import { pipeline } from 'node:stream/promises';
|
|
39
40
|
import { Readable } from 'node:stream';
|
|
40
|
-
import { runningViaNpx, terminalCommand } from './launchCommand.mjs';
|
|
41
|
+
import { relaunchSelf, runningCompiledBinary, runningViaNpx, selfArgv, terminalCommand } from './launchCommand.mjs';
|
|
41
42
|
import { VERSION } from './config.mjs';
|
|
42
43
|
import { compareVersions, versionBelow } from './versionOrder.mjs';
|
|
43
44
|
import { note, ok, warn } from './ui.mjs';
|
|
@@ -48,9 +49,40 @@ import { note, ok, warn } from './ui.mjs';
|
|
|
48
49
|
* never installs, never restarts and never nags on a version it cannot read.
|
|
49
50
|
*/
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
export
|
|
53
|
-
|
|
52
|
+
// Channel detection lives in launchCommand.mjs; re-exported for this module's callers.
|
|
53
|
+
export { runningCompiledBinary };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* NPM RUNS FROM OUTSIDE THE CHECKOUT (2026-10-10, F131). The daemon's working
|
|
57
|
+
* directory IS the repository (the person launched there, or `--dir` chdir'd
|
|
58
|
+
* to it), and npm loads a project's `.npmrc` from the nearest package root —
|
|
59
|
+
* its `registry=` and `node-options=` included — for `npm exec` and `npm view`.
|
|
60
|
+
* A change that reached base could then steer the unattended self-update to
|
|
61
|
+
* another registry or preload code into it, outside any agent posture. From
|
|
62
|
+
* the home directory npm reads only the operator's own user and global config,
|
|
63
|
+
* which is honoured as it is: no registry is forced.
|
|
64
|
+
*/
|
|
65
|
+
export function npmOutsideCheckout() {
|
|
66
|
+
return { cwd: homedir() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The npx relaunch's argv: the same flags, with the checkout named explicitly
|
|
71
|
+
* and ABSOLUTELY, because the relaunch no longer starts inside it. Any `--dir`
|
|
72
|
+
* pair already there is replaced — a relative one would now resolve against
|
|
73
|
+
* the home directory. `cwd` is this process's, which cli.mjs already moved to
|
|
74
|
+
* the `--dir` it was given; a bare start honours `--dir` (commandSpecs.mjs).
|
|
75
|
+
*/
|
|
76
|
+
export function npxRelaunchArgs(args = process.argv.slice(2), cwd = process.cwd()) {
|
|
77
|
+
const kept = [];
|
|
78
|
+
for (let i = 0; i < args.length; i++) {
|
|
79
|
+
if (args[i] === '--dir') {
|
|
80
|
+
i++; // and its value
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
kept.push(args[i]);
|
|
84
|
+
}
|
|
85
|
+
return ['-y', 'flowviant@latest', ...kept, '--dir', cwd];
|
|
54
86
|
}
|
|
55
87
|
|
|
56
88
|
const RELEASE_ORIGIN = 'https://api.flowviant.com';
|
|
@@ -115,9 +147,15 @@ export async function installBinaryUpdate({
|
|
|
115
147
|
* `npm i -g` overwrote the global package in place, so re-running argv[1] loads
|
|
116
148
|
* the NEW code. We tear down first (idle-gated, so nothing's mid-task) and keep
|
|
117
149
|
* this process alive only as a thin proxy waiting on the child, so the user's
|
|
118
|
-
* shell stays attached to one foreground process
|
|
150
|
+
* shell stays attached to one foreground process — the one relaunch proxy
|
|
151
|
+
* (launchCommand.mjs `relaunchSelf`), which passes signals on and reads a
|
|
152
|
+
* successor killed by one (the OOM killer) as a failure, so `Restart=on-failure`
|
|
153
|
+
* relaunches the machine instead of leaving it dark.
|
|
154
|
+
*
|
|
155
|
+
* `deps` (spawn, exit, signals) are relaunchSelf's test seams; the daemon
|
|
156
|
+
* passes none.
|
|
119
157
|
*/
|
|
120
|
-
function reexec(teardown, { viaNpx = false,
|
|
158
|
+
export function reexec(teardown, { viaNpx = false, target = null } = {}, deps = {}) {
|
|
121
159
|
try {
|
|
122
160
|
teardown?.();
|
|
123
161
|
} catch {
|
|
@@ -128,12 +166,16 @@ function reexec(teardown, { viaNpx = false, viaBinary = false, target = null } =
|
|
|
128
166
|
// to leave, and re-running it would reload the stale copy forever. Going back
|
|
129
167
|
// through `npx -y flowviant@latest` is what makes npx resolve `latest` against
|
|
130
168
|
// the registry and fetch the new one. `-y` because a restart must never stop
|
|
131
|
-
// on npx's install prompt — the same rule FLOWVIANT_REEXEC keeps below.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
169
|
+
// on npx's install prompt — the same rule FLOWVIANT_REEXEC keeps below. It
|
|
170
|
+
// runs from outside the checkout and names it with `--dir` (npmOutsideCheckout).
|
|
171
|
+
const [cmd, argv, where] = viaNpx
|
|
172
|
+
? ['npx', npxRelaunchArgs(), npmOutsideCheckout()]
|
|
173
|
+
: [process.execPath, selfArgv(process.argv.slice(2)), {}];
|
|
174
|
+
return relaunchSelf({
|
|
175
|
+
...deps,
|
|
176
|
+
cmd,
|
|
177
|
+
argv,
|
|
178
|
+
...where,
|
|
137
179
|
// MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
|
|
138
180
|
// stdio is inherited, so the child sees two TTYs and believes a human is
|
|
139
181
|
// watching — and 0.55.0 asks a one-time binding question on exactly that
|
|
@@ -163,7 +205,6 @@ function reexec(teardown, { viaNpx = false, viaBinary = false, target = null } =
|
|
|
163
205
|
FLOWVIANT_UPDATE_FROM: VERSION,
|
|
164
206
|
},
|
|
165
207
|
});
|
|
166
|
-
child.on('exit', (code) => process.exit(code ?? 0));
|
|
167
208
|
}
|
|
168
209
|
|
|
169
210
|
/**
|
|
@@ -184,7 +225,29 @@ export function updateRestartFailed(target) {
|
|
|
184
225
|
|
|
185
226
|
/** Install @latest globally. Throws on failure (EACCES without sudo, offline…). */
|
|
186
227
|
function installLatest() {
|
|
187
|
-
execFileSync('npm', ['install', '-g', 'flowviant@latest'], { stdio: 'inherit' });
|
|
228
|
+
execFileSync('npm', ['install', '-g', 'flowviant@latest'], { ...npmOutsideCheckout(), stdio: 'inherit' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* What npm says `flowviant@latest` is, or null when it cannot say (offline, an
|
|
233
|
+
* npm hiccup, a hung registry) — read from outside the checkout like every
|
|
234
|
+
* other npm call here. `exec` is the test seam.
|
|
235
|
+
*/
|
|
236
|
+
export function publishedVersion({ exec = execFileSync } = {}) {
|
|
237
|
+
try {
|
|
238
|
+
return String(
|
|
239
|
+
exec('npm', ['view', 'flowviant', 'version'], {
|
|
240
|
+
...npmOutsideCheckout(),
|
|
241
|
+
encoding: 'utf8',
|
|
242
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
243
|
+
// Synchronous, so it holds the event loop hostage for however long it
|
|
244
|
+
// runs — a hung registry must not become a hung daemon.
|
|
245
|
+
timeout: 10_000,
|
|
246
|
+
})
|
|
247
|
+
).trim();
|
|
248
|
+
} catch {
|
|
249
|
+
return null; // offline / npm hiccup — "can't confirm", skip this poll
|
|
250
|
+
}
|
|
188
251
|
}
|
|
189
252
|
|
|
190
253
|
/** `flowviant update` — explicit, manual update. Does not re-exec into a daemon
|
|
@@ -289,7 +352,7 @@ export async function handleVersionSignal({ latest, min, autoUpdate, safeToUpdat
|
|
|
289
352
|
try {
|
|
290
353
|
spawn(process.execPath, ['uninstall', '--others', '--yes', '--json'], { detached: true, stdio: 'ignore' }).unref();
|
|
291
354
|
} catch (e) { warn(`older-copy cleanup: ${e?.message ?? e}`); }
|
|
292
|
-
reexec(teardown, {
|
|
355
|
+
reexec(teardown, { target: installed });
|
|
293
356
|
return true;
|
|
294
357
|
} catch (e) {
|
|
295
358
|
lastInstallFailAt = Date.now();
|
|
@@ -339,18 +402,7 @@ export async function handleVersionSignal({ latest, min, autoUpdate, safeToUpdat
|
|
|
339
402
|
// the source of truth — only install if npm ACTUALLY has something newer than
|
|
340
403
|
// us, else `npm i -g @latest` reinstalls our own version and we'd re-exec
|
|
341
404
|
// forever.
|
|
342
|
-
|
|
343
|
-
try {
|
|
344
|
-
published = execFileSync('npm', ['view', 'flowviant', 'version'], {
|
|
345
|
-
encoding: 'utf8',
|
|
346
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
347
|
-
// Synchronous, so it holds the event loop hostage for however long it
|
|
348
|
-
// runs — a hung registry must not become a hung daemon.
|
|
349
|
-
timeout: 10_000,
|
|
350
|
-
}).trim();
|
|
351
|
-
} catch {
|
|
352
|
-
/* offline / npm hiccup — treat as "can't confirm", skip this poll */
|
|
353
|
-
}
|
|
405
|
+
const published = publishedVersion();
|
|
354
406
|
// An unreadable answer from npm is "can't confirm", like no answer at all.
|
|
355
407
|
const publishedOrder = published ? compareVersions(published, cur) : null;
|
|
356
408
|
if (publishedOrder === null || publishedOrder <= 0) {
|
package/bin/lib/uplinkScrub.mjs
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
* the PRESENT values of `childEnv`'s `DEPLOY_KEEP` names out of this process's
|
|
11
11
|
* own environment, which is where an operator's `CLOUDFLARE_API_TOKEN`
|
|
12
12
|
* actually lives and which the deploy lane hands to a command whose stdout it
|
|
13
|
-
* then streams to the server.
|
|
13
|
+
* then streams to the server. (Three more feeds joined on 2026-10-10: the
|
|
14
|
+
* person's Codex provider secrets, the nested env files a deploy job copies in
|
|
15
|
+
* for that job's life, and a short list of vendor token SHAPES — see
|
|
16
|
+
* `learnSecrets`, `learnExtraSecrets` and `TOKEN_SHAPES`.) It redacts on
|
|
17
|
+
* SHAPE, never on meaning: a value
|
|
14
18
|
* that looks like an ordinary word is left alone, because a relay that swaps
|
|
15
19
|
* the CLI's own words for `[REDACTED:NODE_ENV]` has stopped being a relay.
|
|
16
20
|
* NOTHING HERE EVER REACHES THE WIRE — it is a list of what to hide, not a
|
|
@@ -30,6 +34,9 @@
|
|
|
30
34
|
// two drifting; `processEnvSecrets`'s docblock carries the whole argument,
|
|
31
35
|
// including why none of it ever reaches the wire.
|
|
32
36
|
import { processEnvSecrets } from './childEnv.mjs';
|
|
37
|
+
// THE PERSON'S CODEX PROVIDER SECRETS (2026-10-10, the audit's F073), read on
|
|
38
|
+
// the same beat as the process half — see `learnSecrets`.
|
|
39
|
+
import { readPersonalCodexSecrets } from './codexPersonal.mjs';
|
|
33
40
|
|
|
34
41
|
// ── What `scrub` will redact ───────────────────────────────────────────────
|
|
35
42
|
//
|
|
@@ -76,25 +83,36 @@ const SCRUB_MIN_LENGTH = 6;
|
|
|
76
83
|
* than meaning (nothing here classifies, guesses, or asks a model):
|
|
77
84
|
*
|
|
78
85
|
* · A PLAIN IDENTIFIER — starts with a letter, runs at most sixteen
|
|
79
|
-
* characters, and is made only of letters, digits and `.` `/`
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
86
|
+
* characters, and is made only of letters, digits and `.` `_` `/` `-`, and
|
|
87
|
+
* does not mix upper case, lower case AND digits (the case-and-digit rule
|
|
88
|
+
* in `worthRedacting`, below). That is what a word, a hostname, a short
|
|
89
|
+
* version string, an enum value and a snake_case name all look like. The
|
|
90
|
+
* underscore IS in the set (`SCRUB_PLAIN_RE`) and has been since the first
|
|
91
|
+
* public release: `db_pass_2024` and `abc_defghijk` are plain and left as
|
|
92
|
+
* they are. (This comment said until 2026-10-10 that `_` was deliberately
|
|
93
|
+
* left out of the set as the mark of a minted string; the code never did
|
|
94
|
+
* that, the public pages — SECURITY.md and the app's Privacy page — follow
|
|
95
|
+
* the code, and dropping `_` is an open owner call that needs a dated
|
|
96
|
+
* ruling, a release and both pages changed.)
|
|
84
97
|
* · A SHORT VALUE WITH LITTLE VARIETY — under twelve characters and drawing on
|
|
85
98
|
* fewer than three of {lower, upper, digit, symbol}. A real credential short
|
|
86
99
|
* enough to be under twelve characters is mixed; `us-east-1` and `verbose`
|
|
87
100
|
* are not.
|
|
88
101
|
*
|
|
102
|
+
* Anything under `SCRUB_MIN_LENGTH` (six) is left alone before either test.
|
|
103
|
+
*
|
|
89
104
|
* THE ACCEPTED COST, STATED RATHER THAN HIDDEN: a genuinely secret value that
|
|
90
|
-
* is short and
|
|
91
|
-
* password
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
105
|
+
* is plain or short and unvaried — `hunter2secret`, a thirteen-character
|
|
106
|
+
* lowercase-and-digit password, or `deadbeef12345678`, a sixteen-character
|
|
107
|
+
* lowercase hex key that starts with a letter — is NOT redacted. Under-redaction is chosen here over making the product
|
|
108
|
+
* illegible, because the alternative is a trace nobody can read and a product
|
|
109
|
+
* that silently rewrites its own CLI's output. An operator whose secret is a
|
|
110
|
+
* dictionary word has a problem this function cannot fix. What IS redacted is
|
|
111
|
+
* a value of six or more characters that is not a plain identifier (longer
|
|
112
|
+
* than sixteen, a character outside the set, a leading non-letter, or an
|
|
113
|
+
* upper-lower-digit mix) and is twelve or more characters or draws on three
|
|
114
|
+
* of {lower, upper, digit, symbol} — which covers the long tokens, keys,
|
|
115
|
+
* URLs, JWTs and hex digests people actually paste into a `.env`.
|
|
98
116
|
*/
|
|
99
117
|
const SCRUB_PLAIN_RE = /^[A-Za-z][A-Za-z0-9._/-]{0,15}$/;
|
|
100
118
|
function characterClasses(value) {
|
|
@@ -112,7 +130,7 @@ function worthRedacting(value) {
|
|
|
112
130
|
// password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
|
|
113
131
|
// character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
|
|
114
132
|
// [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
|
|
115
|
-
//
|
|
133
|
+
// the block's promise then that "a case mix" is redacted. A word, a hostname,
|
|
116
134
|
// a region or a version string does not draw on upper, lower AND digits at
|
|
117
135
|
// once; a minted secret almost always does.
|
|
118
136
|
if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
|
|
@@ -144,7 +162,8 @@ function worthRedacting(value) {
|
|
|
144
162
|
* ── TWO SOURCES, KEPT IN TWO VARIABLES (2026-09-21, the review) ──
|
|
145
163
|
*
|
|
146
164
|
* `values` is what `scrub` reads and is rebuilt on every scan as
|
|
147
|
-
* `processEnvSecrets()` ++ `fileValues
|
|
165
|
+
* `processEnvSecrets()` ++ `fileValues` (++ the Codex and per-job halves below,
|
|
166
|
+
* since 2026-10-10 — see `rebuild`). The split exists because the two halves
|
|
148
167
|
* have opposite failure modes. The CHECKOUT half can come back empty for two
|
|
149
168
|
* different reasons — the operator deleted `.env`, or this pass could not read
|
|
150
169
|
* the directory — so an empty read must NOT replace it (see `learnSecrets`).
|
|
@@ -164,7 +183,46 @@ function worthRedacting(value) {
|
|
|
164
183
|
* because the filter is about redaction and the read is about what is on disk.
|
|
165
184
|
*/
|
|
166
185
|
let fileValues = [];
|
|
167
|
-
|
|
186
|
+
/**
|
|
187
|
+
* THE PERSON'S CODEX PROVIDER SECRETS (2026-10-10, the audit's F073) — the
|
|
188
|
+
* selected provider's `experimental_bearer_token` and literal `http_headers`
|
|
189
|
+
* out of `config.toml`, plus the present values its `env_key` and
|
|
190
|
+
* `env_http_headers` name (`readPersonalCodexSecrets`). A fenced Codex turn
|
|
191
|
+
* carries the literals in its own environment, where `env` prints them, and
|
|
192
|
+
* every posture can read `~/.codex/config.toml` itself — so they are learned
|
|
193
|
+
* WHATEVER the posture, and a turn that prints them relays a redaction.
|
|
194
|
+
*
|
|
195
|
+
* Kept apart for the file half's reason: the read is a FILE, which can blink,
|
|
196
|
+
* so only a read that found something replaces it (see `learnSecrets`). Not
|
|
197
|
+
* read at import — that would be I/O on every import of this module; the
|
|
198
|
+
* daemon's first `learnSecrets` runs before its first poll (fleet.mjs).
|
|
199
|
+
*/
|
|
200
|
+
let codexValues = [];
|
|
201
|
+
/**
|
|
202
|
+
* PER-JOB EXTRAS (2026-10-10, the audit's F085) — `key → [{ name, value }]`,
|
|
203
|
+
* added and removed by their owner (`learnExtraSecrets` / `forgetExtraSecrets`)
|
|
204
|
+
* and never touched by the 60s rescan. The deploy lane registers the nested
|
|
205
|
+
* env files it copied into a job's worktree here for that job's life.
|
|
206
|
+
*/
|
|
207
|
+
const extraValues = new Map();
|
|
208
|
+
let values = [];
|
|
209
|
+
rebuild();
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* THE ONE PLACE `values` IS BUILT, from every half above (2026-10-10).
|
|
213
|
+
*
|
|
214
|
+
* LONGEST FIRST: `scrub` replaces values one after another, so a short value
|
|
215
|
+
* that sits INSIDE a longer one (a password inside a connection URL) was
|
|
216
|
+
* replaced first whenever the scan happened to list it first, and the longer
|
|
217
|
+
* value — now holding a marker where its substring was — no longer matched,
|
|
218
|
+
* leaving the rest of it in plain text. Replacing the longer value first
|
|
219
|
+
* removes both.
|
|
220
|
+
*/
|
|
221
|
+
function rebuild() {
|
|
222
|
+
values = [...processEnvSecrets(), ...codexValues, ...fileValues, ...[...extraValues.values()].flat()]
|
|
223
|
+
.filter((v) => worthRedacting(v.value))
|
|
224
|
+
.sort((a, b) => b.value.length - a.value.length);
|
|
225
|
+
}
|
|
168
226
|
|
|
169
227
|
/**
|
|
170
228
|
* HAND THE SCRUBBER A FRESH READ OF THE CHECKOUT'S `.env*` VALUES —
|
|
@@ -199,24 +257,87 @@ let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
|
|
|
199
257
|
* in this daemon takes. The REPORT half is the scan's, and is unaffected: it
|
|
200
258
|
* returns the real (empty) read, so the app is told what was actually measured.
|
|
201
259
|
*/
|
|
202
|
-
export function learnSecrets(scannedValues) {
|
|
260
|
+
export function learnSecrets(scannedValues, codexSecrets = readCodexHalf()) {
|
|
203
261
|
if (scannedValues.length) fileValues = scannedValues;
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
262
|
+
// THE CODEX HALF RIDES THE SAME BEAT (2026-10-10), under the file half's
|
|
263
|
+
// rule: `config.toml` is a file, so a read that found nothing — the person
|
|
264
|
+
// removed the provider, or the read blinked — leaves what was learned.
|
|
265
|
+
if (Array.isArray(codexSecrets) && codexSecrets.length) codexValues = codexSecrets;
|
|
266
|
+
// FILTERED ONCE, IN `rebuild` — see the predicate's own block above: `scrub`
|
|
267
|
+
// is a hot path and a value that can never match must not survive into its
|
|
268
|
+
// loop.
|
|
269
|
+
rebuild();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The Codex half's read, which must never cost the file half its beat: the
|
|
273
|
+
* read swallows its own I/O errors, and anything else it could throw reads
|
|
274
|
+
* as "nothing new" (2026-10-10). */
|
|
275
|
+
function readCodexHalf() {
|
|
276
|
+
try {
|
|
277
|
+
return readPersonalCodexSecrets();
|
|
278
|
+
} catch {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A SECOND, ADDITIVE DOOR, OWNED BY WHOEVER OPENED IT (2026-10-10, the audit's
|
|
285
|
+
* F085).
|
|
286
|
+
*
|
|
287
|
+
* `learnSecrets` REPLACES the file half on every read that found something,
|
|
288
|
+
* so a value fed through it a second time would be wiped by the next 60s root
|
|
289
|
+
* scan. The deploy lane copies NESTED env files (`apps/api/.dev.vars`,
|
|
290
|
+
* `apps/web/.env.production`) into the worktree its build runs in — files the
|
|
291
|
+
* root-only scan deliberately never reads (env.mjs) — and then relays that
|
|
292
|
+
* build's output. It knows exactly which files it copied, so it teaches their
|
|
293
|
+
* values here under its job's key and forgets them when the job's checkout is
|
|
294
|
+
* gone. Filtered by `worthRedacting` like every other half; nothing here
|
|
295
|
+
* reaches the wire.
|
|
296
|
+
*/
|
|
297
|
+
export function learnExtraSecrets(key, list) {
|
|
298
|
+
extraValues.set(key, (Array.isArray(list) ? list : []).filter((v) => typeof v?.value === 'string'));
|
|
299
|
+
rebuild();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Drop what `learnExtraSecrets` was taught under `key`. */
|
|
303
|
+
export function forgetExtraSecrets(key) {
|
|
304
|
+
if (extraValues.delete(key)) rebuild();
|
|
207
305
|
}
|
|
208
306
|
|
|
209
307
|
// ── Uplink scrubbing ───────────────────────────────────────────────────────
|
|
210
308
|
|
|
211
|
-
/** Redact every known secret value
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* not in it. */
|
|
309
|
+
/** Redact every known secret value, then every known token SHAPE, from
|
|
310
|
+
* daemon-posted text. The value list has already been through
|
|
311
|
+
* `worthRedacting`, so this makes no decisions: an ordinary word, a short flag
|
|
312
|
+
* and anything under the length floor are simply not in it. */
|
|
215
313
|
export function scrub(text) {
|
|
216
314
|
if (typeof text !== 'string' || !text) return text;
|
|
217
315
|
let out = text;
|
|
218
316
|
for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
|
|
219
|
-
return out
|
|
317
|
+
return redactShapes(out);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* SCRUB THE WHOLE VALUE, THEN CUT IT (2026-10-10, the audit's F072/F143).
|
|
322
|
+
*
|
|
323
|
+
* `scrub` matches EXACT values, so a credential that straddles a cap and is cut
|
|
324
|
+
* FIRST arrives in two halves that match nothing, and the surviving half ships.
|
|
325
|
+
* This daemon has shipped that order and recorded learning it more than once
|
|
326
|
+
* (workAgentCheck.mjs, agentPlan.mjs's `progressOf`, runtimeEvents.mjs's
|
|
327
|
+
* `toolEventOf`). These two make the right order the one that is spelled: the
|
|
328
|
+
* whole field is scrubbed, and only the result is cut — so a replacement that
|
|
329
|
+
* grows a string can never push it past `cap` either. `fn` is the scrubber,
|
|
330
|
+
* injectable for the parsers that take one as a parameter.
|
|
331
|
+
*/
|
|
332
|
+
export function scrubCut(text, cap, fn = scrub) {
|
|
333
|
+
return String(fn(String(text ?? ''))).slice(0, cap);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** `scrubCut`'s tail form — the LAST `cap` characters of the scrubbed text,
|
|
337
|
+
* for output whose end says why it failed. */
|
|
338
|
+
export function scrubTail(text, cap, fn = scrub) {
|
|
339
|
+
const out = String(fn(String(text ?? '')));
|
|
340
|
+
return cap > 0 ? out.slice(-cap) : '';
|
|
220
341
|
}
|
|
221
342
|
|
|
222
343
|
/**
|
|
@@ -238,10 +359,142 @@ export function scrub(text) {
|
|
|
238
359
|
* process never saw, with no store read and nothing to keep in sync. Twenty is
|
|
239
360
|
* the floor so the 12-character prefix the app shows for identification
|
|
240
361
|
* (`fva_` + 8) still reads as itself.
|
|
362
|
+
*
|
|
363
|
+
* ONE SPELLING OF THE PREFIX, HELD BY THE RELEASE GATE (2026-10-10, audit
|
|
364
|
+
* F146). The pattern and the byte probe are both built from
|
|
365
|
+
* `FLOWVIANT_TOKEN_PREFIX`, and `scripts/check-app-parity.mjs` holds it to the
|
|
366
|
+
* app's `AGENT_TOKEN_PREFIX` and the floor to the length the app mints: a
|
|
367
|
+
* renamed token family would otherwise pass through every redaction here,
|
|
368
|
+
* silently, until a credential sat in a trace.
|
|
241
369
|
*/
|
|
242
|
-
const
|
|
370
|
+
export const FLOWVIANT_TOKEN_PREFIX = 'fva_';
|
|
371
|
+
export const FLOWVIANT_TOKEN_FLOOR = 20;
|
|
372
|
+
const FLOWVIANT_TOKEN_RE = new RegExp(`${FLOWVIANT_TOKEN_PREFIX}[A-Za-z0-9_-]{${FLOWVIANT_TOKEN_FLOOR},}`, 'g');
|
|
243
373
|
const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
|
|
244
374
|
|
|
375
|
+
/**
|
|
376
|
+
* THE VENDOR LOGINS, CAUGHT BY SHAPE TOO (2026-10-10, the audit's F014).
|
|
377
|
+
*
|
|
378
|
+
* The value lists hold what this box was TOLD about — `.env` files, a few named
|
|
379
|
+
* environment variables, the Codex config. They never held the logins the CLIs
|
|
380
|
+
* themselves keep: Claude Code's OAuth pair in `~/.claude/.credentials.json`
|
|
381
|
+
* (`sk-ant-oat…` / `sk-ant-ort…`), an Anthropic or OpenAI key a shell profile
|
|
382
|
+
* exports under a name nobody listed, Codex's `auth.json` tokens, `gh auth
|
|
383
|
+
* token`'s `gho_…`, a GitHub App's `ghs_…` installation token in a remote URL.
|
|
384
|
+
* A turn that printed one — `cat ~/.claude/.credentials.json`, `gh auth token`
|
|
385
|
+
* — relayed it verbatim into a trace every member of the project reads. So the
|
|
386
|
+
* shapes the vendors document are redacted wherever they appear, whether or
|
|
387
|
+
* not this process ever saw the value.
|
|
388
|
+
*
|
|
389
|
+
* REDACTION MASKS, IT NEVER WITHHOLDS: the line is still relayed, with the
|
|
390
|
+
* token replaced. Each floor is long enough that an ordinary word sharing the
|
|
391
|
+
* prefix (`sk-learn`, `ghost_town`, a `github_pat_` mentioned in prose) is left
|
|
392
|
+
* alone — the canaries in uplinkScrub.test.mjs pin one per shape. Order
|
|
393
|
+
* matters: `sk-ant-` is named before the wider `sk-` rule.
|
|
394
|
+
*/
|
|
395
|
+
const TOKEN_SHAPES = [
|
|
396
|
+
{ name: FLOWVIANT_TOKEN_NAME, re: FLOWVIANT_TOKEN_RE, needle: FLOWVIANT_TOKEN_PREFIX },
|
|
397
|
+
{ name: 'ANTHROPIC_TOKEN', re: /sk-ant-[A-Za-z0-9_-]{20,}/g, needle: 'sk-ant-' },
|
|
398
|
+
{ name: 'OPENAI_API_KEY', re: /\bsk-[A-Za-z0-9_-]{32,}/g, needle: 'sk-' },
|
|
399
|
+
// No word boundary on GitHub's shapes, like `fva_` (2026-10-10, F056): a
|
|
400
|
+
// token after a JSON-escaped newline (`\nghs_…`) or inside URL encoding
|
|
401
|
+
// (`%3Aghs_…`) follows a word character, and a hosted box's installation
|
|
402
|
+
// token reaches gh from a file, so no learned value ever holds it.
|
|
403
|
+
{ name: 'GITHUB_TOKEN', re: /gh[pousr]_[A-Za-z0-9]{30,}/g, needle: '_' },
|
|
404
|
+
{ name: 'GITHUB_TOKEN', re: /github_pat_[A-Za-z0-9_]{40,}/g, needle: 'github_pat_' },
|
|
405
|
+
{ name: 'AWS_ACCESS_KEY_ID', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, needle: 'A' },
|
|
406
|
+
{ name: 'SLACK_TOKEN', re: /\bxox[abpr]-[A-Za-z0-9-]{20,}/g, needle: 'xox' },
|
|
407
|
+
];
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* AN OAUTH TOKEN IN A JSON FIELD — Codex's `auth.json` (`access_token`,
|
|
411
|
+
* `refresh_token`, `id_token`: JWTs with no vendor prefix) and Claude's
|
|
412
|
+
* credential file (`accessToken`, `refreshToken`). The key is kept and only
|
|
413
|
+
* the value is replaced, so the line still reads as what it was. Also matched
|
|
414
|
+
* with escaped quotes, as the field reads once it is itself inside a JSON
|
|
415
|
+
* string (a tool result relayed whole). A value something above already
|
|
416
|
+
* redacted is left as it is.
|
|
417
|
+
*/
|
|
418
|
+
const OAUTH_FIELD_RE =
|
|
419
|
+
/(\\?"(?:access_token|refresh_token|id_token|accessToken|refreshToken|idToken)\\?"\s*:\s*\\?")(?!\[REDACTED:)[^"\\]{20,}/g;
|
|
420
|
+
const OAUTH_FIELD_NAME = 'OAUTH_TOKEN';
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* A PEM PRIVATE KEY — a `-----BEGIN … PRIVATE KEY-----` block (`cat
|
|
424
|
+
* ~/.ssh/id_ed25519`, a service-account JSON's `private_key`). The whole block
|
|
425
|
+
* is replaced: from BEGIN to END when everything between is key material
|
|
426
|
+
* (base64, line breaks, the legacy `Proc-Type`/`DEK-Info` headers) holding at
|
|
427
|
+
* least one 16-character base64 run; or, when no END follows (a `head` of the
|
|
428
|
+
* file), the BEGIN line and the base64 lines straight after it. A BEGIN line
|
|
429
|
+
* alone is a header named in prose, and is left alone.
|
|
430
|
+
*
|
|
431
|
+
* LINEAR, ON PURPOSE. A regex with a lazy body between BEGIN and END rescans
|
|
432
|
+
* the rest of the text from every BEGIN that has no END — quadratic on a text
|
|
433
|
+
* of nothing but BEGIN lines, the freeze `candidateObjects` was hardened
|
|
434
|
+
* against. So the three forward searches each keep a cursor that only moves
|
|
435
|
+
* forward, and the block regexes are sticky.
|
|
436
|
+
*/
|
|
437
|
+
const PEM_BEGIN_RE = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----/g;
|
|
438
|
+
const PEM_END_RE = /-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/g;
|
|
439
|
+
const PEM_NOT_BODY_RE = /[^A-Za-z0-9+/=\s\\:,.-]/g;
|
|
440
|
+
const PEM_RUN_RE = /[A-Za-z0-9+/=]{16}/g;
|
|
441
|
+
const PEM_LINES_RE = /(?:(?:\r?\n|\\r\\n|\\n)[ \t]*[A-Za-z0-9+/=]+)+/y;
|
|
442
|
+
const PEM_MAX_BODY = 32 * 1024;
|
|
443
|
+
const PEM_NAME = 'PRIVATE_KEY';
|
|
444
|
+
|
|
445
|
+
function redactPem(text) {
|
|
446
|
+
if (!text.includes('PRIVATE KEY-----')) return text;
|
|
447
|
+
let out = '';
|
|
448
|
+
let at = 0;
|
|
449
|
+
// Cursors: the first END / non-key character / 16-char run at or after the
|
|
450
|
+
// position each was last searched from (-1: none in the rest of the text).
|
|
451
|
+
let end = -2;
|
|
452
|
+
let endLen = 0;
|
|
453
|
+
let bad = -2;
|
|
454
|
+
let run = -2;
|
|
455
|
+
const after = (re, cursor, from) => {
|
|
456
|
+
if (cursor === -1 || cursor >= from) return cursor;
|
|
457
|
+
re.lastIndex = from;
|
|
458
|
+
const m = re.exec(text);
|
|
459
|
+
return m ? m.index : -1;
|
|
460
|
+
};
|
|
461
|
+
PEM_BEGIN_RE.lastIndex = 0;
|
|
462
|
+
for (let m; (m = PEM_BEGIN_RE.exec(text)); ) {
|
|
463
|
+
const body = m.index + m[0].length;
|
|
464
|
+
let stop = -1;
|
|
465
|
+
if (end !== -1 && end < body) {
|
|
466
|
+
PEM_END_RE.lastIndex = body;
|
|
467
|
+
const e = PEM_END_RE.exec(text);
|
|
468
|
+
end = e ? e.index : -1;
|
|
469
|
+
endLen = e ? e[0].length : 0;
|
|
470
|
+
}
|
|
471
|
+
if (end >= 0 && end - body <= PEM_MAX_BODY) {
|
|
472
|
+
bad = after(PEM_NOT_BODY_RE, bad, body);
|
|
473
|
+
run = after(PEM_RUN_RE, run, body);
|
|
474
|
+
if ((bad === -1 || bad >= end) && run >= 0 && run + 16 <= end) stop = end + endLen;
|
|
475
|
+
}
|
|
476
|
+
if (stop < 0) {
|
|
477
|
+
PEM_LINES_RE.lastIndex = body;
|
|
478
|
+
const lines = PEM_LINES_RE.exec(text);
|
|
479
|
+
if (lines && /[A-Za-z0-9+/=]{16}/.test(lines[0])) stop = body + lines[0].length;
|
|
480
|
+
}
|
|
481
|
+
if (stop < 0) continue;
|
|
482
|
+
out += `${text.slice(at, m.index)}[REDACTED:${PEM_NAME}]`;
|
|
483
|
+
at = stop;
|
|
484
|
+
PEM_BEGIN_RE.lastIndex = stop;
|
|
485
|
+
}
|
|
486
|
+
return at === 0 ? text : out + text.slice(at);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function redactShapes(text) {
|
|
490
|
+
let out = text;
|
|
491
|
+
for (const s of TOKEN_SHAPES) {
|
|
492
|
+
if (out.includes(s.needle)) out = out.replace(s.re, `[REDACTED:${s.name}]`);
|
|
493
|
+
}
|
|
494
|
+
if (out.includes('oken')) out = out.replace(OAUTH_FIELD_RE, `$1[REDACTED:${OAUTH_FIELD_NAME}]`);
|
|
495
|
+
return redactPem(out);
|
|
496
|
+
}
|
|
497
|
+
|
|
245
498
|
/**
|
|
246
499
|
* The NAME of the first known secret whose value appears in `bytes` as an
|
|
247
500
|
* exact byte substring, or null. For the payloads `scrub` cannot touch: a
|
|
@@ -256,10 +509,22 @@ const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
|
|
|
256
509
|
export function secretIn(bytes) {
|
|
257
510
|
if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
|
|
258
511
|
for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
|
|
259
|
-
// The token
|
|
260
|
-
// character, so
|
|
261
|
-
|
|
262
|
-
|
|
512
|
+
// The token SHAPES too (see `scrub`). `latin1` maps every byte to one
|
|
513
|
+
// character, so an ASCII pattern matches exactly where the bytes do. Fresh,
|
|
514
|
+
// non-global copies: a `/g` regex's `test` keeps `lastIndex` between calls.
|
|
515
|
+
let text = null;
|
|
516
|
+
for (const s of TOKEN_SHAPES) {
|
|
517
|
+
if (!bytes.includes(s.needle)) continue;
|
|
518
|
+
text ??= bytes.toString('latin1');
|
|
519
|
+
if (new RegExp(s.re.source).test(text)) return s.name;
|
|
520
|
+
}
|
|
521
|
+
if (bytes.includes('oken')) {
|
|
522
|
+
text ??= bytes.toString('latin1');
|
|
523
|
+
if (new RegExp(OAUTH_FIELD_RE.source).test(text)) return OAUTH_FIELD_NAME;
|
|
524
|
+
}
|
|
525
|
+
if (bytes.includes('PRIVATE KEY-----')) {
|
|
526
|
+
text ??= bytes.toString('latin1');
|
|
527
|
+
if (redactPem(text) !== text) return PEM_NAME;
|
|
263
528
|
}
|
|
264
529
|
return null;
|
|
265
530
|
}
|