flowviant 0.77.3 → 0.77.5

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.
@@ -254,18 +254,53 @@ function forgetPreviewPid(pid) {
254
254
  mutateRegistry((list) => list.filter((e) => e.pid !== pid));
255
255
  }
256
256
 
257
- // Only kill a pid we can VERIFY is still one of ours — its /proc cmdline must
258
- // still contain the signature we stored. A reused pid belonging to something
259
- // unrelated won't match, so we never kill a stranger. Linux-only (that's where
260
- // /proc + process groups work); elsewhere we just clear the registry.
257
+ /**
258
+ * Only kill a pid we can VERIFY is still one of ours — its command line must
259
+ * still contain the signature we stored. A reused pid belonging to something
260
+ * unrelated won't match, so we never kill a stranger.
261
+ *
262
+ * MACOS READS IT THROUGH `ps`, because "Linux-only, elsewhere just clear the
263
+ * registry" was the worst of both. A macOS daemon killed ungracefully leaves
264
+ * cloudflared running — it is spawned detached — so the public hostname keeps
265
+ * resolving to this machine while the gate dies with the daemon. The reaper
266
+ * then verified nothing, killed nothing, and DELETED the entry, so no later run
267
+ * could ever find that process. The tunnel served 502 until anything else on
268
+ * the box bound the gate's old port, which `startAuthProxy` obtained with
269
+ * `listen(0)` and is therefore squarely inside the kernel's ephemeral reuse
270
+ * pool — at which point a live public hostname forwarded straight to an
271
+ * unrelated local service with no gate, no password and no grant check. Exactly
272
+ * what this file's header says the reap exists to prevent.
273
+ *
274
+ * Three states, not two: `true` (ours), `false` (verified NOT ours, or gone),
275
+ * and `null` (we could not look — the caller keeps the record rather than
276
+ * dropping it).
277
+ */
261
278
  function stillOurs(pid, sig) {
262
- if (platform() !== 'linux') return false;
263
- try {
264
- const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
265
- return typeof sig === 'string' && sig.length > 0 && cmd.includes(sig);
266
- } catch {
267
- return false; // process gone / unreadable
279
+ if (typeof sig !== 'string' || sig.length === 0) return false;
280
+ if (platform() === 'linux') {
281
+ try {
282
+ const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
283
+ return cmd.includes(sig);
284
+ } catch {
285
+ return false; // process gone / unreadable
286
+ }
268
287
  }
288
+ if (platform() === 'darwin') {
289
+ try {
290
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
291
+ encoding: 'utf8',
292
+ timeout: 5_000,
293
+ });
294
+ return cmd.includes(sig);
295
+ } catch {
296
+ // `ps` exits non-zero when the pid is gone — which is a real answer.
297
+ return false;
298
+ }
299
+ }
300
+ // Windows: no way to check from here. UNKNOWN, never "not ours" — see the
301
+ // caller, which keeps the record so a later run on a platform that can look
302
+ // is still able to.
303
+ return null;
269
304
  }
270
305
 
271
306
  /** Reap tunnel process groups left behind by a previously-crashed daemon.
@@ -283,8 +318,19 @@ export function reapOrphanPreviews(log) {
283
318
  const handled = new Set();
284
319
  for (const { pid, sig, owner } of list) {
285
320
  if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
321
+ const ours = stillOurs(pid, sig);
322
+ /**
323
+ * THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
324
+ * this check, so an entry was dropped whether or not anything was killed —
325
+ * and on any platform `stillOurs` could not read, that deleted the only
326
+ * trace of a tunnel still serving the public internet. Kept on `null`
327
+ * (unknown); removed on `true` (we killed it) and on `false` (the process
328
+ * is gone, or the pid now belongs to somebody else and the entry is stale
329
+ * either way).
330
+ */
331
+ if (ours === null) continue;
286
332
  handled.add(pid);
287
- if (!stillOurs(pid, sig)) continue;
333
+ if (!ours) continue;
288
334
  try {
289
335
  process.kill(-pid, 'SIGKILL'); // whole group
290
336
  killed++;
package/bin/lib/work.mjs CHANGED
@@ -3783,10 +3783,30 @@ export function createWorkManager({
3783
3783
  * — which the stale path does on purpose before a merge. A receipt naming
3784
3784
  * somebody else's commit is worse than a missing one.
3785
3785
  */
3786
- const out = git(
3787
- ['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()],
3788
- wt
3789
- );
3786
+ /**
3787
+ * IT CANNOT THROW, and that guard is the whole point of it being here.
3788
+ *
3789
+ * `git()` throws on a non-zero exit, and `baseRef()` is built from the
3790
+ * project's Base branch setting — free text an owner types, never verified
3791
+ * against the remote. Point it at a branch with no tracking ref (`develop`
3792
+ * on a repo whose remote branch is `dev`) and this exits "fatal: ambiguous
3793
+ * argument". The throw escaped `runAgentTurn` AFTER the CLI had already run
3794
+ * the card, so `postAgentTurn` was never reached, the server never settled
3795
+ * the turn, and the next poll handed back the identical turn — the same
3796
+ * card re-run every poll for six hours, on the operator's shared account,
3797
+ * piling commits onto the review branch, silently.
3798
+ *
3799
+ * An unreadable range means we cannot MEASURE the receipts, which is a
3800
+ * smaller failure than not settling: the turn still reports, and ship-time
3801
+ * reconciliation books whatever no card claimed. Missing beats fabricated
3802
+ * and both beat a stall.
3803
+ */
3804
+ let out;
3805
+ try {
3806
+ out = git(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()], wt);
3807
+ } catch {
3808
+ return [];
3809
+ }
3790
3810
  return typeof out === 'string'
3791
3811
  ? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
3792
3812
  : [];
@@ -4109,6 +4129,18 @@ export function createWorkManager({
4109
4129
  detached: true,
4110
4130
  stdio: ['ignore', 'pipe', 'pipe'],
4111
4131
  });
4132
+ /**
4133
+ * …AND IT IS TRACKED, so a stop or a takeover takes it with them.
4134
+ *
4135
+ * A check is a full test or build run in the agent's worktree, and it
4136
+ * was the one long-lived child the daemon spawned without telling its
4137
+ * own teardown about it. `shutdownWork` signalled the CLI children and
4138
+ * left this one — so restarting the daemon, or a same-repo takeover,
4139
+ * orphaned a running test suite inside a worktree the sweep may then
4140
+ * try to remove. The ten-minute timer would eventually kill it, but by
4141
+ * then it belongs to no daemon and nothing on any surface names it.
4142
+ */
4143
+ workChildren.set(child, null);
4112
4144
  } catch (e) {
4113
4145
  // TEXT BEFORE FINISH: `finish` captures `text` by value into the
4114
4146
  // resolved object, so assigning afterwards threw the spawn error away
@@ -4117,9 +4149,32 @@ export function createWorkManager({
4117
4149
  finish('failed');
4118
4150
  return;
4119
4151
  }
4120
- // The TAIL, not the head: a failing check says why at the end.
4152
+ /**
4153
+ * The TAIL, not the head: a failing check says why at the end. And
4154
+ * SCRUBBED BEFORE IT IS CUT, which is the order that matters.
4155
+ *
4156
+ * It used to slice first: `text = (text + buf).slice(-CAP)`, with a
4157
+ * single `envScrub` at the very end. `envScrub` replaces EXACT full
4158
+ * values, so any credential straddling either boundary — the rolling
4159
+ * window's, or a chunk's — was already cut in half by the time it was
4160
+ * looked at, matched nothing, and the surviving tail was written to
4161
+ * `agent.checkOutput` and shown to every member of the project. A failing
4162
+ * integration test that dumps its environment is an ordinary way to reach
4163
+ * that, and the partial is enough where the prefix of the key is a
4164
+ * publicly known constant.
4165
+ *
4166
+ * Scrubbing on every chunk fixes both straddles at once: the accumulated
4167
+ * text always holds the previous kept tail plus the whole new chunk, so a
4168
+ * value split across chunks is whole here, and a value near the window
4169
+ * edge is redacted before anything is discarded. Bounded work — the
4170
+ * string is never longer than the cap plus one chunk.
4171
+ *
4172
+ * The one case it cannot cover is a secret LONGER than the cap itself,
4173
+ * which can never sit in the window whole. The final scrub below stays as
4174
+ * the second pass over what actually ships.
4175
+ */
4121
4176
  const keep = (buf) => {
4122
- text = (text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
4177
+ text = envScrub(text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
4123
4178
  };
4124
4179
  child.stdout?.on('data', keep);
4125
4180
  child.stderr?.on('data', keep);
@@ -4143,11 +4198,13 @@ export function createWorkManager({
4143
4198
  }, CHECK_TIMEOUT_MS);
4144
4199
  child.on('error', (e) => {
4145
4200
  clearTimeout(timer);
4201
+ workChildren.delete(child);
4146
4202
  text += String(e?.message || e);
4147
4203
  finish('failed');
4148
4204
  });
4149
4205
  child.on('close', (code) => {
4150
4206
  clearTimeout(timer);
4207
+ workChildren.delete(child);
4151
4208
  finish(code === 0 ? 'passed' : 'failed');
4152
4209
  });
4153
4210
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.77.3",
4
- "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
3
+ "version": "0.77.5",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"