flowviant 0.115.0 → 0.117.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 +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +99 -145
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/git.mjs
CHANGED
|
@@ -3,9 +3,49 @@
|
|
|
3
3
|
import { execFileSync, spawn } from 'node:child_process';
|
|
4
4
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { killGroup } from './processGroup.mjs';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* ONE LOCAL GIT CALL, trimmed. Throws on a non-zero exit (with git's stderr on
|
|
10
|
+
* the error), like `execFileSync` itself.
|
|
11
|
+
*
|
|
12
|
+
* `opts` SPREADS OVER THE DEFAULTS (audit 2026-10-10, F107): with none, the
|
|
13
|
+
* call is exactly what it always was — utf8, stderr piped, no timeout, Node's
|
|
14
|
+
* own 1 MiB `maxBuffer`. A caller that needs a wider buffer, a timeout, a
|
|
15
|
+
* quiet stderr (`stdio: ['ignore', 'pipe', 'ignore']`) or an `env` asks for it
|
|
16
|
+
* here instead of re-spelling `execFileSync('git', …)` with its own drifted
|
|
17
|
+
* options — nine modules did, one of them under this function's own name.
|
|
18
|
+
* Buffer output (`encoding: 'buffer'`) goes through `gitRaw`: there is nothing
|
|
19
|
+
* to trim.
|
|
20
|
+
*/
|
|
21
|
+
export function git(args, cwd, opts = {}) {
|
|
22
|
+
return gitRaw(args, cwd, opts).trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* THE WIDE BUFFER — 8 MiB, for a read whose output grows with the repository
|
|
27
|
+
* (a catch-up range's commit list, a patch, the branch report). Node's 1 MiB
|
|
28
|
+
* default threw ENOBUFS mid-read in three lanes, each of which then learned the
|
|
29
|
+
* number on its own (landed.mjs, repoState.mjs, workDiffs.mjs); one name now.
|
|
30
|
+
*/
|
|
31
|
+
export const GIT_WIDE_BUFFER = 8 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A git fact, or NULL when git could not answer — a non-zero exit (a detached
|
|
35
|
+
* HEAD for `symbolic-ref`, an unborn branch for `rev-parse HEAD`, a range
|
|
36
|
+
* naming a ref that is not there) or empty output. For the reads that are
|
|
37
|
+
* OPTIONAL to their caller: the lanes each re-learned that `git()` throws
|
|
38
|
+
* rather than returning a non-string, and wrote the same try/catch → null ten
|
|
39
|
+
* times, one of them left out (audit 2026-10-10, F144). A caller that must
|
|
40
|
+
* tell one failure from another (the begun-guard's exit status) does not use
|
|
41
|
+
* this.
|
|
42
|
+
*/
|
|
43
|
+
export function gitOrNull(args, cwd) {
|
|
44
|
+
try {
|
|
45
|
+
return git(args, cwd) || null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
9
49
|
}
|
|
10
50
|
|
|
11
51
|
/**
|
|
@@ -37,12 +77,10 @@ export function gitNetEnv(cwd, env = process.env) {
|
|
|
37
77
|
if (env.GIT_SSH_COMMAND || env.GIT_SSH) return out;
|
|
38
78
|
let configured = '';
|
|
39
79
|
try {
|
|
40
|
-
configured =
|
|
41
|
-
cwd,
|
|
42
|
-
encoding: 'utf8',
|
|
80
|
+
configured = git(['config', '--get', 'core.sshCommand'], cwd, {
|
|
43
81
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
44
82
|
timeout: 5_000,
|
|
45
|
-
})
|
|
83
|
+
});
|
|
46
84
|
} catch {
|
|
47
85
|
/* unset (exit 1) or unreadable — ours, then */
|
|
48
86
|
}
|
|
@@ -62,14 +100,7 @@ export function gitNetEnv(cwd, env = process.env) {
|
|
|
62
100
|
* throws with `code: 'ETIMEDOUT'`).
|
|
63
101
|
*/
|
|
64
102
|
export function gitNet(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
|
|
65
|
-
return
|
|
66
|
-
cwd,
|
|
67
|
-
encoding: 'utf8',
|
|
68
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
69
|
-
timeout: ms,
|
|
70
|
-
killSignal: 'SIGKILL',
|
|
71
|
-
env: gitNetEnv(cwd),
|
|
72
|
-
});
|
|
103
|
+
return gitRaw(args, cwd, { timeout: ms, killSignal: 'SIGKILL', env: gitNetEnv(cwd) });
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
/**
|
|
@@ -103,15 +134,7 @@ export function gitNetAsync(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
|
|
|
103
134
|
fn(v);
|
|
104
135
|
};
|
|
105
136
|
const timer = setTimeout(() => {
|
|
106
|
-
|
|
107
|
-
process.kill(-child.pid, 'SIGKILL');
|
|
108
|
-
} catch {
|
|
109
|
-
try {
|
|
110
|
-
child.kill('SIGKILL');
|
|
111
|
-
} catch {
|
|
112
|
-
/* already gone */
|
|
113
|
-
}
|
|
114
|
-
}
|
|
137
|
+
killGroup(child, 'SIGKILL');
|
|
115
138
|
const e = new Error(`git ${args[0]} timed out after ${ms}ms`);
|
|
116
139
|
e.code = 'ETIMEDOUT';
|
|
117
140
|
finish(reject, e);
|
|
@@ -147,8 +170,8 @@ export function gitNetAsync(args, cwd, ms = GIT_NET_TIMEOUT_MS) {
|
|
|
147
170
|
* has an accent in it. For the patch collision check, "silently stops matching"
|
|
148
171
|
* means "overwrites the edits it exists to protect".
|
|
149
172
|
*/
|
|
150
|
-
export function gitRaw(args, cwd) {
|
|
151
|
-
return execFileSync('git', args, {
|
|
173
|
+
export function gitRaw(args, cwd, opts = {}) {
|
|
174
|
+
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts, cwd });
|
|
152
175
|
}
|
|
153
176
|
|
|
154
177
|
/** Split NUL-separated git output into entries. */
|
|
@@ -166,10 +189,10 @@ export function repoRootOrDie() {
|
|
|
166
189
|
}
|
|
167
190
|
|
|
168
191
|
// ── Server-value validation ────────────────────────────────────────────────
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
192
|
+
// Shas and ids arrive from the fleet server. execFileSync blocks SHELL
|
|
193
|
+
// injection but NOT git/gh option injection (a leading '-' becomes a flag) or
|
|
194
|
+
// cross-path abuse. These guards make a malicious or buggy server unable to
|
|
195
|
+
// touch a revision/path outside the expected scope.
|
|
173
196
|
|
|
174
197
|
/** The `owner/repo` the daemon is running inside, from origin's URL. Null if
|
|
175
198
|
* origin isn't a github remote. */
|
|
@@ -183,32 +206,27 @@ export function originSlug(repoRoot) {
|
|
|
183
206
|
}
|
|
184
207
|
}
|
|
185
208
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
git(['check-ref-format', '--branch', branch], repoRoot);
|
|
200
|
-
return true;
|
|
201
|
-
} catch {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
209
|
+
/**
|
|
210
|
+
* THE TWO SHA SHAPES THE DAEMON CHECKS, named in one place (audit 2026-10-10,
|
|
211
|
+
* F749) so the split between them is visible instead of spelled five ways:
|
|
212
|
+
* `SHA1_ABBREV_RE` — lowercase, 7 to 40 hex, a SHA-1 id or its abbreviation
|
|
213
|
+
* (`isValidSha`, and the publish lease's `isSha`, which is this same guard);
|
|
214
|
+
* `OBJECT_ID_RE` — either case, 7 to 64 hex, which also admits a SHA-256
|
|
215
|
+
* repository's ids (the landed observer, the commit-diff lane). Each site
|
|
216
|
+
* keeps the shape it already checked; unifying on 64 is a behaviour change on
|
|
217
|
+
* SHA-256 repositories and an owner's call. Neither admits a leading `-` or a
|
|
218
|
+
* range. Anchored and flag-free of `g`, so `.test` holds no state.
|
|
219
|
+
*/
|
|
220
|
+
export const SHA1_ABBREV_RE = /^[0-9a-f]{7,40}$/;
|
|
221
|
+
export const OBJECT_ID_RE = /^[0-9a-f]{7,64}$/i;
|
|
205
222
|
|
|
206
|
-
/** A commit sha from the server, before it reaches
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
223
|
+
/** A commit sha from the server, before it reaches git argv. Server values
|
|
224
|
+
* reaching git are validated here by convention — a revision RANGE
|
|
225
|
+
* ("HEAD~10..HEAD") or a leading-dash option must never pass, whatever the
|
|
226
|
+
* roster says. (The PR-URL and branch guards beside it served only the
|
|
227
|
+
* retired cleanup lane and went with it, audit 2026-10-10, F083.) */
|
|
210
228
|
export function isValidSha(sha) {
|
|
211
|
-
return typeof sha === 'string' &&
|
|
229
|
+
return typeof sha === 'string' && SHA1_ABBREV_RE.test(sha);
|
|
212
230
|
}
|
|
213
231
|
|
|
214
232
|
/** A roster agent id used as a filesystem path segment — strict allowlist so
|
|
@@ -217,6 +235,45 @@ export function isSafePathSegment(id) {
|
|
|
217
235
|
return typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
|
218
236
|
}
|
|
219
237
|
|
|
238
|
+
/**
|
|
239
|
+
* THE BRANCH A PLACE IS CUT ON — `session/<id>`, for a tab's own worktree, a
|
|
240
|
+
* person's `u-` place and an agent's `a-` place alike. One definition, beside
|
|
241
|
+
* the segment rule that guards it (audit 2026-10-10, F122): the cutter, the
|
|
242
|
+
* begun-guard's rev-parse, the publish push and fetch, ship's and the PR
|
|
243
|
+
* lane's fallback, and the merged-branch sweep all name the branch through
|
|
244
|
+
* here, so a change to the scheme or to the id allowlist lands once instead of
|
|
245
|
+
* at nine template strings that could drift into cutting one branch and
|
|
246
|
+
* checking for another.
|
|
247
|
+
*
|
|
248
|
+
* Null for an id that is not a safe path segment: every caller treats that
|
|
249
|
+
* exactly as it treats a branch that is not there.
|
|
250
|
+
*/
|
|
251
|
+
export const SESSION_BRANCH_PREFIX = 'session/';
|
|
252
|
+
export function sessionBranch(id) {
|
|
253
|
+
return isSafePathSegment(id) ? `${SESSION_BRANCH_PREFIX}${id}` : null;
|
|
254
|
+
}
|
|
255
|
+
/** The same branch as a full ref, for argv that must not let git guess. */
|
|
256
|
+
export function sessionBranchRef(id) {
|
|
257
|
+
const name = sessionBranch(id);
|
|
258
|
+
return name ? `refs/heads/${name}` : null;
|
|
259
|
+
}
|
|
260
|
+
/** A ref Flowviant itself cut for a place. The ONLY thing that makes a branch
|
|
261
|
+
* "ours" — the reader beside the writer, so the two cannot disagree. */
|
|
262
|
+
export function isSessionBranch(name) {
|
|
263
|
+
return typeof name === 'string' && name.startsWith(SESSION_BRANCH_PREFIX);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A REMOTE URL'S USERINFO, REMOVED. `//user:token@host` is how a credential
|
|
268
|
+
* rides a remote, and git or gh can print that URL in the first line of a
|
|
269
|
+
* failure that is about to be stored where the whole project reads it. Shared
|
|
270
|
+
* by every relay of a push or `gh` failure (publishErrorText, ghFirstLine) so
|
|
271
|
+
* the rule is one regex, not two copies (audit 2026-10-10, F153).
|
|
272
|
+
*/
|
|
273
|
+
export function stripUrlUserinfo(text) {
|
|
274
|
+
return String(text ?? '').replace(/\/\/[^/@\s]+@/g, '//');
|
|
275
|
+
}
|
|
276
|
+
|
|
220
277
|
export function detectBaseRef(repoRoot) {
|
|
221
278
|
try {
|
|
222
279
|
return git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoRoot); // e.g. origin/main
|
|
@@ -346,14 +403,7 @@ export function excludeInWorktree(wt, relPaths) {
|
|
|
346
403
|
try {
|
|
347
404
|
let gitdir;
|
|
348
405
|
try {
|
|
349
|
-
gitdir = resolve(
|
|
350
|
-
wt,
|
|
351
|
-
execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
352
|
-
cwd: wt,
|
|
353
|
-
encoding: 'utf8',
|
|
354
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
355
|
-
}).trim()
|
|
356
|
-
);
|
|
406
|
+
gitdir = resolve(wt, git(['rev-parse', '--git-common-dir'], wt, { stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
357
407
|
} catch {
|
|
358
408
|
return; // not a repo — there is no exclude file to write
|
|
359
409
|
}
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* are their own.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import { git } from './git.mjs';
|
|
15
|
+
import { git, gitRaw } from './git.mjs';
|
|
17
16
|
|
|
18
17
|
export const DAEMON_GIT_IDENTITY = Object.freeze({
|
|
19
18
|
GIT_AUTHOR_NAME: 'Flowviant',
|
|
@@ -41,11 +40,6 @@ export function mergeIdentityEnv(repoRoot) {
|
|
|
41
40
|
export function gitMergeIn(repoRoot) {
|
|
42
41
|
return (args, cwd) => {
|
|
43
42
|
const idEnv = mergeIdentityEnv(repoRoot);
|
|
44
|
-
return
|
|
45
|
-
cwd,
|
|
46
|
-
encoding: 'utf8',
|
|
47
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
-
...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
|
|
49
|
-
});
|
|
43
|
+
return gitRaw(args, cwd, idEnv ? { env: { ...process.env, ...idEnv } } : {});
|
|
50
44
|
};
|
|
51
45
|
}
|
package/bin/lib/grant.mjs
CHANGED
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
* hostname is un-gated. It also means a network blip cannot turn somebody's
|
|
16
16
|
* working preview into a 502.
|
|
17
17
|
*
|
|
18
|
-
* THIS FILE IS ONE HALF OF A WIRE CONTRACT. The other half is
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* THIS FILE IS ONE HALF OF A WIRE CONTRACT. The other half is the app's ONE
|
|
19
|
+
* minter, `mintGrant` in `packages/shared/src/previewGrant.ts`, which both the
|
|
20
|
+
* API's `/authorize` and the preview relay sign with. The release gate
|
|
21
|
+
* (`scripts/check-app-parity.mjs`, the grant case) mints with that function and
|
|
22
|
+
* verifies here, so a change on either side that the other does not follow
|
|
23
|
+
* fails a release instead of shipping as a preview nobody can open.
|
|
24
|
+
* (`grant.test.mjs` tests this half alone, against its own minter.)
|
|
22
25
|
*
|
|
23
26
|
* NO CANONICAL-ENCODING CONTRACT, deliberately: the signature covers the
|
|
24
27
|
* RECEIVED payload string bytes and the JSON is parsed only afterwards. Field
|
|
@@ -30,7 +33,9 @@
|
|
|
30
33
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
31
34
|
|
|
32
35
|
export const GRANT_VERSION = 1;
|
|
33
|
-
|
|
36
|
+
/** Domain separation: the signed message is `fvgrant.v1.<body>`. Exported for
|
|
37
|
+
* the release gate's comparison with the app's `GRANT_DOMAIN`. */
|
|
38
|
+
export const GRANT_DOMAIN = `fvgrant.v${GRANT_VERSION}.`;
|
|
34
39
|
|
|
35
40
|
/** `__Host-` is required rather than decorative: every quick tunnel is a
|
|
36
41
|
* hostname under the shared `trycloudflare.com` parent, so without the prefix
|
|
@@ -81,7 +86,7 @@ export function verifyGrant(raw, { secret, shareId, nowMs, skewMs } = {}) {
|
|
|
81
86
|
return { ok: false, reason: 'shape' };
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
const expected = b64url(createHmac('sha256', String(secret)).update(
|
|
89
|
+
const expected = b64url(createHmac('sha256', String(secret)).update(GRANT_DOMAIN + body).digest());
|
|
85
90
|
if (!sameSecret(sig, expected)) return { ok: false, reason: 'sig' };
|
|
86
91
|
|
|
87
92
|
let payload;
|
|
@@ -185,6 +190,15 @@ export function safePathname(url) {
|
|
|
185
190
|
* the cheap first line; the origin comparison is the guarantee. Ported from
|
|
186
191
|
* the server twins (previewAuthorize.routes.ts, preview-relay util.ts), which
|
|
187
192
|
* closed the same hole the same way.
|
|
193
|
+
*
|
|
194
|
+
* …AND THE OUTPUT IS CHECKED, NOT ONLY THE INPUT (2026-10-10, audit F618). The
|
|
195
|
+
* parser resolves dot segments, so `/.//evil.com`, `/..//evil.com`,
|
|
196
|
+
* `/%2e%2e//evil.com` and `/./\evil.com` pass the lexical test above (none
|
|
197
|
+
* starts with two slashes) and then re-serialise to the pathname
|
|
198
|
+
* `//evil.com` — protocol-relative in Location, though the parse stayed on the
|
|
199
|
+
* dummy origin. The value returned must itself be a single-slash path. No
|
|
200
|
+
* version floor: the app's /authorize already re-cleans every `to` it hands
|
|
201
|
+
* this callback, so only a hand-crafted callback URL ever met the hole.
|
|
188
202
|
*/
|
|
189
203
|
export function safeRelative(raw) {
|
|
190
204
|
if (!raw) return '/';
|
|
@@ -198,7 +212,9 @@ export function safeRelative(raw) {
|
|
|
198
212
|
try {
|
|
199
213
|
const u = new URL(s, 'https://x.invalid');
|
|
200
214
|
if (u.origin !== 'https://x.invalid') return '/';
|
|
201
|
-
|
|
215
|
+
const out = u.pathname + u.search + u.hash;
|
|
216
|
+
if (/^\/[/\\]/.test(out)) return '/';
|
|
217
|
+
return out;
|
|
202
218
|
} catch {
|
|
203
219
|
return '/';
|
|
204
220
|
}
|
package/bin/lib/holder.mjs
CHANGED
|
@@ -16,8 +16,23 @@
|
|
|
16
16
|
|
|
17
17
|
import { safeName } from './credentials.mjs';
|
|
18
18
|
import { emitMachineEvent } from './daemonLogging.mjs';
|
|
19
|
+
import { printableLine } from './printable.mjs';
|
|
19
20
|
import { note, warn } from './ui.mjs';
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
/** A bound on a wedged uplink, and never a reason to hang: the timer is
|
|
23
|
+
* unref'd, so the only thing that keeps this process alive is the work.
|
|
24
|
+
* The one copy every stand-down below waits behind (2026-10-10): the
|
|
25
|
+
* commanded stop used to race a plain `sleep(5)` imported from claude.mjs —
|
|
26
|
+
* a timer that held the loop, and an import that dragged the whole runtime
|
|
27
|
+
* registry into a module that is pure or injected throughout. */
|
|
28
|
+
const bounded = (p, seconds) =>
|
|
29
|
+
Promise.race([
|
|
30
|
+
p,
|
|
31
|
+
new Promise((resolve) => {
|
|
32
|
+
const t = setTimeout(resolve, seconds * 1000);
|
|
33
|
+
t.unref?.();
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
21
36
|
|
|
22
37
|
/**
|
|
23
38
|
* A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
|
|
@@ -53,11 +68,11 @@ export function shouldStop(rosterDaemon) {
|
|
|
53
68
|
// on, and an unbounded one would bury the line that matters. The WORDING is
|
|
54
69
|
// untouched — the operator's own sentence is the whole point of the field,
|
|
55
70
|
// and paraphrasing it would leave the person at the keyboard guessing.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
//
|
|
72
|
+
// The SHARED class (printable.mjs, 2026-10-10, F084): this copy used to stop
|
|
73
|
+
// at C0 and DEL, so the 8-bit CSI `\u009b` and every bidi override reached
|
|
74
|
+
// the terminal whenever the server's own scrub was bypassed.
|
|
75
|
+
const reason = printableLine(String(stop.reason ?? ''), 300);
|
|
61
76
|
return { stop: true, reason };
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -263,7 +278,21 @@ export function removedTurnSentence() {
|
|
|
263
278
|
}
|
|
264
279
|
|
|
265
280
|
/**
|
|
266
|
-
* THE
|
|
281
|
+
* …AND FOR THE THIRD: Flowviant STOPPED this machine (2026-10-10, F091).
|
|
282
|
+
*
|
|
283
|
+
* The commanded stop used to leave its agent turns unsettled: they sat pending
|
|
284
|
+
* until the server's six-hour expiry while the board showed an agent working
|
|
285
|
+
* on a machine that had gone dark. Settled now with `nothing` and this
|
|
286
|
+
* sentence — never delivered, so the card goes to Stuck rather than an
|
|
287
|
+
* optimistic Review. It names no box and no reason: nothing took over, and
|
|
288
|
+
* the operator's reason is said on this console, where the stop is read.
|
|
289
|
+
*/
|
|
290
|
+
export function stoppedTurnSentence() {
|
|
291
|
+
return 'This machine was stopped by Flowviant while this turn was running.';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* THE ROSTER ENDED THIS DAEMON. STAND DOWN.
|
|
267
296
|
*
|
|
268
297
|
* The server sends `displaced` only when it is aimed at THIS box (it matches the
|
|
269
298
|
* poll's own `envpub`) and only inside a short window, so there is nothing to
|
|
@@ -276,11 +305,15 @@ export function removedTurnSentence() {
|
|
|
276
305
|
* board shows an agent working on a machine that has gone. `nothing` is the
|
|
277
306
|
* honest outcome and the sentence says what happened.
|
|
278
307
|
*
|
|
279
|
-
* EXIT 0,
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* restart loop fighting a decision somebody
|
|
308
|
+
* EXIT 0, and this is load-bearing: every kind of stand-down was ASKED FOR, so
|
|
309
|
+
* it is not a failure. Under `Restart=on-failure` a nonzero code has systemd
|
|
310
|
+
* relaunch this daemon immediately — a stopped machine fighting the very
|
|
311
|
+
* command that stopped it, a displaced or removed one polling, being told
|
|
312
|
+
* again, and standing down again: a restart loop fighting a decision somebody
|
|
313
|
+
* made on purpose. Exit 0 reads as "the job is done" and leaves it down. For a
|
|
314
|
+
* stop, the server's honor window is what makes the other half work — a
|
|
315
|
+
* deliberate relaunch minutes later comes up clean instead of stopping itself
|
|
316
|
+
* forever.
|
|
284
317
|
*
|
|
285
318
|
* Every dependency is injected so the whole stand-down can be proved without a
|
|
286
319
|
* server, a repo or a process to kill.
|
|
@@ -288,6 +321,7 @@ export function removedTurnSentence() {
|
|
|
288
321
|
export async function standDownDisplaced({
|
|
289
322
|
by,
|
|
290
323
|
kind = 'moved',
|
|
324
|
+
reason,
|
|
291
325
|
project,
|
|
292
326
|
settleAgentTurns,
|
|
293
327
|
flushReports,
|
|
@@ -295,69 +329,83 @@ export async function standDownDisplaced({
|
|
|
295
329
|
exit,
|
|
296
330
|
log = { warn: () => {}, note: () => {} },
|
|
297
331
|
}) {
|
|
298
|
-
/** A bound on a wedged uplink, and never a reason to hang: the timer is
|
|
299
|
-
* unref'd, so the only thing that keeps this process alive is the work. */
|
|
300
|
-
const bounded = (p, seconds) =>
|
|
301
|
-
Promise.race([
|
|
302
|
-
p,
|
|
303
|
-
new Promise((resolve) => {
|
|
304
|
-
const t = setTimeout(resolve, seconds * 1000);
|
|
305
|
-
t.unref?.();
|
|
306
|
-
}),
|
|
307
|
-
]);
|
|
308
332
|
// Scrubbed before it reaches this box's own console (2026-09-24, the
|
|
309
333
|
// audit) — see printable.mjs.
|
|
310
334
|
const name = safeName(by)?.slice(0, 64) ?? null;
|
|
311
335
|
/**
|
|
312
|
-
*
|
|
336
|
+
* THREE KINDS, ONE CHOREOGRAPHY (2026-09-21; the third 2026-10-10, F091).
|
|
313
337
|
*
|
|
314
338
|
* `moved` is the original: another box took the machine, and it is the
|
|
315
339
|
* DEFAULT so the displaced call site reads exactly as it always did.
|
|
316
340
|
* `removed` is the project saying this box is not its machine at all any
|
|
317
|
-
* more — somebody pressed a button in the app
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
341
|
+
* more — somebody pressed a button in the app. `stopped` is Flowviant
|
|
342
|
+
* commanding this machine to stop (`shouldStop`; `reason` is its already
|
|
343
|
+
* scrubbed sentence). Only the WORDS differ. Everything below the words is
|
|
344
|
+
* the same, and it must be: settle the turns first, flush the reports, tear
|
|
345
|
+
* down the detached children, keep the worktrees, exit 0.
|
|
346
|
+
*
|
|
347
|
+
* The commanded stop was a third, hand-written copy of these steps until
|
|
348
|
+
* 2026-10-10, and it had drifted: it never settled its agent turns, which
|
|
349
|
+
* then sat pending until the server's expiry while the board showed agents
|
|
350
|
+
* working on a machine somebody had just stopped.
|
|
321
351
|
*
|
|
322
|
-
* The sentences are picked HERE rather than passed in, so the
|
|
323
|
-
* drift and
|
|
352
|
+
* The sentences are picked HERE rather than passed in, so the kinds cannot
|
|
353
|
+
* drift and no call site can invent a fourth.
|
|
324
354
|
*/
|
|
325
355
|
const removed = kind === 'removed';
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
else
|
|
356
|
+
const stopped = kind === 'stopped';
|
|
357
|
+
if (stopped) {
|
|
358
|
+
// The stop's own words and event, as they always were.
|
|
359
|
+
emitMachineEvent({ event: 'stopped', reason: reason ?? null });
|
|
360
|
+
log.warn(reason ? `stopped by Flowviant — ${reason}` : 'stopped by Flowviant — no reason given.');
|
|
361
|
+
// "Worktrees are kept" and no more: this note used to add "in-flight work
|
|
362
|
+
// resumes next run", which a settled agent turn — Stuck, waiting for a
|
|
363
|
+
// person — no longer does.
|
|
364
|
+
log.note('shutting down — stopping workers. Worktrees are kept.');
|
|
365
|
+
} else {
|
|
366
|
+
const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
|
|
367
|
+
log.warn(
|
|
368
|
+
removed
|
|
369
|
+
? `removed from ${projectName} in the app — stopping.`
|
|
370
|
+
: name
|
|
371
|
+
? `this project's machine moved to ${name} — standing down.`
|
|
372
|
+
: "this project's machine moved to another box — standing down."
|
|
373
|
+
);
|
|
374
|
+
if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
|
|
375
|
+
else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
|
|
376
|
+
}
|
|
336
377
|
// FIRST, and awaited: an unsettled turn is the one thing here that no later
|
|
337
378
|
// poll from anybody can fix — this process holds the only copy of the fact
|
|
338
379
|
// that it was running.
|
|
339
380
|
try {
|
|
340
381
|
await bounded(
|
|
341
|
-
settleAgentTurns(
|
|
382
|
+
settleAgentTurns(
|
|
383
|
+
stopped ? stoppedTurnSentence() : removed ? removedTurnSentence() : displacedTurnSentence(by)
|
|
384
|
+
),
|
|
342
385
|
10
|
|
343
386
|
);
|
|
344
387
|
} catch {
|
|
345
388
|
/* an unsettled turn expires server-side with words of its own */
|
|
346
389
|
}
|
|
347
|
-
// THEN the queued settles, bounded
|
|
348
|
-
//
|
|
349
|
-
//
|
|
390
|
+
// THEN the queued settles, bounded: a queued-but-undelivered report is a
|
|
391
|
+
// COMPLETED turn whose side effects already happened, and dropping it re-runs
|
|
392
|
+
// the whole turn — quota spent twice and every card write doubled. This path
|
|
393
|
+
// is async (unlike the signal handlers, which cannot await), so it can afford
|
|
394
|
+
// five seconds of delivery before it obeys.
|
|
350
395
|
try {
|
|
351
396
|
await bounded(flushReports(), 5);
|
|
352
397
|
} catch {
|
|
353
398
|
/* undelivered reports re-run; delivering them was best-effort */
|
|
354
399
|
}
|
|
355
|
-
// NOT optional
|
|
356
|
-
//
|
|
357
|
-
// worktree on a box that no longer serves the project
|
|
358
|
-
//
|
|
400
|
+
// teardown() is NOT optional. Detached preview tunnels survive this process
|
|
401
|
+
// BY DESIGN, so exiting without it strands a public hostname pointed into a
|
|
402
|
+
// worktree — on a box that no longer serves the project, or until somebody
|
|
403
|
+
// reboots a stopped one, which is precisely the state a remote stop is
|
|
404
|
+
// usually being used to end. It also kills the session CLIs and the wiki
|
|
405
|
+
// Claude, which would otherwise keep editing worktrees and burning quota for
|
|
406
|
+
// a machine nobody is watching any more.
|
|
359
407
|
teardown();
|
|
360
|
-
log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
|
|
408
|
+
if (!stopped) log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
|
|
361
409
|
exit(0);
|
|
362
410
|
}
|
|
363
411
|
|
|
@@ -382,39 +430,18 @@ export async function obeyRosterCommands(
|
|
|
382
430
|
// going away.
|
|
383
431
|
const stopSignal = shouldStop(roster.daemon);
|
|
384
432
|
if (stopSignal) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
// delivery before it obeys.
|
|
398
|
-
try {
|
|
399
|
-
await Promise.race([flushWorkReports(), sleep(5)]);
|
|
400
|
-
} catch {
|
|
401
|
-
/* undelivered reports re-run; delivering them was best-effort */
|
|
402
|
-
}
|
|
403
|
-
// teardown() is NOT optional on this path. Detached preview tunnels
|
|
404
|
-
// survive this process BY DESIGN, so exiting without it strands a public
|
|
405
|
-
// hostname pointed into a worktree until somebody reboots the box — which
|
|
406
|
-
// is precisely the state a remote stop is usually being used to end. It
|
|
407
|
-
// also kills the session CLIs and the wiki Claude, which would otherwise
|
|
408
|
-
// keep editing worktrees and burning quota for a machine nobody is
|
|
409
|
-
// watching any more.
|
|
410
|
-
teardown();
|
|
411
|
-
// EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
|
|
412
|
-
// a failure. Under `Restart=on-failure` a nonzero code has systemd
|
|
413
|
-
// relaunch the daemon immediately, fighting the very command that stopped
|
|
414
|
-
// it; exit 0 reads as "the job is done" and leaves it down. The server's
|
|
415
|
-
// honor window is what makes the other half work — a deliberate relaunch
|
|
416
|
-
// minutes later comes up clean instead of stopping itself forever.
|
|
417
|
-
exit(0);
|
|
433
|
+
// The same choreography as a displacement (`standDownDisplaced`, kind
|
|
434
|
+
// `stopped`): its own words and event, then settle the agent turns, flush
|
|
435
|
+
// the queued reports, tear down, exit 0.
|
|
436
|
+
await standDownDisplaced({
|
|
437
|
+
kind: 'stopped',
|
|
438
|
+
reason: stopSignal.reason,
|
|
439
|
+
settleAgentTurns,
|
|
440
|
+
flushReports: flushWorkReports,
|
|
441
|
+
teardown,
|
|
442
|
+
exit,
|
|
443
|
+
log,
|
|
444
|
+
});
|
|
418
445
|
return true;
|
|
419
446
|
}
|
|
420
447
|
/**
|