flowviant 0.116.0 → 0.124.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 +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -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 +90 -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 +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- 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/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- 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/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -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 +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- 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/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- 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 +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -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 +113 -31
- 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 +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/loginPath.mjs
CHANGED
|
@@ -11,17 +11,27 @@
|
|
|
11
11
|
* measured "not installed" under the tray (2026-09-25).
|
|
12
12
|
*
|
|
13
13
|
* So the daemon asks the person's login shell for its PATH once, and keeps
|
|
14
|
-
* every entry it already had FIRST (a terminal start
|
|
14
|
+
* every entry it already had FIRST (a terminal start keeps its order) with the
|
|
15
15
|
* shell's additions after it; then the usual per-user bin directories that
|
|
16
16
|
* exist. Best-effort and bounded: a shell that hangs or prints nothing costs a
|
|
17
|
-
* few seconds once
|
|
17
|
+
* few seconds once.
|
|
18
|
+
*
|
|
19
|
+
* ONLY ABSOLUTE ENTRIES ARE KEPT, from every input — the current PATH too
|
|
20
|
+
* (2026-10-10, F118). A relative entry (`node_modules/.bin`, `.`, and the empty
|
|
21
|
+
* entry, which also means "here") resolves against whatever directory a child
|
|
22
|
+
* is spawned in, and the daemon spawns `git`, `gh`, `cloudflared` and the CLIs
|
|
23
|
+
* by bare name with `cwd` set to an agent's worktree: a binary an agent plants
|
|
24
|
+
* there would run as the daemon, outside any sandbox, with its credentials. A
|
|
25
|
+
* common ~/.bashrc idiom is enough to open that. `FLOWVIANT_KEEP_PATH=1` is the
|
|
26
|
+
* operator's explicit opt-out and leaves PATH exactly as they set it.
|
|
18
27
|
*/
|
|
19
28
|
|
|
20
|
-
import { execFileSync
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
21
30
|
import { existsSync, readdirSync } from 'node:fs';
|
|
22
31
|
import { homedir, platform } from 'node:os';
|
|
23
32
|
import { join } from 'node:path';
|
|
24
33
|
import { compareVersions } from './versionOrder.mjs';
|
|
34
|
+
import { relaunchSelf, selfArgv } from './launchCommand.mjs';
|
|
25
35
|
|
|
26
36
|
const MARK = '__FLOWVIANT_PATH__=';
|
|
27
37
|
|
|
@@ -60,12 +70,13 @@ export function userBinDirs({ home = homedir(), exists = existsSync, list = read
|
|
|
60
70
|
return dirs.filter((d) => exists(d));
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
/** Current entries first, then any the others add. Order-preserving, deduped
|
|
73
|
+
/** Current entries first, then any the others add. Order-preserving, deduped,
|
|
74
|
+
* absolute only (see the header): an empty or relative entry is dropped. */
|
|
64
75
|
export function mergePath(current, ...more) {
|
|
65
76
|
const seen = new Set();
|
|
66
77
|
const out = [];
|
|
67
78
|
for (const part of [current, ...more].flatMap((p) => (Array.isArray(p) ? p : String(p ?? '').split(':')))) {
|
|
68
|
-
if (!part || seen.has(part)) continue;
|
|
79
|
+
if (!part || !part.startsWith('/') || seen.has(part)) continue;
|
|
69
80
|
seen.add(part);
|
|
70
81
|
out.push(part);
|
|
71
82
|
}
|
|
@@ -91,24 +102,18 @@ export function adoptLoginPath({ shell = true, env = process.env, ...deps } = {}
|
|
|
91
102
|
* processes are looked up and started with the environment the process
|
|
92
103
|
* STARTED with unless a spawn passes `env` — a later `process.env.PATH = …` is
|
|
93
104
|
* invisible to them (measured 2026-09-25: node found ~/.local/bin/claude after
|
|
94
|
-
* the change, the compiled binary did not). So when the PATH
|
|
95
|
-
* binary restarts itself once with it
|
|
96
|
-
*
|
|
97
|
-
* Node needs none of that and adopts in place.
|
|
105
|
+
* the change, the compiled binary did not). So when the PATH changed, the
|
|
106
|
+
* binary restarts itself once with it through the one relaunch proxy
|
|
107
|
+
* (launchCommand.mjs `relaunchSelf`): stdio inherited, exit code and signals
|
|
108
|
+
* passed through. Node needs none of that and adopts in place.
|
|
98
109
|
*/
|
|
99
110
|
export async function adoptLoginPathForProcess(opts = {}) {
|
|
100
111
|
if (process.env.FLOWVIANT_PATH_ADOPTED === '1') return;
|
|
101
112
|
const before = process.env.PATH;
|
|
102
113
|
const after = adoptLoginPath(opts);
|
|
103
114
|
if (!process.versions.bun || after === before) return;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
stdio: 'inherit',
|
|
115
|
+
await relaunchSelf({
|
|
116
|
+
argv: selfArgv(process.argv.slice(2)),
|
|
107
117
|
env: { ...process.env, FLOWVIANT_PATH_ADOPTED: '1' },
|
|
108
118
|
});
|
|
109
|
-
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
110
|
-
process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
|
|
111
|
-
}
|
|
112
|
-
const code = await new Promise((resolve) => child.on('exit', (status, signal) => resolve(status ?? (signal ? 1 : 0))));
|
|
113
|
-
process.exit(code);
|
|
114
119
|
}
|
|
@@ -92,11 +92,14 @@ const JUST_NOW = 'just now';
|
|
|
92
92
|
/**
|
|
93
93
|
* A coarse age. Never "0s": a box heard a moment ago reads as "just now",
|
|
94
94
|
* because a daemon that polled four hundred milliseconds ago has not been
|
|
95
|
-
* silent for no time at all — the same rule `
|
|
96
|
-
* `elapsed` keeps in the web.
|
|
95
|
+
* silent for no time at all — the same rule `elapsed` keeps in the web.
|
|
97
96
|
*
|
|
98
97
|
* NULL for anything unmeasurable, so the caller DROPS the clause rather than
|
|
99
|
-
* printing a duration nobody took.
|
|
98
|
+
* printing a duration nobody took — the one rule this shares with holder.mjs's
|
|
99
|
+
* `agoLabel`. That one is a different formatter on purpose (2026-10-10, F084:
|
|
100
|
+
* this comment used to claim it kept the "never 0s" rule too): it prints a
|
|
101
|
+
* measured zero as "0s" and steps at 60s/60m/48h, and holder.test.mjs pins
|
|
102
|
+
* both.
|
|
100
103
|
*/
|
|
101
104
|
export function agoFrom(iso, now = Date.now()) {
|
|
102
105
|
const t = Date.parse(iso ?? '');
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** A manual tray restart drains intake without signalling any existing run.
|
|
2
|
+
* The lease expires if the tray crashes. A daemon acknowledges only at the
|
|
3
|
+
* reconcile boundary, after all previously admitted work has been counted. */
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
export const MAINTENANCE_LEASE_MS = 120_000;
|
|
10
|
+
const pathFor = (home) => join(home, '.flowviant', 'maintenance.json');
|
|
11
|
+
export function readMaintenanceRequest({ home = homedir(), now = Date.now() } = {}) {
|
|
12
|
+
try {
|
|
13
|
+
const value = JSON.parse(readFileSync(pathFor(home), 'utf8'));
|
|
14
|
+
return typeof value.token === 'string' && /^[a-zA-Z0-9-]{1,100}$/.test(value.token) && typeof value.epoch === 'string' &&
|
|
15
|
+
Number.isFinite(value.expires) && value.expires > now && value.expires <= now + MAINTENANCE_LEASE_MS
|
|
16
|
+
? value : null;
|
|
17
|
+
} catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
export function readMaintenance(options) { return readMaintenanceRequest(options)?.token ?? null; }
|
|
20
|
+
export function requestMaintenance(token, { home = homedir(), now = Date.now() } = {}) {
|
|
21
|
+
if (typeof token !== 'string' || !/^[a-zA-Z0-9-]{1,100}$/.test(token)) throw new Error('Invalid restart request');
|
|
22
|
+
const current = readMaintenanceRequest({ home, now });
|
|
23
|
+
if (current && current.token !== token) throw new Error('Another WSL restart is pending');
|
|
24
|
+
mkdirSync(join(home, '.flowviant'), { recursive: true });
|
|
25
|
+
const path = pathFor(home);
|
|
26
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
27
|
+
const epoch = current?.epoch ?? randomUUID();
|
|
28
|
+
writeFileSync(tmp, JSON.stringify({ token, epoch, expires: now + MAINTENANCE_LEASE_MS }), { mode: 0o600 });
|
|
29
|
+
renameSync(tmp, path);
|
|
30
|
+
return epoch;
|
|
31
|
+
}
|
|
32
|
+
export function releaseMaintenance(token, { home = homedir(), now = Date.now() } = {}) {
|
|
33
|
+
if (readMaintenance({ home, now }) === token) rmSync(pathFor(home), { force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The paused branch does only reporting. In particular, an acknowledgement
|
|
37
|
+
* can never be copied from a pre-pause idle measurement. */
|
|
38
|
+
export async function maintenanceTick({ request = readMaintenanceRequest, busy, agents, flush, report }) {
|
|
39
|
+
const lease = request();
|
|
40
|
+
if (!lease) return false;
|
|
41
|
+
await flush();
|
|
42
|
+
// A slow network flush can outlive the request. Never acknowledge an
|
|
43
|
+
// expired generation whose next reconcile is allowed to admit work again.
|
|
44
|
+
if (request()?.epoch !== lease.epoch) return true;
|
|
45
|
+
report({ maintenance: lease.token, maintenanceEpoch: lease.epoch, busy: busy(), liveAgents: agents(), busyAt: new Date().toISOString() });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The restart must also see live locks not backed by a stored project, and
|
|
2
|
+
* unguarded processes recorded in state. Neither is an empty machine. */
|
|
3
|
+
import { instanceLockPath, listDaemonLocks } from './daemonLocks.mjs';
|
|
4
|
+
import { daemonLockPid, readDaemonState } from './daemonState.mjs';
|
|
5
|
+
import { listStoredProjects } from './credentials.mjs';
|
|
6
|
+
import { processAlive } from './procRegistry.mjs';
|
|
7
|
+
|
|
8
|
+
export function unknownMaintenanceDaemons({ entries = listStoredProjects(), locks = listDaemonLocks(),
|
|
9
|
+
stateFor = readDaemonState, pidFor = daemonLockPid, alive = processAlive } = {}) {
|
|
10
|
+
const known = new Set();
|
|
11
|
+
const unknown = new Set();
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const state = stateFor(entry.projectId);
|
|
14
|
+
const pid = pidFor(entry.fleetToken);
|
|
15
|
+
if (pid != null && pid === state.pid) known.add(pid);
|
|
16
|
+
if (Number.isInteger(state.pid) && state.pid > 0 && state.pid !== pid && alive(state.pid)) unknown.add(state.pid);
|
|
17
|
+
}
|
|
18
|
+
for (const { path, holder } of locks) {
|
|
19
|
+
if (!holder) unknown.add(path); // unreadable is not absent
|
|
20
|
+
else if (alive(holder.pid) && !known.has(holder.pid)) unknown.add(holder.pid);
|
|
21
|
+
}
|
|
22
|
+
return unknown.size;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The ordinary tray status reserves null for an absent lock. For a global
|
|
26
|
+
* restart we additionally measure the whole lock directory: no lock and no
|
|
27
|
+
* live state PID means this stored project's daemon is stopped. Unreadable
|
|
28
|
+
* directories throw rather than becoming a fabricated empty machine. */
|
|
29
|
+
export function maintenanceInventory(status, { entries = listStoredProjects(), locks = listDaemonLocks({ strict: true }),
|
|
30
|
+
stateFor = readDaemonState, pidFor = daemonLockPid, alive = processAlive } = {}) {
|
|
31
|
+
const paths = new Set(locks.map(lock => lock.path));
|
|
32
|
+
const projects = status.projects.map(row => {
|
|
33
|
+
const entry = entries.find(entry => entry.projectId === row.id);
|
|
34
|
+
if (row.running !== null || !entry || paths.has(instanceLockPath(entry.fleetToken))) return row;
|
|
35
|
+
const pid = stateFor(row.id).pid;
|
|
36
|
+
return Number.isInteger(pid) && pid > 0 && alive(pid) ? row : { ...row, running: false };
|
|
37
|
+
});
|
|
38
|
+
return { projects, unknownDaemons: unknownMaintenanceDaemons({ entries, locks, stateFor, pidFor, alive }) };
|
|
39
|
+
}
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -17,13 +17,16 @@
|
|
|
17
17
|
* never from a CLI credential sitting on a shared machine.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { FLEET_TOKEN,
|
|
20
|
+
import { FLEET_TOKEN, MCP_URL, FLEET_URL } from './config.mjs';
|
|
21
21
|
import { terminalCommand } from './launchCommand.mjs';
|
|
22
22
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
23
|
+
import { postFleetJson } from './fleetPost.mjs';
|
|
23
24
|
|
|
24
25
|
const CLI_TOKEN_URL = fleetEndpoint('cli-token', FLEET_URL);
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
/** `timeoutMs` is the request's ceiling — injectable only so a test can prove
|
|
28
|
+
* a hung server is abandoned without waiting thirty seconds. */
|
|
29
|
+
export async function runMcpCommand(args = [], { timeoutMs = 30_000 } = {}) {
|
|
27
30
|
if (!FLEET_TOKEN) {
|
|
28
31
|
console.error(
|
|
29
32
|
`error: no credential resolves here. Run \`${terminalCommand('login')}\` first — or, with\n` +
|
|
@@ -33,16 +36,12 @@ export async function runMcpCommand(args = []) {
|
|
|
33
36
|
process.exit(1);
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
// BOUNDED (audit 2026-10-10, F102): this was the one fleet request with no
|
|
40
|
+
// timeout, so a hung server held the command forever. A timeout lands in
|
|
41
|
+
// the catch below and says so like any other unreachable server.
|
|
36
42
|
let res;
|
|
37
43
|
try {
|
|
38
|
-
res = await
|
|
39
|
-
method: 'POST',
|
|
40
|
-
headers: {
|
|
41
|
-
authorization: `Bearer ${FLEET_TOKEN}`,
|
|
42
|
-
'content-type': 'application/json',
|
|
43
|
-
'user-agent': USER_AGENT,
|
|
44
|
-
},
|
|
45
|
-
});
|
|
44
|
+
res = await postFleetJson(CLI_TOKEN_URL, undefined, { timeoutMs });
|
|
46
45
|
} catch (err) {
|
|
47
46
|
console.error(`error: could not reach Flowviant (${err?.message || err})`);
|
|
48
47
|
process.exit(1);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CONFIRMED MCP INSTALL (2026-10-13, the owner; the app's half is
|
|
3
|
+
* packages/shared `mcpSetup.ts`). A project owner pressed "Install on <this
|
|
4
|
+
* machine>" on the Machines page and confirmed the exact command; the roster
|
|
5
|
+
* hands it to THIS box as `mcpInstallJobs`.
|
|
6
|
+
*
|
|
7
|
+
* WHAT THIS DOES, AND ALL IT DOES:
|
|
8
|
+
* · claims the job (single-flight across processes, bound to this box);
|
|
9
|
+
* · refuses, without running anything, a command its own copy of the rule
|
|
10
|
+
* refuses (`mcpInstallCommandRefusal` — a privilege word, more than one
|
|
11
|
+
* line, over the cap; parity rule 36 holds it to the server's);
|
|
12
|
+
* · runs EXACTLY the text it was handed through `/bin/sh -c`, in the
|
|
13
|
+
* project's checkout, as this daemon's own user, never with sudo, stdin
|
|
14
|
+
* closed, as an unattended child (no machine credential, no display), in
|
|
15
|
+
* its own process group killed whole at the timeout;
|
|
16
|
+
* · streams the output's tail back about once a second, and at the end the
|
|
17
|
+
* exit code;
|
|
18
|
+
* · then looks again (`recheck`): the tool report is rescanned and posted,
|
|
19
|
+
* and Claude Code is probed with the base branch's servers mounted, so the
|
|
20
|
+
* page's checklist clears when the server starts.
|
|
21
|
+
*
|
|
22
|
+
* The command is never rewritten, prefixed or appended to.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
26
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
27
|
+
import { claimFleetLease, postBestEffort, settleLease } from './fleetPost.mjs';
|
|
28
|
+
import { killGroup } from './processGroup.mjs';
|
|
29
|
+
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
30
|
+
import { scrubTail } from './uplinkScrub.mjs';
|
|
31
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
32
|
+
import { printableLine } from './printable.mjs';
|
|
33
|
+
|
|
34
|
+
/** The server's bounds (shared `mcpSetup.ts`; parity rule 36). */
|
|
35
|
+
export const MCP_INSTALL_COMMAND_MAX = 500;
|
|
36
|
+
export const MCP_INSTALL_OUTPUT_MAX = 16_000;
|
|
37
|
+
export const MCP_INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
38
|
+
export const MCP_INSTALL_PRIVILEGE_WORDS = Object.freeze(['sudo', 'doas', 'su', 'pkexec', 'run0']);
|
|
39
|
+
|
|
40
|
+
/** Why this command cannot run, or null — the shared rule, word for word. */
|
|
41
|
+
export function mcpInstallCommandRefusal(command) {
|
|
42
|
+
if (typeof command !== 'string' || !command.trim()) return 'The install command is empty.';
|
|
43
|
+
if (command.length > MCP_INSTALL_COMMAND_MAX) return `The install command is over ${MCP_INSTALL_COMMAND_MAX} characters.`;
|
|
44
|
+
if (/[\r\n\0]/.test(command)) return 'The install command must be one line.';
|
|
45
|
+
const words = command.split(/[^A-Za-z0-9_-]+/);
|
|
46
|
+
const word = MCP_INSTALL_PRIVILEGE_WORDS.find((w) => words.includes(w));
|
|
47
|
+
if (word) return `Flowviant never installs with ${word}. Use a command that installs as the machine's own user.`;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const url = (tail) => fleetEndpoint(tail, FLEET_URL);
|
|
52
|
+
const tail = (text) => (text.length > MCP_INSTALL_OUTPUT_MAX ? text.slice(text.length - MCP_INSTALL_OUTPUT_MAX) : text);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run one command and hand back `{ code, out }`. `onOutput(out)` hears the
|
|
56
|
+
* tail as it grows. `spawnChild` is a test's.
|
|
57
|
+
*/
|
|
58
|
+
export function runInstallCommand(command, { cwd, timeoutMs = MCP_INSTALL_TIMEOUT_MS, env = unattendedEnv(), onOutput = () => {}, spawnChild = spawn } = {}) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let child;
|
|
61
|
+
try {
|
|
62
|
+
child = spawnChild('/bin/sh', ['-c', command], { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
resolve({ code: -1, out: `[flowviant] could not start the command: ${error?.message ?? error}` });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let buf = '';
|
|
68
|
+
let settled = false;
|
|
69
|
+
const settle = (v) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
resolve(v);
|
|
74
|
+
};
|
|
75
|
+
const take = (d) => {
|
|
76
|
+
buf += d;
|
|
77
|
+
if (buf.length > 2 * MCP_INSTALL_OUTPUT_MAX) buf = scrubTail(buf, MCP_INSTALL_OUTPUT_MAX);
|
|
78
|
+
onOutput(scrubTail(buf, MCP_INSTALL_OUTPUT_MAX));
|
|
79
|
+
};
|
|
80
|
+
child.stdout?.setEncoding('utf8');
|
|
81
|
+
child.stderr?.setEncoding('utf8');
|
|
82
|
+
child.stdout?.on('data', take);
|
|
83
|
+
child.stderr?.on('data', take);
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
killGroup(child, 'SIGKILL');
|
|
86
|
+
const minutes = Math.round(timeoutMs / 60_000);
|
|
87
|
+
settle({ code: -1, out: scrubTail(`${buf}\n[flowviant] the command ran past ${minutes >= 1 ? `${minutes} minutes` : `${timeoutMs}ms`} and was stopped`, MCP_INSTALL_OUTPUT_MAX) });
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
child.on('error', (error) => settle({ code: -1, out: scrubTail(`${buf}\n[flowviant] ${error?.message ?? error}`, MCP_INSTALL_OUTPUT_MAX) }));
|
|
91
|
+
child.on('close', (code) => settle({ code: code ?? -1, out: scrubTail(buf, MCP_INSTALL_OUTPUT_MAX) }));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Job ids this process has taken — never run twice in one process. */
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
let running = 0;
|
|
98
|
+
/** Installs this process is running — the machine is busy while one is. */
|
|
99
|
+
export const mcpInstallsInFlight = () => running;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The roster's `mcpInstallJobs` for this box. `ctx` = { repoRoot, myPubB64,
|
|
103
|
+
* recheck }, and for tests `run`, `claim`, `report` and `final`.
|
|
104
|
+
*/
|
|
105
|
+
export function processMcpInstallJobs(jobs, ctx) {
|
|
106
|
+
if (!Array.isArray(jobs)) return;
|
|
107
|
+
for (const job of jobs) {
|
|
108
|
+
if (!job || typeof job.id !== 'string' || typeof job.command !== 'string' || seen.has(job.id)) continue;
|
|
109
|
+
seen.add(job.id);
|
|
110
|
+
void runJob(job, ctx);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runJob(job, ctx) {
|
|
115
|
+
const pubkey = ctx.myPubB64?.();
|
|
116
|
+
if (!pubkey) return;
|
|
117
|
+
const claim = ctx.claim ?? ((fields) => claimFleetLease(url('mcp-install-claim'), fields));
|
|
118
|
+
const report = ctx.report ?? ((body) => settleLease(url('mcp-install-report'), body, { timeoutMs: 15_000 }));
|
|
119
|
+
const final = ctx.final ?? ((body) => postBestEffort(url('mcp-install-report'), { ...body, instance: DAEMON_INSTANCE }));
|
|
120
|
+
const run = ctx.run ?? runInstallCommand;
|
|
121
|
+
if (!(await claim({ jobId: job.id, pubkey }))) return;
|
|
122
|
+
const name = printableLine(String(job.serverName ?? 'MCP server'), 60);
|
|
123
|
+
const refusal = mcpInstallCommandRefusal(job.command);
|
|
124
|
+
if (refusal) {
|
|
125
|
+
warn(`${c.cyan('mcp')} ${c.dim(`— not installing ${name}: ${refusal}`)}`);
|
|
126
|
+
await final({ jobId: job.id, pubkey, output: `[flowviant] ${refusal}`, done: true, exitCode: -1 });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
running += 1;
|
|
130
|
+
note(`${c.cyan('mcp')} ${c.dim(`— installing ${name}, as confirmed in the app: ${printableLine(job.command, 200)}`)}`);
|
|
131
|
+
let latest = '';
|
|
132
|
+
let sentAt = 0;
|
|
133
|
+
let sending = false;
|
|
134
|
+
const beat = () => {
|
|
135
|
+
if (sending || latest === '' || Date.now() - sentAt < 1000) return;
|
|
136
|
+
sending = true;
|
|
137
|
+
sentAt = Date.now();
|
|
138
|
+
const output = latest;
|
|
139
|
+
void Promise.resolve(report({ jobId: job.id, pubkey, output, done: false, exitCode: null })).catch(() => {}).finally(() => { sending = false; });
|
|
140
|
+
};
|
|
141
|
+
const timer = setInterval(beat, 1000);
|
|
142
|
+
timer.unref?.();
|
|
143
|
+
try {
|
|
144
|
+
const timeoutMs = Number.isInteger(job.timeoutMs) && job.timeoutMs > 0 ? Math.min(job.timeoutMs, MCP_INSTALL_TIMEOUT_MS) : MCP_INSTALL_TIMEOUT_MS;
|
|
145
|
+
const { code, out } = await run(job.command, { cwd: ctx.repoRoot, timeoutMs, onOutput: (o) => { latest = tail(o); } });
|
|
146
|
+
clearInterval(timer);
|
|
147
|
+
await final({ jobId: job.id, pubkey, output: tail(out), done: true, exitCode: code });
|
|
148
|
+
if (code === 0) ok(`${c.cyan('mcp')} ${c.dim(`— installed ${name}; checking it again`)}`);
|
|
149
|
+
else warn(`${c.cyan('mcp')} ${c.dim(`— installing ${name} exited ${code}`)}`);
|
|
150
|
+
} finally {
|
|
151
|
+
clearInterval(timer);
|
|
152
|
+
running -= 1;
|
|
153
|
+
try {
|
|
154
|
+
await ctx.recheck?.();
|
|
155
|
+
} catch { /* a re-check is best-effort; the next beat looks anyway */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Tests only. */
|
|
160
|
+
export function resetMcpInstallsForTest() {
|
|
161
|
+
seen.clear();
|
|
162
|
+
running = 0;
|
|
163
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY MCP SERVER EACH CLI ON THIS BOX HAS, FOR THE MACHINES PAGE (2026-10-13,
|
|
3
|
+
* the owner: "see at a glance what Flowviant can do on this project, from what
|
|
4
|
+
* the serving machine's CLIs actually have").
|
|
5
|
+
*
|
|
6
|
+
* Per installed CLI, one list: the servers the CLI itself reported (Claude
|
|
7
|
+
* Code's init, harvested in runtimeCapabilities.mjs; Codex's `mcp list`, read
|
|
8
|
+
* in runtimeOfferings.mjs) and every server the base branch's `.mcp.json`
|
|
9
|
+
* declares (projectTools.mjs). Rides this box's own `tools-report`
|
|
10
|
+
* (fleetReports.mjs), so its 60-second beat and write-on-change dedup are the
|
|
11
|
+
* lane's; the server stores it per box (packages/shared `mcpInventory.ts`,
|
|
12
|
+
* the shape and the bounds, held equal by parity rule 35).
|
|
13
|
+
*
|
|
14
|
+
* DECLARED IS NOT PRESENT. A base `.mcp.json` server the CLI did not report
|
|
15
|
+
* running is `declared`, with the reason this daemon would leave it unmounted
|
|
16
|
+
* (`env X unset`, `command y not on PATH`, a transport the CLI refuses) when
|
|
17
|
+
* there is one — never shown as available because the file lists it.
|
|
18
|
+
*
|
|
19
|
+
* Names, statuses, sources, reasons and tool names only: no env value, header,
|
|
20
|
+
* token, URL or path leaves the box.
|
|
21
|
+
*/
|
|
22
|
+
import { toolReadout } from './projectTools.mjs';
|
|
23
|
+
import { MCP_NAME_MAX } from './runtimeCapabilities.mjs';
|
|
24
|
+
|
|
25
|
+
/** The server's bounds (shared `mcpInventory.ts`; parity rule 35). */
|
|
26
|
+
export const MCP_INVENTORY_MAX_SERVERS = 80;
|
|
27
|
+
export const MCP_INVENTORY_NAME_MAX = MCP_NAME_MAX;
|
|
28
|
+
export const MCP_INVENTORY_TOOLS_PER_SERVER = 100;
|
|
29
|
+
export const MCP_INVENTORY_TOOLS_PER_CLI = 1000;
|
|
30
|
+
export const MCP_INVENTORY_TOOL_NAME_MAX = 64;
|
|
31
|
+
export const MCP_INVENTORY_REASON_MAX = 200;
|
|
32
|
+
|
|
33
|
+
const SOURCE_ORDER = { project: 0, personal: 1, claudeai: 2 };
|
|
34
|
+
const TOOL_NAME = /^[A-Za-z0-9_.:-]+$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Claude Code's scope word → the page's source. `project` is a checkout's
|
|
38
|
+
* `.mcp.json`; `claudeai` an account connector; `dynamic` is a server named on
|
|
39
|
+
* the command line, which on a turn the harvest records is the base copy when
|
|
40
|
+
* the name is declared and otherwise this daemon's own — not the person's.
|
|
41
|
+
* Every other scope (user, local, plugin, enterprise, managed, agent) is the
|
|
42
|
+
* CLI's own config on this box.
|
|
43
|
+
*/
|
|
44
|
+
function claudeSource(scope, name, declared) {
|
|
45
|
+
if (scope === 'claudeai' || name.startsWith('claude.ai ')) return 'claudeai';
|
|
46
|
+
if (scope === 'project') return 'project';
|
|
47
|
+
if (scope === 'dynamic') return declared.has(name) ? 'project' : null;
|
|
48
|
+
return 'personal';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const declaredRows = (snapshot, runner) =>
|
|
52
|
+
snapshot ? toolReadout(snapshot, runner).filter((t) => t.kind === 'mcp') : [];
|
|
53
|
+
|
|
54
|
+
/** One CLI's list, bounded the way the server re-applies it. */
|
|
55
|
+
function bound(servers) {
|
|
56
|
+
let budget = MCP_INVENTORY_TOOLS_PER_CLI;
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
return servers
|
|
59
|
+
.map((s) => ({ ...s, name: s.name.trim().slice(0, MCP_INVENTORY_NAME_MAX).trim() }))
|
|
60
|
+
.filter((s) => s.name && s.name !== 'flowviant' && !seen.has(s.name) && seen.add(s.name))
|
|
61
|
+
.sort((a, b) => SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
62
|
+
.slice(0, MCP_INVENTORY_MAX_SERVERS)
|
|
63
|
+
.map((s) => {
|
|
64
|
+
let tools = null;
|
|
65
|
+
if (Array.isArray(s.tools)) {
|
|
66
|
+
tools = [...new Set(s.tools.filter((t) => typeof t === 'string' && t.length <= MCP_INVENTORY_TOOL_NAME_MAX && TOOL_NAME.test(t)))]
|
|
67
|
+
.sort()
|
|
68
|
+
.slice(0, Math.min(MCP_INVENTORY_TOOLS_PER_SERVER, budget));
|
|
69
|
+
budget -= tools.length;
|
|
70
|
+
}
|
|
71
|
+
return { name: s.name, source: s.source, status: s.status, reason: s.reason == null ? null : String(s.reason).slice(0, MCP_INVENTORY_REASON_MAX), tools };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Claude Code: what its init reported, then each declaration it did not. */
|
|
76
|
+
export function claudeInventory(harvested, snapshot) {
|
|
77
|
+
const declared = new Map(declaredRows(snapshot, 'claude').map((t) => [t.name, t]));
|
|
78
|
+
const servers = [];
|
|
79
|
+
for (const h of harvested ?? []) {
|
|
80
|
+
const source = claudeSource(h.scope, h.name, declared);
|
|
81
|
+
if (!source) continue;
|
|
82
|
+
const why = source === 'project' && h.status !== 'connected' ? declared.get(h.name)?.reason ?? null : null;
|
|
83
|
+
servers.push({ name: h.name, source, status: h.status, reason: why, tools: h.status === 'connected' ? h.tools ?? [] : null });
|
|
84
|
+
}
|
|
85
|
+
const running = new Set(servers.filter((s) => s.source === 'project').map((s) => s.name));
|
|
86
|
+
for (const [name, d] of declared) {
|
|
87
|
+
if (!running.has(name)) servers.push({ name, source: 'project', status: 'declared', reason: d.reason, tools: null });
|
|
88
|
+
}
|
|
89
|
+
return { reported: harvested != null, servers: bound(servers) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Codex: its own config's servers, then each declaration. A declared server
|
|
94
|
+
* replaces a personal one of the same name on a card's turn
|
|
95
|
+
* (projectToolRuntimes.mjs `isolatedCodexHome`), so the declaration is the
|
|
96
|
+
* row. Codex names no tools without starting a server, so `tools` is null.
|
|
97
|
+
*/
|
|
98
|
+
export function codexInventory(rows, snapshot) {
|
|
99
|
+
const declared = declaredRows(snapshot, 'codex');
|
|
100
|
+
const names = new Set(declared.map((d) => d.name));
|
|
101
|
+
const servers = [
|
|
102
|
+
...declared.map((d) => ({ name: d.name, source: 'project', status: 'declared', reason: d.reason, tools: null })),
|
|
103
|
+
...(rows ?? []).filter((r) => !names.has(r.name)).map((r) => ({ name: r.name, source: 'personal', status: r.status, reason: r.reason, tools: null })),
|
|
104
|
+
];
|
|
105
|
+
return { reported: rows != null, servers: bound(servers) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The `mcp` key of the tools report: `{ claude?, codex? }`, a CLI only when it
|
|
110
|
+
* is installed here. `snapshot` is the base-branch read (null when it could
|
|
111
|
+
* not be read — the declarations are then unknown, and only the CLI's own
|
|
112
|
+
* list is said).
|
|
113
|
+
*/
|
|
114
|
+
export function buildMcpInventory({ installed, snapshot, claude, codex }) {
|
|
115
|
+
const out = {};
|
|
116
|
+
if (installed.includes('claude')) out.claude = claudeInventory(claude, snapshot);
|
|
117
|
+
if (installed.includes('codex')) out.codex = codexInventory(codex, snapshot);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
package/bin/lib/noWindowEnv.mjs
CHANGED
|
@@ -41,9 +41,11 @@
|
|
|
41
41
|
* purpose through WSL interop (`explorer.exe`, `cmd.exe /c start`); the kind
|
|
42
42
|
* contracts ask it not to (artifactContracts.mjs, `NO_WINDOW`).
|
|
43
43
|
*/
|
|
44
|
-
import {
|
|
44
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
45
45
|
import { tmpdir } from 'node:os';
|
|
46
46
|
import { join } from 'node:path';
|
|
47
|
+
import { withoutMachineCredentials } from './machineEnv.mjs';
|
|
48
|
+
import { ensureOwnDir } from './ownedDirs.mjs';
|
|
47
49
|
|
|
48
50
|
/** Removed outright; WAYLAND_DISPLAY is kept but emptied (see above). */
|
|
49
51
|
export const WINDOW_ENV_REMOVED = Object.freeze(['DISPLAY', 'WAYLAND_SOCKET']);
|
|
@@ -62,6 +64,24 @@ export function withoutWindows(env, browserHome = null) {
|
|
|
62
64
|
return out;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* EVERY CLI CHILD THE DAEMON SPAWNS UNATTENDED, ONE RULE (2026-10-10, the
|
|
69
|
+
* audit): no machine credential (machineEnv.mjs), and no way to a window
|
|
70
|
+
* unless a person at the keyboard asked for one (`display: true`, a Terminal
|
|
71
|
+
* tab's alone). It was composed by hand twice — a turn's `cliEnv`
|
|
72
|
+
* (runTurn.mjs) and the project check's `checkEnv` (workAgentCheck.mjs) — and
|
|
73
|
+
* skipped outright by the two spawns the daemon makes for its own purposes:
|
|
74
|
+
* the startup skills probe (runtimeCapabilities.mjs), whose `claude` started
|
|
75
|
+
* the person's stdio MCP servers and the checkout's SessionStart hooks with
|
|
76
|
+
* the machine credential in their environment, and the Codex offerings reads
|
|
77
|
+
* (runtimeOfferings.mjs), whose `codex mcp list` may start configured servers.
|
|
78
|
+
* Every one of them now asks here. Never mutates its input.
|
|
79
|
+
*/
|
|
80
|
+
export function unattendedEnv(env = process.env, { display = false, browserHome = null } = {}) {
|
|
81
|
+
const out = withoutMachineCredentials(env);
|
|
82
|
+
return display === true ? out : withoutWindows(out, browserHome);
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
/**
|
|
66
86
|
* AN AGENT'S BROWSER HOME is kept beside its Codex home, in its worktree's
|
|
67
87
|
* private git dir: never committed, the same profile turn after turn, and
|
|
@@ -82,8 +102,8 @@ export const agentBrowserHome = (sessionMetaPath, wt, agentId) =>
|
|
|
82
102
|
export function openBrowserHome(kept) {
|
|
83
103
|
if (kept) {
|
|
84
104
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
// ownedDirs.mjs (2026-10-10): the link goes, never its target.
|
|
106
|
+
ensureOwnDir(kept, { mode: 0o700 });
|
|
87
107
|
return { dir: kept, cleanup: () => {} };
|
|
88
108
|
} catch { /* fall through to a turn's own */ }
|
|
89
109
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DIRECTORIES THE DAEMON WRITES INTO THAT A REPOSITORY COULD HAVE PLANTED —
|
|
3
|
+
* one home for the rule "never follow a link a checkout carries under
|
|
4
|
+
* `.flowviant/`" (2026-10-10, the audit's F100 / F080).
|
|
5
|
+
*
|
|
6
|
+
* git commits symlinks. A checkout can arrive carrying `.flowviant -> ~/.ssh`,
|
|
7
|
+
* or `.flowviant/uploads -> ../../../../.ssh` (a worktree's depth under
|
|
8
|
+
* `~/.flowviant/worktrees` is deterministic), and any daemon write through
|
|
9
|
+
* either lands outside the place it was meant for, as the operator. The rule
|
|
10
|
+
* that stops it was hand-written in ten places in four shapes — a boolean, a
|
|
11
|
+
* true/null/false, a 'dir'/'none'/'other', a replace-then-mkdir — and the
|
|
12
|
+
* attachment lane, the one that writes a MEMBER'S bytes under a member's name,
|
|
13
|
+
* had none of them. Each new `.flowviant/<x>` writer had to rediscover it.
|
|
14
|
+
*
|
|
15
|
+
* TWO POLICIES, deliberately two doors:
|
|
16
|
+
*
|
|
17
|
+
* - REFUSED, NEVER REPLACED (`ensureRealSubdir`): `.flowviant/` and the
|
|
18
|
+
* directories under it a repository could hold — the artifacts directory,
|
|
19
|
+
* the uploads directory. `.flowviant/` is not only ours (a repo declares
|
|
20
|
+
* `check.json` and `deploy.json` there), so a link or a file at one of
|
|
21
|
+
* these names is the repository's content: Flowviant deleting it would be
|
|
22
|
+
* destroying what it did not create, and following it is the write outside
|
|
23
|
+
* the worktree. Either answer is "no", and the caller says so in words.
|
|
24
|
+
* - REPLACED, NEVER FOLLOWED (`ensureOwnDir`): a path that is wholly the
|
|
25
|
+
* daemon's — the knowledge library under a parent its caller has already
|
|
26
|
+
* checked, a kept browser home. A link or a file planted there is removed
|
|
27
|
+
* (the link, never its target) and a real directory made in its place.
|
|
28
|
+
*
|
|
29
|
+
* Leaf: node:fs and node:path only.
|
|
30
|
+
*/
|
|
31
|
+
import { lstatSync, mkdirSync, rmSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What stands at `p`, by `lstat` and never followed: `'dir'` for a real
|
|
36
|
+
* directory, `'none'` for nothing at all, `'other'` for anything else — a
|
|
37
|
+
* symlink (to a directory or dangling), a regular file, or a path this cannot
|
|
38
|
+
* look at (a component that is a file, no permission). The unknown case is
|
|
39
|
+
* `'other'`, never `'none'`: an answer of "absent" invites a create.
|
|
40
|
+
*/
|
|
41
|
+
export function realDirState(p) {
|
|
42
|
+
try {
|
|
43
|
+
const st = lstatSync(p);
|
|
44
|
+
return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return e?.code === 'ENOENT' ? 'none' : 'other';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `<placeDir>/.flowviant`, by the same three states. */
|
|
51
|
+
export const flowviantParentState = (placeDir) => realDirState(join(placeDir, '.flowviant'));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `<placeDir>/<rel>` as a real directory, every component of `rel` a real
|
|
55
|
+
* directory too — or FALSE, with nothing replaced.
|
|
56
|
+
*
|
|
57
|
+
* Each component must already be a real directory or be absent; any one that
|
|
58
|
+
* is a link or a non-directory refuses before anything is made. The missing
|
|
59
|
+
* ones are made, and then EVERY component is checked again: a link swapped in
|
|
60
|
+
* between the look and the make is caught here, and the caller writes
|
|
61
|
+
* nothing. `rel` is a relative path of plain names (`.flowviant/uploads`); a
|
|
62
|
+
* `..`, an empty or an absolute component refuses.
|
|
63
|
+
*/
|
|
64
|
+
export function ensureRealSubdir(placeDir, rel) {
|
|
65
|
+
const parts = String(rel).split('/');
|
|
66
|
+
if (parts.some((p) => p === '' || p === '.' || p === '..')) return false;
|
|
67
|
+
const paths = parts.map((_, i) => join(placeDir, ...parts.slice(0, i + 1)));
|
|
68
|
+
if (paths.some((p) => realDirState(p) === 'other')) return false;
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(paths[paths.length - 1], { recursive: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return paths.every((p) => realDirState(p) === 'dir');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A directory that is wholly the daemon's, made real: anything at `path` that
|
|
79
|
+
* is not a real directory is removed — non-recursively, so a link goes and its
|
|
80
|
+
* target never does — and the directory made (`mode` for each directory this
|
|
81
|
+
* creates). Returns `path`; THROWS when a real directory cannot be had there,
|
|
82
|
+
* a link reappearing between the remove and the make included, so a caller
|
|
83
|
+
* never writes through one.
|
|
84
|
+
*
|
|
85
|
+
* Only for paths that are OURS. A path a repository may legitimately hold is
|
|
86
|
+
* `ensureRealSubdir`'s, which refuses rather than removes.
|
|
87
|
+
*/
|
|
88
|
+
export function ensureOwnDir(path, { mode } = {}) {
|
|
89
|
+
if (realDirState(path) !== 'dir') {
|
|
90
|
+
try {
|
|
91
|
+
rmSync(path, { force: true });
|
|
92
|
+
} catch {
|
|
93
|
+
/* the make below says why */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
mkdirSync(path, mode === undefined ? { recursive: true } : { recursive: true, mode });
|
|
97
|
+
if (realDirState(path) !== 'dir') throw new Error(`${path} is not a real directory`);
|
|
98
|
+
return path;
|
|
99
|
+
}
|