flowviant 0.78.0 → 0.79.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 +5 -1
- package/bin/lib/authproxy.mjs +101 -32
- package/bin/lib/claude.mjs +1 -18
- package/bin/lib/deploy.mjs +13 -1
- package/bin/lib/env.mjs +37 -24
- package/bin/lib/fleet.mjs +26 -37
- package/bin/lib/git.mjs +0 -263
- package/bin/lib/grant.mjs +24 -3
- package/bin/lib/landed.mjs +69 -22
- package/bin/lib/preview.mjs +35 -16
- package/bin/lib/prompts.mjs +25 -26
- package/bin/lib/shipSweep.mjs +16 -5
- package/bin/lib/work.mjs +411 -50
- package/bin/lib/worktreeDiff.mjs +55 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,11 @@ Launch with `@latest` so each start pulls the newest published version — a bar
|
|
|
47
47
|
|
|
48
48
|
Each tab in the Workbench is a persistent Claude session with its own worktree, and it stays where you left it — the branch outlives the tab. The daemon runs each turn in event mode and relays what the CLI is printing (thinking, reads, greps, commands) back to the tab, reports the worktree's branch and diffstat after every turn, and fetches a commit's patch when you click a sha in the app.
|
|
49
49
|
|
|
50
|
-
Nothing starts
|
|
50
|
+
Nothing starts a session except you opening a tab and typing in it.
|
|
51
|
+
|
|
52
|
+
## Agents
|
|
53
|
+
|
|
54
|
+
Press **Deploy** on the board and this machine runs a read-only scratch turn that proposes how the selected cards should be split across agents; accepting the proposal is what cuts worktrees and starts work. From **0.79.0** that planning turn is relayed the same way a session turn is — the reads, greps and thoughts the daemon was already printing behind `[plan]` now reach the press itself, along with the two facts only this side can see: the CLI actually starting, and the press waiting for the checkout while a ship or another turn holds it. A planning CLI that wedges is stopped after fifteen minutes and the press is reported failed in the machine's own words, rather than sitting silent until the server expires it half an hour later.
|
|
51
55
|
|
|
52
56
|
## Sharing a preview
|
|
53
57
|
|
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
120
|
-
* tear the whole share down rather than leaving a
|
|
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
|
|
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
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
439
|
-
// stays a 401 and the page's HMR client reconnects once the human
|
|
440
|
-
// re-authenticated in the main document.
|
|
441
|
-
|
|
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;
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -12,10 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
-
import {
|
|
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
|
*
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -257,7 +257,13 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
257
257
|
*/
|
|
258
258
|
let missed = 0;
|
|
259
259
|
beat = setInterval(() => {
|
|
260
|
-
void post('deploy-heartbeat', {
|
|
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
|
+
})
|
|
261
267
|
.then(() => {
|
|
262
268
|
missed = 0;
|
|
263
269
|
})
|
|
@@ -385,6 +391,12 @@ async function report(job, ctx, outcome, stillBeating = () => true) {
|
|
|
385
391
|
const body = {
|
|
386
392
|
jobId: job.id,
|
|
387
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,
|
|
388
400
|
ok: !!outcome.ok,
|
|
389
401
|
deploymentId: outcome.deploymentId ?? null,
|
|
390
402
|
healthOk: outcome.healthOk ?? null,
|
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
|
-
|
|
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
|
|
268
|
-
*
|
|
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
|
-
|
|
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
|
|
464
|
-
// `
|
|
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
|
|
481
|
-
//
|
|
482
|
-
//
|
|
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
|
-
|
|
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 };
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -38,7 +38,6 @@ import { handleVersionSignal } from './update.mjs';
|
|
|
38
38
|
import {
|
|
39
39
|
git,
|
|
40
40
|
resetWorktree,
|
|
41
|
-
ensureWorktree,
|
|
42
41
|
repoRootOrDie,
|
|
43
42
|
detectBaseRef,
|
|
44
43
|
originSlug,
|
|
@@ -46,7 +45,6 @@ import {
|
|
|
46
45
|
isValidPrUrl,
|
|
47
46
|
isValidBranch,
|
|
48
47
|
isSafePathSegment,
|
|
49
|
-
worktreeDiffstat,
|
|
50
48
|
} from './git.mjs';
|
|
51
49
|
import { c, info, note, ok, warn, fail } from './ui.mjs';
|
|
52
50
|
import { revertPatch, withPatchLock } from './patch.mjs';
|
|
@@ -321,7 +319,13 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
|
|
|
321
319
|
repoStateScanAt = Date.now();
|
|
322
320
|
let payload;
|
|
323
321
|
try {
|
|
324
|
-
|
|
322
|
+
// The PARAM, and nothing else: this function sits at MODULE scope, where
|
|
323
|
+
// runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
|
|
324
|
+
// reached for it anyway, the ReferenceError landed in the catch below —
|
|
325
|
+
// written for an unreadable repo, silent by design — and this endpoint was
|
|
326
|
+
// never once posted to. The caller reads the loop's live `baseRef` at call
|
|
327
|
+
// time, so the value here is always current.
|
|
328
|
+
const state = repoState(repoRoot, baseRef);
|
|
325
329
|
if (!state) return; // not readable — say nothing rather than say "none"
|
|
326
330
|
payload = JSON.stringify(state);
|
|
327
331
|
} catch {
|
|
@@ -773,33 +777,6 @@ export async function runFleetDaemon() {
|
|
|
773
777
|
return run;
|
|
774
778
|
};
|
|
775
779
|
|
|
776
|
-
/** A clean detached checkout at base — what "the real code" has to mean for a
|
|
777
|
-
* question about the repo, rather than whatever half-finished state an agent
|
|
778
|
-
* worktree happens to be in. Shared by the plan check and consults. */
|
|
779
|
-
const ensureWikiWorktree = () => {
|
|
780
|
-
if (!existsSync(wikiWt)) {
|
|
781
|
-
try {
|
|
782
|
-
git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
|
|
783
|
-
} catch {
|
|
784
|
-
git(['worktree', 'prune'], repoRoot);
|
|
785
|
-
git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
|
|
786
|
-
}
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
// It already exists — which means it is pinned to whatever base pointed at
|
|
790
|
-
// when it was FIRST created, possibly weeks ago. "Reads the real code" has
|
|
791
|
-
// to mean the current base, so re-point it. Best-effort: a stale answer
|
|
792
|
-
// beats no answer, and the next turn tries again.
|
|
793
|
-
try {
|
|
794
|
-
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
795
|
-
git(['checkout', '--detach', baseRef], wikiWt);
|
|
796
|
-
git(['reset', '--hard', baseRef], wikiWt);
|
|
797
|
-
git(['clean', '-fd'], wikiWt);
|
|
798
|
-
} catch {
|
|
799
|
-
/* offline, or a turn left it dirty — read what we have */
|
|
800
|
-
}
|
|
801
|
-
};
|
|
802
|
-
|
|
803
780
|
// Machine telemetry — what the box is doing with itself, for the admin view.
|
|
804
781
|
const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
|
|
805
782
|
|
|
@@ -1146,7 +1123,24 @@ export async function runFleetDaemon() {
|
|
|
1146
1123
|
// the turn still reads the real changed files; this catches pages whose
|
|
1147
1124
|
// frontmatter file list has drifted, or that document a concept rather
|
|
1148
1125
|
// than a directory.
|
|
1149
|
-
|
|
1126
|
+
//
|
|
1127
|
+
// SANITIZED AT THE INTAKE, because every entry is server-supplied text
|
|
1128
|
+
// that ends up interpolated into a prompt and printed by the drain's
|
|
1129
|
+
// narration: a control byte can repaint the console it lands on, a
|
|
1130
|
+
// newline can break out of the prompt's own list framing, and an
|
|
1131
|
+
// unbounded array of unbounded strings is an unbounded prompt. This is
|
|
1132
|
+
// the belt at the intake; the prompt keeps its own fence at the
|
|
1133
|
+
// interpolation.
|
|
1134
|
+
dirtiesPages: (Array.isArray(dirtiesPages) ? dirtiesPages : [])
|
|
1135
|
+
.filter((p) => typeof p === 'string')
|
|
1136
|
+
.slice(0, 40)
|
|
1137
|
+
.map((p) =>
|
|
1138
|
+
p
|
|
1139
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
1140
|
+
.trim()
|
|
1141
|
+
.slice(0, 300)
|
|
1142
|
+
)
|
|
1143
|
+
.filter(Boolean),
|
|
1150
1144
|
// THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
|
|
1151
1145
|
// Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
|
|
1152
1146
|
// shas on every reground job, this function never stored them, and the
|
|
@@ -1686,12 +1680,7 @@ export async function runFleetDaemon() {
|
|
|
1686
1680
|
// tab. Without this the daemon that lost the lease removes the worktree the
|
|
1687
1681
|
// winner is working in — absence would mean "somebody else won" instead of
|
|
1688
1682
|
// "the tab closed".
|
|
1689
|
-
retireWorkSessions(
|
|
1690
|
-
Array.isArray(roster.activeWorkSessions)
|
|
1691
|
-
? roster.activeWorkSessions
|
|
1692
|
-
: roster.activeWorkSessions,
|
|
1693
|
-
roster.sessionsHeldElsewhere
|
|
1694
|
-
);
|
|
1683
|
+
retireWorkSessions(roster.activeWorkSessions, roster.sessionsHeldElsewhere);
|
|
1695
1684
|
// Diffs somebody has open and is waiting on. Project-scoped rather than
|
|
1696
1685
|
// per-session: `git show` runs from the repo ROOT, which can see a closed
|
|
1697
1686
|
// tab's branch and a shipped commit on main alike.
|