flowviant 0.9.0 → 0.9.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.9.0';
7
+ export const VERSION = '0.9.1';
8
8
 
9
9
  // Credential stored by `flowviant login` (device auth) — the no-token,
10
10
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
package/bin/lib/fleet.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * MCP token, and only spawns Claude when the server says an agent has work.
6
6
  */
7
7
 
8
- import { mkdirSync, existsSync } from 'node:fs';
8
+ import { mkdirSync, existsSync, rmSync } from 'node:fs';
9
9
  import { execFileSync } from 'node:child_process';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { homedir } from 'node:os';
@@ -23,6 +23,16 @@ import {
23
23
  REFRESH_BEFORE_SECONDS,
24
24
  LIVE,
25
25
  } from './config.mjs';
26
+ import {
27
+ git,
28
+ resetWorktree,
29
+ repoRootOrDie,
30
+ detectBaseRef,
31
+ originSlug,
32
+ isValidPrUrl,
33
+ isValidBranch,
34
+ isSafePathSegment,
35
+ } from './git.mjs';
26
36
  import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
27
37
  import {
28
38
  sleep,
@@ -34,7 +44,6 @@ import {
34
44
  SINGLE_KICKOFF,
35
45
  SINGLE_RESUME,
36
46
  } from './claude.mjs';
37
- import { git, repoRootOrDie, detectBaseRef, resetWorktree } from './git.mjs';
38
47
  import { runLiveWorker } from './live.mjs';
39
48
  import { preflight } from './preflight.mjs';
40
49
 
@@ -45,6 +54,7 @@ async function fetchRoster(haveIds) {
45
54
  // Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
46
55
  const res = await fetch(url, {
47
56
  headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
57
+ signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
48
58
  });
49
59
  if (res.status === 401 || res.status === 403) {
50
60
  // Fleet credential revoked/expired — retrying can't recover; signal exit.
@@ -54,7 +64,20 @@ async function fetchRoster(haveIds) {
54
64
  }
55
65
  if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
56
66
  const body = await res.json();
57
- return body.data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
67
+ // Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
68
+ // throws a NORMAL retryable error inside the loop's try/catch, instead of a
69
+ // `roster.agents.map` TypeError escaping to top-level and killing the daemon.
70
+ const data = body?.data;
71
+ if (!data || !Array.isArray(data.agents)) {
72
+ throw new Error('fleet poll returned an unexpected shape');
73
+ }
74
+ // Drop roster agents with an unsafe id BEFORE they're used as a path segment.
75
+ data.agents = data.agents.filter((a) => {
76
+ if (isSafePathSegment(a?.agentId)) return true;
77
+ warn(`ignoring roster agent with an invalid id: ${JSON.stringify(a?.agentId)}`);
78
+ return false;
79
+ });
80
+ return data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
58
81
  }
59
82
 
60
83
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
@@ -186,6 +209,14 @@ export async function runFleetDaemon() {
186
209
  } catch {
187
210
  /* best-effort */
188
211
  }
212
+ // Stop the detached preview (dev server + cloudflared tunnel) — it's its
213
+ // own process group and survives our exit, otherwise leaking a port-bound
214
+ // server + a live tunnel serving a stale branch until reboot.
215
+ try {
216
+ w.state.stopPreview?.();
217
+ } catch {
218
+ /* best-effort */
219
+ }
189
220
  }
190
221
  };
191
222
  process.on('SIGINT', () => {
@@ -210,6 +241,7 @@ export async function runFleetDaemon() {
210
241
  'User-Agent': USER_AGENT,
211
242
  'Content-Type': 'application/json',
212
243
  },
244
+ signal: AbortSignal.timeout(30_000),
213
245
  body: JSON.stringify(body),
214
246
  });
215
247
  } catch {
@@ -225,6 +257,18 @@ export async function runFleetDaemon() {
225
257
  note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
226
258
  let merged = false;
227
259
  let failedReason = null; // permanent — tell the thread, clear the flag
260
+ // Refuse a PR URL that isn't an https github.com PR in THIS repo — a
261
+ // bad/hostile server must not merge a PR in another repo the user's
262
+ // gh can write to (and a leading '-' would be a gh flag).
263
+ if (!isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
264
+ mergeAttempts.delete(job.id);
265
+ await reportMergeOutcome(MERGE_FAILED_URL, {
266
+ intentId: job.id,
267
+ message: 'refused: PR URL is not a pull request in this repository',
268
+ });
269
+ warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
270
+ return;
271
+ }
228
272
  try {
229
273
  execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
230
274
  cwd: repoRoot,
@@ -234,8 +278,13 @@ export async function runFleetDaemon() {
234
278
  } catch (e) {
235
279
  const err = e.stderr?.toString?.() || e.message || '';
236
280
  const line = err.split('\n')[0] || 'gh pr merge failed';
237
- if (/already merged|not open|closed/i.test(err)) merged = true;
238
- else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
281
+ // Only "already merged" is a real success; a CLOSED-without-merge PR
282
+ // also matches "not open"/"closed" but nothing landed on main —
283
+ // report it as a failure so the thread learns the truth.
284
+ if (/already merged/i.test(err)) merged = true;
285
+ else if (/not open|closed/i.test(err)) {
286
+ failedReason = 'the PR was closed without merging';
287
+ } else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
239
288
  // Permanent until a human/agent acts — don't spin on it.
240
289
  failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
241
290
  } else {
@@ -279,7 +328,10 @@ export async function runFleetDaemon() {
279
328
  (async () => {
280
329
  try {
281
330
  note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
282
- if (job.prUrl) {
331
+ // Same guards as merge: only close a PR in THIS repo, only delete a
332
+ // well-formed non-base branch. A bad server must not close a stranger's
333
+ // PR or delete `main` (`--delete` with `main`) via a cleanup job.
334
+ if (job.prUrl && isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
283
335
  try {
284
336
  execFileSync(
285
337
  'gh',
@@ -299,15 +351,18 @@ export async function runFleetDaemon() {
299
351
  const err = e.stderr?.toString?.() || e.message || '';
300
352
  warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
301
353
  }
302
- } else if (job.branch) {
354
+ } else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
303
355
  try {
304
- execFileSync('git', ['push', 'origin', '--delete', job.branch], {
356
+ // Explicit refspec form so a leading '-' can't be a git flag.
357
+ execFileSync('git', ['push', 'origin', `:refs/heads/${job.branch}`], {
305
358
  cwd: repoRoot,
306
359
  stdio: ['ignore', 'pipe', 'pipe'],
307
360
  });
308
361
  } catch {
309
362
  /* branch already gone — fine */
310
363
  }
364
+ } else if (job.prUrl || job.branch) {
365
+ warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
311
366
  }
312
367
  await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
313
368
  ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
@@ -417,6 +472,11 @@ export async function runFleetDaemon() {
417
472
  onChild: (ch) => {
418
473
  state.child = ch;
419
474
  },
475
+ // Hold the preview's stop fn so teardown/removal can kill the detached
476
+ // dev-server + tunnel (they survive our exit otherwise).
477
+ onPreview: (stop) => {
478
+ state.stopPreview = stop;
479
+ },
420
480
  // A turn that couldn't reach the MCP server: forget the cached token so
421
481
  // the next reconcile poll re-mints a fresh one (self-heals a token that
422
482
  // was rotated/expired out from under a running session).
@@ -441,6 +501,11 @@ export async function runFleetDaemon() {
441
501
  } catch {
442
502
  /* best-effort */
443
503
  }
504
+ try {
505
+ w.state.stopPreview?.();
506
+ } catch {
507
+ /* best-effort */
508
+ }
444
509
  try {
445
510
  git(['worktree', 'remove', '--force', w.wt], repoRoot);
446
511
  } catch {
@@ -449,6 +514,7 @@ export async function runFleetDaemon() {
449
514
  workers.delete(id);
450
515
  tokenByAgent.delete(id);
451
516
  hasWorkByAgent.delete(id);
517
+ mintedAt.delete(id); // was leaked on removal (finding 14)
452
518
  }
453
519
  }
454
520
 
package/bin/lib/git.mjs CHANGED
@@ -15,6 +15,50 @@ export function repoRootOrDie() {
15
15
  }
16
16
  }
17
17
 
18
+ // ── Server-value validation ────────────────────────────────────────────────
19
+ // prUrl / branch / agentId arrive from the fleet server. execFileSync blocks
20
+ // SHELL injection but NOT git/gh option injection (a leading '-' becomes a
21
+ // flag) or cross-repo/cross-path abuse. These guards make a malicious or buggy
22
+ // server unable to touch a repo/branch/path outside the expected scope.
23
+
24
+ /** The `owner/repo` the daemon is running inside, from origin's URL. Null if
25
+ * origin isn't a github remote. */
26
+ export function originSlug(repoRoot) {
27
+ try {
28
+ const url = git(['remote', 'get-url', 'origin'], repoRoot);
29
+ const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i);
30
+ return m ? `${m[1]}/${m[2]}` : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** A PR URL is accepted only if it's an https github.com PR in THIS repo. */
37
+ export function isValidPrUrl(prUrl, slug) {
38
+ if (typeof prUrl !== 'string' || !slug) return false;
39
+ const m = prUrl.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+$/);
40
+ return !!m && m[1].toLowerCase() === slug.toLowerCase();
41
+ }
42
+
43
+ /** A branch name is accepted only if git considers it a well-formed ref, it's
44
+ * not the base branch, and it doesn't start with '-' (option injection). */
45
+ export function isValidBranch(branch, repoRoot, baseRef) {
46
+ if (typeof branch !== 'string' || !branch || branch.startsWith('-')) return false;
47
+ if (baseRef && (branch === baseRef || `origin/${branch}` === baseRef)) return false;
48
+ try {
49
+ git(['check-ref-format', '--branch', branch], repoRoot);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /** A roster agent id used as a filesystem path segment — strict allowlist so
57
+ * it can't traverse (`..`, `/`) out of the worktrees dir. */
58
+ export function isSafePathSegment(id) {
59
+ return typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
60
+ }
61
+
18
62
  export function detectBaseRef(repoRoot) {
19
63
  try {
20
64
  return git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoRoot); // e.g. origin/main
package/bin/lib/live.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
17
17
  */
18
18
 
19
- import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { query } from '@anthropic-ai/claude-agent-sdk';
22
22
  import {
@@ -31,7 +31,7 @@ import {
31
31
  } from './config.mjs';
32
32
  import { c, info, ok, warn } from './ui.mjs';
33
33
  import { sleep } from './claude.mjs';
34
- import { git, resetWorktree } from './git.mjs';
34
+ import { git, resetWorktree, isValidBranch } from './git.mjs';
35
35
  import { loadPreviewConfig, startPreview } from './preview.mjs';
36
36
 
37
37
  // Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
@@ -46,6 +46,7 @@ async function registerLiveTarget(intentId, kind, url) {
46
46
  'User-Agent': USER_AGENT,
47
47
  'Content-Type': 'application/json',
48
48
  },
49
+ signal: AbortSignal.timeout(30_000),
49
50
  body: JSON.stringify({ intentId, kind, url }),
50
51
  });
51
52
  } catch {
@@ -124,6 +125,7 @@ async function mcpCall(mcpUrl, token, name, args) {
124
125
  // without this every live MCP call fails against api.flowviant.com.
125
126
  'User-Agent': USER_AGENT,
126
127
  },
128
+ signal: AbortSignal.timeout(30_000),
127
129
  body: JSON.stringify({
128
130
  jsonrpc: '2.0',
129
131
  id: ++rpcId,
@@ -206,6 +208,13 @@ function writeTaskMarker(cwd, intentId) {
206
208
  /* best-effort — worst case the next restart resets to base */
207
209
  }
208
210
  }
211
+ function clearTaskMarker(cwd) {
212
+ try {
213
+ rmSync(markerPath(cwd), { force: true });
214
+ } catch {
215
+ /* best-effort */
216
+ }
217
+ }
209
218
 
210
219
  // A stop word from any teammate halts the agent (interrupt at the next boundary,
211
220
  // then hold for direction) — the "stop, you're going the wrong way" valve.
@@ -261,7 +270,10 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
261
270
 
262
271
  // Revision resumes its PR branch; a genuinely fresh task gets a clean base
263
272
  // checkout; a resume (in-memory or marker) keeps its dirty worktree untouched.
264
- if (brief.branch) {
273
+ // The branch is server-supplied — validate it's a well-formed non-base ref
274
+ // (not a leading-'-' git option) before checkout; on a bad value fall back to
275
+ // a clean base rather than executing it.
276
+ if (brief.branch && isValidBranch(brief.branch, cwd, baseRef)) {
265
277
  try {
266
278
  git(['fetch', 'origin', '--quiet'], cwd);
267
279
  git(['checkout', brief.branch], cwd);
@@ -284,7 +296,13 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
284
296
  }
285
297
 
286
298
  const env = { ...process.env };
287
- delete env.ANTHROPIC_API_KEY; // force the user's Claude Code subscription
299
+ // Force the user's Claude Code subscription — strip EVERY var that could
300
+ // divert to API billing or a proxy (poll mode strips these too; live mode
301
+ // was only clearing API_KEY, so an exported AUTH_TOKEN/BASE_URL silently
302
+ // billed the API on the default path).
303
+ delete env.ANTHROPIC_API_KEY;
304
+ delete env.ANTHROPIC_AUTH_TOKEN;
305
+ delete env.ANTHROPIC_BASE_URL;
288
306
 
289
307
  // Prior channel transcript — present when resuming a parked/re-claimed task;
290
308
  // seed it so a fresh session picks up where the conversation left off. afterId
@@ -401,7 +419,14 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
401
419
  await flush();
402
420
  turnId = null;
403
421
 
404
- if (completed) return { outcome: 'done', title, intentId };
422
+ // Task finished clear the marker so this worktree is NOT treated as a
423
+ // resume of this intent later (esp. if the task is restarted from
424
+ // scratch, which discards it: a stale marker would resume the discarded
425
+ // attempt's dirty files).
426
+ if (completed) {
427
+ clearTaskMarker(cwd);
428
+ return { outcome: 'done', title, intentId };
429
+ }
405
430
 
406
431
  if (sawBlocker) {
407
432
  const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
@@ -425,6 +450,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
425
450
  // Torn down out from under us (restart / reassign in Flowviant): the
426
451
  // server killed this run — abandon the session, don't keep building.
427
452
  if (poll && poll.ok === false && poll.reason === 'run_not_active') {
453
+ // Discarded (restart/reassign): clear the marker AND reset the
454
+ // worktree so the next fresh claim starts from clean base, never
455
+ // resuming the abandoned attempt.
456
+ clearTaskMarker(cwd);
457
+ resetWorktree(cwd, baseRef);
428
458
  return { outcome: 'torn_down', title, intentId };
429
459
  }
430
460
  const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
@@ -493,6 +523,7 @@ export async function runLiveWorker({
493
523
  isAlive,
494
524
  onTokenSuspect,
495
525
  onChild,
526
+ onPreview,
496
527
  }) {
497
528
  // The intent this worker is holding across iterations. When a task parks on a
498
529
  // blocker its worktree keeps uncommitted work; on the resume claim we must NOT
@@ -519,6 +550,10 @@ export async function runLiveWorker({
519
550
  }
520
551
  preview = null;
521
552
  }
553
+ // Detached preview children (dev server + tunnel) survive process exit, so
554
+ // the daemon's SIGINT teardown needs a handle to stop them — clear it here
555
+ // once they're down.
556
+ onPreview?.(null);
522
557
  };
523
558
  const startReviewPreview = async (intentId) => {
524
559
  stopPreview();
@@ -544,6 +579,7 @@ export async function runLiveWorker({
544
579
  log: (m) => info(`${label} ${c.dim(m)}`),
545
580
  });
546
581
  if (preview) {
582
+ onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
547
583
  await registerLiveTarget(intentId, kind, preview.url);
548
584
  ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
549
585
  }
package/bin/lib/login.mjs CHANGED
@@ -37,6 +37,7 @@ async function post(url, body) {
37
37
  const res = await fetch(url, {
38
38
  method: 'POST',
39
39
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
40
+ signal: AbortSignal.timeout(30_000),
40
41
  body: JSON.stringify(body),
41
42
  });
42
43
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.9.0",
3
+ "version": "0.9.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": {