flowviant 0.26.0 → 0.27.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/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' +
@@ -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.';
@@ -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.26.0';
7
+ export const VERSION = '0.27.0';
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
+ }
@@ -0,0 +1,504 @@
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. 32 glyphs × 8 positions ≈ 40 bits; each position
68
+ // mixes the whole key so no byte is mute (a compromised-server pubkey swap must
69
+ // grind a full collision, not just the tail).
70
+ const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🐝','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
71
+ export function pubkeyEmoji(pubkeyB64) {
72
+ let out = '';
73
+ for (let i = 0; i < 8; i++) {
74
+ let acc = i + 1;
75
+ for (let j = 0; j < pubkeyB64.length; j++) {
76
+ acc = (acc * 31 + pubkeyB64.charCodeAt(j) * (i + 2)) % 1_000_003;
77
+ }
78
+ out += FP_EMOJI[acc % FP_EMOJI.length];
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** This machine's persistent keypair (created on first use, 0600). */
84
+ export async function ensureKeypair() {
85
+ await sodium.ready;
86
+ if (keypair) return keypair;
87
+ try {
88
+ const stored = JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
89
+ keypair = {
90
+ publicKey: sodium.from_base64(stored.pub, B64()),
91
+ privateKey: sodium.from_base64(stored.priv, B64()),
92
+ };
93
+ return keypair;
94
+ } catch {
95
+ /* first run */
96
+ }
97
+ keypair = sodium.crypto_box_keypair();
98
+ mkdirSync(dirname(KEYPAIR_PATH), { recursive: true });
99
+ writeFileSync(
100
+ KEYPAIR_PATH,
101
+ JSON.stringify({
102
+ pub: sodium.to_base64(keypair.publicKey, B64()),
103
+ priv: sodium.to_base64(keypair.privateKey, B64()),
104
+ }),
105
+ { mode: 0o600 }
106
+ );
107
+ return keypair;
108
+ }
109
+
110
+ export function myPubB64() {
111
+ return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
112
+ }
113
+
114
+ /** Query params the roster poll carries: identity + materialized version. */
115
+ export async function envQueryParams() {
116
+ await ensureKeypair();
117
+ const params = { envpub: myPubB64() };
118
+ if (bundleVersion >= 0) params.envv = String(bundleVersion);
119
+ return params;
120
+ }
121
+
122
+ // ── HTTP helpers ───────────────────────────────────────────────────────────
123
+ async function post(tail, body) {
124
+ const res = await fetch(envUrl(tail), {
125
+ method: 'POST',
126
+ headers: {
127
+ Authorization: `Bearer ${FLEET_TOKEN}`,
128
+ 'User-Agent': USER_AGENT,
129
+ 'Content-Type': 'application/json',
130
+ },
131
+ signal: AbortSignal.timeout(30_000),
132
+ body: JSON.stringify(body),
133
+ });
134
+ const json = await res.json().catch(() => ({}));
135
+ if (!res.ok || json?.success === false) {
136
+ throw new Error(`env ${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
137
+ }
138
+ return json?.data;
139
+ }
140
+
141
+ export async function fetchBundle() {
142
+ const res = await fetch(`${envUrl('bundle')}?pubkey=${encodeURIComponent(myPubB64())}`, {
143
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
144
+ signal: AbortSignal.timeout(30_000),
145
+ });
146
+ const json = await res.json().catch(() => ({}));
147
+ if (!res.ok || !json?.data) throw new Error(`env bundle fetch failed (${res.status})`);
148
+ return json.data;
149
+ }
150
+
151
+ // ── Crypto ─────────────────────────────────────────────────────────────────
152
+ const seal = (bytes, pubB64) => sodium.to_base64(sodium.crypto_box_seal(bytes, sodium.from_base64(pubB64, B64())), B64());
153
+ const openSealed = (b64, pub, priv) => sodium.crypto_box_seal_open(sodium.from_base64(b64, B64()), pub, priv);
154
+
155
+ /** Cache the decrypted bundle at rest, encrypted under a key derived from our
156
+ * own priv — the worktrees hold the same plaintext anyway; this just keeps
157
+ * the cache from being a SECOND, tidier copy. */
158
+ function cacheKey() {
159
+ return sodium.crypto_generichash(sodium.crypto_secretbox_KEYBYTES, keypair.privateKey);
160
+ }
161
+ function writeCache(projectId, payload) {
162
+ try {
163
+ mkdirSync(CACHE_DIR, { recursive: true });
164
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
165
+ const box = sodium.crypto_secretbox_easy(sodium.from_string(JSON.stringify(payload)), nonce, cacheKey());
166
+ writeFileSync(
167
+ join(CACHE_DIR, `${projectId}.json`),
168
+ JSON.stringify({ nonce: sodium.to_base64(nonce, B64()), box: sodium.to_base64(box, B64()) }),
169
+ { mode: 0o600 }
170
+ );
171
+ } catch {
172
+ /* cache is best-effort */
173
+ }
174
+ }
175
+ function readCache(projectId) {
176
+ try {
177
+ const { nonce, box } = JSON.parse(readFileSync(join(CACHE_DIR, `${projectId}.json`), 'utf8'));
178
+ const plain = sodium.crypto_secretbox_open_easy(
179
+ sodium.from_base64(box, B64()),
180
+ sodium.from_base64(nonce, B64()),
181
+ cacheKey()
182
+ );
183
+ return JSON.parse(sodium.to_string(plain));
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ /** Offline start: materialize from the encrypted cache before the first poll. */
190
+ export async function loadCachedEnv(projectId) {
191
+ await ensureKeypair();
192
+ const cached = readCache(projectId);
193
+ if (!cached) return false;
194
+ values = cached.values ?? [];
195
+ bundleVersion = cached.bundleVersion ?? -1;
196
+ cachedProjectId = projectId;
197
+ return values.length > 0;
198
+ }
199
+
200
+ // ── Materialization ────────────────────────────────────────────────────────
201
+
202
+ /** Per-worktree git exclude — untracked AND unstageable. A git worktree's
203
+ * `.git` is a FILE pointing at its private gitdir; info/exclude there applies
204
+ * to that worktree only and never touches the user's repo. */
205
+ function excludeInWorktree(wt, relPaths) {
206
+ try {
207
+ const dotGit = join(wt, '.git');
208
+ let gitdir = dotGit;
209
+ try {
210
+ const content = readFileSync(dotGit, 'utf8');
211
+ const m = content.match(/^gitdir:\s*(.+)\s*$/m);
212
+ if (m) gitdir = resolve(wt, m[1].trim());
213
+ } catch {
214
+ /* .git is a directory (main checkout) — use it directly */
215
+ }
216
+ const excludePath = join(gitdir, 'info', 'exclude');
217
+ mkdirSync(dirname(excludePath), { recursive: true });
218
+ let existing = '';
219
+ try {
220
+ existing = readFileSync(excludePath, 'utf8');
221
+ } catch {
222
+ /* fresh */
223
+ }
224
+ const missing = relPaths.filter((p) => !existing.split('\n').includes(`/${p}`));
225
+ if (missing.length) {
226
+ appendFileSync(excludePath, `${existing.endsWith('\n') || !existing ? '' : '\n'}${missing.map((p) => `/${p}`).join('\n')}\n`);
227
+ }
228
+ } catch {
229
+ /* best-effort — the agent prompt still forbids committing secrets */
230
+ }
231
+ }
232
+
233
+ const isSafeTarget = (p) =>
234
+ p &&
235
+ p.length <= 200 &&
236
+ !p.includes('\\') &&
237
+ !p.includes('\0') &&
238
+ !p.startsWith('/') &&
239
+ p.split('/').every((s) => s.length > 0 && s !== '.' && s !== '..');
240
+
241
+ /** Is this path TRACKED in the repo? info/exclude only hides UNTRACKED files —
242
+ * materializing secrets into a tracked file would make them stageable and
243
+ * committable. We refuse those paths entirely. */
244
+ function isTrackedInGit(wt, relPath) {
245
+ try {
246
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', relPath], {
247
+ cwd: wt,
248
+ stdio: 'ignore',
249
+ });
250
+ return true;
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ // Per-worktree: the target files we last materialized, so a file that lost all
257
+ // its keys (or a key that moved files) gets its stale copy removed.
258
+ const lastFilesByWorktree = new Map();
259
+
260
+ /** Render KEY=value with values that contain newlines/= safely quoted so one
261
+ * value can't fabricate another key line. */
262
+ function renderEnvFile(list) {
263
+ const lines = list.map((v) => {
264
+ const needsQuote = /[\n\r"'`$\\ ]/.test(v.value) || v.value === '';
265
+ if (!needsQuote) return `${v.name}=${v.value}`;
266
+ const esc = v.value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
267
+ return `${v.name}="${esc}"`;
268
+ });
269
+ return `# Materialized by flowviant env sync — DO NOT COMMIT.\n${lines.join('\n')}\n`;
270
+ }
271
+
272
+ /** Write the decrypted env into ONE worktree. Never call on the wiki worktree. */
273
+ export function materializeInto(wt) {
274
+ if (!wt || !existsSync(wt)) return;
275
+ const byFile = new Map();
276
+ for (const v of values) {
277
+ if (!isSafeTarget(v.targetFile)) continue;
278
+ const list = byFile.get(v.targetFile) ?? [];
279
+ list.push(v);
280
+ byFile.set(v.targetFile, list);
281
+ }
282
+
283
+ const written = [];
284
+ for (const [file, list] of byFile) {
285
+ if (isTrackedInGit(wt, file)) {
286
+ warn(`env: "${file}" is tracked in git — refusing to write secrets there (gitignore it). Its keys are NOT materialized.`);
287
+ continue;
288
+ }
289
+ try {
290
+ const abs = join(wt, file);
291
+ mkdirSync(dirname(abs), { recursive: true });
292
+ const body = renderEnvFile(list);
293
+ // Skip an identical rewrite — otherwise every bundle bump touches the
294
+ // file mtime and hot-restarts a running preview dev-server mid-review.
295
+ let prior = null;
296
+ try {
297
+ prior = readFileSync(abs, 'utf8');
298
+ } catch {
299
+ /* new file */
300
+ }
301
+ if (prior !== body) writeFileSync(abs, body, { mode: 0o600 });
302
+ written.push(file);
303
+ } catch (e) {
304
+ warn(`env: could not write ${file} into worktree: ${e.message}`);
305
+ }
306
+ }
307
+
308
+ // Remove files we materialized last time that have no keys now (all deleted,
309
+ // or every key moved elsewhere) — a stale secret file must not linger.
310
+ const prevFiles = lastFilesByWorktree.get(wt) ?? [];
311
+ for (const stale of prevFiles) {
312
+ if (!written.includes(stale) && !isTrackedInGit(wt, stale)) {
313
+ try {
314
+ rmSync(join(wt, stale), { force: true });
315
+ } catch {
316
+ /* best-effort */
317
+ }
318
+ }
319
+ }
320
+ lastFilesByWorktree.set(wt, written);
321
+ if (written.length) excludeInWorktree(wt, written);
322
+ }
323
+
324
+ // ── Uplink scrubbing ───────────────────────────────────────────────────────
325
+
326
+ /** Redact every known secret value from daemon-posted text. Values shorter
327
+ * than the floor ("1", "true") would redact half the stream — skipped. */
328
+ export function scrub(text) {
329
+ if (typeof text !== 'string' || !text || !values.length) return text;
330
+ let out = text;
331
+ for (const v of values) {
332
+ if (typeof v.value === 'string' && v.value.length >= SCRUB_MIN_LENGTH) {
333
+ out = out.split(v.value).join(`[REDACTED:${v.name}]`);
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+
339
+ // ── Roster tick ────────────────────────────────────────────────────────────
340
+
341
+ let busy = false; // one env operation at a time — ticks are cheap to skip
342
+
343
+ /**
344
+ * React to the roster's env block. Returns { changed } — true when the bundle
345
+ * was (re)materialized so the caller refreshes its worktrees.
346
+ */
347
+ export async function handleRosterEnv(env, { projectId } = {}) {
348
+ if (!env || busy) return { changed: false };
349
+ busy = true;
350
+ try {
351
+ await ensureKeypair();
352
+ if (projectId) cachedProjectId = projectId;
353
+ // First tick after a restart: warm from the encrypted cache so worktrees
354
+ // can materialize even if the bundle fetch below fails transiently.
355
+ if (bundleVersion < 0 && cachedProjectId) await loadCachedEnv(cachedProjectId);
356
+
357
+ // 1. Introduce this machine (idempotent server-side). registeredOnce is set
358
+ // only AFTER the POST lands — a transient failure must retry next poll, not
359
+ // wedge registration until restart.
360
+ if (env.status === 'none' && !registeredOnce) {
361
+ const label = hostname() || 'daemon';
362
+ await post('register', { pubkey: myPubB64(), label });
363
+ registeredOnce = true;
364
+ const fp = pubkeyEmoji(myPubB64());
365
+ info(`${c.cyan('env')} · this machine requested env access as ${c.bold(label)}`);
366
+ note(` fingerprint ${fp} — an admin approves it in Settings → Environment (compare the emoji).`);
367
+ return { changed: false };
368
+ }
369
+ if (env.status === 'pending') return { changed: false }; // waiting on the admin
370
+ if (env.status === 'revoked') return { changed: false };
371
+
372
+ // 2. Bootstrap: no project keypair exists — this machine creates it.
373
+ if (env.bootstrapNeeded && (env.status === 'approved' || env.status === 'enrolled' || env.status === 'none')) {
374
+ if (env.status === 'none') return { changed: false }; // register first, next tick
375
+ await bootstrapProject();
376
+ return { changed: false }; // next tick syncs as enrolled
377
+ }
378
+ if (env.status !== 'enrolled') return { changed: false };
379
+
380
+ // 3. Wrap jobs + rotation + sync — all need the bundle.
381
+ const needSync = env.bundleVersion !== bundleVersion;
382
+ if (!needSync && !env.pendingWraps && !env.rotationPending) return { changed: false };
383
+ const bundle = await fetchBundle();
384
+ if (!bundle.wrappedPriv || !bundle.projectPub) return { changed: false };
385
+ projectPriv = openSealed(bundle.wrappedPriv, keypair.publicKey, keypair.privateKey);
386
+ const projectPub = sodium.from_base64(bundle.projectPub, B64());
387
+
388
+ // Execute approved enrollments: seal the priv to each new machine. The
389
+ // wrap's epoch rides along — the server rejects (stale) if a rotation moved
390
+ // it since we fetched, so nobody enrolls with a dead key.
391
+ if (bundle.pendingWraps.length) {
392
+ const wraps = bundle.pendingWraps.map((p) => ({
393
+ daemonId: p.daemonId,
394
+ wrappedPriv: seal(projectPriv, p.pubkey),
395
+ }));
396
+ const res = await post('wraps', { pubkey: myPubB64(), keyEpoch: bundle.keyEpoch, wraps });
397
+ if (res?.stale) note(`${c.cyan('env')} ${c.dim('— wraps raced a rotation; retrying next poll')}`);
398
+ else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly approved machine${wraps.length === 1 ? '' : 's'}`)}`);
399
+ }
400
+
401
+ // Decrypt the values we have — carrying each key's VERSION so a rotation can
402
+ // prove it re-sealed the current value (not one a concurrent write moved).
403
+ const opened = [];
404
+ let allOpened = true;
405
+ for (const k of bundle.keys) {
406
+ try {
407
+ const plain = openSealed(k.ciphertext, projectPub, projectPriv);
408
+ opened.push({ name: k.name, env: k.env, targetFile: k.targetFile, value: sodium.to_string(plain), version: k.version });
409
+ } catch {
410
+ allOpened = false;
411
+ warn(`env: could not open ${k.name} (epoch ${k.keyEpoch}) — skipping; a rotation should heal it`);
412
+ }
413
+ }
414
+
415
+ // Execute a pending rotation: new keypair, full coverage, all wraps. If we
416
+ // couldn't open every value, DON'T attempt — a partial rotate would fail
417
+ // the server's coverage check; let another enrolled daemon (which can open
418
+ // them) do it. Server serializes concurrent executors via a claim lock.
419
+ if (bundle.rotationPending) {
420
+ if (!allOpened) {
421
+ warn(`env: skipping rotation — this machine can't open every value; another daemon will rotate`);
422
+ return { changed: false };
423
+ }
424
+ const next = sodium.crypto_box_keypair();
425
+ const nextPubB64 = sodium.to_base64(next.publicKey, B64());
426
+ const res = await post('rotate', {
427
+ pubkey: myPubB64(),
428
+ fromEpoch: bundle.keyEpoch,
429
+ projectPub: nextPubB64,
430
+ values: opened.map((v) => ({ name: v.name, env: v.env, ciphertext: seal(sodium.from_string(v.value), nextPubB64), version: v.version })),
431
+ wraps: bundle.enrolledDaemons.map((d) => ({ daemonId: d.daemonId, wrappedPriv: seal(next.privateKey, d.pubkey) })),
432
+ ...(bundle.recoveryPub ? { recoverySealed: seal(next.privateKey, bundle.recoveryPub) } : {}),
433
+ }).catch((e) => {
434
+ // epoch_stale / value_moved / coverage → a concurrent change; the next
435
+ // poll re-fetches and retries. Not fatal.
436
+ note(`${c.cyan('env')} ${c.dim(`— rotation deferred (${e.message}); retrying next poll`)}`);
437
+ return null;
438
+ });
439
+ if (res) ok(`${c.cyan('env')} ${c.dim('— project key rotated (a machine was revoked); next poll syncs the new epoch')}`);
440
+ return { changed: false }; // resync on the next tick at the new version
441
+ }
442
+
443
+ if (needSync) {
444
+ values = opened;
445
+ bundleVersion = bundle.bundleVersion;
446
+ if (cachedProjectId) writeCache(cachedProjectId, { values, bundleVersion });
447
+ ok(`${c.cyan('env')} ${c.dim(`— synced ${values.length} secret${values.length === 1 ? '' : 's'} (env v${bundleVersion})`)}`);
448
+ return { changed: true };
449
+ }
450
+ return { changed: false };
451
+ } catch (e) {
452
+ warn(`env sync: ${e.message} — will retry next poll`);
453
+ return { changed: false };
454
+ } finally {
455
+ busy = false;
456
+ }
457
+ }
458
+
459
+ /** First machine creates the project keypair + the standing recovery target.
460
+ * The recovery passphrase prints ONCE — rotations re-seal to the same
461
+ * recovery pub, so this passphrase works forever. */
462
+ async function bootstrapProject() {
463
+ const project = sodium.crypto_box_keypair();
464
+ const recovery = sodium.crypto_box_keypair();
465
+ // Human-typable passphrase: 6 groups of 4 from an unambiguous alphabet.
466
+ const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789';
467
+ const raw = sodium.randombytes_buf(24);
468
+ const passphrase = Array.from(raw, (b, i) => ALPHA[b % ALPHA.length] + ((i + 1) % 4 === 0 && i < 23 ? '-' : '')).join('');
469
+ const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
470
+ const kdfKey = sodium.crypto_pwhash(
471
+ sodium.crypto_secretbox_KEYBYTES,
472
+ passphrase,
473
+ salt,
474
+ sodium.crypto_pwhash_OPSLIMIT_MODERATE,
475
+ sodium.crypto_pwhash_MEMLIMIT_MODERATE,
476
+ sodium.crypto_pwhash_ALG_DEFAULT
477
+ );
478
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
479
+ const recoverySecret = JSON.stringify({
480
+ pub: sodium.to_base64(recovery.publicKey, B64()),
481
+ priv: sodium.to_base64(recovery.privateKey, B64()),
482
+ });
483
+ const recoveryBlob = [
484
+ sodium.to_base64(salt, B64()),
485
+ sodium.to_base64(nonce, B64()),
486
+ sodium.to_base64(sodium.crypto_secretbox_easy(sodium.from_string(recoverySecret), nonce, kdfKey), B64()),
487
+ ].join(':');
488
+
489
+ await post('bootstrap', {
490
+ pubkey: myPubB64(),
491
+ projectPub: sodium.to_base64(project.publicKey, B64()),
492
+ selfWrap: seal(project.privateKey, myPubB64()),
493
+ recoveryPub: sodium.to_base64(recovery.publicKey, B64()),
494
+ recoveryBlob,
495
+ recoverySealed: seal(project.privateKey, sodium.to_base64(recovery.publicKey, B64())),
496
+ });
497
+
498
+ console.log('');
499
+ ok(`${c.cyan('env')} — this machine created the project's env keypair.`);
500
+ console.log(` ${c.bold('RECOVERY CODE')} ${c.dim('(shown ONCE — save it in a password manager):')}`);
501
+ console.log(` ${c.bold(c.yellow(passphrase))}`);
502
+ note(' If every enrolled machine is ever lost, this code is the only way back into the secrets.');
503
+ console.log('');
504
+ }
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(body),
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
- body: JSON.stringify({ intentId, text }),
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
- text: turnText.trim(),
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.26.0",
3
+ "version": "0.27.0",
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": [