flowviant 0.27.0 → 0.27.1

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.
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.27.0';
7
+ export const VERSION = '0.27.1';
8
8
 
9
9
  // The model EVERY daemon Claude turn runs on — pinned so autonomous work never
10
10
  // inherits your interactive `~/.claude/settings.json` default. That matters: a
package/bin/lib/env.mjs CHANGED
@@ -64,18 +64,27 @@ export async function sodiumReady() {
64
64
  /** 6-emoji key fingerprint — algorithm MUST match the web's pubkeyEmoji
65
65
  * (EnvironmentSettings.tsx) so the human can compare terminal ↔ approve card. */
66
66
  // MUST stay byte-identical to the web's pubkeyEmoji (EnvironmentSettings.tsx) —
67
- // the human compares the two. 32 glyphs × 8 positions 40 bits; each position
68
- // mixes the whole key so no byte is mute (a compromised-server pubkey swap must
69
- // grind a full collision, not just the tail).
70
- const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🐝','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
67
+ // the human compares the two strings. 32 glyphs × 8 positions, effective ~40
68
+ // bits. Two FNV-1a rolling hashes over the whole key + a murmur3 finalizer per
69
+ // glyph (a plain additive sum collapsed the space to ~10 bits — grindable).
70
+ const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🦂','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
71
71
  export function pubkeyEmoji(pubkeyB64) {
72
+ let h1 = 0x811c9dc5 >>> 0;
73
+ let h2 = 0xc2b2ae35 >>> 0;
74
+ for (let i = 0; i < pubkeyB64.length; i++) {
75
+ const ch = pubkeyB64.charCodeAt(i);
76
+ h1 = Math.imul(h1 ^ ch, 0x01000193) >>> 0;
77
+ h2 = Math.imul(h2 ^ ch, 0x85ebca6b) >>> 0;
78
+ }
72
79
  let out = '';
73
80
  for (let i = 0; i < 8; i++) {
74
- let acc = i + 1;
75
- for (let j = 0; j < pubkeyB64.length; j++) {
76
- acc = (acc * 31 + pubkeyB64.charCodeAt(j) * (i + 2)) % 1_000_003;
77
- }
78
- out += FP_EMOJI[acc % FP_EMOJI.length];
81
+ let x = (((i < 4 ? h1 : h2) + i * 0x9e3779b1) >>> 0);
82
+ x ^= x >>> 16;
83
+ x = Math.imul(x, 0x7feb352d) >>> 0;
84
+ x ^= x >>> 15;
85
+ x = Math.imul(x, 0x846ca68b) >>> 0;
86
+ x ^= x >>> 16;
87
+ out += FP_EMOJI[x & 31];
79
88
  }
80
89
  return out;
81
90
  }
@@ -186,13 +195,15 @@ function readCache(projectId) {
186
195
  }
187
196
  }
188
197
 
189
- /** Offline start: materialize from the encrypted cache before the first poll. */
198
+ /** Offline start: materialize from the encrypted cache before the first poll.
199
+ * Also seeds knownTargetFiles so stale-file cleanup survives a restart. */
190
200
  export async function loadCachedEnv(projectId) {
191
201
  await ensureKeypair();
192
202
  const cached = readCache(projectId);
193
203
  if (!cached) return false;
194
204
  values = cached.values ?? [];
195
205
  bundleVersion = cached.bundleVersion ?? -1;
206
+ knownTargetFiles = new Set(cached.knownFiles ?? values.map((v) => v.targetFile));
196
207
  cachedProjectId = projectId;
197
208
  return values.length > 0;
198
209
  }
@@ -253,9 +264,16 @@ function isTrackedInGit(wt, relPath) {
253
264
  }
254
265
  }
255
266
 
256
- // Per-worktree: the target files we last materialized, so a file that lost all
257
- // its keys (or a key that moved files) gets its stale copy removed.
267
+ // Per-worktree: the target files we last materialized THIS SESSION.
258
268
  const lastFilesByWorktree = new Map();
269
+ // Project-global union of every target file we've ever materialized — PERSISTED
270
+ // in the cache and seeded on load, so a file whose key was deleted while the
271
+ // daemon was down still gets its stale plaintext copy cleaned on the next
272
+ // materialize (lastFilesByWorktree alone is empty after a restart, and
273
+ // `git clean -fd` never removes an info/exclude'd file).
274
+ let knownTargetFiles = new Set();
275
+
276
+ const MATERIALIZE_HEADER = '# Materialized by flowviant env sync';
259
277
 
260
278
  /** Render KEY=value with values that contain newlines/= safely quoted so one
261
279
  * value can't fabricate another key line. */
@@ -266,7 +284,21 @@ function renderEnvFile(list) {
266
284
  const esc = v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
267
285
  return `${v.name}="${esc}"`;
268
286
  });
269
- return `# Materialized by flowviant env sync — DO NOT COMMIT.\n${lines.join('\n')}\n`;
287
+ return `${MATERIALIZE_HEADER} — DO NOT COMMIT.\n${lines.join('\n')}\n`;
288
+ }
289
+
290
+ /** Delete a materialized file from a worktree, but ONLY if it's ours (carries
291
+ * our header) and not git-tracked — never touch a file we didn't write. */
292
+ function removeStaleEnvFile(wt, rel) {
293
+ if (isTrackedInGit(wt, rel)) return;
294
+ const abs = join(wt, rel);
295
+ try {
296
+ if (existsSync(abs) && readFileSync(abs, 'utf8').startsWith(MATERIALIZE_HEADER)) {
297
+ rmSync(abs, { force: true });
298
+ }
299
+ } catch {
300
+ /* best-effort */
301
+ }
270
302
  }
271
303
 
272
304
  /** Write the decrypted env into ONE worktree. Never call on the wiki worktree. */
@@ -305,18 +337,15 @@ export function materializeInto(wt) {
305
337
  }
306
338
  }
307
339
 
308
- // Remove files we materialized last time that have no keys now (all deleted,
309
- // or every key moved elsewhere) — a stale secret file must not linger.
310
- const prevFiles = lastFilesByWorktree.get(wt) ?? [];
311
- for (const stale of prevFiles) {
312
- if (!written.includes(stale) && !isTrackedInGit(wt, stale)) {
313
- try {
314
- rmSync(join(wt, stale), { force: true });
315
- } catch {
316
- /* best-effort */
317
- }
318
- }
340
+ // Remove any file we ever materialized (this session OR a prior one, via the
341
+ // persisted knownTargetFiles) that has no keys now — a deleted secret's
342
+ // plaintext file must not linger, even across a daemon restart.
343
+ const writtenSet = new Set(written);
344
+ const candidates = new Set([...(lastFilesByWorktree.get(wt) ?? []), ...knownTargetFiles]);
345
+ for (const stale of candidates) {
346
+ if (!writtenSet.has(stale)) removeStaleEnvFile(wt, stale);
319
347
  }
348
+ for (const f of written) knownTargetFiles.add(f);
320
349
  lastFilesByWorktree.set(wt, written);
321
350
  if (written.length) excludeInWorktree(wt, written);
322
351
  }
@@ -359,7 +388,18 @@ export async function handleRosterEnv(env, { projectId } = {}) {
359
388
  // wedge registration until restart.
360
389
  if (env.status === 'none' && !registeredOnce) {
361
390
  const label = hostname() || 'daemon';
362
- await post('register', { pubkey: myPubB64(), label });
391
+ try {
392
+ await post('register', { pubkey: myPubB64(), label });
393
+ } catch (e) {
394
+ // A 429 = the project is at its machine cap; retrying every poll would
395
+ // just hammer it. Stop for this session (a restart re-tries).
396
+ if (/\(429/.test(e.message)) {
397
+ registeredOnce = true;
398
+ warn('env: this project is at its machine limit — env access not requested. Ask an admin to remove an old machine.');
399
+ return { changed: false };
400
+ }
401
+ throw e; // transient — retry next poll (registeredOnce still false)
402
+ }
363
403
  registeredOnce = true;
364
404
  const fp = pubkeyEmoji(myPubB64());
365
405
  info(`${c.cyan('env')} · this machine requested env access as ${c.bold(label)}`);
@@ -443,7 +483,10 @@ export async function handleRosterEnv(env, { projectId } = {}) {
443
483
  if (needSync) {
444
484
  values = opened;
445
485
  bundleVersion = bundle.bundleVersion;
446
- if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion });
486
+ // Fold the current target files into the persisted known set so stale
487
+ // cleanup survives a restart (a key deleted while down still gets swept).
488
+ for (const v of values) knownTargetFiles.add(v.targetFile);
489
+ if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion, knownFiles: [...knownTargetFiles] });
447
490
  ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
448
491
  return { changed: true };
449
492
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {