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.
- 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 +71 -139
- 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
|
@@ -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`)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
29
|
-
export async function renderDesignPreview(bytes, capture =
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 (
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
191
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
}
|
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -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)
|
|
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 });
|
package/bin/lib/childEnv.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
'
|
|
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',
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
* runtime runs the turn.
|
|
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
|
|
24
|
-
//
|
|
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')`
|
|
28
|
-
//
|
|
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 {
|
|
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',
|
|
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',
|
|
209
|
+
return [...perm.slice(0, at), '--settings', write({ ...personal, ...posture }), ...perm.slice(at + 2)];
|
|
145
210
|
}
|