flowviant 0.77.5 → 0.78.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.
@@ -92,12 +92,28 @@ import { createServer, request } from 'node:http';
92
92
  import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
93
93
  import { GRANT_COOKIE, cookieValues, safePathname, safeRelative, stripCookie, verifyGrant } from './grant.mjs';
94
94
 
95
- /** Failed attempts before the proxy stops answering at all. A quick tunnel's
96
- * hostname is unguessable, so this is not the primary control — it is what
97
- * turns a discovered URL from an offline guessing target into a visible,
98
- * self-closing incident. */
95
+ /** Wrong-password attempts from ONE source before that source's Basic
96
+ * attempts are refused outright. A quick tunnel's hostname is unguessable,
97
+ * so this is not the primary control but it is KNOWN to every past
98
+ * member, tester and password recipient, whose access cannot be recalled.
99
+ * The threshold is per source because one global counter made 25 wrong
100
+ * guesses from any URL holder a kill switch on everyone else's share. */
99
101
  const MAX_FAILED = 25;
100
102
 
103
+ /** Counted failures across ALL sources before the whole share tears down.
104
+ * The per-source block above answers a single abuser; this answers a
105
+ * DISTRIBUTED guessing run (addresses rotating to stay under MAX_FAILED
106
+ * each) — the original self-closing-incident property, kept at a threshold
107
+ * ordinary use cannot reach: a blocked source stops counting, so getting
108
+ * here takes eight independent sources each burning their full allowance. */
109
+ const MAX_FAILED_TOTAL = 200;
110
+
111
+ /** Bound on the per-source map — a rotating attacker must not grow daemon
112
+ * memory without limit. Eviction is oldest-first and CAN un-block an evicted
113
+ * source, but cycling 500 fresh sources costs at least 500 counted failures,
114
+ * and the global backstop closes the share long before that. */
115
+ const MAX_SOURCES = 500;
116
+
101
117
  const digest = (s) => createHash('sha256').update(String(s)).digest();
102
118
 
103
119
  /** Constant-time over sha256 digests, so length never leaks and a missing
@@ -116,8 +132,10 @@ function sameSecret(a, b) {
116
132
  * hard failure. Binds loopback only; cloudflared connects locally, and the
117
133
  * password is what gates the public hostname.
118
134
  *
119
- * `onAbuse` fires once, after MAX_FAILED rejected attempts, so the caller can
120
- * tear the whole share down rather than leaving a URL under attack.
135
+ * `onAbuse` fires once, after MAX_FAILED_TOTAL rejected attempts across all
136
+ * sources, so the caller can tear the whole share down rather than leaving a
137
+ * URL under attack. A single source is blocked on its own, at MAX_FAILED,
138
+ * without ending anybody else's share.
121
139
  */
122
140
  export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId, authorizeUrl }) {
123
141
  // ALL THREE OR NONE. Two of the three is a gate that cannot bounce anybody:
@@ -134,44 +152,82 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
134
152
  const password = randomBytes(24).toString('base64url');
135
153
  const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
136
154
 
137
- let failed = 0;
155
+ let failedTotal = 0;
138
156
  let abused = false;
157
+ /** source → wrong-password count, insertion-ordered so eviction below is
158
+ * oldest-first. A source at MAX_FAILED is BLOCKED: its Basic attempts are
159
+ * refused BEFORE the comparison and stop counting toward the total. */
160
+ const failedBySource = new Map();
139
161
  /** The payload of the last validly-signed but EXPIRED grant seen, so a
140
162
  * tester can be re-bounced with the token inside it. */
141
163
  let lastExpired = null;
142
164
 
165
+ /** cloudflared forwards the real client address in Cf-Connecting-Ip; a
166
+ * direct local connection (the machine's own curl, the tests) has only the
167
+ * socket. The header is attacker-writable in principle, but lying in it
168
+ * only SPREADS one attacker across per-source counters — which is exactly
169
+ * the shape MAX_FAILED_TOTAL exists to answer. */
170
+ const sourceOf = (req) =>
171
+ String(req.headers['cf-connecting-ip'] || req.socket?.remoteAddress || 'unknown');
172
+
173
+ const sourceBlocked = (req) => (failedBySource.get(sourceOf(req)) ?? 0) >= MAX_FAILED;
174
+
175
+ const noteFailure = (req) => {
176
+ const src = sourceOf(req);
177
+ const count = (failedBySource.get(src) ?? 0) + 1;
178
+ // Delete-then-set so Map insertion order tracks recency, making the
179
+ // eviction below an LRU rather than "whoever failed first".
180
+ failedBySource.delete(src);
181
+ failedBySource.set(src, count);
182
+ if (failedBySource.size > MAX_SOURCES) {
183
+ failedBySource.delete(failedBySource.keys().next().value);
184
+ }
185
+ if (count === MAX_FAILED) {
186
+ log?.(`preview gate: ${count} failed attempts from ${src} — refusing that source.`);
187
+ }
188
+ // MONOTONE, never reset by a success: a distributed run has no successes
189
+ // to hide behind, and a legitimate share cannot reach the backstop —
190
+ // every source stops counting at MAX_FAILED, so 200 needs eight distinct
191
+ // sources each exhausting their own allowance.
192
+ failedTotal += 1;
193
+ if (failedTotal >= MAX_FAILED_TOTAL && !abused) {
194
+ abused = true;
195
+ log?.(`preview gate: ${failedTotal} failed attempts across sources — closing the share.`);
196
+ try {
197
+ onAbuse?.();
198
+ } catch {
199
+ /* the caller's teardown is best-effort */
200
+ }
201
+ }
202
+ };
203
+
143
204
  /**
144
205
  * A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
145
206
  * lets the websocket upgrade handler reuse it verbatim, and it is why routing
146
207
  * decisions live in the request handler instead.
147
208
  *
148
- * Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass'.
209
+ * Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass' | 'blocked'.
149
210
  */
150
211
  const credential = (req) => {
151
212
  if (abused) return 'badpass';
152
- if (sameSecret(req.headers['authorization'], expected)) {
153
- failed = 0;
154
- return 'ok';
155
- }
156
213
  // ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
157
214
  // than a preference. A forged HMAC is not brute-forceable, so counting it
158
215
  // buys nothing — while counting it would hand any stranger who finds the
159
- // hostname a 25-request KILL SWITCH on the owner's share, because onAbuse
160
- // tears the whole thing down. An expired-but-validly-signed grant must
161
- // never count either, or a viewer who left a tab open overnight closes the
162
- // share on their own reload. MAX_FAILED keeps its exact meaning, which is
163
- // also why the 'abuse' ended-reason sentence stays true.
216
+ // hostname a kill switch on the owner's share, because onAbuse tears the
217
+ // whole thing down. An expired-but-validly-signed grant must never count
218
+ // either, or a viewer who left a tab open overnight closes the share on
219
+ // their own reload.
164
220
  if (req.headers['authorization']) {
165
- failed += 1;
166
- if (failed >= MAX_FAILED && !abused) {
167
- abused = true;
168
- log?.(`preview gate: ${failed} failed attempts — closing the share.`);
169
- try {
170
- onAbuse?.();
171
- } catch {
172
- /* the caller's teardown is best-effort */
173
- }
221
+ // A blocked source is refused BEFORE the comparison — a block that
222
+ // still grades guesses would let the brute force run to a correct hit.
223
+ if (sourceBlocked(req)) return 'blocked';
224
+ if (sameSecret(req.headers['authorization'], expected)) {
225
+ // Per-source only: a shared NAT recovers when one person behind it
226
+ // gets the password right; failedTotal stays monotone (see above).
227
+ failedBySource.delete(sourceOf(req));
228
+ return 'ok';
174
229
  }
230
+ noteFailure(req);
175
231
  return 'badpass';
176
232
  }
177
233
 
@@ -188,8 +244,6 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
188
244
  return String(req.headers.cookie ?? '').includes(GRANT_COOKIE) ? 'forged' : 'none';
189
245
  };
190
246
 
191
- const authed = (req) => credential(req) === 'ok';
192
-
193
247
  /**
194
248
  * The CSRF backstop for the partitioned cookie (header rule 4a): a browser
195
249
  * old enough to ignore `Partitioned` while honouring `SameSite=None` will
@@ -388,6 +442,14 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
388
442
  }
389
443
  // 3. One predicate.
390
444
  const verdict = credential(req);
445
+ // A blocked source gets 429, not the challenge — a WWW-Authenticate here
446
+ // would invite the retry the block exists to end. Only that source's
447
+ // Basic attempts are refused; every other viewer's share is untouched.
448
+ if (verdict === 'blocked') {
449
+ res.writeHead(429, { 'Content-Type': 'text/plain', ...noStore });
450
+ res.end('too many failed attempts from this address');
451
+ return;
452
+ }
391
453
  if (verdict !== 'ok') {
392
454
  if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
393
455
  return challenge(res);
@@ -435,10 +497,17 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
435
497
  }
436
498
  // NEVER 302 HERE — browsers fail an upgrade rather than following a 3xx, so
437
499
  // a bounce would read as a dead dev server. The cookie IS sent on a
438
- // same-origin handshake, so `authed` works unchanged; a cookie-less upgrade
439
- // stays a 401 and the page's HMR client reconnects once the human has
440
- // re-authenticated in the main document.
441
- if (!authed(req)) {
500
+ // same-origin handshake, so the predicate works unchanged; a cookie-less
501
+ // upgrade stays a 401 and the page's HMR client reconnects once the human
502
+ // has re-authenticated in the main document. A blocked source's Basic
503
+ // handshake gets the same 429 the request path sends, and no challenge.
504
+ const upgradeVerdict = credential(req);
505
+ if (upgradeVerdict === 'blocked') {
506
+ socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
507
+ socket.destroy();
508
+ return;
509
+ }
510
+ if (upgradeVerdict !== 'ok') {
442
511
  socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
443
512
  socket.destroy();
444
513
  return;
@@ -12,10 +12,7 @@
12
12
  */
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
- import { mkdtempSync, writeFileSync } from 'node:fs';
16
- import { tmpdir } from 'node:os';
17
- import { join } from 'node:path';
18
- import { SAFE, MODEL } from './config.mjs';
15
+ import { SAFE } from './config.mjs';
19
16
  import { runtimeById, humanizeClaudeTool } from './runtimes.mjs';
20
17
 
21
18
  // Every prompt/kickoff constant lives in prompts.mjs and is re-exported here:
@@ -141,20 +138,6 @@ export const blockedId = (out) => {
141
138
  return m ? m[1] : null;
142
139
  };
143
140
 
144
- export function mcpConfigFor(token, mcpUrl) {
145
- const dir = mkdtempSync(join(tmpdir(), 'flowviant-'));
146
- const p = join(dir, 'mcp.json');
147
- writeFileSync(
148
- p,
149
- JSON.stringify({
150
- mcpServers: {
151
- flowviant: { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${token}` } },
152
- },
153
- })
154
- );
155
- return { dir, path: p };
156
- }
157
-
158
141
  /**
159
142
  * Hand a runtime the flowviant MCP server, however that runtime wants it.
160
143
  *
@@ -259,7 +242,17 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
259
242
  // permission, an aborted run). Under `answerFromResult` this is the only
260
243
  // stdout that would have said so, and a caller whose `out` is the answer
261
244
  // must not report "no output" for a turn that explained itself.
262
- const msg = ev.error?.message ?? ev.error ?? ev.subtype;
245
+ // `errors[]` FIRST, because it is where the real sentence is. Claude
246
+ // Code reports a dead `--resume` id as
247
+ // `{subtype:'error_during_execution', errors:['No conversation found
248
+ // with session ID: …']}` — reading only `error`/`subtype` dropped that
249
+ // and appended the literal string `error_during_execution`, which told
250
+ // the driver nothing and hid the one phrase the caller needs to
251
+ // recognise a lost conversation.
252
+ const listed = Array.isArray(ev.errors)
253
+ ? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
254
+ : '';
255
+ const msg = listed || ev.error?.message || ev.error || ev.subtype;
263
256
  appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
264
257
  }
265
258
  }
@@ -10,13 +10,14 @@
10
10
  * wrangler output routinely echoes secrets.
11
11
  */
12
12
 
13
- import { readFileSync, existsSync } from 'node:fs';
13
+
14
14
  import { spawn } from 'node:child_process';
15
15
  import { join } from 'node:path';
16
16
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
17
17
  import { c, note, ok, warn } from './ui.mjs';
18
18
  import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
19
19
  import { childEnv } from './childEnv.mjs';
20
+ import { git } from './git.mjs';
20
21
 
21
22
  const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
22
23
 
@@ -38,27 +39,64 @@ async function post(tail, body) {
38
39
  return json?.data;
39
40
  }
40
41
 
41
- /** Read + parse .flowviant/deploy.json from the repo root. Returns [] if none. */
42
- export function readDeployConfig(repoRoot) {
43
- const path = join(repoRoot, '.flowviant', 'deploy.json');
44
- if (!existsSync(path)) return [];
42
+ /**
43
+ * Read + parse `.flowviant/deploy.json` AS IT IS ON THE BASE BRANCH. Returns []
44
+ * if the branch has none.
45
+ *
46
+ * IT USED TO READ THE WORKING TREE, which made the feature's one stated bound
47
+ * false. `deploy_target`'s own description says "only ids already declared in
48
+ * `.flowviant/deploy.json` on MAIN can be named (the daemon reads and runs from
49
+ * the repo ROOT, never a session worktree, so an agent cannot author the command
50
+ * it triggers without shipping it first)" — and the repo root's WORKING TREE is
51
+ * exactly where the machine operator's tabs stand (their place is the checkout).
52
+ * So an agent that had read an injected instruction could write an uncommitted
53
+ * `.flowviant/deploy.json` naming any shell command, call `deploy_target`, and
54
+ * have the daemon run it from the repo root with `CLOUDFLARE_API_TOKEN` and
55
+ * every other deploy-scope credential in its environment. Nothing about that
56
+ * needed a commit, a review, or an owner.
57
+ *
58
+ * Reading the COMMITTED tree is what makes the sentence true: authoring the
59
+ * command now requires landing it on base, which is a reviewed act. The file is
60
+ * read through git rather than the filesystem, so an uncommitted edit is simply
61
+ * not there.
62
+ *
63
+ * A base ref that does not resolve yields NO TARGETS, and says so once. That is
64
+ * the withholding direction and it is the right one here — a deploy is
65
+ * irreversible and running the wrong file is worse than running nothing.
66
+ */
67
+ export function readDeployConfig(repoRoot, baseRef) {
68
+ let raw;
69
+ if (baseRef) {
70
+ try {
71
+ raw = git(['show', `${baseRef}:.flowviant/deploy.json`], repoRoot);
72
+ } catch {
73
+ // No such file on base, or a base ref that does not resolve. Both mean
74
+ // "this branch declares no targets", which is a real answer.
75
+ return [];
76
+ }
77
+ } else {
78
+ // No base ref in hand (a caller that has not been updated). Refuse rather
79
+ // than silently falling back to the working tree — that fallback IS the bug.
80
+ warn('deploy: no base branch resolved, so no deploy targets were read.');
81
+ return [];
82
+ }
45
83
  try {
46
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
84
+ const parsed = JSON.parse(raw);
47
85
  const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
48
86
  // Keep only fields the server + runner need; the daemon holds the commands.
49
87
  return targets
50
88
  .filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
51
89
  .slice(0, 20);
52
90
  } catch (e) {
53
- warn(`deploy: .flowviant/deploy.json is not valid JSON — ${e.message}`);
91
+ warn(`deploy: .flowviant/deploy.json on the base branch is not valid JSON — ${e.message}`);
54
92
  return [];
55
93
  }
56
94
  }
57
95
 
58
96
  /** Report the parsed config to the server (only when it changed). */
59
97
  let lastConfigJson = null;
60
- export async function reportDeployConfig(repoRoot) {
61
- const targets = readDeployConfig(repoRoot);
98
+ export async function reportDeployConfig(repoRoot, baseRef) {
99
+ const targets = readDeployConfig(repoRoot, baseRef);
62
100
  const json = JSON.stringify(targets);
63
101
  if (json === lastConfigJson) return;
64
102
  // Scrub command strings before the server sees them — a command line can embed
@@ -154,6 +192,23 @@ async function verifyHealth(url, status) {
154
192
  }
155
193
 
156
194
  const claiming = new Set(); // in-flight guard (single-flight per daemon process)
195
+ /**
196
+ * JOBS THIS PROCESS HAS ALREADY RUN, whatever the server thinks.
197
+ *
198
+ * A deploy is IRREVERSIBLE and the report is not: a transient 5xx, a DNS blip
199
+ * or the 30s timeout meant the outcome never landed, the heartbeat stopped,
200
+ * and three minutes later the server requeued the job and this same daemon ran
201
+ * `wrangler rollback` — or a full prod deploy with every pushSecret re-pushed —
202
+ * a SECOND time, leaving production two versions behind the intended one with
203
+ * nothing recording that it happened twice.
204
+ *
205
+ * So the process remembers. Not a substitute for the report (see the retry
206
+ * below, which is the real fix); a floor under it, for the case where the
207
+ * report never lands at all. It does not survive a restart — nothing local
208
+ * could be trusted to — which is why the retry has to keep the heartbeat alive
209
+ * while it runs.
210
+ */
211
+ const ran = new Set();
157
212
 
158
213
  /**
159
214
  * Process queued deploy jobs from the roster. `ctx` = { repoRoot, baseRef,
@@ -168,6 +223,7 @@ export function processDeployJobs(jobs, ctx) {
168
223
  // reconcile loop, since this runs unguarded from the fleet tick.
169
224
  if (!job || typeof job.id !== 'string') continue;
170
225
  if (claiming.has(job.id)) continue;
226
+ if (ran.has(job.id)) continue; // already executed here — never twice
171
227
  claiming.add(job.id);
172
228
  void (async () => {
173
229
  let beat = null;
@@ -185,10 +241,41 @@ export function processDeployJobs(jobs, ctx) {
185
241
  // Keep the claim fresh while we run — a long deploy must never be
186
242
  // re-queued out from under us (that would double-deploy). The async
187
243
  // run() below keeps the event loop free so this fires.
244
+ /**
245
+ * …AND IT CAN DIE, which is what makes the `stillBeating` predicate
246
+ * below mean anything.
247
+ *
248
+ * `report` is handed `() => beat != null` so it stops retrying once the
249
+ * claim is certainly stale — but `beat` only ever held a timer handle
250
+ * and was never nulled, so that predicate could not return false and
251
+ * `report` retried into a job another daemon may already own.
252
+ *
253
+ * The server re-queues a deploy whose heartbeat is older than three
254
+ * minutes, and this fires every sixty seconds — so three consecutive
255
+ * failures is exactly the point past which the claim cannot be assumed.
256
+ * A single blip does not count: only an unbroken run does.
257
+ */
258
+ let missed = 0;
188
259
  beat = setInterval(() => {
189
- void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => {});
260
+ void post('deploy-heartbeat', {
261
+ jobId: job.id,
262
+ pubkey: ctx.myPubB64(),
263
+ // Instance rides the heartbeat too, or a same-box sibling's beat
264
+ // could keep a dead claimer's job "running" past the stale sweep.
265
+ instance: DAEMON_INSTANCE,
266
+ })
267
+ .then(() => {
268
+ missed = 0;
269
+ })
270
+ .catch(() => {
271
+ missed += 1;
272
+ if (missed >= 3 && beat) {
273
+ clearInterval(beat);
274
+ beat = null;
275
+ }
276
+ });
190
277
  }, 60_000);
191
- const targets = readDeployConfig(ctx.repoRoot);
278
+ const targets = readDeployConfig(ctx.repoRoot, ctx.baseRef);
192
279
  const target = targets.find((t) => t.id === job.targetId);
193
280
  if (!target) {
194
281
  await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
@@ -196,13 +283,19 @@ export function processDeployJobs(jobs, ctx) {
196
283
  }
197
284
  note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
198
285
  const outcome = await runDeploy(job, target, ctx);
199
- await report(job, ctx, outcome);
286
+ // From here the work is DONE. Whatever the report does, this job must
287
+ // never run again in this process.
288
+ ran.add(job.id);
289
+ await report(job, ctx, outcome, () => beat != null);
200
290
  if (outcome.ok) ok(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} done${outcome.healthOk === false ? ' (health failed)' : ''}`)}`);
201
291
  else warn(`deploy: ${job.targetId} → ${job.env} failed — ${outcome.message}`);
202
292
  } catch (e) {
203
293
  warn(`deploy job ${job.id} errored: ${e.message}`);
204
294
  await report(job, ctx, { ok: false, message: e.message }).catch(() => {});
205
295
  } finally {
296
+ // Stopped only AFTER the report has landed or given up — the requeue is
297
+ // gated on heartbeat staleness, so beating through the retries is what
298
+ // stops the server handing this job out again mid-retry.
206
299
  if (beat) clearInterval(beat);
207
300
  claiming.delete(job.id);
208
301
  }
@@ -278,13 +371,55 @@ async function runDeploy(job, target, ctx) {
278
371
  };
279
372
  }
280
373
 
281
- function report(job, ctx, outcome) {
282
- return post('deploy-report', {
374
+ /**
375
+ * THE OUTCOME IS RETRIED, because losing it re-runs the deploy.
376
+ *
377
+ * One `post` with a `.catch(warn)` was the whole of this: a transient 5xx, a
378
+ * DNS blip or the 30s timeout dropped the outcome, the `finally` stopped the
379
+ * heartbeat, and the server — which requeues a running job after three minutes
380
+ * without one — handed the SAME job back to the SAME daemon, which ran it
381
+ * again. For a rollback that is production two versions behind the intended
382
+ * one; for a prod deploy it is every pushSecret pushed twice. Nothing recorded
383
+ * that it had happened at all.
384
+ *
385
+ * The heartbeat keeps running throughout (the caller's `finally` is what stops
386
+ * it), so the requeue window stays shut for as long as we are still trying.
387
+ * Bounded: six attempts over roughly a minute, then a warning and the local
388
+ * `ran` guard as the floor.
389
+ */
390
+ async function report(job, ctx, outcome, stillBeating = () => true) {
391
+ const body = {
283
392
  jobId: job.id,
284
393
  pubkey: ctx.myPubB64(),
394
+ // The same term the claim carries, for the same reason: the pubkey is one
395
+ // keypair per home directory, so two daemons on one box share it, and a
396
+ // stale holder's late report would otherwise settle the RECLAIMER's
397
+ // running job. The server matches it when present; an older server
398
+ // ignores the extra key.
399
+ instance: DAEMON_INSTANCE,
285
400
  ok: !!outcome.ok,
286
401
  deploymentId: outcome.deploymentId ?? null,
287
402
  healthOk: outcome.healthOk ?? null,
288
403
  message: scrub(outcome.message || ''),
289
- }).catch((e) => warn(`deploy: could not report outcome — ${e.message}`));
404
+ };
405
+ for (let attempt = 0; attempt < 6; attempt++) {
406
+ try {
407
+ await post('deploy-report', body);
408
+ return;
409
+ } catch (e) {
410
+ // The last attempt says so; the ones before it are noise on a path that
411
+ // usually recovers.
412
+ if (attempt === 5) {
413
+ warn(`deploy: could not report outcome after 6 tries — ${e.message}`);
414
+ return;
415
+ }
416
+ // If the heartbeat is already gone the requeue window is open and
417
+ // retrying buys nothing — the job may have been handed to somebody else.
418
+ if (!stillBeating()) {
419
+ warn(`deploy: could not report outcome — ${e.message}`);
420
+ return;
421
+ }
422
+ await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
423
+ }
424
+ }
290
425
  }
package/bin/lib/env.mjs CHANGED
@@ -249,7 +249,9 @@ export async function loadCachedEnv(projectId) {
249
249
  if (!cached) return false;
250
250
  values = cached.values ?? [];
251
251
  bundleVersion = cached.bundleVersion ?? -1;
252
- knownTargetFiles = new Set(cached.knownFiles ?? values.map((v) => v.targetFile));
252
+ // Filtered even though we wrote the cache: this set feeds removeStaleEnvFile,
253
+ // and a cache file predates whatever rules the running daemon enforces.
254
+ knownTargetFiles = new Set((cached.knownFiles ?? values.map((v) => v.targetFile)).filter(isSafeTarget));
253
255
  cachedProjectId = projectId;
254
256
  return values.length > 0;
255
257
  }
@@ -264,8 +266,8 @@ export async function loadCachedEnv(projectId) {
264
266
  * only and never touches the user's repo". Git does not read that file: it
265
267
  * resolves `info/exclude` against $GIT_COMMON_DIR — the main `.git` — so in
266
268
  * every linked worktree the daemon creates, the exclusion did nothing at all.
267
- * The plaintext secret files stayed visible to `git add -A`, which is what
268
- * `checkpointWip` runs before force-pushing a WIP commit to the remote.
269
+ * The plaintext secret files stayed visible to `git add -A` the first thing
270
+ * an agent runs before committing and pushing the branch it is working on.
269
271
  *
270
272
  * `--git-common-dir` is asked of git rather than derived, because that is the
271
273
  * one answer that cannot drift from what git itself will consult. The file is
@@ -307,13 +309,25 @@ export function excludeInWorktree(wt, relPaths) {
307
309
  }
308
310
  }
309
311
 
312
+ /** MUST match the server's isSafeEnvTargetFile (env.schema.ts) — the server
313
+ * validates at intake, but this file also refills paths from the on-disk
314
+ * cache and hands them to rmSync, so the daemon holds its own line rather
315
+ * than trusting either source. `.git` is refused at ANY depth and
316
+ * case-insensitively (git treats `.GIT` the same on case-insensitive
317
+ * filesystems): a target of `.git/hooks/pre-commit` would turn a
318
+ * materialized value into code git runs on the operator's next commit —
319
+ * check-ignore alone must not be the only thing standing there. The control
320
+ * range subsumes the old bare `\0` check and keeps a newline out of a PATH,
321
+ * where nothing downstream expects one. */
310
322
  const isSafeTarget = (p) =>
311
- p &&
323
+ typeof p === 'string' &&
324
+ p.length > 0 &&
312
325
  p.length <= 200 &&
313
326
  !p.includes('\\') &&
314
- !p.includes('\0') &&
327
+ // eslint-disable-next-line no-control-regex
328
+ !/[\x00-\x1f\x7f]/.test(p) &&
315
329
  !p.startsWith('/') &&
316
- p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..');
330
+ p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..' && s.toLowerCase() !== '.git');
317
331
 
318
332
  /** Is this path TRACKED in the repo? info/exclude only hides UNTRACKED files —
319
333
  * materializing secrets into a tracked file would make them stageable and
@@ -406,8 +420,16 @@ function renderEnvFile(list) {
406
420
  }
407
421
 
408
422
  /** Delete a materialized file from a worktree, but ONLY if it's ours (carries
409
- * our header) and not git-tracked — never touch a file we didn't write. */
423
+ * our header) and not git-tracked — never touch a file we didn't write.
424
+ *
425
+ * `rel` gets the SAME gate the write path has: it arrives via
426
+ * knownTargetFiles, which is refilled from the server bundle and from the
427
+ * on-disk cache, and this function joins it to a worktree and calls rmSync.
428
+ * The isTrackedInGit check cannot stand in for validation — `ls-files` on a
429
+ * path outside the worktree THROWS, the catch reads as "not tracked", and
430
+ * the deletion proceeds. A delete primitive validates its own input. */
410
431
  function removeStaleEnvFile(wt, rel) {
432
+ if (!isSafeTarget(rel)) return;
411
433
  if (isTrackedInGit(wt, rel)) return;
412
434
  const abs = join(wt, rel);
413
435
  try {
@@ -460,8 +482,8 @@ export function materializeInto(wt) {
460
482
 
461
483
  // Exclude BEFORE writing, not after. The old order wrote plaintext first and
462
484
  // tried to hide it afterwards, so every failure mode — and the exclude file
463
- // being the wrong one, which it was — left a readable secret in a tree that
464
- // `checkpointWip` force-pushes.
485
+ // being the wrong one, which it was — left a readable secret sitting where
486
+ // the next `git add -A` in that tree would stage it for a push.
465
487
  excludeInWorktree(wt, [...byFile.keys()]);
466
488
 
467
489
  const written = [];
@@ -477,9 +499,9 @@ export function materializeInto(wt) {
477
499
  anyProblem = true;
478
500
  continue;
479
501
  }
480
- // The load-bearing check. A materialized secret sits in a worktree whose
481
- // whole tree gets `git add -A`'d and force-pushed by the WIP checkpoint, so
482
- // "git cannot see this file" is a precondition for writing it, not a nicety.
502
+ // The load-bearing check. A materialized secret sits in a worktree where an
503
+ // agent runs `git add -A` and pushes as a matter of course, so "git cannot
504
+ // see this file" is a precondition for writing it, not a nicety.
483
505
  if (!isIgnoredInGit(wt, file)) {
484
506
  warn(`env: "${file}" is not gitignored — refusing to write secrets there. Add it to .gitignore. Its keys are NOT materialized.`);
485
507
  refusedForGit.push(file);
@@ -557,17 +579,6 @@ export function materializeInto(wt) {
557
579
  everMaterialized = true;
558
580
  }
559
581
 
560
- /**
561
- * The secret files this daemon has materialized into `wt`.
562
- *
563
- * Exists so anything that stages the whole tree can subtract them by pathspec.
564
- * Belt to the check-ignore braces: `git add -A` obeys .gitignore, so a properly
565
- * ignored file is already safe — but "already safe" was the assumption that put
566
- * plaintext on a remote branch, and a second, independent mechanism is cheap.
567
- */
568
- export function materializedFiles(wt) {
569
- return [...(lastFilesByWorktree.get(wt) ?? [])];
570
- }
571
582
 
572
583
  // ── Uplink scrubbing ───────────────────────────────────────────────────────
573
584
 
@@ -704,7 +715,9 @@ export async function handleRosterEnv(env, { projectId } = {}) {
704
715
  bundleVersion = bundle.bundleVersion;
705
716
  // Fold the current target files into the persisted known set so stale
706
717
  // cleanup survives a restart (a key deleted while down still gets swept).
707
- for (const v of values) knownTargetFiles.add(v.targetFile);
718
+ // Filtered at the fill, not just at the delete: this set is persisted,
719
+ // so an unsafe path admitted here would outlive the bundle that sent it.
720
+ for (const v of values) if (isSafeTarget(v.targetFile)) knownTargetFiles.add(v.targetFile);
708
721
  if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion, knownFiles: [...knownTargetFiles] });
709
722
  ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
710
723
  return { changed: true };