agmsg-cloud 0.1.0-rc.7 → 0.1.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.
@@ -2,12 +2,17 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { CourierClient } from '../api.js';
5
- import { connectedTeams, keyHandoff, remoteBinding, unlockAuthenticatedBundle } from '../oss.js';
5
+ import { connectedTeams, keyHandoff, remoteBinding, runAllowingFailure, unlockAuthenticatedBundle, } from '../oss.js';
6
+ import { cmdPull } from './pull.js';
7
+ import { dataPlaneIdentity } from '../data-plane.js';
8
+ import { originOf, readCredential } from '../credentials.js';
6
9
  import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
7
10
  import { generateRecoveryKey, normalizeRecoveryKey, promptRecoveryKey, showRecoveryKey, } from '../recovery-key.js';
8
11
  import { appendVaultVersionWithVdk, createVault, openVaultWithVdk, readAccountVault, vdkFromRecoveryKey, } from '../vault-protocol.js';
9
12
  import { openDeviceSlot, saveDeviceSlot } from '../device-slot.js';
10
13
  import { adviseOnSlot, renderSlotAdvice } from '../slot-advice.js';
14
+ import { inventoryLines, inventoryOf, } from '../vault-inventory.js';
15
+ import { placementLines } from '../vault-placement.js';
11
16
  import { emptyContainer, parseContainer, selectTeam, serializeContainer, upsertTeamIfMoved, } from '../vault-container.js';
12
17
  // The two recovery commands: resolve the team, obtain the bundle from the OSS
13
18
  // side, get the recovery key from the terminal, and hand off to
@@ -52,7 +57,13 @@ export function slotAddress(identity, vaultId, generation) {
52
57
  * the same place; what differs is what the person is told, and that judgement
53
58
  * lives in slot-advice.ts rather than here.
54
59
  */
55
- async function vdkForVault(identity, version, team) {
60
+ async function vdkForVault(identity, version,
61
+ /**
62
+ * Optional: `restore` with no team has none to name, and the advice below is
63
+ * the only thing that used it. `setupCommand` already carries the no-team
64
+ * form, so the remedy stays executable either way.
65
+ */
66
+ team) {
56
67
  const address = slotAddress(identity, version.vault_id, version.recovery_generation);
57
68
  const opened = await openDeviceSlot(address);
58
69
  if (opened.ok) {
@@ -61,7 +72,7 @@ async function vdkForVault(identity, version, team) {
61
72
  // does not fit. A check here would be a third place that has to agree.
62
73
  return { vdk: opened.vdk, usedRecoveryKey: false };
63
74
  }
64
- const advice = renderSlotAdvice(adviseOnSlot(opened, { team }));
75
+ const advice = renderSlotAdvice(adviseOnSlot(opened));
65
76
  if (advice)
66
77
  process.stdout.write(advice);
67
78
  const recoveryKey = normalizeRecoveryKey(await promptRecoveryKey('Recovery key: '));
@@ -126,19 +137,11 @@ function vaultTeamKey(serverInstanceId, teamId) {
126
137
  * `recovery setup` — put the keys of every team THIS MACHINE reports into the
127
138
  * account's vault.
128
139
  *
129
- * NO ARGUMENT IS THE ORDINARY FORM. The vault holds one recovery key for the
130
- * whole account, so "set up recovery" is an account-level act; naming a team
131
- * made it something to repeat every time a team was added, which is the folding
132
- * this design exists to do. A team may still be named, for adding one on its
133
- * own.
134
- *
135
- * NOT "every team on the account", and the difference is load-bearing until
136
- * fujibee/agmsg#650 lands. The set comes from `remote.sh status --json`, which
137
- * drops a team it could not read and exits 0 — so an active team that has never
138
- * been backed up can be missed with nothing said. Every sentence describing this
139
- * command, here and in `--help` and in the README, stops at what the machine
140
- * reported for exactly that reason. Widening them is the change that closes
141
- * #182, and it comes after #650, not before.
140
+ * The vault holds one recovery key for the whole account, so "set up recovery"
141
+ * is an account-level act. The old optional team argument existed while
142
+ * fujibee/agmsg#650 could silently omit an unreadable team. That producer now
143
+ * distinguishes the failure, so this command has one account-wide form and a
144
+ * short enumeration is refused rather than presented as a complete backup.
142
145
  *
143
146
  * IDEMPOTENT. A run where no team's epochs have moved writes nothing and leaves
144
147
  * the revision where it was. `setup` that appended an identical version every
@@ -146,7 +149,7 @@ function vaultTeamKey(serverInstanceId, teamId) {
146
149
  * cost is not storage, it is that a version history exists to show when
147
150
  * something changed, and identical versions make it unreadable.
148
151
  */
149
- export async function cmdVaultPut(config, args) {
152
+ export async function cmdVaultPut(config) {
150
153
  // Same check connect gets. Without it a machine with no agmsg install runs
151
154
  // straight into `bash exited 127: …/remote.sh: No such file or directory` —
152
155
  // the same absence connect reports as a named checklist.
@@ -162,13 +165,11 @@ export async function cmdVaultPut(config, args) {
162
165
  // disconnected team's ids are a leftover, and a vault entry
163
166
  // under one records a backup against a remote this machine is no
164
167
  // longer bound to.
165
- const observed = args.team === undefined ? await connectedTeams(config.scriptsDir) : [];
166
- const targets = args.team === undefined
167
- ? observed.filter((t) => t.state === 'active')
168
- : [{ team: args.team, ...(await remoteBinding(config.scriptsDir, args.team)) }];
168
+ const observed = await connectedTeams(config.scriptsDir);
169
+ const targets = observed.filter((t) => t.state === 'active');
169
170
  if (targets.length === 0) {
170
171
  throw new Error('no team on this machine is connected to the hosted service, so there are no keys to back up.\n' +
171
- 'Run `agmsg-cloud connect <team>` first, or name a team if one is connected under a different name.');
172
+ 'Run `agmsg-cloud connect <team>` first.');
172
173
  }
173
174
  // The account's vault, not this team's: one vault, one recovery key. A second
174
175
  // team finds the vault that already exists and is added to it.
@@ -192,7 +193,7 @@ export async function cmdVaultPut(config, args) {
192
193
  // X" was true when a team had its own vault; saying it now would teach
193
194
  // someone to expect a different key per team, which is the belief the
194
195
  // account vault exists to remove.
195
- const obtained = await vdkForVault(identity, version, args.team ?? targets[0].team);
196
+ const obtained = await vdkForVault(identity, version, targets[0].team);
196
197
  vdk = obtained.vdk;
197
198
  askedForTheKey = obtained.usedRecoveryKey;
198
199
  // Open before writing. The container has to be read to add a team to it,
@@ -229,35 +230,15 @@ export async function cmdVaultPut(config, args) {
229
230
  // becomes safe only when the slot is durable before the vault is created,
230
231
  // and that ordering is a separate change with its own failure to think
231
232
  // through (a slot addressed to a vault that was never made).
232
- await showRecoveryKey(minted, args.team);
233
+ await showRecoveryKey(minted);
233
234
  container = emptyContainer();
234
235
  }
235
- // THE ENUMERATION IS NOT FAIL-CLOSED, AND THIS DOES NOT PRETEND TO FIX THAT.
236
- //
237
- // `remote.sh status --json` with no team DROPS a team it could not read and
238
- // still exits 0, and the single-team form cannot tell "never connected" from
239
- // "could not be read" either — same message, same exit code. Measured, and
240
- // filed as fujibee/agmsg#650.
241
- //
242
- // The first version of this REFUSED when the vault held a team the run did
243
- // not see. That was wrong, and the reason is worth keeping: a team
244
- // deliberately disconnected here disappears from the enumeration too, and the
245
- // vault keeps its entry forever by design — so the refusal fired every run,
246
- // for the one reason that is not a problem, with no way out. Refusing on a
247
- // distinction the system cannot draw treats "unknown" as "wrong".
248
- //
249
- // So it reports instead. What was not seen is put on the screen by name and
250
- // the judgement goes back to the operator, who is the only party that knows
251
- // whether they disconnected it. That leaves a real gap — nobody who does not
252
- // read the output is protected — and the gap is smaller than a command that
253
- // cannot be run.
254
- //
255
- // When #650 lands this can become a refusal again, because by then a team
256
- // that could not be read will say so.
236
+ // agmsg#650 made unreadable status fail instead of silently disappearing, so
237
+ // `observed` is now a fail-closed account-wide enumeration. An old vault row
238
+ // can still be absent because this machine has no current binding for it;
239
+ // those rows remain untouched and are named below rather than deleted.
257
240
  const seenThisRun = new Set(observed.map((t) => vaultTeamKey(t.serverInstanceId, t.teamId)));
258
- const unseen = args.team === undefined
259
- ? container.teams.filter((e) => !seenThisRun.has(vaultTeamKey(e.server_instance_id, e.team_id)))
260
- : [];
241
+ const unseen = container.teams.filter((e) => !seenThisRun.has(vaultTeamKey(e.server_instance_id, e.team_id)));
261
242
  // Seen, and deliberately not filed. Kept apart from `unseen` because they are
262
243
  // different facts: this machine HEARD about these, and the ones above it did
263
244
  // not hear about at all.
@@ -286,10 +267,6 @@ export async function cmdVaultPut(config, args) {
286
267
  // "Covered".
287
268
  const coverage = (written, unchanged, stranded) => {
288
269
  const group = (title, members) => members.length === 0 ? [] : [`\n ${title}\n`, ...members.map((m) => ` ${m}\n`)];
289
- // The first two hold for either form — a run that names one team still has
290
- // to say what it did with it. The rest are about the ENUMERATION, so they
291
- // only mean anything when the enumeration is what chose the set.
292
- const enumerated = args.team === undefined;
293
270
  const lines = [
294
271
  ...group('Backed up in this revision:', written),
295
272
  ...group('Already current, nothing to write:', unchanged),
@@ -299,26 +276,17 @@ export async function cmdVaultPut(config, args) {
299
276
  // that needs acting on — and the reason is what says which team's state
300
277
  // to look at, rather than leaving "something failed" for them to locate.
301
278
  ...group('Recorded as connected here, but this machine could not open them:', stranded.map((f) => `${f.team} — ${f.reason}`)),
302
- ...(!enumerated
303
- ? []
304
- : group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team))),
279
+ ...group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team)),
305
280
  // Ids, because a team that never reported has no local name here to
306
281
  // print — the same absence that put it on this list. Said plainly rather
307
282
  // than dressed up as an instruction: this machine does not know whether
308
283
  // these were disconnected on purpose, and the operator does.
309
- ...(!enumerated
310
- ? []
311
- : group('In the vault, but not reported by this machine on this run:', unseen.map((e) => `${e.team_id} (server ${e.server_instance_id})`))),
284
+ ...group('In the vault, but not reported by this machine on this run:', unseen.map((e) => `${e.team_id} (server ${e.server_instance_id})`)),
312
285
  ];
313
286
  if (unseen.length > 0) {
314
- lines.push('\n Those entries are untouched. If you disconnected them here, nothing is\n', ' wrong. If you did not, this machine could not read their state and they\n', ' were not re-checked — that is agmsg#650, and it cannot be told apart\n', ' from here.\n');
315
- }
316
- // Said only by the form that DERIVED its set. A run given a team covered
317
- // what it was told to; claiming it covered what the store reported would
318
- // be a statement about an enumeration that never ran.
319
- if (enumerated) {
320
- lines.push("\n That is every team this machine's store reported. A team connected only\n", ' on another machine is backed up by running this there.\n');
287
+ lines.push('\n Those entries are untouched. If you disconnected them here, nothing is\n', ' wrong. This machine has no current binding to re-check for those ids.\n');
321
288
  }
289
+ lines.push("\n That is every team this machine's store reported. A team connected only\n", ' on another machine is backed up by running this there.\n');
322
290
  return lines.join('');
323
291
  };
324
292
  const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-'));
@@ -487,17 +455,14 @@ export async function cmdVaultPut(config, args) {
487
455
  //
488
456
  // Discarding the key is still right and still first: it opens nothing,
489
457
  // and someone who files it will trust it for years. What changed is the
490
- // second half — a bare retry is only the answer when the cause was
491
- // transient, and this exit does not know that it was. So it names the
492
- // condition to check instead, and offers the narrow form, which is the
493
- // one thing that lets an account with a broken leftover back up the team
494
- // actually in use.
458
+ // second half — a bare retry is only the answer after the named cause is
459
+ // repaired. There is no per-team escape now: recovery setup is
460
+ // account-wide and refuses an incomplete backup (#182).
495
461
  process.stdout.write('\nThe backup did NOT complete, so the recovery key above was never used\n' +
496
462
  'and opens nothing. Discard it.\n\n' +
497
- 'Re-running is only worth it if the cause above was temporary. If a team\n' +
498
- 'could not be opened, re-running will stop at the same place and cost you\n' +
499
- 'another key — back up the one you are using instead:\n\n' +
500
- ' agmsg-cloud recovery setup <team>\n\n');
463
+ 'Re-running before fixing the cause above will stop at the same place and\n' +
464
+ 'cost you another key. Repair that team first, then run:\n\n' +
465
+ ' agmsg-cloud recovery setup\n\n');
501
466
  }
502
467
  throw err;
503
468
  }
@@ -530,9 +495,37 @@ export function bundleKeyIds(bundle) {
530
495
  'in the vault without them');
531
496
  }
532
497
  }
533
- // `agmsg-cloud recovery restore <team>` — pull the current version, open it with the
534
- // recovery key, and hand the bundle to the OSS unlock path.
535
- export async function cmdVaultRestore(config, args) {
498
+ /**
499
+ * `agmsg-cloud recovery restore [team]` — open the account vault, and either
500
+ * unlock one named team from it or say what is in it.
501
+ *
502
+ * WITH A TEAM this is unchanged: resolve the local binding, open, select that
503
+ * team's entry, unlock. That path works on a machine that already has the team,
504
+ * and it is the one people use.
505
+ *
506
+ * WITHOUT ONE is the disaster path this command is named for, and it did not
507
+ * work at all. The binding lookup ran BEFORE the vault was ever read, so a
508
+ * machine that had lost everything got
509
+ *
510
+ * agmsg: team 'x' has never been connected
511
+ *
512
+ * and stopped — measured against a stub endpoint that logs every request, which
513
+ * received nothing. The vault was never opened, so nothing could say what was
514
+ * in it, and a person with no local teams had no name to give that would have
515
+ * helped. Knowing the name was never the missing thing.
516
+ *
517
+ * So with no team the order is inverted: authenticate, open the vault, ask the
518
+ * control plane for names, and place what can be named — #342b. The order is
519
+ * forced, not chosen: `remote.sh unlock` requires the team to exist locally
520
+ * already (`remote.sh:1126`), so the bind (`pull`) comes before the unlock, and
521
+ * the vault has no names to bind with, so the names come before both.
522
+ *
523
+ * The team argument is kept and becomes "place just this one". Removing it is a
524
+ * decision available now that everything is placeable; it is not made here.
525
+ */
526
+ export async function cmdVaultRestore(config, args = {}) {
527
+ if (args.team === undefined)
528
+ return placeFromVault(config);
536
529
  ensurePreflight(preflight(config.scriptsDir, NEEDS.vaultRestore));
537
530
  const client = new CourierClient(config);
538
531
  const binding = await remoteBinding(config.scriptsDir, args.team);
@@ -569,3 +562,277 @@ export async function cmdVaultRestore(config, args) {
569
562
  await keepSlot(slotAddress(identity, version.vault_id, version.recovery_generation), vdk);
570
563
  }
571
564
  }
565
+ /**
566
+ * The no-team form: open the account vault and say what is in it.
567
+ *
568
+ * NO TEAM-LOCAL OSS STATE IS READ: no remote binding, no `remote.sh` preflight,
569
+ * no unlock. Those are the reads that fail on the machine this command exists
570
+ * to rescue — it has no teams, so every one of them ends the command before the
571
+ * vault is reached. `NEEDS.vaultRestore` requires `remote.sh` for the same
572
+ * reason it is skipped here: refusing to LIST a backup because the tool that
573
+ * PLACES one is missing is the same defect one layer out.
574
+ *
575
+ * IT DOES READ THIS MACHINE'S SECURE STORE, and that is deliberate.
576
+ * `vdkForVault` opens the device slot first; a machine that holds one is not
577
+ * asked for the recovery key, and a machine without one falls back to the
578
+ * recovery-key wrap. A replacement machine has no slot and types the key, which
579
+ * is the disaster path working as designed — the slot is an optimisation for
580
+ * machines that have already proven they hold this vault's key, not a
581
+ * prerequisite. Saying "nothing local" here was wider than what is true, and
582
+ * the check that was supposed to back it up did not look (review P1-artifact).
583
+ *
584
+ * The names come from the control plane, not from the vault, because the vault
585
+ * has none. That request uses the same credential the vault read just used, so
586
+ * it cannot introduce a new way to be unauthenticated — and if it fails, the
587
+ * inventory still reports every entry, as unnamed. A failure to name is not a
588
+ * failure to find.
589
+ */
590
+ async function placeFromVault(config) {
591
+ const client = new CourierClient(config);
592
+ const { identity, version } = await readAccountVault(client);
593
+ if (!version) {
594
+ throw new Error('this account has no recovery vault yet — nothing to restore from');
595
+ }
596
+ const { vdk, usedRecoveryKey } = await vdkForVault(identity, version);
597
+ const opened = openVaultWithVdk(identity, version, vdk);
598
+ const container = parseContainer(opened.content);
599
+ // The slot is kept HERE, before any placing, and that moved.
600
+ //
601
+ // While this command only reported, saving at the end cost nothing. Now it
602
+ // spawns a `pull` and an `unlock` per team, and any of those can fail — so
603
+ // saving afterwards would mean a run that placed four teams and tripped on
604
+ // the fifth asks for the recovery key again next time. The moment this
605
+ // machine has proven it holds the vault's key is the moment the vault opened,
606
+ // which is here.
607
+ if (usedRecoveryKey) {
608
+ await keepSlot(slotAddress(identity, version.vault_id, version.recovery_generation), vdk);
609
+ }
610
+ const inv = inventoryOf(container, await serverNames(client));
611
+ for (const line of inventoryLines(inv, opened.revision)) {
612
+ process.stdout.write(`${line}\n`);
613
+ }
614
+ if (!inv.namesKnown) {
615
+ // Nothing is attempted, and the reason is the missing question rather than
616
+ // anything about the entries. Both `pull` and `unlock` are addressed by
617
+ // name; with no names there is no argument to give them.
618
+ process.stdout.write('nothing was placed: placing needs the names, and the server could not be asked\n');
619
+ return;
620
+ }
621
+ if (inv.named.length === 0) {
622
+ process.stdout.write('nothing here can be placed: none of these entries has a name\n');
623
+ return;
624
+ }
625
+ // Required only now. Listing a backup does not need the tool that places one
626
+ // — refusing to say what is in the vault because `remote.sh` is missing is
627
+ // the same defect one layer out — so the gate sits after the report and
628
+ // before the first spawn.
629
+ ensurePreflight(preflight(config.scriptsDir, NEEDS.vaultRestore));
630
+ const present = await teamsAlreadyHere(config.scriptsDir);
631
+ // WHICH SERVER, asked once, BEFORE anything is pulled.
632
+ //
633
+ // This was a post-pull check and that was not enough (review P1-1). A pull
634
+ // creates a local team under the person's name and gives it history, and the
635
+ // history is what makes the next pull refuse — measured. So discovering
636
+ // afterwards that the entry belonged to another server left a team that
637
+ // could not be replaced by the right one, with no way to free the name
638
+ // (#446). A restore that builds its own permanent obstacle is worse than one
639
+ // that places nothing.
640
+ //
641
+ // Only entries whose own instance matches are pulled at all. If the server
642
+ // cannot be asked, nothing is pulled: an unanswered question is not a match.
643
+ const endpoint = await endpointInstance(config, inv.named);
644
+ const results = [];
645
+ for (const named of inv.named) {
646
+ results.push(await placeOne(config, named, present, endpoint));
647
+ }
648
+ for (const line of placementLines(results, inv.total)) {
649
+ process.stdout.write(`${line}\n`);
650
+ }
651
+ }
652
+ /**
653
+ * The control plane's answer, or the fact that it had none.
654
+ *
655
+ * A failure to name is not a failure to find: the person is one step from
656
+ * having lost everything, and "here is what your backup holds, by id" is worth
657
+ * more than a stack trace. But it is not reported as an empty answer either —
658
+ * see `ServerNames`, which exists so this cannot be flattened into "the server
659
+ * mentioned none of them", which reads as "your credential cannot reach any of
660
+ * this".
661
+ */
662
+ async function serverNames(client) {
663
+ try {
664
+ return { asked: true, teams: await client.listTeams() };
665
+ }
666
+ catch (err) {
667
+ process.stdout.write(`could not ask the server for team names (${err instanceof Error ? err.message : String(err)})\n`);
668
+ return { asked: false };
669
+ }
670
+ }
671
+ async function endpointInstance(config, named) {
672
+ if (named.length === 0)
673
+ return { known: false, why: 'there was nothing to place' };
674
+ const credential = readCredential(originOf(config.baseUrl));
675
+ if (!credential) {
676
+ return {
677
+ known: false,
678
+ why: `no stored credential for ${originOf(config.baseUrl)} — run \`agmsg-cloud login\` on this machine first`,
679
+ };
680
+ }
681
+ try {
682
+ // No team id is passed, and that is the point: naming one reaches a route
683
+ // that CLAIMS an unclaimed id on the hosted gateway. See `data-plane.ts`.
684
+ const { serverInstanceId } = await dataPlaneIdentity(credential.capabilityUrl);
685
+ return { known: true, serverInstanceId };
686
+ }
687
+ catch (err) {
688
+ return { known: false, why: err instanceof Error ? err.message : String(err) };
689
+ }
690
+ }
691
+ const remoteKey = (serverInstanceId, teamId) => `${serverInstanceId}${teamId}`;
692
+ /**
693
+ * What this machine already holds, asked once.
694
+ *
695
+ * One `remote.sh status --json` for the whole loop rather than one per entry:
696
+ * the answer is a list, and asking per team would spawn a process per backup to
697
+ * learn what a single call already said.
698
+ *
699
+ * BOTH DIRECTIONS ARE KEPT, because the loop asks two different questions of
700
+ * the same answer — "is this backup already here (under any name)?" and "is
701
+ * this name taken by something else?" — and a single map answers only one.
702
+ *
703
+ * A team is matched on the PAIR. The same team id on a different instance is a
704
+ * different team, which is why the vault entry binds both.
705
+ */
706
+ async function teamsAlreadyHere(scriptsDir) {
707
+ const byRemote = new Map();
708
+ const byName = new Map();
709
+ for (const t of await connectedTeams(scriptsDir)) {
710
+ // `disconnected` still counts as HERE — the question is whether the data is
711
+ // on this machine, and a re-pull would be refused for exactly that reason.
712
+ // But it does not count as unlockable: MEASURED, `remote.sh unlock` on a
713
+ // disconnected team exits 1 with `connected team binding is invalid or
714
+ // disconnected`, so the state is carried rather than flattened.
715
+ byRemote.set(remoteKey(t.serverInstanceId, t.teamId), {
716
+ team: t.team,
717
+ live: t.state === 'active',
718
+ });
719
+ byName.set(t.team, { teamId: t.teamId, instance: t.serverInstanceId });
720
+ }
721
+ return { byRemote, byName };
722
+ }
723
+ /**
724
+ * Place one entry: bind it if it is not here, then import its key.
725
+ *
726
+ * THE ORDER IS MEASURED, not assumed. `remote.sh pull` refuses a team with any
727
+ * local history BEFORE it compares team ids, so on a second run every team the
728
+ * first run placed comes back as `already has history; pull clones into an
729
+ * empty team` — a sentence that is right for `pull` and says nothing about a
730
+ * restore. So "is it already here" is decided from `remote.sh status --json`,
731
+ * which answers with both ids while the team is still locked, and `pull` is
732
+ * never called for a team that would be refused for that reason.
733
+ *
734
+ * `unlock` is safe to repeat: the OSS suite pins a second unlock with the same
735
+ * bundle at `imported 0 envelope(s)` and exit 0. It restarts that team's sync
736
+ * engine each time, so it is called once per entry and never in a retry.
737
+ */
738
+ async function placeOne(config, { entry, team }, present, endpoint) {
739
+ // Under whatever local name it already has, which need not be the name the
740
+ // server uses now. Matching on the name instead would pull a second copy of a
741
+ // team this machine already holds.
742
+ const here = present.byRemote.get(remoteKey(entry.server_instance_id, entry.team_id));
743
+ const holder = present.byName.get(team);
744
+ if (here === undefined && holder !== undefined) {
745
+ return {
746
+ kind: 'name-held',
747
+ team,
748
+ teamId: entry.team_id,
749
+ instance: entry.server_instance_id,
750
+ localTeamId: holder.teamId,
751
+ localInstance: holder.instance,
752
+ };
753
+ }
754
+ if (here !== undefined && !here.live) {
755
+ // Not attempted: the unlock is known to refuse a disconnected binding, and
756
+ // reporting a call we knew would fail teaches the reader nothing about
757
+ // their own situation.
758
+ return { kind: 'disconnected', team: here.team, teamId: entry.team_id };
759
+ }
760
+ if (here === undefined) {
761
+ // BEFORE the pull, because the pull is what cannot be taken back.
762
+ if (!endpoint.known) {
763
+ return { kind: 'server-unknown', team, teamId: entry.team_id, why: endpoint.why };
764
+ }
765
+ if (endpoint.serverInstanceId !== entry.server_instance_id) {
766
+ return {
767
+ kind: 'wrong-instance',
768
+ team,
769
+ teamId: entry.team_id,
770
+ wanted: entry.server_instance_id,
771
+ got: endpoint.serverInstanceId,
772
+ };
773
+ }
774
+ const refused = await pullTeam(config, team, entry.team_id);
775
+ if (refused !== null)
776
+ return { kind: 'refused', team, teamId: entry.team_id, said: refused };
777
+ // THE PAIR IS CONFIRMED BEFORE THE KEY GOES IN. `listTeams` carries no
778
+ // server instance and the control plane cannot supply one — `team_id` is
779
+ // the primary key of `edge_team_claims` — so a name resolved there does not
780
+ // establish that the team it names lives on the server this backup came
781
+ // from. The binding the pull just wrote does, and it is read now because
782
+ // the NEXT step is the one that would install this server's key material
783
+ // into a different server's team.
784
+ const bound = await remoteBinding(config.scriptsDir, team);
785
+ if (bound.serverInstanceId !== entry.server_instance_id ||
786
+ bound.teamId !== entry.team_id) {
787
+ return {
788
+ kind: 'wrong-instance',
789
+ team,
790
+ teamId: entry.team_id,
791
+ wanted: entry.server_instance_id,
792
+ got: bound.serverInstanceId,
793
+ };
794
+ }
795
+ }
796
+ const localName = here?.team ?? team;
797
+ const failed = await unlockTeam(config, localName, entry.bundle);
798
+ if (failed !== null)
799
+ return { kind: 'locked', team: localName, teamId: entry.team_id, said: failed };
800
+ return here === undefined
801
+ ? { kind: 'placed', team: localName, teamId: entry.team_id }
802
+ : { kind: 'already', team: localName, teamId: entry.team_id };
803
+ }
804
+ /** Returns what `remote.sh` said when it refused, or null when the pull worked. */
805
+ async function pullTeam(config, team, teamId) {
806
+ let said = '';
807
+ // Piped rather than inherited: this is a loop, and each team's output belongs
808
+ // under that team in the report. What comes back is printed as it arrived —
809
+ // the OSS side is the one that knows why it refused, and a paraphrase here is
810
+ // a second copy of its wording that goes stale.
811
+ const runner = async (command, args) => {
812
+ const r = await runAllowingFailure(command, [...args]);
813
+ said = [r.stdout.trim(), r.stderr.trim()].filter((s) => s !== '').join('\n');
814
+ return r.code;
815
+ };
816
+ try {
817
+ await cmdPull(config, { team, teamId, runner, nextStepsFromCaller: true });
818
+ return null;
819
+ }
820
+ catch (err) {
821
+ // `said` is empty when the failure was this side's — an unresolvable
822
+ // credential, say — and then our own message is the only account there is.
823
+ return said !== '' ? said : err instanceof Error ? err.message : String(err);
824
+ }
825
+ }
826
+ /** Returns what failed, or null when the key imported. */
827
+ async function unlockTeam(config, team, bundle) {
828
+ try {
829
+ // Straight from the vault's bytes into the OSS import, with no file in
830
+ // between — the same reason the one-team path does it this way: a path can
831
+ // be made to point at other bytes after we vouched for these ones.
832
+ await unlockAuthenticatedBundle(config.scriptsDir, team, Buffer.from(bundle, 'base64'));
833
+ return null;
834
+ }
835
+ catch (err) {
836
+ return err instanceof Error ? err.message : String(err);
837
+ }
838
+ }
@@ -10,6 +10,30 @@ import { shellArg } from '../shell-arg.js';
10
10
  // not revealed until an approver has committed. Anything shown here would have
11
11
  // to be invented, and a code that appears before the ceremony has run is exactly
12
12
  // the thing a user must never learn to accept.
13
+ /**
14
+ * The line one waiting request is announced with.
15
+ *
16
+ * Split out so it can be read by a test. `cmdWatch` writes straight to
17
+ * `process.stdout` and takes no sink, so the only other way to check what it
18
+ * says is a global spy on the stream — which this suite has already paid for
19
+ * once: a spy installed for one file leaked into three others and timed them
20
+ * out. A pure function is the seam that costs nothing.
21
+ */
22
+ export function watchLine(req) {
23
+ return (
24
+ // The id is the server's, not ours, and it lands in an argument
25
+ // position of a line written to be pasted. Quoted for that reason
26
+ // rather than because a UUID needs it: what makes a value safe here is
27
+ // where it goes, not what it happens to contain today.
28
+ //
29
+ // ONCE, not twice. The id opened this line as well until #329 — the
30
+ // same value, in a position where nothing can be done with it, three
31
+ // words before the position where it is the argument to paste. This is
32
+ // the one place in the CLI where a record id passes #275's test, and it
33
+ // passes it by being part of the next step rather than by being shown.
34
+ `enrollment request "${req.label}" — run \`agmsg-cloud approve <team> ${shellArg(req.id)}\` ` +
35
+ `to compare codes with them (expires ${req.expires_at})\n`);
36
+ }
13
37
  export async function cmdWatch(config, args, deps = {}) {
14
38
  const client = new CourierClient(config);
15
39
  const interval = args.intervalMs ?? 15_000;
@@ -32,13 +56,7 @@ export async function cmdWatch(config, args, deps = {}) {
32
56
  if (seen.has(req.id))
33
57
  continue;
34
58
  seen.add(req.id);
35
- process.stdout.write(
36
- // The id is the server's, not ours, and it lands in an argument
37
- // position of a line written to be pasted. Quoted for that reason
38
- // rather than because a UUID needs it: what makes a value safe here is
39
- // where it goes, not what it happens to contain today.
40
- `enrollment request ${req.id} "${req.label}" — run \`agmsg-cloud approve <team> ${shellArg(req.id)}\` ` +
41
- `to compare codes with them (expires ${req.expires_at})\n`);
59
+ process.stdout.write(watchLine(req));
42
60
  }
43
61
  if (deps.signal?.aborted)
44
62
  break;
@@ -80,7 +80,11 @@ export function defaultScriptsDir(home = homedir()) {
80
80
  * review again). One body, so there is nothing to keep in step.
81
81
  */
82
82
  function resolveCredential(env) {
83
- return fromEnv(env) ?? readCredential(null, env);
83
+ const viaEnv = fromEnv(env);
84
+ if (viaEnv)
85
+ return { ...viaEnv, from: 'env' };
86
+ const stored = readCredential(null, env);
87
+ return stored ? { ...stored, from: 'stored' } : null;
84
88
  }
85
89
  /**
86
90
  * Whether this machine has a credential to act with — `loadConfig`'s own
@@ -99,5 +103,10 @@ export function loadConfig(env = process.env) {
99
103
  if (!resolved) {
100
104
  throw new Error('not signed in on this machine — run `agmsg-cloud login --endpoint <url>` first');
101
105
  }
102
- return { baseUrl: resolved.endpoint, secret: resolved.secret, scriptsDir: resolveScriptsDir(env) };
106
+ return {
107
+ baseUrl: resolved.endpoint,
108
+ secret: resolved.secret,
109
+ scriptsDir: resolveScriptsDir(env),
110
+ credentialFrom: resolved.from,
111
+ };
103
112
  }