flowviant 0.97.0 → 0.100.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 +15 -13
- package/bin/cli.mjs +108 -28
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +36 -9
- package/bin/lib/credentials.mjs +29 -5
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +121 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +129 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +36 -12
- package/bin/lib/machines.mjs +24 -14
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +114 -25
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1151 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -3
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
* into a flag), a newline. A read-only turn runs ONE plain command; every
|
|
39
39
|
* one of these is a second command or a redirect wearing the first one's
|
|
40
40
|
* permission.
|
|
41
|
+
* · shell syntax that REWRITES A WORD after this guard read it — braces,
|
|
42
|
+
* parentheses and glob characters (`{ } ( ) * ? [`). `--outp{u,u}t=x` is
|
|
43
|
+
* `--output=x` by the time git sees it, and the substring tests below can
|
|
44
|
+
* only judge the word as written. (Quoted or not: the normalisation above
|
|
45
|
+
* has already dropped the quotes, so a quoted `*` is refused too — the
|
|
46
|
+
* over-refusal the paragraph above accepts.)
|
|
41
47
|
* · the git arguments above, as substrings (so `--config` covers
|
|
42
48
|
* `--config-env`, `--output` covers `--output-directory`), plus a bare `-c`
|
|
43
49
|
* token and any token beginning `-O`.
|
|
@@ -75,6 +81,24 @@ const SHELL = [
|
|
|
75
81
|
['$', 'an expansion'],
|
|
76
82
|
['\n', 'a second line'],
|
|
77
83
|
['\r', 'a second line'],
|
|
84
|
+
// SHELL SYNTAX THE SUBSTRING TESTS BELOW DO NOT MODEL (2026-09-24, the
|
|
85
|
+
// audit). The shell rewrites a word AFTER this guard has read it, so a banned
|
|
86
|
+
// flag can be ASSEMBLED past a substring check: `--outp{u,u}t=x` and
|
|
87
|
+
// `-{c,c}` brace-expand into `--output=x` and `-c`, and `git log -1
|
|
88
|
+
// --outp{u,u}t=pwned.txt` wrote the file with this guard returning null
|
|
89
|
+
// (measured, git 2.55). Globs do the same with a planted file name —
|
|
90
|
+
// `-?` expands to a file called `-c` — and zsh adds glob QUALIFIERS
|
|
91
|
+
// (`*(e:…:)`, which evaluate code). None of them is something a single
|
|
92
|
+
// plain read needs, so the guard refuses the syntax rather than trying to
|
|
93
|
+
// expand it the way some shell would: over-refusal is the safe direction,
|
|
94
|
+
// and the Glob tool is the tool-shaped way to match names.
|
|
95
|
+
['{', 'brace expansion'],
|
|
96
|
+
['}', 'brace expansion'],
|
|
97
|
+
['(', 'a subshell or glob qualifier'],
|
|
98
|
+
[')', 'a subshell or glob qualifier'],
|
|
99
|
+
['*', 'a glob'],
|
|
100
|
+
['?', 'a glob'],
|
|
101
|
+
['[', 'a glob'],
|
|
78
102
|
];
|
|
79
103
|
|
|
80
104
|
/**
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -97,7 +97,7 @@ import {
|
|
|
97
97
|
import { homedir, platform } from 'node:os';
|
|
98
98
|
import { join } from 'node:path';
|
|
99
99
|
import { createHash } from 'node:crypto';
|
|
100
|
-
import { VERSION } from './config.mjs';
|
|
100
|
+
import { VERSION, PROCESS_STARTED_AT } from './config.mjs';
|
|
101
101
|
|
|
102
102
|
/** Deliberately a HASH: a credential must never become a filename. */
|
|
103
103
|
export function instanceLockPath(fleetToken) {
|
|
@@ -139,11 +139,56 @@ function readHolder(path) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** Read-only desktop measurement. No lock is unknown because an unguarded or
|
|
143
|
+
* older daemon may still run; a stale lock with a dead pid measures empty. */
|
|
144
|
+
export function daemonRunningFor(fleetToken) {
|
|
145
|
+
const path = instanceLockPath(fleetToken);
|
|
146
|
+
let raw;
|
|
147
|
+
try { raw = readFileSync(path, 'utf8'); }
|
|
148
|
+
catch { return null; }
|
|
149
|
+
let holder;
|
|
150
|
+
try { holder = JSON.parse(raw); } catch { return null; }
|
|
151
|
+
if (!Number.isInteger(holder?.pid) || holder.pid <= 0) return null;
|
|
152
|
+
if (!alive(holder.pid)) return false;
|
|
153
|
+
return stillTheHolder(holder);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* argv[1] AS IT WAS TYPED, when that differs from the resolved form.
|
|
158
|
+
*
|
|
159
|
+
* Node resolves `process.argv[1]` to an absolute path, while `/proc/<pid>/cmdline`
|
|
160
|
+
* and `ps` show the argv the process was actually given. A daemon started as
|
|
161
|
+
* `node bin/cli.mjs` (a dev checkout, `npm start`, a Docker CMD with a WORKDIR)
|
|
162
|
+
* therefore recorded `/abs/bin/cli.mjs` and was matched against `node
|
|
163
|
+
* bin/cli.mjs`, judged stale WHILE ALIVE — a second start ran beside it and
|
|
164
|
+
* `flowviant stop` signalled nothing. The raw token is recorded beside `entry`
|
|
165
|
+
* so both spellings identify the process. Linux only: `ps` joins argv with
|
|
166
|
+
* spaces, so no token can be recovered from it reliably, and there the absolute
|
|
167
|
+
* form plus the start-time check stand as before.
|
|
168
|
+
*/
|
|
169
|
+
function rawEntry() {
|
|
170
|
+
if (platform() !== 'linux') return null;
|
|
171
|
+
try {
|
|
172
|
+
const tokens = readFileSync('/proc/self/cmdline', 'utf8').split('\0');
|
|
173
|
+
const raw = tokens[1 + process.execArgv.length];
|
|
174
|
+
return raw && raw !== process.argv[1] ? raw : null;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
const record = (repoRoot) =>
|
|
143
181
|
JSON.stringify({
|
|
144
182
|
pid: process.pid,
|
|
145
183
|
repoRoot,
|
|
146
|
-
|
|
184
|
+
// THE PROCESS'S START, not the moment the lock was written. The start path
|
|
185
|
+
// can wait on a person at the picker or the binding confirm for as long as
|
|
186
|
+
// they take (there is no answer time limit), and a lock line written three
|
|
187
|
+
// minutes after the process began put a LIVE daemon outside the 120s window
|
|
188
|
+
// startedAroundLockWrite brackets — so a second start cleared its "stale"
|
|
189
|
+
// lock and ran beside it, and stop and disconnect skipped it. The window now
|
|
190
|
+
// measures clock granularity only, whatever a prompt cost.
|
|
191
|
+
startedAt: PROCESS_STARTED_AT,
|
|
147
192
|
// The script we were started from, and what we are. A takeover matches the
|
|
148
193
|
// live command line against `entry` before signalling anything — a lock
|
|
149
194
|
// records a PID, and a crashed daemon's PID can be reused by anything.
|
|
@@ -151,9 +196,68 @@ const record = (repoRoot) =>
|
|
|
151
196
|
// matched on the holder's process START TIME instead; stillTheHolder says
|
|
152
197
|
// why that is the weaker of the two claims and still strong enough.
|
|
153
198
|
entry: process.argv[1] || '',
|
|
199
|
+
...(rawEntry() ? { entryRaw: rawEntry() } : {}),
|
|
154
200
|
version: VERSION,
|
|
155
201
|
});
|
|
156
202
|
|
|
203
|
+
/** Up to this many parents are walked looking for the daemon we re-exec'd from.
|
|
204
|
+
* `npx -y flowviant@latest` puts `npm exec` (and a `sh -c`) between the old
|
|
205
|
+
* daemon and the new one; eight is several times that chain. */
|
|
206
|
+
const ANCESTOR_DEPTH = 8;
|
|
207
|
+
|
|
208
|
+
/** The parent pid of `pid`, or null where it cannot be read. */
|
|
209
|
+
function parentOf(pid) {
|
|
210
|
+
try {
|
|
211
|
+
if (platform() === 'linux') {
|
|
212
|
+
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
213
|
+
const close = raw.lastIndexOf(')');
|
|
214
|
+
if (close < 0) return null;
|
|
215
|
+
const ppid = Number.parseInt(raw.slice(close + 1).trim().split(/\s+/)[1], 10);
|
|
216
|
+
return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
|
|
217
|
+
}
|
|
218
|
+
if (platform() === 'darwin') {
|
|
219
|
+
const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
|
|
220
|
+
encoding: 'utf8',
|
|
221
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
222
|
+
timeout: 3000,
|
|
223
|
+
});
|
|
224
|
+
const ppid = Number.parseInt(out.trim(), 10);
|
|
225
|
+
return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
/* unreadable — ancestry unknown */
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* IS THIS HOLDER THE DAEMON THAT RE-EXEC'D US? — the self-update case.
|
|
235
|
+
*
|
|
236
|
+
* The direct parent is the global-install path: update.mjs spawns
|
|
237
|
+
* `process.execPath` itself, so the successor's ppid IS the holder. Under npx
|
|
238
|
+
* — the documented launch — the successor is spawned by `npm exec`, which the
|
|
239
|
+
* old daemon spawned, so the holder is a GRANDPARENT and a ppid check read it
|
|
240
|
+
* as a rival: the new daemon SIGTERMed its own proxy, the foreground process
|
|
241
|
+
* exited 143 while the successor ran on detached (or, with --no-takeover, both
|
|
242
|
+
* exited and the machine went dark after every update). So update.mjs names
|
|
243
|
+
* the pid it re-exec'd from, and that pid is adopted only when it really is an
|
|
244
|
+
* ANCESTOR of this process: the env var alone is inherited by anything the
|
|
245
|
+
* daemon spawns and proves nothing on its own. Ancestry that cannot be read
|
|
246
|
+
* adopts nothing — the pre-existing takeover rules then apply.
|
|
247
|
+
*/
|
|
248
|
+
function isOurReexecParent(pid) {
|
|
249
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
250
|
+
if (pid === process.ppid) return true;
|
|
251
|
+
if (process.env.FLOWVIANT_REEXEC_FROM !== String(pid)) return false;
|
|
252
|
+
let p = process.ppid;
|
|
253
|
+
for (let i = 0; i < ANCESTOR_DEPTH && p && p > 1; i++) {
|
|
254
|
+
const up = parentOf(p);
|
|
255
|
+
if (up === pid) return true;
|
|
256
|
+
p = up;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
157
261
|
/** Same directory, whatever it is spelled as — symlinks and trailing slashes
|
|
158
262
|
* included. A repo compared by string would let `/repo` and `/repo/` past. */
|
|
159
263
|
function samePath(a, b) {
|
|
@@ -208,7 +312,7 @@ export function daemonInSameRepo(repoRoot, ownPath) {
|
|
|
208
312
|
if (path === ownPath) continue; // our own credential — the lock above owns that question
|
|
209
313
|
const holder = readHolder(path);
|
|
210
314
|
if (!holder || !alive(holder.pid)) continue;
|
|
211
|
-
if (holder.pid
|
|
315
|
+
if (isOurReexecParent(holder.pid)) continue; // ourselves mid self-update re-exec
|
|
212
316
|
if (samePath(holder.repoRoot, repoRoot)) {
|
|
213
317
|
NEIGHBOUR_PATHS.set(holder, path);
|
|
214
318
|
return holder;
|
|
@@ -226,7 +330,10 @@ export function daemonInSameRepo(repoRoot, ownPath) {
|
|
|
226
330
|
* around 41s. 120s is ~3x that worst path and ~4000x the typical one, and it is
|
|
227
331
|
* still short enough that pid reuse cannot reach into it: reuse means cycling
|
|
228
332
|
* the entire pid space (4194304 by default), which no machine does inside two
|
|
229
|
-
* minutes.
|
|
333
|
+
* minutes. (Since the lock records the PROCESS start rather than the moment it
|
|
334
|
+
* was written, a lock from this version sits a few milliseconds from its
|
|
335
|
+
* process whatever the start path cost; the window's width is for locks older
|
|
336
|
+
* daemons wrote, which still stamp the write.) */
|
|
230
337
|
const TAKEOVER_START_WINDOW_MS = 120_000;
|
|
231
338
|
|
|
232
339
|
/** ...and how far the OTHER way, which is a unit problem rather than a real
|
|
@@ -421,15 +528,20 @@ function stillTheHolder(holder) {
|
|
|
421
528
|
// alive, so it takes the same road as a missing one.
|
|
422
529
|
if (!want) return startedAroundLockWrite(holder);
|
|
423
530
|
let cmdline;
|
|
531
|
+
let tokens;
|
|
424
532
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
533
|
+
if (platform() === 'linux') {
|
|
534
|
+
const raw = readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8');
|
|
535
|
+
tokens = raw.split('\0');
|
|
536
|
+
cmdline = raw.replace(/\0/g, ' ');
|
|
537
|
+
} else {
|
|
538
|
+
cmdline = execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
|
|
539
|
+
encoding: 'utf8',
|
|
540
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
541
|
+
timeout: 3000,
|
|
542
|
+
});
|
|
543
|
+
tokens = cmdline.trim().split(/\s+/);
|
|
544
|
+
}
|
|
433
545
|
} catch {
|
|
434
546
|
// NOT `false`. takeOverFrom already returned early if the pid were gone, so
|
|
435
547
|
// reaching here means the process is alive and we could not READ it —
|
|
@@ -437,7 +549,10 @@ function stillTheHolder(holder) {
|
|
|
437
549
|
// here is what made the refusal claim the pid belonged to somebody else.
|
|
438
550
|
return null;
|
|
439
551
|
}
|
|
440
|
-
|
|
552
|
+
// The typed spelling is matched as a whole TOKEN, never a substring: a
|
|
553
|
+
// relative `bin/cli.mjs` is a fragment of countless command lines.
|
|
554
|
+
const raw = typeof holder.entryRaw === 'string' && holder.entryRaw ? holder.entryRaw : null;
|
|
555
|
+
if (!cmdline.includes(want) && !(raw && tokens.includes(raw))) return false;
|
|
441
556
|
// AN ENTRY MATCH ALONE IS NOT IDENTITY. Every daemon on the box shares one
|
|
442
557
|
// entry path under a global install, so "cmdline contains this cli.mjs"
|
|
443
558
|
// proves "is SOME flowviant daemon", not "is the daemon that wrote THIS
|
|
@@ -711,7 +826,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
711
826
|
// ownership-checked, so it will not delete the lock it handed over.
|
|
712
827
|
// (`flowviant login` also proxies a child, but that parent never reached
|
|
713
828
|
// the daemon and holds nothing — the child simply acquires.)
|
|
714
|
-
if (holder.pid
|
|
829
|
+
if (isOurReexecParent(holder.pid)) {
|
|
715
830
|
try {
|
|
716
831
|
writeFileSync(path, record(repoRoot));
|
|
717
832
|
} catch {
|
package/bin/lib/knowledge.mjs
CHANGED
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
* `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
|
|
64
64
|
* every one (path, title, kind, the card that asked for it, the date, what it
|
|
65
65
|
* supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
|
|
66
|
-
* design A, and reading the page.
|
|
66
|
+
* design A, and reading the page. At 0.99.0 a kept design is one folder,
|
|
67
|
+
* including its original relative sidecar paths and measured preview.
|
|
67
68
|
*
|
|
68
69
|
* SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
|
|
69
70
|
* forbids every separator; a library item is the one thing allowed ONE, and
|
|
@@ -132,7 +133,7 @@ export const FLOWVIANT_OWN_PATHS = [
|
|
|
132
133
|
export const LIBRARY_FILE = 'LIBRARY.md';
|
|
133
134
|
/** The two subdirectories a kept item may live in, by kind. */
|
|
134
135
|
export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
|
|
135
|
-
const LIBRARY_EXT = { design: '.html', research: '.md' };
|
|
136
|
+
const LIBRARY_EXT = { design: ['.html', '.htm', '.gltf', '.obj', '.glb', '.bin'], research: ['.md'] };
|
|
136
137
|
/** The server caps a library at 200 items; a longer manifest is cut. */
|
|
137
138
|
const MAX_LIBRARY_ITEMS = 256;
|
|
138
139
|
|
|
@@ -148,23 +149,55 @@ const MARKER = '.flowviant/knowledge.rev';
|
|
|
148
149
|
* this is somebody's disk, and one place checking is one deploy away from
|
|
149
150
|
* zero places — the attachment fetch's own rule. */
|
|
150
151
|
export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
152
|
+
export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
|
|
153
|
+
const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
|
|
154
|
+
? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
|
|
151
155
|
/** The manifest is capped at the server (25 files); a manifest longer than
|
|
152
156
|
* this is not one the product produced, and is cut rather than trusted. */
|
|
153
157
|
const MAX_FILES = 64;
|
|
154
158
|
|
|
159
|
+
/** FNV-1a, 32-bit, as 8 hex — deterministic, so the same long name always maps
|
|
160
|
+
* to the same stored name. Verbatim against the server's `fnv1a8`
|
|
161
|
+
* (apps/api/src/routes/sessionsAttachments.routes.ts). */
|
|
162
|
+
function fnv1a8(s) {
|
|
163
|
+
let h = 0x811c9dc5;
|
|
164
|
+
for (let i = 0; i < s.length; i++) {
|
|
165
|
+
h ^= s.charCodeAt(i);
|
|
166
|
+
h = Math.imul(h, 0x01000193);
|
|
167
|
+
}
|
|
168
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
169
|
+
}
|
|
170
|
+
/** What counts as an extension worth keeping through a cut. */
|
|
171
|
+
const SAFE_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
|
|
172
|
+
const SAFE_NAME_MAX = 80;
|
|
173
|
+
|
|
155
174
|
/**
|
|
156
|
-
* `
|
|
157
|
-
* every path separator, never starts with a dot or a dash
|
|
158
|
-
*
|
|
175
|
+
* `safeFileName`, verbatim (apps/api/src/routes/sessionsAttachments.routes.ts):
|
|
176
|
+
* drops every path separator, never starts with a dot or a dash, and — since
|
|
177
|
+
* 2026-09-24 — THE EXTENSION SURVIVES A CUT. A bare `slice(0, 80)` truncated
|
|
178
|
+
* an over-long name mid-extension (an 84-character `….html` mockup stored as
|
|
179
|
+
* `….` with an unrecognised type and its bytes discarded), so a long name's
|
|
180
|
+
* stem is now cut and an 8-hex FNV-1a hash of the WHOLE sanitised name is
|
|
181
|
+
* appended before the extension: stable across calls, at most 80 characters,
|
|
182
|
+
* idempotent over its own output, and — the reason it is re-applied here
|
|
183
|
+
* rather than trusted to the server's own pass — byte-identical to what
|
|
184
|
+
* `safeFileName` computes there, so a knowledge file synced under this name
|
|
185
|
+
* and one requested by name later never disagree over the cut.
|
|
159
186
|
*/
|
|
160
187
|
export function safeKnowledgeName(raw) {
|
|
161
|
-
const
|
|
188
|
+
const clean = String(raw ?? '')
|
|
162
189
|
.split(/[\\/]/)
|
|
163
190
|
.pop()
|
|
164
191
|
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
165
|
-
.replace(/^[.-]+/, '')
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
.replace(/^[.-]+/, '');
|
|
193
|
+
if (!clean) return 'file';
|
|
194
|
+
if (clean.length <= SAFE_NAME_MAX) return clean;
|
|
195
|
+
const dot = clean.lastIndexOf('.');
|
|
196
|
+
const ext = dot > 0 && SAFE_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
|
|
197
|
+
const stem = ext ? clean.slice(0, dot) : clean;
|
|
198
|
+
const tag = `-${fnv1a8(clean)}`;
|
|
199
|
+
const room = SAFE_NAME_MAX - tag.length - (ext ? ext.length + 1 : 0);
|
|
200
|
+
return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
|
|
168
201
|
}
|
|
169
202
|
|
|
170
203
|
/**
|
|
@@ -326,12 +359,30 @@ function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
|
|
|
326
359
|
export function safeLibraryPath(name, kind) {
|
|
327
360
|
if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
|
|
328
361
|
const parts = name.split('/');
|
|
329
|
-
if (parts.length !== 2) return null;
|
|
330
|
-
const [dir,
|
|
362
|
+
if (parts.length !== 2 && !(kind === 'design' && parts.length >= 3 && parts.length <= 7)) return null;
|
|
363
|
+
const [dir, ...rest] = parts;
|
|
364
|
+
const file = rest.at(-1);
|
|
331
365
|
if (dir !== LIBRARY_DIRS[kind]) return null;
|
|
332
|
-
if (
|
|
333
|
-
if (
|
|
334
|
-
return
|
|
366
|
+
if (rest.length >= 2 && !/^[a-z0-9-]+-v[1-9][0-9]*$/.test(rest[0])) return null;
|
|
367
|
+
if (rest.slice(1, -1).some((p) => !safeBundleSegment(p))) return null;
|
|
368
|
+
if (!file || (rest.length === 1 ? file !== safeKnowledgeName(file) : !safeBundleSegment(file))) return null;
|
|
369
|
+
if (!LIBRARY_EXT[kind].some((ext) => file.toLowerCase().endsWith(ext) && file.length > ext.length)) return null;
|
|
370
|
+
return name;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function safeBundleSegment(part) {
|
|
374
|
+
return typeof part === 'string' && part.length > 0 && part.length <= 80 &&
|
|
375
|
+
/^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !part.endsWith('.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function safeBundleFile(path, rootDir) {
|
|
379
|
+
if (typeof path !== 'string' || !path.startsWith(`${rootDir}/`)) return null;
|
|
380
|
+
const relative = path.slice(rootDir.length + 1);
|
|
381
|
+
if (relative === 'preview.png') return null;
|
|
382
|
+
const parts = relative.split('/');
|
|
383
|
+
if (!parts.length || parts.some((p) => !safeBundleSegment(p))) return null;
|
|
384
|
+
if (!/\.(gltf|obj|glb|bin|png|jpg|jpeg|gif|webp|svg|json)$/i.test(relative)) return null;
|
|
385
|
+
return path;
|
|
335
386
|
}
|
|
336
387
|
|
|
337
388
|
/** The manifest's library items that are safe to write, in manifest order, or
|
|
@@ -347,7 +398,14 @@ function libraryItemsOf(manifest) {
|
|
|
347
398
|
const path = safeLibraryPath(it.name, it.kind);
|
|
348
399
|
if (!path || seen.has(path.toLowerCase())) continue;
|
|
349
400
|
seen.add(path.toLowerCase());
|
|
350
|
-
|
|
401
|
+
const bundleDir = path.split('/').length >= 3 ? path.split('/').slice(0, 2).join('/') : null;
|
|
402
|
+
const expectedPreview = bundleDir ? `${bundleDir}/preview.png` : it.name.replace(/\.html$/, '.png');
|
|
403
|
+
const previewPath = it.kind === 'design' && it.preview && it.preview.name === expectedPreview ? expectedPreview : null;
|
|
404
|
+
const files = bundleDir && Array.isArray(it.files) ? it.files.map((f, index) => ({ ...f,
|
|
405
|
+
index, path: safeBundleFile(f.name, bundleDir),
|
|
406
|
+
})).filter((f) => f.path && !seen.has(f.path.toLowerCase())) : [];
|
|
407
|
+
for (const f of files) seen.add(f.path.toLowerCase());
|
|
408
|
+
out.push({ ...it, path, files, previewPath, previewUnknown: it.kind === 'design' && path.endsWith('.html') && it.preview === undefined });
|
|
351
409
|
}
|
|
352
410
|
return out;
|
|
353
411
|
}
|
|
@@ -408,6 +466,32 @@ function ensureSubdir(dir, name) {
|
|
|
408
466
|
return path;
|
|
409
467
|
}
|
|
410
468
|
|
|
469
|
+
function ensureLibraryParents(dir, relativePath) {
|
|
470
|
+
const parts = relativePath.split('/');
|
|
471
|
+
let at = dir;
|
|
472
|
+
for (const part of parts.slice(0, -1)) at = ensureSubdir(at, part);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function sweepLibraryTree(dir, sub, wanted, result) {
|
|
476
|
+
const walk = (at, prefix = '') => {
|
|
477
|
+
let entries;
|
|
478
|
+
try { entries = readdirSync(at); } catch { return; }
|
|
479
|
+
for (const name of entries) {
|
|
480
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
481
|
+
const path = join(at, name);
|
|
482
|
+
let st;
|
|
483
|
+
try { st = lstatSync(path); } catch { continue; }
|
|
484
|
+
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
485
|
+
walk(path, rel);
|
|
486
|
+
try { if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); } catch { /* next sync */ }
|
|
487
|
+
} else if (!wanted.has(rel)) {
|
|
488
|
+
try { rmSync(path, { recursive: true, force: true }); result.removed.push(`${sub}/${rel}`); } catch { /* next sync */ }
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
walk(join(dir, sub));
|
|
493
|
+
};
|
|
494
|
+
|
|
411
495
|
/** Does this manifest look like one the server produced? Anything else is
|
|
412
496
|
* ignored whole rather than half-applied. */
|
|
413
497
|
export function isKnowledgeManifest(k) {
|
|
@@ -486,7 +570,8 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
486
570
|
|
|
487
571
|
for (const f of planned) {
|
|
488
572
|
const path = join(dir, f.local);
|
|
489
|
-
|
|
573
|
+
const fileMax = knowledgeMaxFor(f.name, maxBytes);
|
|
574
|
+
if (Number(f.bytes) > fileMax) {
|
|
490
575
|
// Permanent for this rev, and NOT a reason to delete the rest. Any stale
|
|
491
576
|
// local copy under this name goes, because it is not the file the
|
|
492
577
|
// manifest now names.
|
|
@@ -497,7 +582,7 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
497
582
|
if (typeof f.sha256 === 'string' && localSha(path) === f.sha256.toLowerCase()) continue;
|
|
498
583
|
try {
|
|
499
584
|
const buf = await fetchFile(f.id);
|
|
500
|
-
if (!Buffer.isBuffer(buf) || buf.byteLength >
|
|
585
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) {
|
|
501
586
|
result.refused.push(f.local);
|
|
502
587
|
keep.delete(f.local);
|
|
503
588
|
continue;
|
|
@@ -574,58 +659,84 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
|
574
659
|
}
|
|
575
660
|
}
|
|
576
661
|
for (const it of items) {
|
|
577
|
-
const [sub,
|
|
578
|
-
const
|
|
662
|
+
const [sub, ...tail] = it.path.split('/');
|
|
663
|
+
const file = tail.join('/');
|
|
664
|
+
const path = join(dir, ...it.path.split('/'));
|
|
665
|
+
ensureLibraryParents(dir, it.path);
|
|
666
|
+
const itemMax = knowledgeMaxFor(it.path, maxBytes);
|
|
579
667
|
wanted[sub].add(file);
|
|
580
|
-
if (
|
|
668
|
+
if (it.previewPath) wanted[sub].add(it.previewPath.split('/').slice(1).join('/'));
|
|
669
|
+
if (it.previewUnknown) {
|
|
670
|
+
const oldPng = file.includes('/') ? `${file.slice(0, file.lastIndexOf('/'))}/preview.png` : file.replace(/\.html$/, '.png');
|
|
671
|
+
if (localSha(join(dir, sub, ...oldPng.split('/'))) !== null) wanted[sub].add(oldPng);
|
|
672
|
+
}
|
|
673
|
+
if (Number(it.bytes) > itemMax) {
|
|
581
674
|
result.refused.push(it.path);
|
|
582
675
|
wanted[sub].delete(file);
|
|
583
676
|
continue;
|
|
584
677
|
}
|
|
585
678
|
if (typeof it.sha256 === 'string' && localSha(path) === it.sha256.toLowerCase()) {
|
|
586
679
|
onDisk.push(it);
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
680
|
+
} else {
|
|
681
|
+
try {
|
|
682
|
+
const buf = await fetchFile(it.id, { library: true });
|
|
683
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > itemMax) {
|
|
684
|
+
result.refused.push(it.path);
|
|
685
|
+
wanted[sub].delete(file);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
|
|
689
|
+
throw new Error('sha256 mismatch');
|
|
690
|
+
}
|
|
691
|
+
ensureLibraryParents(dir, it.path);
|
|
692
|
+
writeAtomic(path, buf);
|
|
693
|
+
result.wrote.push(it.path);
|
|
694
|
+
onDisk.push(it);
|
|
695
|
+
} catch {
|
|
696
|
+
result.ok = false;
|
|
697
|
+
result.failed.push(it.path);
|
|
698
|
+
if (localSha(path) !== null) onDisk.push(it);
|
|
595
699
|
}
|
|
596
|
-
|
|
597
|
-
|
|
700
|
+
}
|
|
701
|
+
if (it.previewPath && it.preview?.sha256) {
|
|
702
|
+
ensureLibraryParents(dir, it.previewPath);
|
|
703
|
+
const previewFile = join(dir, ...it.previewPath.split('/'));
|
|
704
|
+
if (localSha(previewFile) !== it.preview.sha256.toLowerCase()) {
|
|
705
|
+
try {
|
|
706
|
+
const buf = await fetchFile(it.id, { library: true, preview: true });
|
|
707
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > 2 * 1024 * 1024 || sha256Of(buf) !== it.preview.sha256.toLowerCase()) throw new Error('preview mismatch');
|
|
708
|
+
ensureLibraryParents(dir, it.previewPath);
|
|
709
|
+
writeAtomic(previewFile, buf);
|
|
710
|
+
result.wrote.push(it.previewPath);
|
|
711
|
+
} catch {
|
|
712
|
+
result.ok = false;
|
|
713
|
+
result.failed.push(it.previewPath);
|
|
714
|
+
}
|
|
598
715
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (localSha(
|
|
716
|
+
}
|
|
717
|
+
for (const f of it.files ?? []) {
|
|
718
|
+
ensureLibraryParents(dir, f.path);
|
|
719
|
+
const rel = f.path.split('/').slice(1).join('/');
|
|
720
|
+
wanted[sub].add(rel);
|
|
721
|
+
const filePath = join(dir, ...f.path.split('/'));
|
|
722
|
+
const fileMax = knowledgeMaxFor(f.path, maxBytes);
|
|
723
|
+
if (Number(f.bytes) > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
|
|
724
|
+
if (localSha(filePath) === String(f.sha256).toLowerCase()) continue;
|
|
725
|
+
try {
|
|
726
|
+
const buf = await fetchFile(it.id, { library: true, fileIndex: f.index });
|
|
727
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
|
|
728
|
+
if (sha256Of(buf) !== String(f.sha256).toLowerCase()) throw new Error('sha256 mismatch');
|
|
729
|
+
ensureLibraryParents(dir, f.path);
|
|
730
|
+
writeAtomic(filePath, buf);
|
|
731
|
+
result.wrote.push(f.path);
|
|
732
|
+
} catch { result.ok = false; result.failed.push(f.path); }
|
|
608
733
|
}
|
|
609
734
|
}
|
|
610
735
|
// Each subdirectory holds exactly what the manifest names.
|
|
611
736
|
for (const sub of Object.values(LIBRARY_DIRS)) {
|
|
612
737
|
const subPath = join(dir, sub);
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if (!lstatSync(subPath).isDirectory()) continue;
|
|
616
|
-
entries = readdirSync(subPath);
|
|
617
|
-
} catch {
|
|
618
|
-
continue;
|
|
619
|
-
}
|
|
620
|
-
for (const name of entries) {
|
|
621
|
-
if (wanted[sub].has(name)) continue;
|
|
622
|
-
try {
|
|
623
|
-
rmSync(join(subPath, name), { recursive: true, force: true });
|
|
624
|
-
result.removed.push(`${sub}/${name}`);
|
|
625
|
-
} catch {
|
|
626
|
-
/* best-effort */
|
|
627
|
-
}
|
|
628
|
-
}
|
|
738
|
+
try { if (!lstatSync(subPath).isDirectory()) continue; } catch { continue; }
|
|
739
|
+
sweepLibraryTree(dir, sub, wanted[sub], result);
|
|
629
740
|
// An emptied subdirectory leaves with its last item.
|
|
630
741
|
if (wanted[sub].size === 0) keep.delete(sub);
|
|
631
742
|
}
|
|
@@ -697,18 +808,26 @@ function libraryStale(checkoutDir, manifest, { synced, refused }) {
|
|
|
697
808
|
const items = libraryItemsOf(manifest);
|
|
698
809
|
if (items === null) return false;
|
|
699
810
|
if (!synced) return true;
|
|
700
|
-
const expected = items.filter((it) => !(Number(it.bytes) > KNOWLEDGE_FILE_MAX_BYTES) && !refused.has(it.path));
|
|
811
|
+
const expected = items.filter((it) => !(Number(it.bytes) > knowledgeMaxFor(it.path, KNOWLEDGE_FILE_MAX_BYTES)) && !refused.has(it.path));
|
|
701
812
|
if (expected.length === 0) return false;
|
|
702
813
|
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
703
814
|
const isFile = (p) => {
|
|
704
815
|
try {
|
|
816
|
+
let parent = dir;
|
|
817
|
+
for (const part of p.slice(dir.length + 1).split('/').slice(0, -1)) {
|
|
818
|
+
parent = join(parent, part);
|
|
819
|
+
const st = lstatSync(parent);
|
|
820
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return false;
|
|
821
|
+
}
|
|
705
822
|
return lstatSync(p).isFile();
|
|
706
823
|
} catch {
|
|
707
824
|
return false;
|
|
708
825
|
}
|
|
709
826
|
};
|
|
710
827
|
if (!isFile(join(dir, LIBRARY_FILE))) return true;
|
|
711
|
-
return expected.some((it) => !isFile(join(dir, ...it.path.split('/')))
|
|
828
|
+
return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))) ||
|
|
829
|
+
(it.previewPath && !refused.has(it.previewPath) && !isFile(join(dir, ...it.previewPath.split('/')))) ||
|
|
830
|
+
(it.files ?? []).some((f) => !refused.has(f.path) && !isFile(join(dir, ...f.path.split('/')))));
|
|
712
831
|
}
|
|
713
832
|
|
|
714
833
|
/**
|
|
@@ -827,13 +946,14 @@ export function createKnowledgeSync({
|
|
|
827
946
|
* URL is derived from the roster URL the way every `/fleet/*` path is, so a
|
|
828
947
|
* self-hosted `FLOWVIANT_FLEET_URL` is honoured.
|
|
829
948
|
*/
|
|
830
|
-
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes =
|
|
949
|
+
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_BINARY_MODEL_MAX_BYTES }) {
|
|
831
950
|
const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
|
|
832
951
|
// A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
|
|
833
952
|
// the same credential, the same shape, the same caps.
|
|
834
953
|
const libraryBase = String(fleetUrl).replace(/\/agents\/?$/, '/library');
|
|
835
|
-
return async (id, { library = false } = {}) => {
|
|
836
|
-
const
|
|
954
|
+
return async (id, { library = false, preview = false, fileIndex = null } = {}) => {
|
|
955
|
+
const suffix = preview ? '/preview' : Number.isSafeInteger(fileIndex) && fileIndex >= 0 ? `/file/${fileIndex}` : '';
|
|
956
|
+
const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}${suffix}`, {
|
|
837
957
|
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
838
958
|
signal: AbortSignal.timeout(60_000),
|
|
839
959
|
});
|