flowviant 0.91.1 → 0.91.2

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.
@@ -130,7 +130,7 @@ export async function runEnvCommand(args) {
130
130
  // that file in every worktree.
131
131
  const targetFile = argAfter(args, '--file') ?? file.replace(/^\.\//, '');
132
132
  const bundle = await fetchBundle();
133
- if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
133
+ if (bundle.status !== 'enrolled') die('this machine is not enrolled yet — leave `flowviant` running in this project; enrolment happens on its own within a poll or two.');
134
134
  if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
135
135
  const existing = new Map(bundle.keys.map((k) => [k.name, k]));
136
136
  let added = 0;
@@ -159,7 +159,7 @@ export async function runEnvCommand(args) {
159
159
  const name = args[1];
160
160
  if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) die('usage: flowviant env set <KEY> [--file <targetFile>]');
161
161
  const bundle = await fetchBundle();
162
- if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
162
+ if (bundle.status !== 'enrolled') die('this machine is not enrolled yet — leave `flowviant` running in this project; enrolment happens on its own within a poll or two.');
163
163
  if (!bundle.projectPub) die('no project env keypair yet — start the daemon once to bootstrap it.');
164
164
  const prior = bundle.keys.find((k) => k.name === name);
165
165
  const targetFile = argAfter(args, '--file') ?? prior?.targetFile ?? '.env';
@@ -179,7 +179,7 @@ export async function runEnvCommand(args) {
179
179
 
180
180
  if (cmd === 'show') {
181
181
  const bundle = await fetchBundle();
182
- if (bundle.status !== 'enrolled') die('this machine is not enrolled — approve it in Settings → Environment first.');
182
+ if (bundle.status !== 'enrolled') die('this machine is not enrolled yet — leave `flowviant` running in this project; enrolment happens on its own within a poll or two.');
183
183
  if (!bundle.wrappedPriv) die('no key material for this machine yet.');
184
184
  const kp = await ensureKeypair();
185
185
  const priv = sodium.crypto_box_seal_open(
package/bin/lib/env.mjs CHANGED
@@ -5,15 +5,22 @@
5
5
  * server stores is ciphertext it cannot open.
6
6
  *
7
7
  * Duties per roster tick (handleRosterEnv):
8
- * - register this machine's pubkey (once) → an admin approves in Settings.
8
+ * - register this machine's pubkey (once) → that IS the enrolment (server
9
+ * 2026-09-20): the box already holds this project's machine credential,
10
+ * which a person handed it by typing a device code into the app, so there is
11
+ * nothing left for anybody to approve. Nobody clicks; nothing is printed
12
+ * except one quiet line.
9
13
  * - 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.
14
+ * it + a standing RECOVERY keypair wrapped under a one-time passphrase —
15
+ * rotations re-seal to the same recovery pub, so that passphrase survives
16
+ * forever. Bootstrap itself is SILENT since 2026-09-20: the passphrase is
17
+ * parked in this box's keypair file and printed the first time this box
18
+ * materializes a secret, which is the first moment it is about anything
19
+ * (`stashRecoveryCode`).
13
20
  * - sync: on a bundle version change, unwrap the priv, open every sealed
14
21
  * value, cache (encrypted under a key derived from our own priv), and
15
22
  * rematerialize env files into the agent worktrees.
16
- * - execute wrap jobs (admin approved a new machine → seal the priv to it).
23
+ * - execute wrap jobs (another box registered → seal the priv to it).
17
24
  * - execute rotations (a machine was revoked → new keypair, re-seal all
18
25
  * values, re-wrap every enrolled machine, re-seal recovery).
19
26
  *
@@ -66,33 +73,18 @@ export async function sodiumReady() {
66
73
  await sodium.ready;
67
74
  }
68
75
 
69
- /** 6-emoji key fingerprint — algorithm MUST match the web's pubkeyEmoji
70
- * (EnvironmentSettings.tsx) so the human can compare terminal ↔ approve card. */
71
- // MUST stay byte-identical to the web's pubkeyEmoji (EnvironmentSettings.tsx) —
72
- // the human compares the two strings. 32 glyphs × 8 positions, effective ~40
73
- // bits. Two FNV-1a rolling hashes over the whole key + a murmur3 finalizer per
74
- // glyph (a plain additive sum collapsed the space to ~10 bits — grindable).
75
- const FP_EMOJI = ['🦊','🐙','🦕','🐝','🦉','🐬','🦁','🐸','🦄','🐢','🦋','🐺','🦜','🐳','🦔','🐌','🦩','🐿️','🦥','🐨','🦦','🐇','🦡','🦂','🦨','🐜','🦢','🐋','🦭','🐞','🦚','🐊'];
76
- export function pubkeyEmoji(pubkeyB64) {
77
- let h1 = 0x811c9dc5 >>> 0;
78
- let h2 = 0xc2b2ae35 >>> 0;
79
- for (let i = 0; i < pubkeyB64.length; i++) {
80
- const ch = pubkeyB64.charCodeAt(i);
81
- h1 = Math.imul(h1 ^ ch, 0x01000193) >>> 0;
82
- h2 = Math.imul(h2 ^ ch, 0x85ebca6b) >>> 0;
83
- }
84
- let out = '';
85
- for (let i = 0; i < 8; i++) {
86
- let x = (((i < 4 ? h1 : h2) + i * 0x9e3779b1) >>> 0);
87
- x ^= x >>> 16;
88
- x = Math.imul(x, 0x7feb352d) >>> 0;
89
- x ^= x >>> 15;
90
- x = Math.imul(x, 0x846ca68b) >>> 0;
91
- x ^= x >>> 16;
92
- out += FP_EMOJI[x & 31];
93
- }
94
- return out;
95
- }
76
+ /*
77
+ * `pubkeyEmoji` IS DELETED (2026-09-20), and so is its byte-identical twin in
78
+ * the web app.
79
+ *
80
+ * It existed for exactly one gesture: the terminal printed eight glyphs, the
81
+ * browser's approve card printed the same eight, and a human compared them
82
+ * before pressing Approve. Registering IS enrolling now — the device code that
83
+ * gave this box the project's machine credential was the decision — so there is
84
+ * no approve card, no comparison, and nobody to make it. A fingerprint kept
85
+ * byte-identical across two repos for nobody to look at is worse than none:
86
+ * it reads like a live MITM defence and defends nothing.
87
+ */
96
88
 
97
89
  /** This machine's persistent keypair (created on first use, 0600). */
98
90
  export async function ensureKeypair() {
@@ -171,6 +163,120 @@ export function readStoredPubB64() {
171
163
  }
172
164
  }
173
165
 
166
+ /**
167
+ * ── THE RECOVERY CODE WAITS UNTIL THERE IS SOMETHING TO RECOVER (2026-09-20) ──
168
+ *
169
+ * `bootstrapProject` used to end with the loudest artefact this product owns:
170
+ * a blank line, RECOVERY CODE in bold, a passphrase in yellow, and "the only
171
+ * way back into the secrets". That fired on the FIRST DAEMON OF EVERY PROJECT
172
+ * — a keypair is bootstrapped whether or not a single secret exists — so the
173
+ * overwhelming majority of the people who saw it were being handed a code for
174
+ * an empty vault, in custody vocabulary they had not asked for, having run
175
+ * `npx flowviant` to connect a machine. That is the same complaint the approve
176
+ * gate died of, printed instead of clicked, and leaving it in would have
177
+ * contradicted the change it shipped beside.
178
+ *
179
+ * So bootstrap is SILENT (one quiet line that the keypair exists) and the code
180
+ * is kept HERE, in this box's own keypair file, until the first time this box
181
+ * materializes a secret — the moment the person actually has something to
182
+ * protect, and the first moment the sentence is true.
183
+ *
184
+ * THE FILE IS THE RIGHT PLACE AND COSTS NOTHING. It already holds the private
185
+ * key the passphrase would recover — anybody who can read one can read the
186
+ * other — so storing it there adds no exposure that was not already the whole
187
+ * security model of this machine. It is 0600 and stays 0600.
188
+ *
189
+ * EXTENDING THE SHAPE IS SAFE. `ensureKeypair` reads `pub` and `priv` and
190
+ * ignores every other key, and it only WRITES the file when there is none, so
191
+ * an older daemon reading a file with a `recovery` field behaves identically
192
+ * and cannot clobber it.
193
+ *
194
+ * THE ONE ACCEPTED COST, stated: a box that bootstraps and never materializes
195
+ * anything never prints the code. That is the point — there is nothing to
196
+ * recover — and the code is not lost, it is on this disk beside the key.
197
+ */
198
+ function readKeypairFile() {
199
+ try {
200
+ return JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /** 0600 on every write, not just at creation: `writeFileSync`'s `mode` applies
207
+ * only when the file is created, which is the same trap `materializeInto`
208
+ * documents. Returns whether it landed — the caller has to know, because the
209
+ * thing being written is unrecoverable if it does not. */
210
+ function writeKeypairFile(stored) {
211
+ try {
212
+ writeFileSync(KEYPAIR_PATH, JSON.stringify(stored), { mode: 0o600 });
213
+ try {
214
+ chmodSync(KEYPAIR_PATH, 0o600);
215
+ } catch {
216
+ /* a filesystem without modes is not a reason to refuse */
217
+ }
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /** Park the bootstrap passphrase beside the key it recovers. False means it did
225
+ * NOT land, and the caller's answer to that is to print it immediately —
226
+ * losing a recovery code silently is the one outcome worse than printing it
227
+ * early. */
228
+ export function stashRecoveryCode(code, projectId) {
229
+ const stored = readKeypairFile();
230
+ if (!stored?.priv) return false;
231
+ stored.recovery = { code, projectId: projectId ?? null, shown: false };
232
+ return writeKeypairFile(stored);
233
+ }
234
+
235
+ /** Once per process at most, and once per box for real. */
236
+ let recoveryUnprinted = true;
237
+
238
+ function printRecoveryBlock(code) {
239
+ console.log('');
240
+ ok(`${c.cyan('env')} — this machine now holds this project's secrets.`);
241
+ console.log(` ${c.bold('RECOVERY CODE')} ${c.dim('(shown ONCE — save it in a password manager):')}`);
242
+ console.log(` ${c.bold(c.yellow(code))}`);
243
+ note(' If every enrolled machine is ever lost, this code is the only way back into the secrets.');
244
+ console.log('');
245
+ }
246
+
247
+ /**
248
+ * Print the parked code the first time this box writes a secret to disk, then
249
+ * mark it shown so it never prints again.
250
+ *
251
+ * The mark is written to the FILE, not just to the module flag, because "once"
252
+ * has to survive a restart. If that write fails the flag still stops this
253
+ * process from repeating itself and a later process prints again — noisy in
254
+ * the safe direction, which is the only direction available when the thing at
255
+ * stake is the only way back into somebody's secrets.
256
+ *
257
+ * The parked `projectId` is checked because the keypair file is per BOX while
258
+ * the code is per PROJECT: a daemon serves one project, so a mismatch means
259
+ * this code belongs to a different one and printing it here would attribute it
260
+ * to the wrong vault. An older stash carries no id and prints regardless.
261
+ */
262
+ function printRecoveryCodeOnce() {
263
+ if (!recoveryUnprinted) return;
264
+ const stored = readKeypairFile();
265
+ const rec = stored?.recovery;
266
+ if (!rec?.code || rec.shown) {
267
+ recoveryUnprinted = false;
268
+ return;
269
+ }
270
+ if (rec.projectId && cachedProjectId && rec.projectId !== cachedProjectId) {
271
+ recoveryUnprinted = false; // one project per daemon — this will not change
272
+ return;
273
+ }
274
+ recoveryUnprinted = false;
275
+ printRecoveryBlock(rec.code);
276
+ stored.recovery = { ...rec, shown: true };
277
+ writeKeypairFile(stored);
278
+ }
279
+
174
280
  /** Query params the roster poll carries: identity, materialized version, and
175
281
  * the target files we REFUSED to write.
176
282
  *
@@ -623,6 +729,13 @@ export function materializeInto(wt) {
623
729
  if (anyProblem) cleanWorktrees.delete(wt);
624
730
  else cleanWorktrees.add(wt);
625
731
  everMaterialized = true;
732
+
733
+ // THE FIRST SECRET THIS BOX EVER WROTE TO DISK is the moment the recovery
734
+ // code stops being a warning about nothing — see `printRecoveryCodeOnce`.
735
+ // Gated on `written`, not on reaching this line: a pass that refused every
736
+ // file for a git reason materialized nothing, and the sentence would be
737
+ // false. Nothing is printed on any later pass.
738
+ if (written.length > 0) printRecoveryCodeOnce();
626
739
  }
627
740
 
628
741
 
@@ -664,8 +777,9 @@ export async function handleRosterEnv(env, { projectId } = {}) {
664
777
  // wedge registration until restart.
665
778
  if (env.status === 'none' && !registeredOnce) {
666
779
  const label = hostname() || 'daemon';
780
+ let registered = null;
667
781
  try {
668
- await post('register', { pubkey: myPubB64(), label });
782
+ registered = await post('register', { pubkey: myPubB64(), label });
669
783
  } catch (e) {
670
784
  // A 429 = the project is at its machine cap; retrying every poll would
671
785
  // just hammer it. Stop for this session (a restart re-tries).
@@ -677,12 +791,45 @@ export async function handleRosterEnv(env, { projectId } = {}) {
677
791
  throw e; // transient — retry next poll (registeredOnce still false)
678
792
  }
679
793
  registeredOnce = true;
680
- const fp = pubkeyEmoji(myPubB64());
681
- info(`${c.cyan('env')} · this machine requested env access as ${c.bold(label)}`);
682
- note(` fingerprint ${fp} — an admin approves it in Settings → Environment (compare the emoji).`);
794
+ /*
795
+ * ONE LINE, AND IT RELAYS WHAT THE SERVER ANSWERED.
796
+ *
797
+ * It used to print a key fingerprint and say "an admin approves it in
798
+ * Settings → Environment (compare the emoji)" — an instruction for a
799
+ * button that no longer exists, about a comparison nobody was making, on
800
+ * top of a credential this box was already trusted with. What replaced it
801
+ * asserted the opposite and just as blindly: "enrolled … secrets sync to
802
+ * this box automatically", printed whatever the response said. A fresh
803
+ * registration comes back `approved`, not `enrolled` — the key is not
804
+ * here yet and arrives only once some box that holds it polls — so the
805
+ * line was claiming a state this box was one or more ticks away from, and
806
+ * on an OLDER server (which still answers `pending`) it was claiming one
807
+ * the box would never reach at all.
808
+ *
809
+ * So the status is read off the response and each state says its own
810
+ * true sentence, and an answer we do not recognise — or no body at all —
811
+ * says only the part we measured: the registration went out.
812
+ */
813
+ const registeredAs = `${c.cyan('env')} · registered as ${c.bold(label)}`;
814
+ if (registered?.status === 'enrolled') {
815
+ info(`${c.cyan('env')} · enrolled as ${c.bold(label)} — secrets sync to this box automatically`);
816
+ } else if (registered?.status === 'approved') {
817
+ info(`${registeredAs} — secrets sync here as soon as a machine holding the key is online`);
818
+ } else if (registered?.status === 'pending') {
819
+ // An older server, which still has the approve gate. Say what IT is
820
+ // waiting on rather than what we are: this daemon cannot clear it.
821
+ info(`${registeredAs} — this server is waiting on an approval in Settings`);
822
+ } else {
823
+ info(registeredAs);
824
+ }
683
825
  return { changed: false };
684
826
  }
685
- if (env.status === 'pending') return { changed: false }; // waiting on the admin
827
+ // A HARMLESS WAIT, and the server no longer produces this: registering IS
828
+ // enrolling since 2026-09-20, and a row left `pending` by the old gate is
829
+ // promoted on the register above. Kept because an OLDER server still
830
+ // answers `pending`, and a daemon must not treat an unrecognised state as a
831
+ // reason to act.
832
+ if (env.status === 'pending') return { changed: false };
686
833
  if (env.status === 'revoked') return { changed: false };
687
834
 
688
835
  // 2. Bootstrap: no project keypair exists — this machine creates it.
@@ -701,7 +848,9 @@ export async function handleRosterEnv(env, { projectId } = {}) {
701
848
  projectPriv = openSealed(bundle.wrappedPriv, keypair.publicKey, keypair.privateKey);
702
849
  const projectPub = sodium.from_base64(bundle.projectPub, B64());
703
850
 
704
- // Execute approved enrollments: seal the priv to each new machine. The
851
+ // Execute pending enrollments: seal the priv to each newly registered
852
+ // machine. Registering is what puts a box on this list now — there is no
853
+ // approval step between the two. The
705
854
  // wrap's epoch rides along — the server rejects (stale) if a rotation moved
706
855
  // it since we fetched, so nobody enrolls with a dead key.
707
856
  if (bundle.pendingWraps.length) {
@@ -711,7 +860,7 @@ export async function handleRosterEnv(env, { projectId } = {}) {
711
860
  }));
712
861
  const res = await post('wraps', { pubkey: myPubB64(), keyEpoch: bundle.keyEpoch, wraps });
713
862
  if (res?.stale) note(`${c.cyan('env')} ${c.dim('— wraps raced a rotation; retrying next poll')}`);
714
- else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly approved machine${wraps.length === 1 ? '' : 's'}`)}`);
863
+ else ok(`${c.cyan('env')} ${c.dim(`— delivered the key to ${wraps.length} newly registered machine${wraps.length === 1 ? '' : 's'}`)}`);
715
864
  }
716
865
 
717
866
  // Decrypt the values we have — carrying each key's VERSION so a rotation can
@@ -777,9 +926,15 @@ export async function handleRosterEnv(env, { projectId } = {}) {
777
926
  }
778
927
  }
779
928
 
780
- /** First machine creates the project keypair + the standing recovery target.
781
- * The recovery passphrase prints ONCE — rotations re-seal to the same
782
- * recovery pub, so this passphrase works forever. */
929
+ /**
930
+ * First machine creates the project keypair + the standing recovery target.
931
+ *
932
+ * THIS IS SILENT (2026-09-20). Rotations re-seal to the same recovery pub, so
933
+ * the passphrase minted here works forever — which is exactly why it does not
934
+ * have to be shouted at somebody who has no secrets yet. It is parked in this
935
+ * box's keypair file and printed the first time this box materializes a
936
+ * secret; `stashRecoveryCode`'s docblock carries the whole argument.
937
+ */
783
938
  async function bootstrapProject() {
784
939
  const project = sodium.crypto_box_keypair();
785
940
  const recovery = sodium.crypto_box_keypair();
@@ -816,10 +971,14 @@ async function bootstrapProject() {
816
971
  recoverySealed: seal(project.privateKey, sodium.to_base64(recovery.publicKey, B64())),
817
972
  });
818
973
 
819
- console.log('');
820
- ok(`${c.cyan('env')} — this machine created the project's env keypair.`);
821
- console.log(` ${c.bold('RECOVERY CODE')} ${c.dim('(shown ONCE — save it in a password manager):')}`);
822
- console.log(` ${c.bold(c.yellow(passphrase))}`);
823
- note(' If every enrolled machine is ever lost, this code is the only way back into the secrets.');
824
- console.log('');
974
+ // ONE QUIET LINE. The code goes into the keypair file and waits for the
975
+ // first secret. A stash that did NOT land is the one case that prints now:
976
+ // the passphrase exists only in this closure, and losing the only way back
977
+ // into a project's secrets to keep the terminal tidy is not a trade.
978
+ if (stashRecoveryCode(passphrase, cachedProjectId)) {
979
+ ok(`${c.cyan('env')} ${c.dim('— created this project\'s env keypair')}`);
980
+ } else {
981
+ warn('env: could not save the recovery code to this machine — here it is, once:');
982
+ printRecoveryBlock(passphrase);
983
+ }
825
984
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.91.1",
3
+ "version": "0.91.2",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {