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.
- package/bin/lib/authproxy.mjs +101 -32
- package/bin/lib/claude.mjs +12 -19
- package/bin/lib/deploy.mjs +150 -15
- package/bin/lib/env.mjs +37 -24
- package/bin/lib/fleet.mjs +136 -46
- package/bin/lib/git.mjs +0 -263
- package/bin/lib/grant.mjs +24 -3
- package/bin/lib/landed.mjs +69 -22
- package/bin/lib/localSessions.mjs +85 -3
- package/bin/lib/preflight.mjs +1 -1
- package/bin/lib/preview.mjs +35 -16
- package/bin/lib/prompts.mjs +40 -23
- package/bin/lib/shipSweep.mjs +16 -5
- package/bin/lib/work.mjs +364 -55
- package/bin/lib/worktreeDiff.mjs +55 -20
- package/package.json +1 -1
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
|
*
|
|
@@ -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
|
-
|
|
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
|
}
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* wrangler output routinely echoes secrets.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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 };
|