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.
Files changed (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +99 -145
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -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
- /** Bun marks compiled entrypoints with a virtual /$bunfs/ script path. */
52
- export function runningCompiledBinary() {
53
- return Boolean(process.versions.bun && process.argv[1]?.startsWith('/$bunfs/'));
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, viaBinary = false, target = null } = {}) {
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
- const [cmd, args] = viaNpx
133
- ? ['npx', ['-y', 'flowviant@latest', ...process.argv.slice(2)]]
134
- : [process.execPath, process.argv.slice(viaBinary ? 2 : 1)];
135
- const child = spawn(cmd, args, {
136
- stdio: 'inherit',
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, { viaBinary: true, target: installed });
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
- let published = null;
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) {
@@ -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. It redacts on SHAPE, never on meaning: a value
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 `.` `/` `-`. That is
80
- * what a word, a hostname, a short version string and an enum value all look
81
- * like. `_` is deliberately NOT in the set: an underscore is rare in prose
82
- * and common in key material (`sk-live_…`), so its presence is the cheapest
83
- * honest signal that a string was minted rather than written.
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 lowercase — `hunter2secret`, a thirteen-character all-letters
91
- * password — is NOT redacted. Under-redaction is chosen here over making the
92
- * product illegible, because the alternative is a trace nobody can read and a
93
- * product that silently rewrites its own CLI's output. An operator whose secret
94
- * is a dictionary word has a problem this function cannot fix. Anything with a
95
- * symbol, a case mix, twelve-plus characters, or an underscore — which is every
96
- * token, key, URL, JWT and hex digest anybody actually pastes into a `.env` —
97
- * is redacted.
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
- // this block's own promise that "a case mix" is redacted. A word, a hostname,
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`. The split exists because the two halves
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
- let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
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
- // FILTERED ONCE, HERE — see the predicate's own block above: `scrub` is a hot
205
- // path and a value that can never match must not survive into its loop.
206
- values = [...processEnvSecrets(), ...fileValues].filter((v) => worthRedacting(v.value));
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 from daemon-posted text. The list has
212
- * already been through `worthRedacting`, so this makes no decisions: an
213
- * ordinary word, a short flag and anything under the length floor are simply
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.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
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 FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
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 SHAPE too (see `scrub`). `latin1` maps every byte to one
260
- // character, so the ASCII pattern matches exactly where the bytes do.
261
- if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
262
- return FLOWVIANT_TOKEN_NAME;
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
  }