flowviant 0.116.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 +71 -139
  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
@@ -9,35 +9,108 @@
9
9
  * that never launches a browser.
10
10
  *
11
11
  * It renders the SCRUBBED bytes, never the checkout's source, under the
12
- * artifact box's own network fence (`PREVIEW_CSP`) placed before any
13
- * model-written markup executes. A failed measurement is explicit
14
- * (`renderState: 'unavailable'`); a daemon older than this simply sends no
15
- * `renderState`, which the server reads as unmeasured.
12
+ * artifact box's own network fence (`PREVIEW_CSP`). A failed measurement is
13
+ * explicit (`renderState: 'unavailable'`); a daemon older than this simply
14
+ * sends no `renderState`, which the server reads as unmeasured.
15
+ *
16
+ * MODEL-WRITTEN HTML IS NEVER A TOP-LEVEL `file://` DOCUMENT (audit
17
+ * 2026-10-10, F657/F756). It used to be: the bytes went to a temp file and
18
+ * Chrome opened that file, and a file page may navigate to any other file —
19
+ * so a meta refresh to `~/.flowviant/credentials.json` turned the uploaded
20
+ * PNG into a picture of every machine credential on the box. The page is now
21
+ * served once, over loopback http, from a listener on 127.0.0.1 at a random
22
+ * port and an unguessable path, with the fence as a response header (Chrome
23
+ * refuses an http page's navigation to `file://`), and the capture
24
+ * (`pinnedCapture.mjs`) refuses every navigation away from that URL. A render
25
+ * whose final document is not the served page is 'unavailable', and no
26
+ * preview is uploaded. The listener closes when the capture is done.
27
+ *
28
+ * `PREVIEW_CSP` is the served `ARTIFACT_CSP_BASE` (apps/api
29
+ * artifactDelivery.ts) minus `navigate-to` and `webrtc`, which Chrome does
30
+ * not enforce, so the thumbnail renders what the app renders:
31
+ * the same sandbox, and blob workers (`worker-src blob:`, added 2026-10-10 —
32
+ * a three.js/DRACO page rendered blank here and whole in the app). Exported
33
+ * for the release parity gate.
16
34
  */
17
35
 
18
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
36
+ import { createServer } from 'node:http';
37
+ import { randomBytes } from 'node:crypto';
38
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
19
39
  import { join } from 'node:path';
20
40
  import { tmpdir } from 'node:os';
21
- import { pathToFileURL } from 'node:url';
22
- import { captureScreenshot } from './shot.mjs';
41
+ import { capturePinnedPage } from './pinnedCapture.mjs';
23
42
 
24
43
  export const DESIGN_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
25
- const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
44
+ export const PREVIEW_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; worker-src blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
45
+
46
+ /** The https sources PREVIEW_CSP names — the only hosts a subresource of the
47
+ * page may come from, read from the policy so the two cannot drift. */
48
+ const CSP_SOURCES = [...new Set(PREVIEW_CSP.match(/https:\/\/[^\s;]+/g) ?? [])].map((s) => new URL(s));
49
+
50
+ /** CSP host-source matching for the sources above: the same host over https,
51
+ * and a path ending in `/` is a prefix while any other path is exact. */
52
+ export function previewSubresourceAllowed(raw) {
53
+ let u;
54
+ try {
55
+ u = new URL(raw);
56
+ } catch {
57
+ return false;
58
+ }
59
+ if (u.protocol !== 'https:') return false;
60
+ return CSP_SOURCES.some((s) =>
61
+ s.host === u.host &&
62
+ (s.pathname === '/' || (s.pathname.endsWith('/') ? u.pathname.startsWith(s.pathname) : u.pathname === s.pathname)));
63
+ }
64
+
65
+ /** Serve `bytes` at one random path on 127.0.0.1, with the fence as a
66
+ * header. Resolves `{ url, close }`. */
67
+ function servePage(bytes) {
68
+ const path = `/${randomBytes(16).toString('hex')}.html`;
69
+ const server = createServer((req, res) => {
70
+ if ((req.method === 'GET' || req.method === 'HEAD') && req.url === path) {
71
+ res.writeHead(200, {
72
+ 'Content-Type': 'text/html; charset=utf-8',
73
+ 'Content-Security-Policy': PREVIEW_CSP,
74
+ 'Cache-Control': 'no-store',
75
+ 'X-Content-Type-Options': 'nosniff',
76
+ });
77
+ res.end(req.method === 'HEAD' ? undefined : bytes);
78
+ return;
79
+ }
80
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
81
+ res.end();
82
+ });
83
+ return new Promise((resolve, reject) => {
84
+ server.once('error', reject);
85
+ server.listen(0, '127.0.0.1', () => {
86
+ const { port } = server.address();
87
+ resolve({
88
+ url: `http://127.0.0.1:${port}${path}`,
89
+ close: () => {
90
+ server.closeAllConnections?.();
91
+ server.close();
92
+ },
93
+ });
94
+ });
95
+ });
96
+ }
26
97
 
27
98
  /** Render the scrubbed HTML, never the checkout's unsanitized source. A
28
- * failed browser measurement is explicit; older daemon reports are absent. */
29
- export async function renderDesignPreview(bytes, capture = captureScreenshot) {
99
+ * failed browser measurement is explicit; older daemon reports are absent. */
100
+ export async function renderDesignPreview(bytes, capture = capturePinnedPage) {
30
101
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-design-'));
102
+ let page = null;
31
103
  try {
32
- const html = join(dir, 'design.html');
33
104
  const png = join(dir, 'design.png');
34
- // A fresh Chrome profile has no Flowviant credentials. Put the artifact
35
- // box's network fence before any model-written markup executes.
36
- writeFileSync(html, Buffer.concat([
37
- Buffer.from(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`),
38
- bytes,
39
- ]));
40
- const result = await capture({ url: pathToFileURL(html).href, out: png, width: 720, height: 450, timeoutMs: 15_000 });
105
+ page = await servePage(bytes);
106
+ const result = await capture({
107
+ url: page.url,
108
+ out: png,
109
+ width: 720,
110
+ height: 450,
111
+ timeoutMs: 15_000,
112
+ allowSubresource: previewSubresourceAllowed,
113
+ });
41
114
  if (!result.ok) return { renderState: 'unavailable' };
42
115
  const image = readFileSync(png);
43
116
  if (image.byteLength > DESIGN_PREVIEW_MAX_BYTES || !image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) {
@@ -47,6 +120,7 @@ export async function renderDesignPreview(bytes, capture = captureScreenshot) {
47
120
  } catch {
48
121
  return { renderState: 'unavailable' };
49
122
  } finally {
123
+ page?.close();
50
124
  rmSync(dir, { recursive: true, force: true });
51
125
  }
52
126
  }
@@ -12,10 +12,16 @@
12
12
  *
13
13
  * ── DELIVERY ──
14
14
  *
15
- * A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
16
- * older SERVER 404s the whole route, and holding bodies it will refuse forever
17
- * would be the trace relay's wedge wearing a retry's clothes; a refusal of one
18
- * file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
15
+ * A 2xx is delivered. A considered 4xx (fleetWire.mjs's `isConsideredRefusal`:
16
+ * other than 408/429) is TREATED AS DELIVERED — an older SERVER 404s the whole
17
+ * route, and holding bodies it will refuse forever would be the trace relay's
18
+ * wedge wearing a retry's clothes; a refusal of one file (ended session, wrong
19
+ * owner) is equally permanent. EXCEPT 401 AND 403, which are a "later"
20
+ * (2026-10-10 ruling, audit F071): a credential blip — a rotation, a box that
21
+ * is momentarily not the holder — or an edge 403 (Cloudflare's WAF on a large
22
+ * HTML mockup, authReject.mjs's whole subject) says nothing about this BODY,
23
+ * and settling on one lost the artifact for good. The same argument as
24
+ * fleetReports.mjs's `envReportIsPermanent`. A 5xx, 408, 429, 401, 403 or a
19
25
  * network error keeps the STORED BODY — the fields and the bytes as they were
20
26
  * scrubbed — and retries it on the next report beat, the settle's shape, at
21
27
  * most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
@@ -24,7 +30,7 @@
24
30
  * `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
25
31
  */
26
32
 
27
- import { fleetEndpoint } from './fleetWire.mjs';
33
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
28
34
 
29
35
  /** A held upload is tried this many times in all, then dropped with a line. */
30
36
  const MAX_TRIES = 4;
@@ -65,9 +71,11 @@ export function createArtifactReporter({
65
71
  signal: AbortSignal.timeout(30_000),
66
72
  body: form,
67
73
  });
68
- return (
69
- res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
70
- );
74
+ if (res.ok) return true;
75
+ // A credential blip or an edge page is a "later", bounded by MAX_TRIES
76
+ // (2026-10-10 ruling — see the header).
77
+ if (res.status === 401 || res.status === 403) return false;
78
+ return isConsideredRefusal(res.status);
71
79
  } catch {
72
80
  return false;
73
81
  }
@@ -78,6 +78,7 @@ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdi
78
78
  import { createHash } from 'node:crypto';
79
79
  import { join } from 'node:path';
80
80
  import { cutKeepingExtension } from './safeFileName.mjs';
81
+ import { ensureRealSubdir, flowviantParentState, realDirState } from './ownedDirs.mjs';
81
82
  import {
82
83
  ARTIFACT_BINARY_MODEL_EXTS,
83
84
  ARTIFACT_BINARY_MODEL_MAX_BYTES,
@@ -119,23 +120,9 @@ export const ARTIFACT_MAX_FILES = ARTIFACT_MAX_PER_OWNER;
119
120
  * refuses the turn in words.
120
121
  */
121
122
  export function ensureArtifactDir(placeDir) {
122
- const parent = join(placeDir, '.flowviant');
123
- const dir = join(placeDir, ARTIFACT_DIR);
124
- const real = (p) => {
125
- try {
126
- const st = lstatSync(p);
127
- return st.isDirectory() && !st.isSymbolicLink() ? true : false;
128
- } catch (e) {
129
- return e?.code === 'ENOENT' ? null : false;
130
- }
131
- };
132
- if (real(parent) === false || real(dir) === false) return false;
133
- try {
134
- mkdirSync(dir, { recursive: true });
135
- } catch {
136
- return false;
137
- }
138
- return real(parent) === true && real(dir) === true;
123
+ // The rule's one home since 2026-10-10 (ownedDirs.mjs, the audit's F100):
124
+ // refused, never replaced, and both components checked again after the make.
125
+ return ensureRealSubdir(placeDir, ARTIFACT_DIR);
139
126
  }
140
127
 
141
128
  /**
@@ -159,25 +146,16 @@ export function ensureArtifactDir(placeDir) {
159
146
  */
160
147
  export const FENCE_TMP_DIR = '.flowviant/tmp';
161
148
  export function ensureFenceScratch(placeDir) {
162
- const parent = join(placeDir, '.flowviant');
163
149
  const dir = join(placeDir, FENCE_TMP_DIR);
164
- const kind = (p) => {
165
- try {
166
- const st = lstatSync(p);
167
- return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
168
- } catch (e) {
169
- return e?.code === 'ENOENT' ? 'none' : 'other';
170
- }
171
- };
172
- if (kind(parent) !== 'dir' || kind(dir) === 'other') return false;
150
+ if (flowviantParentState(placeDir) !== 'dir' || realDirState(dir) === 'other') return false;
173
151
  try {
174
152
  // A real directory (checked above): its contents go, never through a link.
175
- if (kind(dir) === 'dir') rmSync(dir, { recursive: true, force: true });
153
+ if (realDirState(dir) === 'dir') rmSync(dir, { recursive: true, force: true });
176
154
  mkdirSync(dir);
177
155
  } catch {
178
156
  return false;
179
157
  }
180
- return kind(parent) === 'dir' && kind(dir) === 'dir';
158
+ return flowviantParentState(placeDir) === 'dir' && realDirState(dir) === 'dir';
181
159
  }
182
160
 
183
161
  export function artifactTypeFor(name) {
@@ -187,46 +165,72 @@ export function artifactTypeFor(name) {
187
165
  }
188
166
 
189
167
  /**
190
- * The directory's regular files, newest first, at most `ARTIFACT_MAX_FILES`.
191
- * `[]` for a directory that does not exist, is not a directory, or is a
192
- * symlink — nothing to report is the only honest reading of any of those.
168
+ * HOW DEEP A BUNDLE GOES: files are listed to depth four (a path of up to five
169
+ * components), so a directory found AT depth four is never looked inside.
193
170
  */
194
- export function scanArtifacts(placeDir) {
171
+ export const ARTIFACT_MAX_DEPTH = 4;
172
+
173
+ /**
174
+ * THE ONE WALK OF THE ARTIFACTS DIRECTORY (2026-10-10, the audit's F067) —
175
+ * what the scan lists and what the check's fence keeps are read off the same
176
+ * entries, so the two cannot disagree about what an artifact is again. They
177
+ * did: the scan learned folders in 0.99.0 (a bundle's sidecars, the 3D-model
178
+ * page's `<name>/index.html`) and the fence went on removing every
179
+ * subdirectory whole, so a model agent whose repo declared a check lost its
180
+ * page the moment its queue emptied — and a send-back turn that did not
181
+ * rewrite it settled "nothing".
182
+ *
183
+ * `visit({ name, path, kind, st })` is called for every entry, depth first.
184
+ * `name` is the relative path; `kind` is one of:
185
+ * 'dir' — a real directory within depth: walked next
186
+ * 'file' — a regular file (any type: the allowlist is each caller's)
187
+ * 'hidden' — a dot-name, whatever it is; never looked at further
188
+ * 'deep' — a real directory past the depth: nothing in it is ever listed
189
+ * 'other' — a symlink, a fifo, a socket, a device: never followed or read
190
+ *
191
+ * `lstat` and never followed, and BOTH components checked first — `lstat` on
192
+ * `.flowviant/artifacts` refuses a symlink AT that name, but it resolves
193
+ * THROUGH a symlinked `.flowviant`, and git commits symlinks: a cloned repo
194
+ * carrying `.flowviant -> /some/dir` once had the scan listing, reading and
195
+ * uploading `/some/dir/artifacts/*` (2026-09-23). False, having visited
196
+ * nothing, when either is not a real directory.
197
+ */
198
+ export function walkArtifactDir(placeDir, visit) {
195
199
  const dir = join(placeDir, ARTIFACT_DIR);
196
- /**
197
- * BOTH components are checked, not only the last (2026-09-23). `lstat` on
198
- * `.flowviant/artifacts` refuses a symlink AT that name, but it resolves
199
- * THROUGH a symlinked `.flowviant` — and git commits symlinks, so a cloned
200
- * repo carrying `.flowviant -> /some/dir` would have had this scan listing,
201
- * reading and uploading `/some/dir/artifacts/*`, a directory outside the
202
- * place the turn stood in. The knowledge sync already refuses a `.flowviant`
203
- * that is not a real directory (`flowviantDirOk`); this is the same check on
204
- * the read side.
205
- */
206
- try {
207
- const parent = lstatSync(join(placeDir, '.flowviant'));
208
- if (parent.isSymbolicLink() || !parent.isDirectory()) return [];
209
- if (!lstatSync(dir).isDirectory()) return [];
210
- } catch {
211
- return [];
212
- }
213
- const out = [];
214
- const walk = (at, prefix = '', depth = 0) => {
215
- if (depth > 4) return;
200
+ if (flowviantParentState(placeDir) !== 'dir' || realDirState(dir) !== 'dir') return false;
201
+ const walk = (at, prefix, depth) => {
216
202
  let names;
217
203
  try { names = readdirSync(at); } catch { return; }
218
204
  for (const part of names) {
219
- if (part.startsWith('.')) continue;
220
205
  const name = prefix ? `${prefix}/${part}` : part;
221
206
  const path = join(at, part);
222
- try {
223
- const st = lstatSync(path);
224
- if (st.isDirectory() && !st.isSymbolicLink()) walk(path, name, depth + 1);
225
- else if (st.isFile() && !st.isSymbolicLink()) out.push({ name, path, size: st.size, mtimeMs: st.mtimeMs });
226
- } catch { /* vanished */ }
207
+ let st = null;
208
+ let kind = 'hidden';
209
+ if (!part.startsWith('.')) {
210
+ try { st = lstatSync(path); } catch { continue; /* vanished */ }
211
+ kind = st.isSymbolicLink() ? 'other'
212
+ : st.isDirectory() ? (depth < ARTIFACT_MAX_DEPTH ? 'dir' : 'deep')
213
+ : st.isFile() ? 'file'
214
+ : 'other';
215
+ }
216
+ visit({ name, path, kind, st });
217
+ if (kind === 'dir') walk(path, name, depth + 1);
227
218
  }
228
219
  };
229
- walk(dir);
220
+ walk(dir, '', 0);
221
+ return true;
222
+ }
223
+
224
+ /**
225
+ * The directory's regular files, newest first, at most `ARTIFACT_MAX_FILES`.
226
+ * `[]` for a directory that does not exist, is not a directory, or is a
227
+ * symlink — nothing to report is the only honest reading of any of those.
228
+ */
229
+ export function scanArtifacts(placeDir) {
230
+ const out = [];
231
+ walkArtifactDir(placeDir, ({ name, path, kind, st }) => {
232
+ if (kind === 'file') out.push({ name, path, size: st.size, mtimeMs: st.mtimeMs });
233
+ });
230
234
  out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
231
235
  return out.slice(0, ARTIFACT_MAX_FILES);
232
236
  }
@@ -230,7 +230,8 @@ export function startAuthProxy({
230
230
  return;
231
231
  }
232
232
  // 4. The grant is a cookie, and a cookie can be ridden (header rule 4a's
233
- // backstop). Never a bounce — the caller HAS a credential; the request
233
+ // backstop) — and so can a cached Basic password, by a cross-site write
234
+ // (2026-10-10). Never a bounce — the caller HAS a credential; the request
234
235
  // SHAPE is what is refused.
235
236
  if (verdict === 'cross-site') {
236
237
  res.writeHead(403, { 'Content-Type': 'text/plain', ...noStore });
@@ -28,6 +28,9 @@
28
28
  * surface may describe anything here as "sandboxed" or "isolated".
29
29
  */
30
30
  import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
31
+ // The variables that can carry Claude's auth in from the environment — the
32
+ // one list claudeAuth.mjs reads presence from (2026-10-10, the audit's F073).
33
+ import { AUTH_ENV_VARS } from './claudeAuth.mjs';
31
34
 
32
35
  /**
33
36
  * The complete kept set. Two groups, and both are here for a reason that bit
@@ -203,7 +206,8 @@ export const DEPLOY_KEEP_NAMES = [...DEPLOY_KEEP];
203
206
  * other half. Deriving it FROM that array is what makes forgetting impossible,
204
207
  * and it is why the merge happens inside `learnSecrets` rather than at a
205
208
  * call site somebody has to remember. The dependency direction is the only one
206
- * that is acyclic besides: this module imports nothing at all.
209
+ * that is acyclic besides: this module imports only two leaves (the name lists
210
+ * in machineEnv.mjs and claudeAuth.mjs), neither of which imports back.
207
211
  *
208
212
  * TWO NAMES ARE HERE THAT ARE NOT IN `DEPLOY_KEEP`, and the asymmetry is the
209
213
  * point. `CF_API_TOKEN` and `CF_ACCOUNT_ID` are wrangler's older spellings — it
@@ -249,9 +253,10 @@ const REDACT_ONLY = [
249
253
  // of (machineEnv.mjs): a name removed from children is redacted by
250
254
  // construction, never by a second hand-kept copy.
251
255
  ...MACHINE_CREDENTIAL_ENV,
252
- 'ANTHROPIC_API_KEY',
253
- 'ANTHROPIC_AUTH_TOKEN',
254
- 'CLAUDE_CODE_OAUTH_TOKEN',
256
+ // Every Claude auth variable, from the list claudeAuth.mjs reports presence
257
+ // from — derived, not re-listed, for the same reason as the line above
258
+ // (2026-10-10, the audit's F073).
259
+ ...AUTH_ENV_VARS,
255
260
  'OPENAI_API_KEY',
256
261
  'GH_TOKEN',
257
262
  'GITHUB_TOKEN',
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * The Flowviant side of driving a coding CLI that is not a posture, a stream
3
- * or a process: the sentinel protocol (a line the contract prompts require a
4
- * turn to end on) and the hand-over of the flowviant MCP server to whichever
5
- * runtime runs the turn. The hard rule baked into the prompts: there is no
6
- * interactive user — the only channel to a human is the blocker loop.
3
+ * or a process: the sentinel protocol (a line the wiki prompts require a turn
4
+ * to end on — WIKI_DONE, REGROUND_DONE) and the hand-over of the flowviant MCP
5
+ * server to whichever runtime runs the turn.
7
6
  *
8
7
  * This file used to hold everything about a turn. It was split by reason to
9
8
  * change (SOLID 2026-09-26): the contract prompts live in prompts.mjs, the
@@ -11,22 +10,21 @@
11
10
  * claudeStream.mjs, and the spawn-and-supervise of one turn in runTurn.mjs.
12
11
  * The argv, the binary and the event shape of each vendor come from each
13
12
  * CLI's registry row (runtime{Claude,Codex,Antigravity}.mjs, assembled in
14
- * runtimes.mjs; the stream's meaning in runtimeEvents.mjs).
13
+ * runtimes.mjs; the stream's meaning in runtimeEvents.mjs). The generic
14
+ * `sleep` that used to live here is a leaf of its own (sleep.mjs, 2026-10-10):
15
+ * importing it from here loaded the whole runtime registry into `login`.
15
16
  */
16
17
 
17
18
  import { runtimeById } from './runtimes.mjs';
18
19
 
19
- export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
20
-
21
20
  // Sentinels must appear on their OWN line (the prompts require it). Substring
22
21
  // matching falsely fired when an agent merely *mentioned* the word in prose
23
- // (e.g. "I won't fabricate a BLOCKED:<id> line"), trapping the worker in a fake
24
- // blocked loop. Anchor to a full line instead.
22
+ // (e.g. "I won't print WIKI_DONE until the pages are written"), ending a turn
23
+ // that had not finished. Anchor to a full line instead.
25
24
  //
26
25
  // LINE-SPLIT, NEVER A `^...$` REGEX WITH A `\s*` ON BOTH ENDS (audit
27
- // 2026-09-24). `new RegExp('^\\s*NAME\\s*$', 'm')` and
28
- // `/^\s*BLOCKED:(\S+)\s*$/m` are QUADRATIC against a long run of
29
- // whitespace on one line — the same shape `taskIdsFromMessage` already had
26
+ // 2026-09-24). `new RegExp('^\\s*NAME\\s*$', 'm')` is QUADRATIC against a
27
+ // long run of whitespace on one line — the same shape `taskIdsFromMessage` already had
30
28
  // to fix, measured ~0.5s at 40KB here — and a CLI's own stdout is exactly
31
29
  // the kind of text a stray control sequence or a pasted blob can grow past
32
30
  // that. A length cap first, then plain string work, same as there.
@@ -35,14 +33,6 @@ export const sawSentinel = (out, name) =>
35
33
  String(out ?? '')
36
34
  .split('\n')
37
35
  .some((l) => l.length < SENTINEL_MAX_LINE && l.trim() === name);
38
- export const blockedId = (out) => {
39
- for (const l of String(out ?? '').split('\n')) {
40
- if (l.length > SENTINEL_MAX_LINE) continue;
41
- const t = l.trim();
42
- if (t.startsWith('BLOCKED:') && /^BLOCKED:\S+$/.test(t)) return t.slice(8);
43
- }
44
- return null;
45
- };
46
36
 
47
37
  /**
48
38
  * Hand a runtime the flowviant MCP server, however that runtime wants it.
@@ -33,10 +33,22 @@
33
33
  * later CLI adds — a sandbox auto-allow, a permission mode — never rides by
34
34
  * default. It rides INSIDE the posture's own `--settings`, because the CLI
35
35
  * reads one: given two, only the last one's hook fired (measured).
36
+ *
37
+ * …AND NEVER ON THE COMMAND LINE (2026-10-10, the audit). What is kept is
38
+ * exactly where a person puts ANTHROPIC_API_KEY, an auth token or Bedrock's
39
+ * AWS keys (`env`, `apiKeyHelper`), and argv is every local user's to read
40
+ * (`ps`, /proc/<pid>/cmdline) for the life of the turn. So the merged object
41
+ * is written to a file only this user can read — 0600, in a 0700
42
+ * `~/.flowviant/run/` — and `--settings` names its path, which the CLI reads
43
+ * as a settings file ("--settings <file-or-json>"). Still exactly one
44
+ * `--settings`. The file goes when the turn settles (runTurn.mjs asks the
45
+ * adapter's `releaseArgs`), and a leftover from a process that is gone is
46
+ * swept on the next write.
36
47
  */
37
- import { readFileSync, statSync } from 'node:fs';
48
+ import { randomBytes } from 'node:crypto';
49
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
38
50
  import { homedir } from 'node:os';
39
- import { join } from 'node:path';
51
+ import { basename, dirname, join } from 'node:path';
40
52
  import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
41
53
  import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
42
54
 
@@ -129,17 +141,70 @@ export function personalClaudeSettings(env = process.env) {
129
141
  return out;
130
142
  }
131
143
 
144
+ /** Where a fenced turn's settings file lives — the daemon's own state
145
+ * directory, private to this user. */
146
+ export const turnSettingsDir = () => join(homedir(), '.flowviant', 'run');
147
+ /** A settings file of ours: the writing process's pid, then a random tag. */
148
+ const TURN_SETTINGS_FILE = /^settings-(\d+)-[0-9a-f]{16}\.json$/;
149
+
150
+ const pidAlive = (pid) => {
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ } catch (error) {
155
+ return error?.code === 'EPERM';
156
+ }
157
+ };
158
+
159
+ /**
160
+ * Write `settings` to a fresh 0600 file in the run directory and return its
161
+ * path. The directory is made 0700 (and tightened if it was not), and must be
162
+ * a real directory — a link standing there is refused, never written through.
163
+ * A file another process left behind is removed first if that process is gone;
164
+ * this process's own files are live turns'. THROWS when it cannot write: the
165
+ * turn then fails in words rather than carrying the person's settings on argv.
166
+ */
167
+ export function writeTurnSettings(settings, { dir = turnSettingsDir() } = {}) {
168
+ try {
169
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
170
+ if (!lstatSync(dir).isDirectory()) throw new Error(`${dir} is not a directory`);
171
+ chmodSync(dir, 0o700);
172
+ for (const name of readdirSync(dir)) {
173
+ const m = TURN_SETTINGS_FILE.exec(name);
174
+ if (!m || Number(m[1]) === process.pid || pidAlive(Number(m[1]))) continue;
175
+ rmSync(join(dir, name), { force: true });
176
+ }
177
+ const path = join(dir, `settings-${process.pid}-${randomBytes(8).toString('hex')}.json`);
178
+ writeFileSync(path, JSON.stringify(settings), { mode: 0o600, flag: 'wx' });
179
+ return path;
180
+ } catch (error) {
181
+ throw new Error(`cannot write this turn's Claude settings file under ${dir} (${error?.message ?? error}) — the person's settings never ride the command line`);
182
+ }
183
+ }
184
+
185
+ /** Remove the settings file a turn's argv names, when it is one of ours. */
186
+ export function releaseTurnSettings(args, { dir = turnSettingsDir() } = {}) {
187
+ if (!Array.isArray(args)) return;
188
+ args.forEach((a, i) => {
189
+ if (i === 0 || args[i - 1] !== '--settings' || typeof a !== 'string') return;
190
+ if (dirname(a) !== dir || !TURN_SETTINGS_FILE.test(basename(a))) return;
191
+ rmSync(a, { force: true });
192
+ });
193
+ }
194
+
132
195
  /**
133
196
  * `perm` with `kept` folded into its `--settings` — the posture's keys win, so
134
197
  * a kept key a posture ever names is the fence's word. A posture with no
135
198
  * `--settings` gets one before its lists (`--allowedTools` is variadic). With
136
- * nothing kept, `perm` is returned as it was.
199
+ * nothing kept, `perm` is returned as it was (the posture's own read guard
200
+ * names no secret). With something kept, the one `--settings` names the
201
+ * merged object's file (`write`, writeTurnSettings) and never carries it.
137
202
  */
138
- export function withPersonalSettings(perm, kept) {
203
+ export function withPersonalSettings(perm, kept, { write = writeTurnSettings } = {}) {
139
204
  const personal = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
140
205
  if (!Object.keys(personal).length) return [...perm];
141
206
  const at = perm.indexOf('--settings');
142
- if (at < 0) return ['--settings', JSON.stringify(personal), ...perm];
207
+ if (at < 0) return ['--settings', write(personal), ...perm];
143
208
  const posture = JSON.parse(perm[at + 1]);
144
- return [...perm.slice(0, at), '--settings', JSON.stringify({ ...personal, ...posture }), ...perm.slice(at + 2)];
209
+ return [...perm.slice(0, at), '--settings', write({ ...personal, ...posture }), ...perm.slice(at + 2)];
145
210
  }