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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Actual project files and Git history, with no host-wide read fallback.
|
|
2
|
+
* The CLI retains its provider/MCP login outside the tool sandbox. Shells
|
|
3
|
+
* receive neither that environment nor native image reads/shell snapshots.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { isAbsolute, join, dirname } from 'node:path';
|
|
8
|
+
import { git } from './git.mjs';
|
|
9
|
+
import { codexResearchHelper } from './codexResearch.mjs';
|
|
10
|
+
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
11
|
+
|
|
12
|
+
export function projectReadBoundary({ cwd, knowledgeDir, env = process.env }) {
|
|
13
|
+
if (process.platform !== 'linux') throw new Error('Project-scoped Codex reads require Linux or WSL.');
|
|
14
|
+
if (!isAbsolute(cwd ?? '')) throw new Error('Project-scoped Codex reads require an absolute project directory.');
|
|
15
|
+
const helper = codexResearchHelper(env);
|
|
16
|
+
const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+)\.(\d+)\.(\d+)/);
|
|
17
|
+
if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
|
|
18
|
+
throw new Error('Project-scoped reads need Codex CLI 0.159.2 or newer. Update Codex before retrying.');
|
|
19
|
+
const project = realpathSync(cwd);
|
|
20
|
+
// Resolve both: linked worktrees have their own HEAD/index and shared
|
|
21
|
+
// objects/refs in the common directory. Never grant the parent checkout.
|
|
22
|
+
const gitOpts = { timeout: 10000, env: { PATH: env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } };
|
|
23
|
+
const local = realpathSync(git(['rev-parse', '--absolute-git-dir'], project, gitOpts));
|
|
24
|
+
const common = realpathSync(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], project, gitOpts));
|
|
25
|
+
const paths = [project, local, common];
|
|
26
|
+
// Git directories also hold daemon login/thread/browser stores. They are
|
|
27
|
+
// not history. Mask non-Git directories, including other worktree stores.
|
|
28
|
+
const metadata = new Set(['HEAD', 'index', 'commondir', 'gitdir', 'config', 'config.worktree', 'objects', 'refs', 'logs', 'packed-refs', 'shallow', 'description', 'info', 'branches']);
|
|
29
|
+
const denied = [];
|
|
30
|
+
for (const dir of new Set([local, common])) {
|
|
31
|
+
for (const name of readdirSync(dir)) {
|
|
32
|
+
if (dir === common && name === 'worktrees' && local !== common) {
|
|
33
|
+
for (const sibling of readdirSync(join(common, name))) {
|
|
34
|
+
const path = join(common, name, sibling);
|
|
35
|
+
if (path !== local && path !== dirname(local)) denied.push(path);
|
|
36
|
+
}
|
|
37
|
+
} else if (!metadata.has(name) && lstatSync(join(dir, name)).isDirectory()) denied.push(join(dir, name));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (knowledgeDir) paths.push(realpathSync(knowledgeDir));
|
|
41
|
+
return { paths: [...new Set(paths)], denied, helper };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function projectReadFence({ paths, denied = [], helper }) {
|
|
45
|
+
const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read' };
|
|
46
|
+
for (const path of paths) {
|
|
47
|
+
if (!isAbsolute(path) || path === '/') throw new Error('Invalid project read boundary.');
|
|
48
|
+
rules[path] = 'read';
|
|
49
|
+
}
|
|
50
|
+
for (const path of denied) rules[path] = 'deny';
|
|
51
|
+
const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
|
|
52
|
+
return [
|
|
53
|
+
'-c', `permissions.flowviantproject.filesystem=${table}`,
|
|
54
|
+
'-c', 'permissions.flowviantproject.network.enabled=false',
|
|
55
|
+
'-c', 'default_permissions="flowviantproject"',
|
|
56
|
+
'-c', 'approval_policy="never"',
|
|
57
|
+
'-c', 'project_doc_max_bytes=0',
|
|
58
|
+
'-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
|
|
59
|
+
'-c', 'shell_environment_policy.inherit="none"',
|
|
60
|
+
'-c', 'shell_environment_policy.set={PATH="/usr/bin:/bin",HOME="/nonexistent",GIT_CONFIG_NOSYSTEM="1",GIT_CONFIG_GLOBAL="/dev/null",GIT_PAGER="cat",BROWSER="none"}',
|
|
61
|
+
'--disable', 'view_image',
|
|
62
|
+
];
|
|
63
|
+
}
|
package/bin/lib/codexRelay.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* classifier. The sentence names what was measured and the one remedy the
|
|
21
21
|
* machine's own CLI offers; Codex's own words stay beside it.
|
|
22
22
|
*/
|
|
23
|
-
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
23
|
+
import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
|
|
24
24
|
import { CODEX_RUNTIME } from './runtimeCodex.mjs';
|
|
25
25
|
|
|
26
26
|
const NO_LOGIN = /unexpected status 401 Unauthorized: Missing bearer or basic authentication/;
|
|
@@ -55,9 +55,25 @@ export function codexApprovalSentence(refused) {
|
|
|
55
55
|
return `Codex on the machine refused ${n} tool call${n === 1 ? '' : 's'} in this turn${which} — "MCP tool call requires approval, but approval policy is never" — so nothing ${n === 1 ? 'it' : 'they'} would have done was done.`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* WHAT A TERMINAL OR NEW TASK REPLY MAY CARRY BACK (2026-10-10, the audit's
|
|
60
|
+
* final cleanup; F042's daemon half). The server's `SESSION_ANSWER_MAX`
|
|
61
|
+
* (shared `fieldCaps.ts`): the settle route (`/fleet/work-turn-done`) cuts an
|
|
62
|
+
* answer there and the session writer clamps every message to it. The daemon
|
|
63
|
+
* cuts there too before it sends — `withRelay` below and the plain reply in
|
|
64
|
+
* `workSessionTurns.mjs` — so nothing past it crosses the wire. It was a bare
|
|
65
|
+
* `16000` in both; the release gate (`scripts/check-app-parity.mjs`, rule 29)
|
|
66
|
+
* holds this copy equal to the shared one.
|
|
67
|
+
*/
|
|
68
|
+
export const SESSION_ANSWER_MAX = 16_000;
|
|
69
|
+
|
|
58
70
|
/** A reply with the sentence first and Codex's own words kept under it,
|
|
59
|
-
* scrubbed like every reply and cut to the lane's cap
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
* scrubbed like every reply and cut to the lane's cap — the words scrubbed
|
|
72
|
+
* whole before they are trimmed, and the reply cut only after (`scrubCut`). */
|
|
73
|
+
export function withRelay(sentence, raw, cap = SESSION_ANSWER_MAX) {
|
|
74
|
+
const reply = (s) => {
|
|
75
|
+
const words = envScrub(s).trim();
|
|
76
|
+
return words ? `${sentence}\n\n${words}` : sentence;
|
|
77
|
+
};
|
|
78
|
+
return scrubCut(raw, cap, reply);
|
|
63
79
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Codex research reads a copied view, never the host checkout or login store.
|
|
2
|
+
* Linux/WSL only: the CLI process retains its login outside its tool sandbox;
|
|
3
|
+
* tools get root-denied reads, a clean environment and no shell snapshots.
|
|
4
|
+
* Web search is a CLI tool, not direct shell network access.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { codexHome } from './codexHome.mjs';
|
|
8
|
+
import { git } from './git.mjs';
|
|
9
|
+
import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
10
|
+
import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const MAX_FILES = 20_000;
|
|
15
|
+
const MAX_BYTES = 128 * 1024 * 1024;
|
|
16
|
+
const omitted = (name) => /^(\.git|\.env.*|\.claude|\.codex|\.mcp\.json|node_modules|credentials(?:\..*)?|auth\.json)$/i.test(name) || /\.(pem|key|p12|pfx)$/i.test(name);
|
|
17
|
+
|
|
18
|
+
/** Fresh copies also break hardlinks. Symlinks, devices and sockets never ride.
|
|
19
|
+
* Refuse over the bound instead of presenting a silently incomplete repo.
|
|
20
|
+
*/
|
|
21
|
+
export function copyResearchView(source, destination, budget = { files: 0, bytes: 0 }) {
|
|
22
|
+
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
23
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
24
|
+
if (omitted(entry.name)) continue;
|
|
25
|
+
const from = join(source, entry.name);
|
|
26
|
+
const to = join(destination, entry.name);
|
|
27
|
+
const stat = lstatSync(from);
|
|
28
|
+
if (stat.isSymbolicLink()) continue;
|
|
29
|
+
if (stat.isDirectory()) {
|
|
30
|
+
// Only the card's files and kept library are inputs from .flowviant.
|
|
31
|
+
if (entry.name === '.flowviant') {
|
|
32
|
+
for (const input of ['uploads', 'knowledge', 'artifacts']) {
|
|
33
|
+
const dir = join(from, input);
|
|
34
|
+
if (realDirState(dir) === 'dir')
|
|
35
|
+
copyResearchView(dir, join(to, input), budget);
|
|
36
|
+
}
|
|
37
|
+
} else copyResearchView(from, to, budget);
|
|
38
|
+
} else if (stat.isFile()) {
|
|
39
|
+
budget.files += 1;
|
|
40
|
+
budget.bytes += stat.size;
|
|
41
|
+
if (budget.files > MAX_FILES || budget.bytes > MAX_BYTES)
|
|
42
|
+
throw new Error('The isolated research copy exceeds 20,000 files or 128 MB. Narrow the repository inputs before trying again.');
|
|
43
|
+
// A file replaced by a symlink between enumeration and copy must not
|
|
44
|
+
// bring host data into the readable view. Copy through a no-follow fd.
|
|
45
|
+
const fd = openSync(from, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
46
|
+
try {
|
|
47
|
+
if (!fstatSync(fd).isFile()) throw new Error('A research input changed while copying.');
|
|
48
|
+
copyFileSync(`/proc/self/fd/${fd}`, to);
|
|
49
|
+
} finally { closeSync(fd); }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve the actual sandbox helper without granting the person's bin/home.
|
|
55
|
+
* Support native installs and the npm wrapper's platform package. Unknown
|
|
56
|
+
* installations refuse; granting the whole home to find a helper is no fence.
|
|
57
|
+
*/
|
|
58
|
+
export function codexResearchHelper(env = process.env) {
|
|
59
|
+
const command = (env.PATH ?? '').split(':').map((dir) => join(dir, 'codex')).find((file) => existsSync(file));
|
|
60
|
+
if (!command) throw new Error('Codex is not installed on PATH.');
|
|
61
|
+
const executable = realpathSync(command);
|
|
62
|
+
const fd = openSync(executable, 'r');
|
|
63
|
+
const magic = Buffer.alloc(4);
|
|
64
|
+
try { readSync(fd, magic, 0, 4, 0); } finally { closeSync(fd); }
|
|
65
|
+
if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return executable;
|
|
66
|
+
const target = process.arch === 'arm64' ? 'aarch64-unknown-linux-musl' : 'x86_64-unknown-linux-musl';
|
|
67
|
+
const platform = `@openai/codex-linux-${process.arch === 'arm64' ? 'arm64' : 'x64'}`;
|
|
68
|
+
let root;
|
|
69
|
+
try { root = dirname(createRequire(executable).resolve(`${platform}/package.json`)); }
|
|
70
|
+
catch { root = resolve(dirname(executable), '..'); }
|
|
71
|
+
const binary = join(root, 'vendor', target, 'bin', 'codex');
|
|
72
|
+
if (!existsSync(binary)) throw new Error('This Codex installation has no measured Linux research sandbox helper.');
|
|
73
|
+
return realpathSync(binary);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The research posture's own pairs: the copied view's filesystem table, no
|
|
77
|
+
* shell network, live web, a clean shell environment. runtimeCodex.mjs ends it
|
|
78
|
+
* in the shared fenced tail (sub-agents, browser, image generation, the
|
|
79
|
+
* person's config and rules), with the web left on: it is this posture's tool.
|
|
80
|
+
*/
|
|
81
|
+
export function researchFence({ cwd, helper, generatedImages }) {
|
|
82
|
+
if (process.platform !== 'linux' || !isAbsolute(cwd ?? '') || !isAbsolute(helper ?? ''))
|
|
83
|
+
throw new Error('Isolated Codex research requires Linux or WSL and absolute sandbox paths.');
|
|
84
|
+
const artifacts = join(cwd, '.flowviant/artifacts');
|
|
85
|
+
const scratch = join(cwd, '.flowviant/tmp');
|
|
86
|
+
const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read', [cwd]: 'read', [artifacts]: 'write', [scratch]: 'write' };
|
|
87
|
+
if (generatedImages) {
|
|
88
|
+
if (!isAbsolute(generatedImages)) throw new Error('Generated image directory must be absolute.');
|
|
89
|
+
rules[generatedImages] = 'read';
|
|
90
|
+
}
|
|
91
|
+
const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
|
|
92
|
+
return [
|
|
93
|
+
'-c', `permissions.flowviantresearch.filesystem=${table}`,
|
|
94
|
+
'-c', 'permissions.flowviantresearch.network.enabled=false',
|
|
95
|
+
'-c', 'default_permissions="flowviantresearch"',
|
|
96
|
+
'-c', 'approval_policy="never"',
|
|
97
|
+
'-c', 'web_search="live"', '-c', 'tools.web_search=true',
|
|
98
|
+
'-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
|
|
99
|
+
'-c', 'shell_environment_policy.inherit="none"',
|
|
100
|
+
'-c', `shell_environment_policy.set={PATH="/usr/bin:/bin",HOME=${JSON.stringify(scratch)},TMPDIR=${JSON.stringify(scratch)},BROWSER="none"}`,
|
|
101
|
+
'--disable', 'view_image', // native host-file reads bypass the tool fence
|
|
102
|
+
'--skip-git-repo-check',
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Copy only regular Markdown outputs back. Never follow a model-written link,
|
|
107
|
+
* nor one standing in the worktree: a destination directory must be a real one
|
|
108
|
+
* or absent (ownedDirs.mjs, the daemon's one link rule), and a file is written
|
|
109
|
+
* only where nothing or a regular file stands, created exclusively so a link
|
|
110
|
+
* swapped in after the look is never followed. A write-up already holding
|
|
111
|
+
* these exact bytes is left alone: the copied view carried it in, and its
|
|
112
|
+
* mtime is the artifact scan's evidence that the turn did not write it
|
|
113
|
+
* (artifacts.mjs `writtenSince`), so a turn that wrote nothing delivers
|
|
114
|
+
* nothing. Artifact publication stays in the original worktree for the
|
|
115
|
+
* existing uploader.
|
|
116
|
+
*/
|
|
117
|
+
export function publishResearch(source, destination) {
|
|
118
|
+
if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
|
|
119
|
+
if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
|
|
120
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
121
|
+
const from = join(source, entry.name);
|
|
122
|
+
const to = join(destination, entry.name);
|
|
123
|
+
const stat = lstatSync(from);
|
|
124
|
+
if (stat.isSymbolicLink()) throw new Error('An isolated research output is a symlink; nothing was accepted through it.');
|
|
125
|
+
if (stat.isDirectory()) publishResearch(from, to);
|
|
126
|
+
else if (stat.isFile() && /\.md$/i.test(entry.name)) {
|
|
127
|
+
if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
|
|
128
|
+
let there = null;
|
|
129
|
+
try {
|
|
130
|
+
there = lstatSync(to);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (e?.code !== 'ENOENT') throw e;
|
|
133
|
+
}
|
|
134
|
+
if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
|
|
135
|
+
const bytes = readFileSync(from);
|
|
136
|
+
if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
|
|
137
|
+
if (there) unlinkSync(to);
|
|
138
|
+
writeFileSync(to, bytes, { flag: 'wx' });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function runIsolatedResearch(opts, run, {
|
|
144
|
+
resolveHelper = codexResearchHelper,
|
|
145
|
+
readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
|
|
146
|
+
} = {}) {
|
|
147
|
+
if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
|
|
148
|
+
const original = realpathSync(opts.cwd);
|
|
149
|
+
const helper = resolveHelper();
|
|
150
|
+
const version = readVersion(helper).match(/codex-cli (\d+)\.(\d+)\.(\d+)/);
|
|
151
|
+
if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
|
|
152
|
+
throw new Error('Isolated research needs Codex CLI 0.159.2 or newer. Restart after updating Codex.');
|
|
153
|
+
// Both publication parents must be real directories in this checkout, or
|
|
154
|
+
// absent — never a link, dangling or not (ownedDirs.mjs).
|
|
155
|
+
for (const dir of [join(original, '.flowviant'), join(original, '.flowviant/artifacts')])
|
|
156
|
+
if (realDirState(dir) === 'other')
|
|
157
|
+
throw new Error('The research artifact destination is not a real directory.');
|
|
158
|
+
// Kept path across resumes, outside the source tree and separate from Codex's
|
|
159
|
+
// login/thread home. The caller's agent lock serializes turns in this place.
|
|
160
|
+
const gitDir = git(['rev-parse', '--absolute-git-dir'], original);
|
|
161
|
+
const isolation = join(gitDir, 'flowviant-research');
|
|
162
|
+
if (existsSync(isolation) && lstatSync(isolation).isSymbolicLink()) throw new Error('The research isolation directory is a symlink.');
|
|
163
|
+
rmSync(isolation, { recursive: true, force: true });
|
|
164
|
+
const snapshot = join(isolation, 'repo');
|
|
165
|
+
try {
|
|
166
|
+
copyResearchView(original, snapshot);
|
|
167
|
+
let knowledge = null;
|
|
168
|
+
if (opts.knowledgeDir) {
|
|
169
|
+
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
170
|
+
copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
|
|
171
|
+
}
|
|
172
|
+
for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
|
|
173
|
+
const rewrite = (text) => typeof text === 'string'
|
|
174
|
+
? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
|
|
175
|
+
const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
|
|
176
|
+
// Only native output pixels, never the surrounding login/config home.
|
|
177
|
+
let generatedImages;
|
|
178
|
+
if (opts.mcpEnv?.CODEX_HOME) {
|
|
179
|
+
const imageHome = realpathSync(codexHome({ ...process.env, ...opts.mcpEnv }));
|
|
180
|
+
if (!ensureRealSubdir(imageHome, 'generated_images'))
|
|
181
|
+
throw new Error('The native generated image directory is not a real directory.');
|
|
182
|
+
generatedImages = join(imageHome, 'generated_images');
|
|
183
|
+
}
|
|
184
|
+
const result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
|
|
185
|
+
prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
|
|
186
|
+
researchIsolation: { cwd: snapshot, helper, generatedImages } });
|
|
187
|
+
// Made, or found, real at publication too: every component checked again.
|
|
188
|
+
if (!ensureRealSubdir(original, '.flowviant/artifacts'))
|
|
189
|
+
throw new Error('The research artifact destination is not a real directory.');
|
|
190
|
+
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
|
|
191
|
+
return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
|
|
192
|
+
} finally { rmSync(isolation, { recursive: true, force: true }); }
|
|
193
|
+
}
|
package/bin/lib/commandSpecs.mjs
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
export const COMMANDS = [
|
|
24
|
+
{
|
|
25
|
+
name: 'maintenance', usesCheckout: false, hidden: true,
|
|
26
|
+
usage: 'flowviant maintenance <request|release> <token>',
|
|
27
|
+
summary: 'Hold new runs for a manual WSL restart; existing work finishes.',
|
|
28
|
+
group: 'manage',
|
|
29
|
+
},
|
|
24
30
|
{
|
|
25
31
|
name: 'start',
|
|
26
32
|
usesCheckout: true,
|
package/bin/lib/config.mjs
CHANGED
|
@@ -69,6 +69,10 @@ function argFlag(name) {
|
|
|
69
69
|
*/
|
|
70
70
|
const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api';
|
|
71
71
|
export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
|
|
72
|
+
/** The web app a terminal view or a device login sends a person to. One
|
|
73
|
+
* declaration (audit 2026-10-10, F753): login.mjs and views.mjs each held a
|
|
74
|
+
* copy of this line. */
|
|
75
|
+
export const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
|
|
72
76
|
export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
|
|
73
77
|
// Push channel: the daemon holds this WebSocket open and the server nudges it
|
|
74
78
|
// the instant a job lands, so dispatch is ~a round-trip instead of a full poll.
|
|
@@ -76,7 +80,6 @@ export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/a
|
|
|
76
80
|
export const STREAM_URL =
|
|
77
81
|
process.env.FLOWVIANT_STREAM_URL ||
|
|
78
82
|
FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
|
|
79
|
-
export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
|
|
80
83
|
|
|
81
84
|
// What this machine can actually be given — the cgroup's limits before the
|
|
82
85
|
// host's. The rule and its reader live in machineLimits.mjs; this is the
|
|
@@ -147,16 +150,42 @@ export const MACHINE = measureMachineLimits();
|
|
|
147
150
|
* every agent. `cores * 2` lets the scheduler do what it is for, and memory
|
|
148
151
|
* stays the bound that actually refuses.
|
|
149
152
|
*
|
|
150
|
-
* `min(
|
|
151
|
-
* bound on a box, not a product decision, and an operator who typed
|
|
152
|
-
* the machine gets exactly that number.
|
|
153
|
+
* `min(TURN_CEILING_MAX, …)` and the env override are untouched: the hard 32 is
|
|
154
|
+
* a runaway bound on a box, not a product decision, and an operator who typed
|
|
155
|
+
* a number at the machine gets exactly that number.
|
|
153
156
|
*/
|
|
154
157
|
export function deriveMaxConcurrent(memBytes, cores) {
|
|
155
158
|
const byMem = Math.floor(memBytes / 2 ** 30 - 2);
|
|
156
159
|
const byCpu = Number(cores) * 2;
|
|
157
|
-
return Math.max(1, Math.min(
|
|
160
|
+
return Math.max(1, Math.min(TURN_CEILING_MAX, byMem, byCpu));
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
/**
|
|
164
|
+
* THE RUNAWAY BOUND ON TURNS AT ONCE, AND THE ONE READING OF A CEILING
|
|
165
|
+
* (2026-10-10, audit F070).
|
|
166
|
+
*
|
|
167
|
+
* Every author of the ceiling — the env var, the server's dial, the
|
|
168
|
+
* derivation and an admission's own read — goes through `clampTurnCeiling`,
|
|
169
|
+
* which was five hand copies of one rule across this file and admission.mjs:
|
|
170
|
+
* a usable ceiling is a finite number ≥ 1, floored and capped at
|
|
171
|
+
* `TURN_CEILING_MAX`; anything else is ABSENT (null), never 0 or NaN — a 0
|
|
172
|
+
* refuses every spawn forever, and `live >= NaN` is no ceiling at all.
|
|
173
|
+
*
|
|
174
|
+
* 32 is the server's own bound on the dial (`machineTurnsSchema.maxTurns`,
|
|
175
|
+
* `max(32)` in the shared agent-runner schema): the release parity gate
|
|
176
|
+
* (`scripts/check-app-parity.mjs`) holds this copy to it, so the app never
|
|
177
|
+
* stores a dial no machine honours, and no machine caps one it would.
|
|
178
|
+
*/
|
|
179
|
+
export const TURN_CEILING_MAX = 32;
|
|
180
|
+
export function clampTurnCeiling(raw) {
|
|
181
|
+
const v = Number(raw);
|
|
182
|
+
return Number.isFinite(v) && v >= 1 ? Math.min(Math.floor(v), TURN_CEILING_MAX) : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The operator's `FLOWVIANT_MAX_CONCURRENT`, read once through the one clamp:
|
|
186
|
+
* null when unset or unusable (the derivation stands). */
|
|
187
|
+
const MAX_CONCURRENT_ASKED = clampTurnCeiling(process.env.FLOWVIANT_MAX_CONCURRENT);
|
|
188
|
+
|
|
160
189
|
/**
|
|
161
190
|
* WAS THE ENV VAR THE SOURCE? — the one fact the precedence rule cannot
|
|
162
191
|
* reconstruct afterwards.
|
|
@@ -166,20 +195,10 @@ export function deriveMaxConcurrent(memBytes, cores) {
|
|
|
166
195
|
* last word and must outrank the app's dial, the other is a default the app may
|
|
167
196
|
* override. See `pickMaxTurns` in admission.mjs for what is done with it.
|
|
168
197
|
*/
|
|
169
|
-
export const MAX_CONCURRENT_FROM_ENV =
|
|
170
|
-
const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
|
|
171
|
-
return Number.isFinite(asked) && asked >= 1;
|
|
172
|
-
})();
|
|
198
|
+
export const MAX_CONCURRENT_FROM_ENV = MAX_CONCURRENT_ASKED !== null;
|
|
173
199
|
|
|
174
|
-
export const MAX_CONCURRENT =
|
|
175
|
-
|
|
176
|
-
if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
|
|
177
|
-
return deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
|
|
178
|
-
})();
|
|
179
|
-
export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
|
|
180
|
-
// Live mode: after this long idle-parked on a blocker, tear the session down to
|
|
181
|
-
// free the Claude process (the intent stays claimed; it resumes when answered).
|
|
182
|
-
export const PARK_TIMEOUT_SECONDS = Number(process.env.PARK_TIMEOUT_SECONDS || 900);
|
|
200
|
+
export const MAX_CONCURRENT =
|
|
201
|
+
MAX_CONCURRENT_ASKED ?? deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
|
|
183
202
|
export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
|
|
184
203
|
// Proactively refresh a worker token this many seconds before its lease lapses,
|
|
185
204
|
// so a long-lived daemon never silently 401s on an expired token.
|
|
@@ -190,12 +209,6 @@ export const SAFE = process.env.FLOWVIANT_SAFE === '1';
|
|
|
190
209
|
// keeps it nag-only (it still tells you to update, never installs). Below the
|
|
191
210
|
// server's MIN version it updates regardless, since live mode won't work.
|
|
192
211
|
export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
|
|
193
|
-
// Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
|
|
194
|
-
// streams into the task channel, injectable mid-task, blocker-parks in place,
|
|
195
|
-
// delivery card on complete, branch preview tunnels. The legacy poll/sentinel
|
|
196
|
-
// path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
|
|
197
|
-
// escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
|
|
198
|
-
export const LIVE = process.env.FLOWVIANT_POLL !== '1';
|
|
199
212
|
// Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
|
|
200
213
|
// them (Node's default UA is treated as a bot). Claude Code sends its own UA.
|
|
201
214
|
export const USER_AGENT = `flowviant/${VERSION}`;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH CONVERSATION A TAB RESUMES — the shape each CLI's conversation id must
|
|
3
|
+
* have, and the marker each tab pins it under (audit 2026-10-10, F157).
|
|
4
|
+
*
|
|
5
|
+
* A LEAF ON PURPOSE: no imports. These regexes are argv-injection and
|
|
6
|
+
* path-traversal guards — an id rides argv as `--resume <id>`, `resume <id>`
|
|
7
|
+
* or `--conversation <id>`, and Claude's is joined into a transcript path the
|
|
8
|
+
* probe cleanup then REMOVES — and they were written out by hand: Claude's
|
|
9
|
+
* four times, Antigravity's twice. A CLI that changes its id format would
|
|
10
|
+
* have had someone loosen one copy and miss the one guarding `rmSync`. The
|
|
11
|
+
* readers (claudeSessions, agySessions, runtimeCapabilities, runtimeLimits)
|
|
12
|
+
* sit below the work lanes, so the shapes cannot live in a lane module.
|
|
13
|
+
*
|
|
14
|
+
* THE MARKER NAMES ARE HERE FOR THE SAME REASON. `workSessionTurns.mjs`
|
|
15
|
+
* WRITES them (`sessionMetaPath(wt, <name>, sessionId)`), workPlaceReports
|
|
16
|
+
* reads Claude's for the tab's title, and localSessions READS all of them to
|
|
17
|
+
* know which conversations are the daemon's own — a drift between the writer
|
|
18
|
+
* and that fence is silent, and offers somebody their own tab back as an
|
|
19
|
+
* adoptable terminal session.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Claude Code's session id, as `system.init` reports it. Conservative: word
|
|
24
|
+
* characters and dash, 8–64 of them, so it can neither climb a path nor
|
|
25
|
+
* carry whitespace into argv.
|
|
26
|
+
*/
|
|
27
|
+
export const CLAUDE_SESSION_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The shape a codex thread id must have before it is written to disk or —
|
|
31
|
+
* decisive — pushed into argv as `resume <id>`. Conservative on purpose:
|
|
32
|
+
* alphanumeric plus dash/underscore, never a leading dash (an argv that
|
|
33
|
+
* parses as a flag), never whitespace. Anything else is dropped and the
|
|
34
|
+
* session simply runs fresh in its own worktree.
|
|
35
|
+
*/
|
|
36
|
+
export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
|
|
37
|
+
|
|
38
|
+
/** agy conversation ids are plain UUIDs (the db filename IS the identity —
|
|
39
|
+
* measured: a renamed copy fails "trajectory not found"). Guarded the same
|
|
40
|
+
* way as the codex id: it rides in argv as `--conversation <id>`. */
|
|
41
|
+
export const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
42
|
+
|
|
43
|
+
/** Each runtime's per-tab conversation marker (scoped by the session id). */
|
|
44
|
+
export const RESUME_MARKERS = Object.freeze({
|
|
45
|
+
claude: 'flowviant-claude-session',
|
|
46
|
+
codex: 'flowviant-codex-thread',
|
|
47
|
+
antigravity: 'flowviant-agy-conversation',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Every tab's conversation marker — what the adopt fence reads back. */
|
|
51
|
+
export const SESSION_MARKERS = Object.freeze(Object.values(RESUME_MARKERS));
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -44,7 +44,6 @@
|
|
|
44
44
|
* mistake permanent.
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
|
-
import { execFileSync } from 'node:child_process';
|
|
48
47
|
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
49
48
|
import { homedir } from 'node:os';
|
|
50
49
|
import { join } from 'node:path';
|
|
@@ -52,6 +51,7 @@ import { printable } from './printable.mjs';
|
|
|
52
51
|
import { connectedOn } from './credentialDate.mjs';
|
|
53
52
|
import { terminalCommand } from './launchCommand.mjs';
|
|
54
53
|
import { samePath } from './repoPath.mjs';
|
|
54
|
+
import { git } from './git.mjs';
|
|
55
55
|
|
|
56
56
|
const CRED_DIR = join(homedir(), '.flowviant');
|
|
57
57
|
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
@@ -76,12 +76,7 @@ function writeFile(v) {
|
|
|
76
76
|
/** The repo root of `cwd`, realpath'd, or null when not inside a git repo. */
|
|
77
77
|
export function detectRepoRoot(cwd = process.cwd()) {
|
|
78
78
|
try {
|
|
79
|
-
const root =
|
|
80
|
-
cwd,
|
|
81
|
-
encoding: 'utf8',
|
|
82
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
83
|
-
timeout: 5000,
|
|
84
|
-
}).trim();
|
|
79
|
+
const root = git(['rev-parse', '--show-toplevel'], cwd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
|
|
85
80
|
return root ? realpathSync(root) : null;
|
|
86
81
|
} catch {
|
|
87
82
|
return null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DAEMON LOCK FILE — its name, its record, and the one scan of them
|
|
3
|
+
* (audit 2026-10-10, F090/F123).
|
|
4
|
+
*
|
|
5
|
+
* instance.mjs arbitrates with these files; the status readout
|
|
6
|
+
* (daemonState.mjs, desktopContract.mjs) and `flowviant uninstall` only READ
|
|
7
|
+
* them. Each of those readers used to keep its own copy of the filename
|
|
8
|
+
* pattern or the record parse, and they had drifted: uninstall carried its own
|
|
9
|
+
* regex and pid read, the readout returned a pid that was not a positive
|
|
10
|
+
* integer, and `daemonRunningFor` re-implemented `readHolder` line for line. The
|
|
11
|
+
* FILENAME PATTERN is the thing that must not drift between them: it is what
|
|
12
|
+
* separates our locks from anything else living in ~/.flowviant, and a scan
|
|
13
|
+
* that stops matching sees an empty machine — so `uninstall --others` would
|
|
14
|
+
* remove the copy a live daemon runs from.
|
|
15
|
+
*
|
|
16
|
+
* A leaf (node builtins only), so a reader never has to load instance.mjs's
|
|
17
|
+
* takeover machinery to look at a file; instance.mjs re-exports all of it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as nodeFs from 'node:fs';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
/** Every daemon lock's file name: `daemon-` + 12 hex of the credential's hash. */
|
|
26
|
+
export const DAEMON_LOCK_RE = /^daemon-[0-9a-f]{12}\.lock$/;
|
|
27
|
+
|
|
28
|
+
/** Deliberately a HASH: a credential must never become a filename. */
|
|
29
|
+
export function instanceLockPath(fleetToken, home = homedir()) {
|
|
30
|
+
const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
|
|
31
|
+
return join(home, '.flowviant', `daemon-${key}.lock`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A lock record, or null: absent, truncated, half-written, or naming no
|
|
36
|
+
* positive integer pid (a pid of '12' is not a pid). Everything else the
|
|
37
|
+
* record carries — repoRoot, startedAt, entry, version, draining — is the
|
|
38
|
+
* caller's to read; none of it is required.
|
|
39
|
+
*/
|
|
40
|
+
export function parseLockRecord(text) {
|
|
41
|
+
try {
|
|
42
|
+
const v = JSON.parse(text);
|
|
43
|
+
return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The record in the lock file at `path`, or null (see parseLockRecord). */
|
|
50
|
+
export function readLockFile(path, { fs = nodeFs } = {}) {
|
|
51
|
+
try {
|
|
52
|
+
return parseLockRecord(fs.readFileSync(path, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The record in this credential's lock, or null. */
|
|
59
|
+
export function readLockHolder(fleetToken) {
|
|
60
|
+
return readLockFile(instanceLockPath(fleetToken));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every daemon lock on this machine, sorted by name for a stable read:
|
|
65
|
+
* `[{ path, holder }]`, where `holder` is null for a file with no readable
|
|
66
|
+
* record (no caller reads that as a daemon). ONE scan, because three callers
|
|
67
|
+
* walk this directory for different reasons — instance.mjs looking for a
|
|
68
|
+
* neighbour in the same repo, `flowviant stop` sweeping the lot, uninstall
|
|
69
|
+
* protecting the copy a live daemon runs from. An unreadable (or absent)
|
|
70
|
+
* directory is not an error here, it is an empty machine. `fs` and `home` are
|
|
71
|
+
* injectable for uninstall's test seams. The manual WSL restart asks for
|
|
72
|
+
* `strict`: only a missing directory is empty; other scan failures throw.
|
|
73
|
+
*/
|
|
74
|
+
export function listDaemonLocks({ fs = nodeFs, home = homedir(), strict = false } = {}) {
|
|
75
|
+
const dir = join(home, '.flowviant');
|
|
76
|
+
let names;
|
|
77
|
+
try {
|
|
78
|
+
names = fs.readdirSync(dir);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (strict && error.code !== 'ENOENT') throw error;
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
return names
|
|
84
|
+
.filter((name) => DAEMON_LOCK_RE.test(name))
|
|
85
|
+
.sort()
|
|
86
|
+
.map((name) => {
|
|
87
|
+
const path = join(dir, name);
|
|
88
|
+
return { path, holder: readLockFile(path, { fs }) };
|
|
89
|
+
});
|
|
90
|
+
}
|
package/bin/lib/daemonState.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { emitMachineEvent } from './daemonLogging.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { readLockHolder } from './daemonLocks.mjs';
|
|
17
17
|
|
|
18
18
|
const store = () => join(homedir(), '.flowviant');
|
|
19
19
|
const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
|
|
@@ -56,9 +56,9 @@ export function setRuntimeLimit(projectId, runtime, words) {
|
|
|
56
56
|
return true;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** The pid in the instance lock for this credential, or null
|
|
59
|
+
/** The pid in the instance lock for this credential, or null — read through
|
|
60
|
+
* the lock's one record parse, so a pid that is not a positive integer is no
|
|
61
|
+
* pid (2026-10-10, F090). */
|
|
60
62
|
export function daemonLockPid(token) {
|
|
61
|
-
|
|
62
|
-
return JSON.parse(readFileSync(instanceLockPath(token), 'utf8')).pid;
|
|
63
|
-
} catch { return null; }
|
|
63
|
+
return readLockHolder(token)?.pid ?? null;
|
|
64
64
|
}
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
* - deployRunner.mjs the commands, their environment and log scrubbing,
|
|
17
17
|
* the health check (and why a deploy's secrets are
|
|
18
18
|
* the machine's own environment)
|
|
19
|
-
* - deployWire.mjs the
|
|
19
|
+
* - deployWire.mjs the lane's `/fleet/deploy-*` wire (post, claim)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { DAEMON_INSTANCE } from './config.mjs';
|
|
23
23
|
import { c, note, ok, warn } from './ui.mjs';
|
|
24
24
|
import { scrub } from './uplinkScrub.mjs';
|
|
25
|
-
import { post } from './deployWire.mjs';
|
|
25
|
+
import { claim, post } from './deployWire.mjs';
|
|
26
26
|
import { runDeploy } from './deployRunner.mjs';
|
|
27
27
|
import { workLanesStopped } from './standDownGate.mjs';
|
|
28
28
|
|
|
@@ -99,16 +99,13 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
99
99
|
void (async () => {
|
|
100
100
|
let beat = null;
|
|
101
101
|
try {
|
|
102
|
-
// `instance` names THIS PROCESS
|
|
102
|
+
// `instance` names THIS PROCESS — the shared lease claim stamps it
|
|
103
|
+
// (fleetPost.mjs's `claimFleetLease`). The pubkey cannot: it is the env
|
|
103
104
|
// keypair read from one file per home directory, so two daemons on one
|
|
104
105
|
// box share it and a pubkey-only read-back let both "win" the claim
|
|
105
106
|
// and run the same deploy twice concurrently.
|
|
106
|
-
const claimed = await
|
|
107
|
-
|
|
108
|
-
pubkey: ctx.myPubB64(),
|
|
109
|
-
instance: DAEMON_INSTANCE,
|
|
110
|
-
}).catch(() => null);
|
|
111
|
-
if (!claimed?.claimed) return; // another daemon won the claim
|
|
107
|
+
const claimed = await claim('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() });
|
|
108
|
+
if (!claimed) return; // another daemon won the claim, or none was made
|
|
112
109
|
/**
|
|
113
110
|
* A CLAIM THAT COMES BACK AFTER THE STAND-DOWN BEGAN IS NOT RUN. The
|
|
114
111
|
* loop gate cannot see it (the claim was already on the wire), and the
|