flowviant 0.26.0 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +9 -0
- package/bin/lib/claude.mjs +8 -2
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/env-cli.mjs +212 -0
- package/bin/lib/env.mjs +547 -0
- package/bin/lib/fleet.mjs +46 -1
- package/bin/lib/live.mjs +14 -3
- package/bin/lib/vault.mjs +5 -1
- package/package.json +2 -1
package/bin/cli.mjs
CHANGED
|
@@ -101,6 +101,15 @@ if (process.argv[2] === 'clean') {
|
|
|
101
101
|
process.exit(0);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// `flowviant env <import|set|show>` — the CLI half of team env sync. Values
|
|
105
|
+
// are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
|
|
106
|
+
// the browser); `show` decrypts locally — it only works on an ENROLLED machine.
|
|
107
|
+
if (process.argv[2] === 'env') {
|
|
108
|
+
const { runEnvCommand } = await import('./lib/env-cli.mjs');
|
|
109
|
+
await runEnvCommand(process.argv.slice(3));
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
if (!FLEET_TOKEN && tokens.length === 0) {
|
|
105
114
|
console.error(
|
|
106
115
|
'error: no credential found. Easiest:\n' +
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -35,7 +35,10 @@ Operate this loop:
|
|
|
35
35
|
5. Return to step 1.
|
|
36
36
|
|
|
37
37
|
Keep every change scoped to the claimed intent. If a tool errors, report_progress with
|
|
38
|
-
the error, then retry or report_blocker
|
|
38
|
+
the error, then retry or report_blocker.
|
|
39
|
+
SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their VALUES
|
|
40
|
+
must NEVER appear in evidence, progress, summaries, commits, or PRs — reference keys
|
|
41
|
+
by NAME only. Never commit an env file.`;
|
|
39
42
|
|
|
40
43
|
// Single-task turn (FLEET mode): claim EXACTLY ONE intent, then stop. The daemon
|
|
41
44
|
// owns the loop so it can reset the worktree + start a fresh conversation per task.
|
|
@@ -63,7 +66,10 @@ Do EXACTLY ONE task this turn:
|
|
|
63
66
|
|
|
64
67
|
Do NOT claim a second intent — exactly one per turn. Keep every change scoped to the
|
|
65
68
|
claimed intent. If a tool errors, report_progress with the error, then retry or
|
|
66
|
-
report_blocker
|
|
69
|
+
report_blocker.
|
|
70
|
+
SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their VALUES
|
|
71
|
+
must NEVER appear in evidence, progress, summaries, commits, or PRs — reference keys
|
|
72
|
+
by NAME only. Never commit an env file.`;
|
|
67
73
|
|
|
68
74
|
export const KICKOFF =
|
|
69
75
|
'Begin the loop: claim and complete all dispatched Flowviant intents per your instructions.';
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
|
-
export const VERSION = '0.
|
|
7
|
+
export const VERSION = '0.27.1';
|
|
8
8
|
|
|
9
9
|
// The model EVERY daemon Claude turn runs on — pinned so autonomous work never
|
|
10
10
|
// inherits your interactive `~/.claude/settings.json` default. That matters: a
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flowviant env <import|set|show>` — the CLI half of team env sync.
|
|
3
|
+
*
|
|
4
|
+
* import <file> [--file <targetFile>] seed the synced bundle from an existing
|
|
5
|
+
* env file (KEY=value lines; comments and
|
|
6
|
+
* blank lines skipped). The onboarding
|
|
7
|
+
* moment: one command, whole team synced.
|
|
8
|
+
* set <KEY> [--file <targetFile>] set/rotate one value (prompted on stdin,
|
|
9
|
+
* never argv — argv leaks into shell
|
|
10
|
+
* history and `ps`).
|
|
11
|
+
* show [KEY] decrypt locally and print — only works
|
|
12
|
+
* on an ENROLLED machine (this is the
|
|
13
|
+
* only place values are ever readable).
|
|
14
|
+
*
|
|
15
|
+
* Writes are sealed to the project pubkey on this machine (same write-only
|
|
16
|
+
* crypto as the browser) and pushed via the fleet-token endpoint; every
|
|
17
|
+
* enrolled daemon resyncs within seconds via the push channel.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { basename } from 'node:path';
|
|
22
|
+
import { createInterface } from 'node:readline';
|
|
23
|
+
import sodium from 'libsodium-wrappers';
|
|
24
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
25
|
+
import { ensureKeypair, myPubB64, fetchBundle } from './env.mjs';
|
|
26
|
+
|
|
27
|
+
const B64 = () => sodium.base64_variants.ORIGINAL;
|
|
28
|
+
const KEYS_URL = FLEET_URL.replace(/\/agents\/?$/, '/env/keys');
|
|
29
|
+
|
|
30
|
+
const die = (msg) => {
|
|
31
|
+
console.error(`error: ${msg}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const fingerprint = (value) => (value ? `${value.slice(0, 4)}…(${value.length})` : '');
|
|
36
|
+
|
|
37
|
+
async function postKey({ name, targetFile, value, keyEpoch, baseVersion, projectPub }) {
|
|
38
|
+
const ciphertext = sodium.to_base64(
|
|
39
|
+
sodium.crypto_box_seal(sodium.from_string(value), sodium.from_base64(projectPub, B64())),
|
|
40
|
+
B64()
|
|
41
|
+
);
|
|
42
|
+
const res = await fetch(KEYS_URL, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
46
|
+
'User-Agent': USER_AGENT,
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
},
|
|
49
|
+
signal: AbortSignal.timeout(30_000),
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
pubkey: myPubB64(),
|
|
52
|
+
name,
|
|
53
|
+
env: 'dev',
|
|
54
|
+
targetFile,
|
|
55
|
+
ciphertext,
|
|
56
|
+
fingerprint: fingerprint(value),
|
|
57
|
+
keyEpoch,
|
|
58
|
+
baseVersion: baseVersion ?? null,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
const json = await res.json().catch(() => ({}));
|
|
62
|
+
if (!res.ok || json?.success === false) {
|
|
63
|
+
throw new Error(`${name}: ${json?.error ?? `HTTP ${res.status}`}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseEnvFile(text) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const raw of text.split('\n')) {
|
|
70
|
+
const line = raw.trim();
|
|
71
|
+
if (!line || line.startsWith('#')) continue;
|
|
72
|
+
const eq = line.indexOf('=');
|
|
73
|
+
if (eq <= 0) continue;
|
|
74
|
+
const name = line.slice(0, eq).trim().replace(/^export\s+/, '');
|
|
75
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
|
|
76
|
+
let value = line.slice(eq + 1).trim();
|
|
77
|
+
// Strip one layer of matching quotes — the convention .env parsers follow.
|
|
78
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
79
|
+
value = value.slice(1, -1);
|
|
80
|
+
}
|
|
81
|
+
if (value) out.push({ name, value });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function argAfter(args, flag) {
|
|
87
|
+
const i = args.indexOf(flag);
|
|
88
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readSecretFromStdin(promptText) {
|
|
92
|
+
// Muted input — the typed secret must NOT echo to the terminal (a shoulder-
|
|
93
|
+
// surf / screen-share leak). We write the prompt ourselves, then swallow ALL
|
|
94
|
+
// readline output while reading. Keying the mute on "does this write contain
|
|
95
|
+
// the prompt?" is unsafe: readline's line-refresh (backspace, mid-line edit,
|
|
96
|
+
// paste, resize) re-emits `prompt + buffer` as ONE string, which would sail
|
|
97
|
+
// through such a check and echo the secret. So: mute EVERYTHING.
|
|
98
|
+
process.stderr.write(promptText);
|
|
99
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
100
|
+
rl._writeToOutput = () => {}; // swallow every echo/refresh unconditionally
|
|
101
|
+
const value = await new Promise((resolve) => rl.question('', resolve));
|
|
102
|
+
process.stderr.write('\n');
|
|
103
|
+
rl.close();
|
|
104
|
+
return value.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function runEnvCommand(args) {
|
|
108
|
+
if (!FLEET_TOKEN) die('no fleet credential — run `flowviant login` first.');
|
|
109
|
+
await sodium.ready;
|
|
110
|
+
await ensureKeypair();
|
|
111
|
+
const cmd = args[0];
|
|
112
|
+
|
|
113
|
+
if (cmd === 'import') {
|
|
114
|
+
const file = args[1];
|
|
115
|
+
if (!file) die('usage: flowviant env import <path/to/.env> [--file <targetFile>]');
|
|
116
|
+
let text;
|
|
117
|
+
try {
|
|
118
|
+
text = readFileSync(file, 'utf8');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
die(`could not read ${file}: ${e.message}`);
|
|
121
|
+
}
|
|
122
|
+
const entries = parseEnvFile(text);
|
|
123
|
+
if (!entries.length) die(`no KEY=value lines found in ${file}.`);
|
|
124
|
+
// Default target: the file's repo-relative-looking path as given (minus
|
|
125
|
+
// leading ./) — `flowviant env import apps/api/.dev.vars` targets exactly
|
|
126
|
+
// that file in every worktree.
|
|
127
|
+
const targetFile = argAfter(args, '--file') ?? file.replace(/^\.\//, '');
|
|
128
|
+
const bundle = await fetchBundle();
|
|
129
|
+
if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
|
|
130
|
+
if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
|
|
131
|
+
const existing = new Map(bundle.keys.map((k) => [k.name, k]));
|
|
132
|
+
let added = 0;
|
|
133
|
+
let updated = 0;
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
const prior = existing.get(e.name);
|
|
136
|
+
try {
|
|
137
|
+
await postKey({
|
|
138
|
+
name: e.name,
|
|
139
|
+
targetFile,
|
|
140
|
+
value: e.value,
|
|
141
|
+
keyEpoch: bundle.keyEpoch,
|
|
142
|
+
baseVersion: prior?.version ?? null,
|
|
143
|
+
projectPub: bundle.projectPub,
|
|
144
|
+
});
|
|
145
|
+
prior ? updated++ : added++;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error(` skip ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
console.log(`imported ${basename(file)} → ${targetFile}: ${added} added, ${updated} updated. Every daemon syncs in seconds.`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (cmd === 'set') {
|
|
155
|
+
const name = args[1];
|
|
156
|
+
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) die('usage: flowviant env set <KEY> [--file <targetFile>]');
|
|
157
|
+
const bundle = await fetchBundle();
|
|
158
|
+
if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
|
|
159
|
+
if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
|
|
160
|
+
const prior = bundle.keys.find((k) => k.name === name);
|
|
161
|
+
const targetFile = argAfter(args, '--file') ?? prior?.targetFile ?? '.env';
|
|
162
|
+
const value = await readSecretFromStdin(`value for ${name} (hidden): `);
|
|
163
|
+
if (!value) die('empty value — nothing set.');
|
|
164
|
+
await postKey({
|
|
165
|
+
name,
|
|
166
|
+
targetFile,
|
|
167
|
+
value,
|
|
168
|
+
keyEpoch: bundle.keyEpoch,
|
|
169
|
+
baseVersion: prior?.version ?? null,
|
|
170
|
+
projectPub: bundle.projectPub,
|
|
171
|
+
});
|
|
172
|
+
console.log(`${name} ${prior ? `rotated (v${prior.version + 1})` : 'added'} → ${targetFile}. Every daemon syncs in seconds.`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (cmd === 'show') {
|
|
177
|
+
const bundle = await fetchBundle();
|
|
178
|
+
if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
|
|
179
|
+
if (!bundle.wrappedPriv) die('no key material for this machine yet.');
|
|
180
|
+
const kp = await ensureKeypair();
|
|
181
|
+
const priv = sodium.crypto_box_seal_open(
|
|
182
|
+
sodium.from_base64(bundle.wrappedPriv, B64()),
|
|
183
|
+
kp.publicKey,
|
|
184
|
+
kp.privateKey
|
|
185
|
+
);
|
|
186
|
+
const pub = sodium.from_base64(bundle.projectPub, B64());
|
|
187
|
+
const filter = args[1];
|
|
188
|
+
let shown = 0;
|
|
189
|
+
for (const k of bundle.keys) {
|
|
190
|
+
if (filter && k.name !== filter) continue;
|
|
191
|
+
try {
|
|
192
|
+
const plain = sodium.to_string(sodium.crypto_box_seal_open(sodium.from_base64(k.ciphertext, B64()), pub, priv));
|
|
193
|
+
console.log(`${k.name}=${plain}`);
|
|
194
|
+
shown++;
|
|
195
|
+
} catch {
|
|
196
|
+
console.error(`# ${k.name}: cannot open (stale epoch — a rotation should heal it)`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (filter && !shown) die(`no key named ${filter}.`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(
|
|
204
|
+
[
|
|
205
|
+
'flowviant env — team-synced, end-to-end-encrypted dev secrets',
|
|
206
|
+
'',
|
|
207
|
+
' flowviant env import <file> [--file <targetFile>] seed from an existing env file',
|
|
208
|
+
' flowviant env set <KEY> [--file <targetFile>] set/rotate one value (stdin)',
|
|
209
|
+
' flowviant env show [KEY] decrypt locally (enrolled machines only)',
|
|
210
|
+
].join('\n')
|
|
211
|
+
);
|
|
212
|
+
}
|
package/bin/lib/env.mjs
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team env sync — the daemon is the CRYPTO ANCHOR. This machine holds a
|
|
3
|
+
* persistent X25519 keypair (~/.flowviant/env-keypair.json, 0600); the
|
|
4
|
+
* project's private key reaches it only sealed to that pubkey. Everything the
|
|
5
|
+
* server stores is ciphertext it cannot open.
|
|
6
|
+
*
|
|
7
|
+
* Duties per roster tick (handleRosterEnv):
|
|
8
|
+
* - register this machine's pubkey (once) → an admin approves in Settings.
|
|
9
|
+
* - bootstrap the project keypair when none exists (first machine): generate
|
|
10
|
+
* it + a standing RECOVERY keypair wrapped under a one-time passphrase
|
|
11
|
+
* printed exactly once — rotations re-seal to the same recovery pub, so
|
|
12
|
+
* that passphrase survives forever.
|
|
13
|
+
* - sync: on a bundle version change, unwrap the priv, open every sealed
|
|
14
|
+
* value, cache (encrypted under a key derived from our own priv), and
|
|
15
|
+
* rematerialize env files into the agent worktrees.
|
|
16
|
+
* - execute wrap jobs (admin approved a new machine → seal the priv to it).
|
|
17
|
+
* - execute rotations (a machine was revoked → new keypair, re-seal all
|
|
18
|
+
* values, re-wrap every enrolled machine, re-seal recovery).
|
|
19
|
+
*
|
|
20
|
+
* Materialization writes per-targetFile KEY=value files into a worktree and
|
|
21
|
+
* registers each path in the worktree's git info/exclude — untracked AND
|
|
22
|
+
* unstageable, so an agent can never commit them. The WIKI worktree never
|
|
23
|
+
* gets env (the cartographer doesn't need secrets).
|
|
24
|
+
*
|
|
25
|
+
* scrub() redacts every known plaintext value from daemon-posted uplinks
|
|
26
|
+
* (turn streams, wiki progress, vault sync). Agent-MCP-direct payloads
|
|
27
|
+
* (evidence, progress, complete) never pass through the daemon — those are
|
|
28
|
+
* covered by the prompt contract, not here.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
readFileSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
existsSync,
|
|
36
|
+
appendFileSync,
|
|
37
|
+
rmSync,
|
|
38
|
+
} from 'node:fs';
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
import { homedir, hostname } from 'node:os';
|
|
41
|
+
import { join, dirname, resolve } from 'node:path';
|
|
42
|
+
import sodium from 'libsodium-wrappers';
|
|
43
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
44
|
+
import { c, info, note, ok, warn } from './ui.mjs';
|
|
45
|
+
|
|
46
|
+
const B64 = () => sodium.base64_variants.ORIGINAL;
|
|
47
|
+
const KEYPAIR_PATH = join(homedir(), '.flowviant', 'env-keypair.json');
|
|
48
|
+
const CACHE_DIR = join(homedir(), '.flowviant', 'env-cache');
|
|
49
|
+
const SCRUB_MIN_LENGTH = 6; // mirrors shared ENV_SCRUB_MIN_LENGTH
|
|
50
|
+
const envUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/env/${tail}`);
|
|
51
|
+
|
|
52
|
+
// ── Module state (one project per daemon, same as the vault) ───────────────
|
|
53
|
+
let keypair = null; // { publicKey: Uint8Array, privateKey: Uint8Array }
|
|
54
|
+
let registeredOnce = false;
|
|
55
|
+
let projectPriv = null; // Uint8Array — unwrapped project private key
|
|
56
|
+
let bundleVersion = -1; // last materialized bundle version (-1 = never)
|
|
57
|
+
let values = []; // [{ name, targetFile, value }]
|
|
58
|
+
let cachedProjectId = null;
|
|
59
|
+
|
|
60
|
+
export async function sodiumReady() {
|
|
61
|
+
await sodium.ready;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 6-emoji key fingerprint — algorithm MUST match the web's pubkeyEmoji
|
|
65
|
+
* (EnvironmentSettings.tsx) so the human can compare terminal ↔ approve card. */
|
|
66
|
+
// MUST stay byte-identical to the web's pubkeyEmoji (EnvironmentSettings.tsx) —
|
|
67
|
+
// the human compares the two strings. 32 glyphs × 8 positions, effective ~40
|
|
68
|
+
// bits. Two FNV-1a rolling hashes over the whole key + a murmur3 finalizer per
|
|
69
|
+
// glyph (a plain additive sum collapsed the space to ~10 bits — grindable).
|
|
70
|
+
const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🦂','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
|
|
71
|
+
export function pubkeyEmoji(pubkeyB64) {
|
|
72
|
+
let h1 = 0x811c9dc5 >>> 0;
|
|
73
|
+
let h2 = 0xc2b2ae35 >>> 0;
|
|
74
|
+
for (let i = 0; i < pubkeyB64.length; i++) {
|
|
75
|
+
const ch = pubkeyB64.charCodeAt(i);
|
|
76
|
+
h1 = Math.imul(h1 ^ ch, 0x01000193) >>> 0;
|
|
77
|
+
h2 = Math.imul(h2 ^ ch, 0x85ebca6b) >>> 0;
|
|
78
|
+
}
|
|
79
|
+
let out = '';
|
|
80
|
+
for (let i = 0; i < 8; i++) {
|
|
81
|
+
let x = (((i < 4 ? h1 : h2) + i * 0x9e3779b1) >>> 0);
|
|
82
|
+
x ^= x >>> 16;
|
|
83
|
+
x = Math.imul(x, 0x7feb352d) >>> 0;
|
|
84
|
+
x ^= x >>> 15;
|
|
85
|
+
x = Math.imul(x, 0x846ca68b) >>> 0;
|
|
86
|
+
x ^= x >>> 16;
|
|
87
|
+
out += FP_EMOJI[x & 31];
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** This machine's persistent keypair (created on first use, 0600). */
|
|
93
|
+
export async function ensureKeypair() {
|
|
94
|
+
await sodium.ready;
|
|
95
|
+
if (keypair) return keypair;
|
|
96
|
+
try {
|
|
97
|
+
const stored = JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
|
|
98
|
+
keypair = {
|
|
99
|
+
publicKey: sodium.from_base64(stored.pub, B64()),
|
|
100
|
+
privateKey: sodium.from_base64(stored.priv, B64()),
|
|
101
|
+
};
|
|
102
|
+
return keypair;
|
|
103
|
+
} catch {
|
|
104
|
+
/* first run */
|
|
105
|
+
}
|
|
106
|
+
keypair = sodium.crypto_box_keypair();
|
|
107
|
+
mkdirSync(dirname(KEYPAIR_PATH), { recursive: true });
|
|
108
|
+
writeFileSync(
|
|
109
|
+
KEYPAIR_PATH,
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
pub: sodium.to_base64(keypair.publicKey, B64()),
|
|
112
|
+
priv: sodium.to_base64(keypair.privateKey, B64()),
|
|
113
|
+
}),
|
|
114
|
+
{ mode: 0o600 }
|
|
115
|
+
);
|
|
116
|
+
return keypair;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function myPubB64() {
|
|
120
|
+
return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Query params the roster poll carries: identity + materialized version. */
|
|
124
|
+
export async function envQueryParams() {
|
|
125
|
+
await ensureKeypair();
|
|
126
|
+
const params = { envpub: myPubB64() };
|
|
127
|
+
if (bundleVersion >= 0) params.envv = String(bundleVersion);
|
|
128
|
+
return params;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── HTTP helpers ───────────────────────────────────────────────────────────
|
|
132
|
+
async function post(tail, body) {
|
|
133
|
+
const res = await fetch(envUrl(tail), {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
137
|
+
'User-Agent': USER_AGENT,
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
},
|
|
140
|
+
signal: AbortSignal.timeout(30_000),
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
const json = await res.json().catch(() => ({}));
|
|
144
|
+
if (!res.ok || json?.success === false) {
|
|
145
|
+
throw new Error(`env ${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
|
|
146
|
+
}
|
|
147
|
+
return json?.data;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function fetchBundle() {
|
|
151
|
+
const res = await fetch(`${envUrl('bundle')}?pubkey=${encodeURIComponent(myPubB64())}`, {
|
|
152
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
153
|
+
signal: AbortSignal.timeout(30_000),
|
|
154
|
+
});
|
|
155
|
+
const json = await res.json().catch(() => ({}));
|
|
156
|
+
if (!res.ok || !json?.data) throw new Error(`env bundle fetch failed (${res.status})`);
|
|
157
|
+
return json.data;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Crypto ─────────────────────────────────────────────────────────────────
|
|
161
|
+
const seal = (bytes, pubB64) => sodium.to_base64(sodium.crypto_box_seal(bytes, sodium.from_base64(pubB64, B64())), B64());
|
|
162
|
+
const openSealed = (b64, pub, priv) => sodium.crypto_box_seal_open(sodium.from_base64(b64, B64()), pub, priv);
|
|
163
|
+
|
|
164
|
+
/** Cache the decrypted bundle at rest, encrypted under a key derived from our
|
|
165
|
+
* own priv — the worktrees hold the same plaintext anyway; this just keeps
|
|
166
|
+
* the cache from being a SECOND, tidier copy. */
|
|
167
|
+
function cacheKey() {
|
|
168
|
+
return sodium.crypto_generichash(sodium.crypto_secretbox_KEYBYTES, keypair.privateKey);
|
|
169
|
+
}
|
|
170
|
+
function writeCache(projectId, payload) {
|
|
171
|
+
try {
|
|
172
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
173
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
174
|
+
const box = sodium.crypto_secretbox_easy(sodium.from_string(JSON.stringify(payload)), nonce, cacheKey());
|
|
175
|
+
writeFileSync(
|
|
176
|
+
join(CACHE_DIR, `${projectId}.json`),
|
|
177
|
+
JSON.stringify({ nonce: sodium.to_base64(nonce, B64()), box: sodium.to_base64(box, B64()) }),
|
|
178
|
+
{ mode: 0o600 }
|
|
179
|
+
);
|
|
180
|
+
} catch {
|
|
181
|
+
/* cache is best-effort */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function readCache(projectId) {
|
|
185
|
+
try {
|
|
186
|
+
const { nonce, box } = JSON.parse(readFileSync(join(CACHE_DIR, `${projectId}.json`), 'utf8'));
|
|
187
|
+
const plain = sodium.crypto_secretbox_open_easy(
|
|
188
|
+
sodium.from_base64(box, B64()),
|
|
189
|
+
sodium.from_base64(nonce, B64()),
|
|
190
|
+
cacheKey()
|
|
191
|
+
);
|
|
192
|
+
return JSON.parse(sodium.to_string(plain));
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Offline start: materialize from the encrypted cache before the first poll.
|
|
199
|
+
* Also seeds knownTargetFiles so stale-file cleanup survives a restart. */
|
|
200
|
+
export async function loadCachedEnv(projectId) {
|
|
201
|
+
await ensureKeypair();
|
|
202
|
+
const cached = readCache(projectId);
|
|
203
|
+
if (!cached) return false;
|
|
204
|
+
values = cached.values ?? [];
|
|
205
|
+
bundleVersion = cached.bundleVersion ?? -1;
|
|
206
|
+
knownTargetFiles = new Set(cached.knownFiles ?? values.map((v) => v.targetFile));
|
|
207
|
+
cachedProjectId = projectId;
|
|
208
|
+
return values.length > 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Materialization ────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/** Per-worktree git exclude — untracked AND unstageable. A git worktree's
|
|
214
|
+
* `.git` is a FILE pointing at its private gitdir; info/exclude there applies
|
|
215
|
+
* to that worktree only and never touches the user's repo. */
|
|
216
|
+
function excludeInWorktree(wt, relPaths) {
|
|
217
|
+
try {
|
|
218
|
+
const dotGit = join(wt, '.git');
|
|
219
|
+
let gitdir = dotGit;
|
|
220
|
+
try {
|
|
221
|
+
const content = readFileSync(dotGit, 'utf8');
|
|
222
|
+
const m = content.match(/^gitdir:\s*(.+)\s*$/m);
|
|
223
|
+
if (m) gitdir = resolve(wt, m[1].trim());
|
|
224
|
+
} catch {
|
|
225
|
+
/* .git is a directory (main checkout) — use it directly */
|
|
226
|
+
}
|
|
227
|
+
const excludePath = join(gitdir, 'info', 'exclude');
|
|
228
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
229
|
+
let existing = '';
|
|
230
|
+
try {
|
|
231
|
+
existing = readFileSync(excludePath, 'utf8');
|
|
232
|
+
} catch {
|
|
233
|
+
/* fresh */
|
|
234
|
+
}
|
|
235
|
+
const missing = relPaths.filter((p) => !existing.split('\n').includes(`/${p}`));
|
|
236
|
+
if (missing.length) {
|
|
237
|
+
appendFileSync(excludePath, `${existing.endsWith('\n') || !existing ? '' : '\n'}${missing.map((p) => `/${p}`).join('\n')}\n`);
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
/* best-effort — the agent prompt still forbids committing secrets */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const isSafeTarget = (p) =>
|
|
245
|
+
p &&
|
|
246
|
+
p.length <= 200 &&
|
|
247
|
+
!p.includes('\\') &&
|
|
248
|
+
!p.includes('\0') &&
|
|
249
|
+
!p.startsWith('/') &&
|
|
250
|
+
p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..');
|
|
251
|
+
|
|
252
|
+
/** Is this path TRACKED in the repo? info/exclude only hides UNTRACKED files —
|
|
253
|
+
* materializing secrets into a tracked file would make them stageable and
|
|
254
|
+
* committable. We refuse those paths entirely. */
|
|
255
|
+
function isTrackedInGit(wt, relPath) {
|
|
256
|
+
try {
|
|
257
|
+
execFileSync('git', ['ls-files', '--error-unmatch', '--', relPath], {
|
|
258
|
+
cwd: wt,
|
|
259
|
+
stdio: 'ignore',
|
|
260
|
+
});
|
|
261
|
+
return true;
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Per-worktree: the target files we last materialized THIS SESSION.
|
|
268
|
+
const lastFilesByWorktree = new Map();
|
|
269
|
+
// Project-global union of every target file we've ever materialized — PERSISTED
|
|
270
|
+
// in the cache and seeded on load, so a file whose key was deleted while the
|
|
271
|
+
// daemon was down still gets its stale plaintext copy cleaned on the next
|
|
272
|
+
// materialize (lastFilesByWorktree alone is empty after a restart, and
|
|
273
|
+
// `git clean -fd` never removes an info/exclude'd file).
|
|
274
|
+
let knownTargetFiles = new Set();
|
|
275
|
+
|
|
276
|
+
const MATERIALIZE_HEADER = '# Materialized by flowviant env sync';
|
|
277
|
+
|
|
278
|
+
/** Render KEY=value with values that contain newlines/= safely quoted so one
|
|
279
|
+
* value can't fabricate another key line. */
|
|
280
|
+
function renderEnvFile(list) {
|
|
281
|
+
const lines = list.map((v) => {
|
|
282
|
+
const needsQuote = /[\n\r"'`$\\ ]/.test(v.value) || v.value === '';
|
|
283
|
+
if (!needsQuote) return `${v.name}=${v.value}`;
|
|
284
|
+
const esc = v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
|
|
285
|
+
return `${v.name}="${esc}"`;
|
|
286
|
+
});
|
|
287
|
+
return `${MATERIALIZE_HEADER} — DO NOT COMMIT.\n${lines.join('\n')}\n`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Delete a materialized file from a worktree, but ONLY if it's ours (carries
|
|
291
|
+
* our header) and not git-tracked — never touch a file we didn't write. */
|
|
292
|
+
function removeStaleEnvFile(wt, rel) {
|
|
293
|
+
if (isTrackedInGit(wt, rel)) return;
|
|
294
|
+
const abs = join(wt, rel);
|
|
295
|
+
try {
|
|
296
|
+
if (existsSync(abs) && readFileSync(abs, 'utf8').startsWith(MATERIALIZE_HEADER)) {
|
|
297
|
+
rmSync(abs, { force: true });
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
/* best-effort */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Write the decrypted env into ONE worktree. Never call on the wiki worktree. */
|
|
305
|
+
export function materializeInto(wt) {
|
|
306
|
+
if (!wt || !existsSync(wt)) return;
|
|
307
|
+
const byFile = new Map();
|
|
308
|
+
for (const v of values) {
|
|
309
|
+
if (!isSafeTarget(v.targetFile)) continue;
|
|
310
|
+
const list = byFile.get(v.targetFile) ?? [];
|
|
311
|
+
list.push(v);
|
|
312
|
+
byFile.set(v.targetFile, list);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const written = [];
|
|
316
|
+
for (const [file, list] of byFile) {
|
|
317
|
+
if (isTrackedInGit(wt, file)) {
|
|
318
|
+
warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const abs = join(wt, file);
|
|
323
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
324
|
+
const body = renderEnvFile(list);
|
|
325
|
+
// Skip an identical rewrite — otherwise every bundle bump touches the
|
|
326
|
+
// file mtime and hot-restarts a running preview dev-server mid-review.
|
|
327
|
+
let prior = null;
|
|
328
|
+
try {
|
|
329
|
+
prior = readFileSync(abs, 'utf8');
|
|
330
|
+
} catch {
|
|
331
|
+
/* new file */
|
|
332
|
+
}
|
|
333
|
+
if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
|
|
334
|
+
written.push(file);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
warn(`env: could not write ${file} into worktree: ${e.message}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Remove any file we ever materialized (this session OR a prior one, via the
|
|
341
|
+
// persisted knownTargetFiles) that has no keys now — a deleted secret's
|
|
342
|
+
// plaintext file must not linger, even across a daemon restart.
|
|
343
|
+
const writtenSet = new Set(written);
|
|
344
|
+
const candidates = new Set([...(lastFilesByWorktree.get(wt) ?? []), ...knownTargetFiles]);
|
|
345
|
+
for (const stale of candidates) {
|
|
346
|
+
if (!writtenSet.has(stale)) removeStaleEnvFile(wt, stale);
|
|
347
|
+
}
|
|
348
|
+
for (const f of written) knownTargetFiles.add(f);
|
|
349
|
+
lastFilesByWorktree.set(wt, written);
|
|
350
|
+
if (written.length) excludeInWorktree(wt, written);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Uplink scrubbing ───────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/** Redact every known secret value from daemon-posted text. Values shorter
|
|
356
|
+
* than the floor ("1", "true") would redact half the stream — skipped. */
|
|
357
|
+
export function scrub(text) {
|
|
358
|
+
if (typeof text !== 'string' || !text || !values.length) return text;
|
|
359
|
+
let out = text;
|
|
360
|
+
for (const v of values) {
|
|
361
|
+
if (typeof v.value === 'string' && v.value.length >= SCRUB_MIN_LENGTH) {
|
|
362
|
+
out = out.split(v.value).join(`[REDACTED:${v.name}]`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Roster tick ────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
let busy = false; // one env operation at a time — ticks are cheap to skip
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* React to the roster's env block. Returns { changed } — true when the bundle
|
|
374
|
+
* was (re)materialized so the caller refreshes its worktrees.
|
|
375
|
+
*/
|
|
376
|
+
export async function handleRosterEnv(env, { projectId } = {}) {
|
|
377
|
+
if (!env || busy) return { changed: false };
|
|
378
|
+
busy = true;
|
|
379
|
+
try {
|
|
380
|
+
await ensureKeypair();
|
|
381
|
+
if (projectId) cachedProjectId = projectId;
|
|
382
|
+
// First tick after a restart: warm from the encrypted cache so worktrees
|
|
383
|
+
// can materialize even if the bundle fetch below fails transiently.
|
|
384
|
+
if (bundleVersion < 0 && cachedProjectId) await loadCachedEnv(cachedProjectId);
|
|
385
|
+
|
|
386
|
+
// 1. Introduce this machine (idempotent server-side). registeredOnce is set
|
|
387
|
+
// only AFTER the POST lands — a transient failure must retry next poll, not
|
|
388
|
+
// wedge registration until restart.
|
|
389
|
+
if (env.status === 'none' && !registeredOnce) {
|
|
390
|
+
const label = hostname() || 'daemon';
|
|
391
|
+
try {
|
|
392
|
+
await post('register', { pubkey: myPubB64(), label });
|
|
393
|
+
} catch (e) {
|
|
394
|
+
// A 429 = the project is at its machine cap; retrying every poll would
|
|
395
|
+
// just hammer it. Stop for this session (a restart re-tries).
|
|
396
|
+
if (/\(429/.test(e.message)) {
|
|
397
|
+
registeredOnce = true;
|
|
398
|
+
warn('env: this project is at its machine limit — env access not requested. Ask an admin to remove an old machine.');
|
|
399
|
+
return { changed: false };
|
|
400
|
+
}
|
|
401
|
+
throw e; // transient — retry next poll (registeredOnce still false)
|
|
402
|
+
}
|
|
403
|
+
registeredOnce = true;
|
|
404
|
+
const fp = pubkeyEmoji(myPubB64());
|
|
405
|
+
info(`${c.cyan('env')} · this machine requested env access as ${c.bold(label)}`);
|
|
406
|
+
note(` fingerprint ${fp} — an admin approves it in Settings → Environment (compare the emoji).`);
|
|
407
|
+
return { changed: false };
|
|
408
|
+
}
|
|
409
|
+
if (env.status === 'pending') return { changed: false }; // waiting on the admin
|
|
410
|
+
if (env.status === 'revoked') return { changed: false };
|
|
411
|
+
|
|
412
|
+
// 2. Bootstrap: no project keypair exists — this machine creates it.
|
|
413
|
+
if (env.bootstrapNeeded && (env.status === 'approved' || env.status === 'enrolled' || env.status === 'none')) {
|
|
414
|
+
if (env.status === 'none') return { changed: false }; // register first, next tick
|
|
415
|
+
await bootstrapProject();
|
|
416
|
+
return { changed: false }; // next tick syncs as enrolled
|
|
417
|
+
}
|
|
418
|
+
if (env.status !== 'enrolled') return { changed: false };
|
|
419
|
+
|
|
420
|
+
// 3. Wrap jobs + rotation + sync — all need the bundle.
|
|
421
|
+
const needSync = env.bundleVersion !== bundleVersion;
|
|
422
|
+
if (!needSync && !env.pendingWraps && !env.rotationPending) return { changed: false };
|
|
423
|
+
const bundle = await fetchBundle();
|
|
424
|
+
if (!bundle.wrappedPriv || !bundle.projectPub) return { changed: false };
|
|
425
|
+
projectPriv = openSealed(bundle.wrappedPriv, keypair.publicKey, keypair.privateKey);
|
|
426
|
+
const projectPub = sodium.from_base64(bundle.projectPub, B64());
|
|
427
|
+
|
|
428
|
+
// Execute approved enrollments: seal the priv to each new machine. The
|
|
429
|
+
// wrap's epoch rides along — the server rejects (stale) if a rotation moved
|
|
430
|
+
// it since we fetched, so nobody enrolls with a dead key.
|
|
431
|
+
if (bundle.pendingWraps.length) {
|
|
432
|
+
const wraps = bundle.pendingWraps.map((p) => ({
|
|
433
|
+
daemonId: p.daemonId,
|
|
434
|
+
wrappedPriv: seal(projectPriv, p.pubkey),
|
|
435
|
+
}));
|
|
436
|
+
const res = await post('wraps', { pubkey: myPubB64(), keyEpoch: bundle.keyEpoch, wraps });
|
|
437
|
+
if (res?.stale) note(`${c.cyan('env')} ${c.dim('— wraps raced a rotation; retrying next poll')}`);
|
|
438
|
+
else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly approved machine${wraps.length === 1 ? '' : 's'}`)}`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Decrypt the values we have — carrying each key's VERSION so a rotation can
|
|
442
|
+
// prove it re-sealed the current value (not one a concurrent write moved).
|
|
443
|
+
const opened = [];
|
|
444
|
+
let allOpened = true;
|
|
445
|
+
for (const k of bundle.keys) {
|
|
446
|
+
try {
|
|
447
|
+
const plain = openSealed(k.ciphertext, projectPub, projectPriv);
|
|
448
|
+
opened.push({ name: k.name, env: k.env, targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
|
|
449
|
+
} catch {
|
|
450
|
+
allOpened = false;
|
|
451
|
+
warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Execute a pending rotation: new keypair, full coverage, all wraps. If we
|
|
456
|
+
// couldn't open every value, DON'T attempt — a partial rotate would fail
|
|
457
|
+
// the server's coverage check; let another enrolled daemon (which can open
|
|
458
|
+
// them) do it. Server serializes concurrent executors via a claim lock.
|
|
459
|
+
if (bundle.rotationPending) {
|
|
460
|
+
if (!allOpened) {
|
|
461
|
+
warn(`env: skipping rotation — this machine can't open every value; another daemon will rotate`);
|
|
462
|
+
return { changed: false };
|
|
463
|
+
}
|
|
464
|
+
const next = sodium.crypto_box_keypair();
|
|
465
|
+
const nextPubB64 = sodium.to_base64(next.publicKey, B64());
|
|
466
|
+
const res = await post('rotate', {
|
|
467
|
+
pubkey: myPubB64(),
|
|
468
|
+
fromEpoch: bundle.keyEpoch,
|
|
469
|
+
projectPub: nextPubB64,
|
|
470
|
+
values: opened.map((v) => ({ name: v.name, env: v.env, ciphertext: seal(sodium.from_string(v.value), nextPubB64), version: v.version })),
|
|
471
|
+
wraps: bundle.enrolledDaemons.map((d) => ({ daemonId: d.daemonId, wrappedPriv: seal(next.privateKey, d.pubkey) })),
|
|
472
|
+
...(bundle.recoveryPub ? { recoverySealed: seal(next.privateKey, bundle.recoveryPub) } : {}),
|
|
473
|
+
}).catch((e) => {
|
|
474
|
+
// epoch_stale / value_moved / coverage → a concurrent change; the next
|
|
475
|
+
// poll re-fetches and retries. Not fatal.
|
|
476
|
+
note(`${c.cyan('env')} ${c.dim(`— rotation deferred (${e.message}); retrying next poll`)}`);
|
|
477
|
+
return null;
|
|
478
|
+
});
|
|
479
|
+
if (res) ok(`${c.cyan('env')} ${c.dim('— project key rotated (a machine was revoked); next poll syncs the new epoch')}`);
|
|
480
|
+
return { changed: false }; // resync on the next tick at the new version
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (needSync) {
|
|
484
|
+
values = opened;
|
|
485
|
+
bundleVersion = bundle.bundleVersion;
|
|
486
|
+
// Fold the current target files into the persisted known set so stale
|
|
487
|
+
// cleanup survives a restart (a key deleted while down still gets swept).
|
|
488
|
+
for (const v of values) knownTargetFiles.add(v.targetFile);
|
|
489
|
+
if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion, knownFiles: [...knownTargetFiles] });
|
|
490
|
+
ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
|
|
491
|
+
return { changed: true };
|
|
492
|
+
}
|
|
493
|
+
return { changed: false };
|
|
494
|
+
} catch (e) {
|
|
495
|
+
warn(`env sync: ${e.message} — will retry next poll`);
|
|
496
|
+
return { changed: false };
|
|
497
|
+
} finally {
|
|
498
|
+
busy = false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** First machine creates the project keypair + the standing recovery target.
|
|
503
|
+
* The recovery passphrase prints ONCE — rotations re-seal to the same
|
|
504
|
+
* recovery pub, so this passphrase works forever. */
|
|
505
|
+
async function bootstrapProject() {
|
|
506
|
+
const project = sodium.crypto_box_keypair();
|
|
507
|
+
const recovery = sodium.crypto_box_keypair();
|
|
508
|
+
// Human-typable passphrase: 6 groups of 4 from an unambiguous alphabet.
|
|
509
|
+
const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
510
|
+
const raw = sodium.randombytes_buf(24);
|
|
511
|
+
const passphrase = Array.from(raw, (b, i) => ALPHA[b % ALPHA.length] + ((i + 1) % 4 === 0 && i < 23 ? '-' : '')).join('');
|
|
512
|
+
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
|
513
|
+
const kdfKey = sodium.crypto_pwhash(
|
|
514
|
+
sodium.crypto_secretbox_KEYBYTES,
|
|
515
|
+
passphrase,
|
|
516
|
+
salt,
|
|
517
|
+
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
|
|
518
|
+
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
|
|
519
|
+
sodium.crypto_pwhash_ALG_DEFAULT
|
|
520
|
+
);
|
|
521
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
522
|
+
const recoverySecret = JSON.stringify({
|
|
523
|
+
pub: sodium.to_base64(recovery.publicKey, B64()),
|
|
524
|
+
priv: sodium.to_base64(recovery.privateKey, B64()),
|
|
525
|
+
});
|
|
526
|
+
const recoveryBlob = [
|
|
527
|
+
sodium.to_base64(salt, B64()),
|
|
528
|
+
sodium.to_base64(nonce, B64()),
|
|
529
|
+
sodium.to_base64(sodium.crypto_secretbox_easy(sodium.from_string(recoverySecret), nonce, kdfKey), B64()),
|
|
530
|
+
].join(':');
|
|
531
|
+
|
|
532
|
+
await post('bootstrap', {
|
|
533
|
+
pubkey: myPubB64(),
|
|
534
|
+
projectPub: sodium.to_base64(project.publicKey, B64()),
|
|
535
|
+
selfWrap: seal(project.privateKey, myPubB64()),
|
|
536
|
+
recoveryPub: sodium.to_base64(recovery.publicKey, B64()),
|
|
537
|
+
recoveryBlob,
|
|
538
|
+
recoverySealed: seal(project.privateKey, sodium.to_base64(recovery.publicKey, B64())),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
console.log('');
|
|
542
|
+
ok(`${c.cyan('env')} — this machine created the project's env keypair.`);
|
|
543
|
+
console.log(` ${c.bold('RECOVERY CODE')} ${c.dim('(shown ONCE — save it in a password manager):')}`);
|
|
544
|
+
console.log(` ${c.bold(c.yellow(passphrase))}`);
|
|
545
|
+
note(' If every enrolled machine is ever lost, this code is the only way back into the secrets.');
|
|
546
|
+
console.log('');
|
|
547
|
+
}
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -55,10 +55,24 @@ import { reapOrphanPreviews } from './preview.mjs';
|
|
|
55
55
|
import { preflight } from './preflight.mjs';
|
|
56
56
|
import { connectStream } from './stream.mjs';
|
|
57
57
|
import { ensureVault, syncVault } from './vault.mjs';
|
|
58
|
+
import {
|
|
59
|
+
envQueryParams,
|
|
60
|
+
handleRosterEnv,
|
|
61
|
+
materializeInto,
|
|
62
|
+
scrub as envScrub,
|
|
63
|
+
} from './env.mjs';
|
|
58
64
|
|
|
59
65
|
async function fetchRoster(haveIds) {
|
|
60
66
|
const url = new URL(FLEET_URL);
|
|
61
67
|
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
68
|
+
// Env-sync identity + materialized version (the Settings "env vN" chip).
|
|
69
|
+
try {
|
|
70
|
+
for (const [k, v] of Object.entries(await envQueryParams())) {
|
|
71
|
+
if (v) url.searchParams.set(k, v);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
/* env identity is best-effort — the poll must never fail on it */
|
|
75
|
+
}
|
|
62
76
|
// An explicit User-Agent is required: Node's default ("node"/empty) trips
|
|
63
77
|
// Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
|
|
64
78
|
const res = await fetch(url, {
|
|
@@ -117,6 +131,7 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
|
|
|
117
131
|
}
|
|
118
132
|
if (!resuming && needsReset) {
|
|
119
133
|
resetWorktree(cwd, baseRef); // clean slate for a new task
|
|
134
|
+
materializeInto(cwd); // reset wiped the env files (git clean -fd) — rewrite
|
|
120
135
|
needsReset = false;
|
|
121
136
|
}
|
|
122
137
|
const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
|
|
@@ -441,6 +456,14 @@ export async function runFleetDaemon() {
|
|
|
441
456
|
const now = Date.now();
|
|
442
457
|
if (!force && now - lastProgressAt < 600) return;
|
|
443
458
|
lastProgressAt = now;
|
|
459
|
+
// Uplink scrub: narration/labels can quote repo content, and repo content
|
|
460
|
+
// can contain a synced secret — redact known values before anything leaves
|
|
461
|
+
// this machine.
|
|
462
|
+
const safe = {
|
|
463
|
+
...body,
|
|
464
|
+
...(typeof body.activity === 'string' ? { activity: envScrub(body.activity) } : {}),
|
|
465
|
+
...(Array.isArray(body.recent) ? { recent: body.recent.map((s) => envScrub(s)) } : {}),
|
|
466
|
+
};
|
|
444
467
|
try {
|
|
445
468
|
await fetch(WIKI_PROGRESS_URL, {
|
|
446
469
|
method: 'POST',
|
|
@@ -450,7 +473,7 @@ export async function runFleetDaemon() {
|
|
|
450
473
|
'Content-Type': 'application/json',
|
|
451
474
|
},
|
|
452
475
|
signal: AbortSignal.timeout(15_000),
|
|
453
|
-
body: JSON.stringify(
|
|
476
|
+
body: JSON.stringify(safe),
|
|
454
477
|
});
|
|
455
478
|
} catch {
|
|
456
479
|
/* best-effort — a dropped frame is harmless, the next one supersedes it */
|
|
@@ -575,6 +598,8 @@ export async function runFleetDaemon() {
|
|
|
575
598
|
// Powers the GitHub blob links behind every cited file path.
|
|
576
599
|
repoFullName: originSlug(repoRoot) || undefined,
|
|
577
600
|
warn,
|
|
601
|
+
// Redact synced secrets a page may have quoted from the repo.
|
|
602
|
+
scrub: envScrub,
|
|
578
603
|
});
|
|
579
604
|
if (r.skipped) note(`${c.cyan('wiki')} ${c.dim('— vault unchanged, nothing to sync')}`);
|
|
580
605
|
else
|
|
@@ -815,6 +840,11 @@ export async function runFleetDaemon() {
|
|
|
815
840
|
fail(`could not create worktree for "${a.name}": ${e.message}`);
|
|
816
841
|
continue;
|
|
817
842
|
}
|
|
843
|
+
try {
|
|
844
|
+
materializeInto(wt); // synced env into the fresh worktree
|
|
845
|
+
} catch {
|
|
846
|
+
/* best-effort */
|
|
847
|
+
}
|
|
818
848
|
const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
|
|
819
849
|
const label = colorFn(`[${a.name}]`);
|
|
820
850
|
const state = { alive: true, child: null };
|
|
@@ -860,6 +890,21 @@ export async function runFleetDaemon() {
|
|
|
860
890
|
for (const j of roster.regroundJobs ?? []) enqueueReground(j.intentId, j.prUrl, j.title);
|
|
861
891
|
void drainWiki();
|
|
862
892
|
|
|
893
|
+
// Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
|
|
894
|
+
// dictates (self-guarded — one operation at a time, errors retry next
|
|
895
|
+
// poll). A fresh bundle rematerializes every AGENT worktree; the wiki
|
|
896
|
+
// worktree NEVER gets env (the cartographer doesn't need secrets).
|
|
897
|
+
void handleRosterEnv(roster.env, { projectId: roster.project?.id }).then(({ changed }) => {
|
|
898
|
+
if (!changed) return;
|
|
899
|
+
for (const [, w] of workers) {
|
|
900
|
+
try {
|
|
901
|
+
materializeInto(w.wt);
|
|
902
|
+
} catch {
|
|
903
|
+
/* best-effort */
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
863
908
|
// Stop workers whose agent left the roster (removed in the app).
|
|
864
909
|
for (const [id, w] of [...workers]) {
|
|
865
910
|
if (!rosterIds.has(id)) {
|
package/bin/lib/live.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { c, info, ok, warn } from './ui.mjs';
|
|
|
34
34
|
import { sleep } from './claude.mjs';
|
|
35
35
|
import { git, resetWorktree, isValidBranch } from './git.mjs';
|
|
36
36
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
37
|
+
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
37
38
|
|
|
38
39
|
// Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
|
|
39
40
|
// reviewer then drives it via "Open live preview" in the node.
|
|
@@ -92,7 +93,9 @@ function postPreviewNote(intentId, text) {
|
|
|
92
93
|
'Content-Type': 'application/json',
|
|
93
94
|
},
|
|
94
95
|
signal: AbortSignal.timeout(10_000),
|
|
95
|
-
|
|
96
|
+
// Scrub: preview failure reasons can quote dev-server output, which can
|
|
97
|
+
// echo env values.
|
|
98
|
+
body: JSON.stringify({ intentId, text: envScrub(text) }),
|
|
96
99
|
}).catch(() => {});
|
|
97
100
|
}
|
|
98
101
|
|
|
@@ -143,7 +146,12 @@ self-report becomes your DELIVERY CARD in the task thread — it's what the team
|
|
|
143
146
|
reads to confirm done, so write it for them, not for a log. A live preview of
|
|
144
147
|
your branch is started for you automatically — you do NOT need to open a tunnel
|
|
145
148
|
or register a live target. NEVER merge — a human confirms done in the thread
|
|
146
|
-
(the merge card) and the merge runs separately
|
|
149
|
+
(the merge card) and the merge runs separately.
|
|
150
|
+
SECRETS: env files (.env, .dev.vars, …) in your worktree hold the team's synced
|
|
151
|
+
secrets. Their VALUES must NEVER appear in evidence, progress reports, blocker
|
|
152
|
+
questions, delivery summaries, commits, or PRs — reference keys by NAME only
|
|
153
|
+
(e.g. "set STRIPE_KEY"). Never screenshot a terminal or page that displays a
|
|
154
|
+
credential, and never commit an env file.`;
|
|
147
155
|
|
|
148
156
|
function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
149
157
|
return [
|
|
@@ -383,6 +391,7 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
383
391
|
} else if (!resuming && !resumedInPlace) {
|
|
384
392
|
resetWorktree(cwd, baseRef);
|
|
385
393
|
}
|
|
394
|
+
materializeInto(cwd); // resets wipe the synced env files — rewrite them
|
|
386
395
|
writeTaskMarker(cwd, intentId);
|
|
387
396
|
|
|
388
397
|
if (resumedInPlace) {
|
|
@@ -480,7 +489,9 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
480
489
|
await mcpCall(mcpUrl, token, 'stream_turn', {
|
|
481
490
|
runId,
|
|
482
491
|
turnId,
|
|
483
|
-
|
|
492
|
+
// Uplink scrub: the model's narration can quote file contents, and a
|
|
493
|
+
// file can contain a synced secret — redact before it leaves the box.
|
|
494
|
+
text: envScrub(turnText.trim()),
|
|
484
495
|
createdAt: turnAt,
|
|
485
496
|
}).catch(() => {});
|
|
486
497
|
}
|
package/bin/lib/vault.mjs
CHANGED
|
@@ -108,7 +108,7 @@ function commitVault(dir, message) {
|
|
|
108
108
|
* upload (the sync state is only advanced after EVERY request lands, so a
|
|
109
109
|
* partial failure re-uploads next time — server upserts are idempotent).
|
|
110
110
|
*/
|
|
111
|
-
export async function syncVault({ dir, url, token, userAgent, finalize, groundedAtSha, repoFullName, warn = () => {} }) {
|
|
111
|
+
export async function syncVault({ dir, url, token, userAgent, finalize, groundedAtSha, repoFullName, warn = () => {}, scrub = (t) => t }) {
|
|
112
112
|
const walkErrors = { count: 0 };
|
|
113
113
|
const found = walkMd(dir, dir, [], walkErrors).sort();
|
|
114
114
|
if (walkErrors.count > 0 && found.length === 0) {
|
|
@@ -155,6 +155,10 @@ export async function syncVault({ dir, url, token, userAgent, finalize, grounded
|
|
|
155
155
|
carry(p, 'unreadable');
|
|
156
156
|
continue;
|
|
157
157
|
}
|
|
158
|
+
// Uplink scrub: the cartographer quotes real repo files, and a repo file
|
|
159
|
+
// can contain a synced secret — redact known values before upload. The
|
|
160
|
+
// hash is computed on the SCRUBBED text so the diff state stays coherent.
|
|
161
|
+
text = scrub(text);
|
|
158
162
|
if (Buffer.byteLength(text) > MAX_FILE_BYTES) {
|
|
159
163
|
carry(p, 'exceeds 256KB');
|
|
160
164
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.1",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@anthropic-ai/claude-agent-sdk": "^0.3.0",
|
|
19
|
+
"libsodium-wrappers": "^0.8.4",
|
|
19
20
|
"ws": "^8.18.0"
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|