agmsg-cloud 0.1.0-rc.4 → 0.1.0-rc.6

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.
@@ -8,7 +8,7 @@ import { generateRecoveryKey, normalizeRecoveryKey, promptRecoveryKey, showRecov
8
8
  import { appendVaultVersionWithVdk, createVault, openVaultWithVdk, readAccountVault, vdkFromRecoveryKey, } from '../vault-protocol.js';
9
9
  import { openDeviceSlot, saveDeviceSlot } from '../device-slot.js';
10
10
  import { adviseOnSlot, renderSlotAdvice } from '../slot-advice.js';
11
- import { emptyContainer, parseContainer, selectTeam, serializeContainer, upsertTeam, } from '../vault-container.js';
11
+ import { emptyContainer, parseContainer, selectTeam, serializeContainer, upsertTeamIfMoved, } from '../vault-container.js';
12
12
  // The two recovery commands: resolve the team, obtain the bundle from the OSS
13
13
  // side, get the recovery key from the terminal, and hand off to
14
14
  // vault-protocol.ts for everything that talks to the server.
@@ -27,7 +27,10 @@ import { emptyContainer, parseContainer, selectTeam, serializeContainer, upsertT
27
27
  // omits the field name generation 1 by omission, and — worse — a slot saved
28
28
  // under a made-up 1 would be indistinguishable from a legitimate generation-1
29
29
  // slot once a re-issuance produced one.
30
- function slotAddress(identity, vaultId, generation) {
30
+ // Exported for vault-filing.ts, which addresses the same slot from connect.
31
+ // One derivation, because this decides WHICH slot is read: a second copy that
32
+ // drifted would read a slot belonging to another vault or generation.
33
+ export function slotAddress(identity, vaultId, generation) {
31
34
  return {
32
35
  vaultServiceId: identity.vaultServiceId,
33
36
  accountId: identity.accountId,
@@ -119,13 +122,6 @@ async function keepSlot(address, vdk) {
119
122
  function vaultTeamKey(serverInstanceId, teamId) {
120
123
  return JSON.stringify([serverInstanceId, teamId]);
121
124
  }
122
- function sameEpochs(a, b) {
123
- if (a.length !== b.length)
124
- return false;
125
- const left = [...a].sort();
126
- const right = [...b].sort();
127
- return left.every((v, i) => v === right[i]);
128
- }
129
125
  /**
130
126
  * `recovery setup` — put the keys of every team THIS MACHINE reports into the
131
127
  * account's vault.
@@ -282,7 +278,13 @@ export async function cmdVaultPut(config, args) {
282
278
  // backed-up ones are not known until the loop below has run. Hence the
283
279
  // parameters: the groups are passed in rather than closed over, so the
284
280
  // heading cannot outrun what is under it.
285
- const coverage = (written, unchanged) => {
281
+ // `stranded` joins written/unchanged as a PARAMETER for the reason the two
282
+ // above are parameters: this function is defined before the loop fills any
283
+ // of them, and closing over a list that is still empty here prints a
284
+ // heading with nothing under it. The first version of this got that wrong
285
+ // in the other direction and listed the disconnected teams under
286
+ // "Covered".
287
+ const coverage = (written, unchanged, stranded) => {
286
288
  const group = (title, members) => members.length === 0 ? [] : [`\n ${title}\n`, ...members.map((m) => ` ${m}\n`)];
287
289
  // The first two hold for either form — a run that names one team still has
288
290
  // to say what it did with it. The rest are about the ENUMERATION, so they
@@ -291,6 +293,12 @@ export async function cmdVaultPut(config, args) {
291
293
  const lines = [
292
294
  ...group('Backed up in this revision:', written),
293
295
  ...group('Already current, nothing to write:', unchanged),
296
+ // FIRST among the things that did not happen, and with the reason
297
+ // attached. This is the only group whose members are a contradiction in
298
+ // the store rather than a choice the operator made, so it is the one
299
+ // that needs acting on — and the reason is what says which team's state
300
+ // to look at, rather than leaving "something failed" for them to locate.
301
+ ...group('Recorded as connected here, but this machine could not open them:', stranded.map((f) => `${f.team} — ${f.reason}`)),
294
302
  ...(!enumerated
295
303
  ? []
296
304
  : group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team))),
@@ -321,34 +329,96 @@ export async function cmdVaultPut(config, args) {
321
329
  let next = container;
322
330
  const written = [];
323
331
  const unchanged = [];
332
+ // Recorded as connected by this machine, and not openable by it. Kept
333
+ // apart from `unseen` and `disconnected` because it is a third fact: the
334
+ // machine reported this team AND could not produce its keys, which is the
335
+ // store disagreeing with itself rather than a team the operator retired.
336
+ const couldNotOpen = [];
324
337
  for (const [i, target] of targets.entries()) {
325
338
  const bundleFile = join(scratch, `handoff-${i}.bundle`);
326
- await keyHandoff(config.scriptsDir, target.team, bundleFile);
327
- const bundle = readFileSync(bundleFile);
328
- const keyIds = bundleKeyIds(bundle);
329
- const already = next.teams.find((e) => e.server_instance_id === target.serverInstanceId && e.team_id === target.teamId);
330
- if (already && sameEpochs(already.key_ids, keyIds)) {
331
- unchanged.push(target.team);
339
+ // ONE TEAM THIS MACHINE CANNOT OPEN DOES NOT DECIDE THE ACCOUNT (#276).
340
+ //
341
+ // The enumeration and this loop read DIFFERENT sources of truth, and
342
+ // that is the whole defect. `remote.sh status --json` reads only
343
+ // `teams/<team>/config`: a binding with a `connected_at` and no
344
+ // `disconnected_at` is reported active, and that read never opens
345
+ // `db/remote-sync/<team>.json`. The key handoff does. So a team whose
346
+ // binding record outlived its sync state arrives here looking ordinary
347
+ // and throws — measured on the 2026-08-11 walk, where a leftover named
348
+ // `oss-rc2` stopped the backup of four intact teams.
349
+ //
350
+ // The failure was TOTAL, and its blast radius was set by the oldest
351
+ // leftover on the machine rather than by anything the operator was
352
+ // doing. Skipping and naming is the answer, not refusing to start: this
353
+ // command already reports what it did not cover — `unseen`,
354
+ // `disconnected` — instead of refusing, for the same reason. A backup of
355
+ // four teams that says which fifth is missing leaves someone strictly
356
+ // better off than a backup of none; a backup of four that said nothing
357
+ // would not, which is why this is a report and not a silence.
358
+ //
359
+ // Any failure, not a matched message. Parsing `ENOENT` would name one
360
+ // shape of one version of one script, and the honest claim here is
361
+ // narrower: this machine could not produce this team's keys. The reason
362
+ // is carried through verbatim for whoever reads it.
363
+ let bundle;
364
+ try {
365
+ await keyHandoff(config.scriptsDir, target.team, bundleFile);
366
+ bundle = readFileSync(bundleFile);
367
+ }
368
+ catch (err) {
369
+ couldNotOpen.push({
370
+ team: target.team,
371
+ reason: err instanceof Error ? err.message : String(err),
372
+ });
332
373
  continue;
333
374
  }
334
- next = upsertTeam(next, {
375
+ const keyIds = bundleKeyIds(bundle);
376
+ // The decision and the write are one call, in the container module, so
377
+ // `connect` cannot make the same decision differently (#181).
378
+ const result = upsertTeamIfMoved(next, {
335
379
  server_instance_id: target.serverInstanceId,
336
380
  team_id: target.teamId,
337
381
  key_ids: keyIds,
338
382
  bundle: bundle.toString('base64'),
339
383
  });
340
- written.push(target.team);
384
+ next = result.container;
385
+ (result.written ? written : unchanged).push(target.team);
341
386
  }
342
387
  // Nothing moved, and the vault already exists: there is no version to
343
388
  // write. Saying "stored revision N+1" here would be true of the server and
344
389
  // false about the account — the point of a revision is that something is
345
390
  // different in it.
391
+ // NOTHING OPENED IS NOT A PARTIAL BACKUP. Skipping a team that cannot be
392
+ // opened is right while others can; when the whole set fails there is no
393
+ // backup to report, and writing here would store a container holding no
394
+ // teams — a recovery key that opens an empty vault, which reads as success
395
+ // and is worse than the abort this change replaced.
396
+ //
397
+ // Found by the test for this change, not by reading it back: the skip made
398
+ // the loop finish, and every path after the loop assumed finishing meant
399
+ // something had been filed.
400
+ if (written.length === 0 && unchanged.length === 0 && couldNotOpen.length > 0) {
401
+ process.stdout.write(coverage(written, unchanged, couldNotOpen));
402
+ throw new Error('no team on this machine could be opened, so nothing was backed up.\n' +
403
+ 'Each team above is recorded here as connected while the state its keys ' +
404
+ 'come from is missing.');
405
+ }
346
406
  if (written.length === 0 && version && vdk) {
347
407
  // The headline carries no bare count: what "up to date" covers is the
348
408
  // list below it, and a number on its own is the thing this was reported
349
409
  // for.
350
- process.stdout.write(`already up to date — nothing has new keys. Revision stays at ${version.revision}.\n`);
351
- process.stdout.write(coverage(written, unchanged));
410
+ //
411
+ // AND IT NARROWS TO WHAT WAS OPENED. "nothing has new keys" is a claim
412
+ // over every team on the machine, but a team that could not be opened
413
+ // had its bundle and key epoch read zero times — whether it has new keys
414
+ // is not a thing this run knows. Unqualified, the line contradicts the
415
+ // coverage printed two statements later, which names that same team as
416
+ // one this run could not open (review P1-contract).
417
+ process.stdout.write(couldNotOpen.length > 0
418
+ ? `the teams this machine could open have no new keys; the ones below ` +
419
+ `it could not open were not checked. Revision stays at ${version.revision}.\n`
420
+ : `already up to date — nothing has new keys. Revision stays at ${version.revision}.\n`);
421
+ process.stdout.write(coverage(written, unchanged, couldNotOpen));
352
422
  // A run that had to type the key still earns a slot: the key is in hand,
353
423
  // and the next run should not ask again just because this one had nothing
354
424
  // to store.
@@ -389,7 +459,7 @@ export async function cmdVaultPut(config, args) {
389
459
  // (agmsg#650). Teams already in the vault are covered by the refusal
390
460
  // higher up; a team that has never been in it cannot be seen from here at
391
461
  // all, so the sentence stops at the machine rather than the account.
392
- process.stdout.write(coverage(written, unchanged));
462
+ process.stdout.write(coverage(written, unchanged, couldNotOpen));
393
463
  // After the write, and only when this run had to reach the key. A run the
394
464
  // slot already answered has a working slot at this address; saving again
395
465
  // would mint a fresh KEK, replace it, and touch the keychain on every
@@ -406,9 +476,28 @@ export async function cmdVaultPut(config, args) {
406
476
  // filing a key that opens nothing and trusting it for years. A re-run
407
477
  // mints a new one, which is correct — and only correct if they know to
408
478
  // discard this one.
479
+ // A REMEDY THAT IS PRINTED HAS TO END SOMEWHERE (#276).
480
+ //
481
+ // This used to say "running this command again will show you a new key",
482
+ // full stop. It does show a new key — and then fails in the same place,
483
+ // because re-running creates nothing that was missing. On the
484
+ // 2026-08-11 walk that sentence was followed each time, and each attempt
485
+ // burned a key and arrived back here, with the failure still advising
486
+ // the retry.
487
+ //
488
+ // Discarding the key is still right and still first: it opens nothing,
489
+ // 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.
409
495
  process.stdout.write('\nThe backup did NOT complete, so the recovery key above was never used\n' +
410
- 'and opens nothing. Discard it. Running this command again will show you\n' +
411
- 'a new key.\n\n');
496
+ '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');
412
501
  }
413
502
  throw err;
414
503
  }
@@ -419,7 +508,11 @@ export async function cmdVaultPut(config, args) {
419
508
  // The key epochs the bundle declares, for the entry's binding. This is the only
420
509
  // part of the bundle the cloud side reads: everything else is carried opaquely
421
510
  // and handed back to the OSS unlock path, so the bundle's format stays theirs.
422
- function bundleKeyIds(bundle) {
511
+ // Exported alongside slotAddress, and for the same reason: vault-filing.ts
512
+ // writes the same entry shape from connect, and a second reading of the
513
+ // bundle that disagreed would record a binding to key epochs the bundle does
514
+ // not declare.
515
+ export function bundleKeyIds(bundle) {
423
516
  try {
424
517
  const parsed = JSON.parse(bundle.toString('utf8'));
425
518
  const ids = (parsed.identities ?? [])
@@ -0,0 +1,44 @@
1
+ import { readCredential } from '../credentials.js';
2
+ import { machinePrefix } from '../machine-id.js';
3
+ export function whoami(env = process.env) {
4
+ // The ACTIVE credential, which is what every other command sends. Asking by
5
+ // origin would answer for a host this machine may not be using, and the
6
+ // question is "which machine am I", not "what do I have for X".
7
+ const credential = readCredential(null, env);
8
+ if (!credential)
9
+ return { signedIn: false };
10
+ return {
11
+ signedIn: true,
12
+ endpoint: credential.endpoint,
13
+ org: credential.org,
14
+ machineName: credential.machineName,
15
+ prefix: machinePrefix(credential.secret),
16
+ };
17
+ }
18
+ export function renderWhoami(found) {
19
+ if (!found.signedIn) {
20
+ return ('This machine is not signed in, so it is not one of the machines in any account yet.\n' +
21
+ 'Run `agmsg-cloud login` first.\n');
22
+ }
23
+ const lines = [
24
+ `endpoint ${found.endpoint}`,
25
+ `organization ${found.org}`,
26
+ `machine name ${found.machineName}`,
27
+ ];
28
+ if (found.prefix === null) {
29
+ // Said rather than omitted. A missing line reads as "this machine has no
30
+ // prefix", and the console will be showing one for it — so the person would
31
+ // be comparing against a row that does exist, with nothing to compare.
32
+ lines.push('', 'This version cannot read the prefix out of the stored credential: it is not', 'the shape this build knows. The machine still works — every other command', 'sends the secret as it is — but matching it against the console has to be', 'done another way. Upgrading `agmsg-cloud` is the thing to try first.');
33
+ return `${lines.join('\n')}\n`;
34
+ }
35
+ lines.push(`prefix ${found.prefix}`, '',
36
+ // Why the prefix is here at all, in the place someone reads it.
37
+ 'The prefix is how you tell this machine apart from the others in the account:', 'it is the value shown beside the name on the console\'s machines screen, and', 'machine names are not unique. Match this one before revoking anything.', '',
38
+ // Pre-empting the obvious worry about seeing part of a credential.
39
+ 'It is not a secret — the console shows it to everyone in the organization.', 'The credential itself is the part that is not printed here, and never is.');
40
+ return `${lines.join('\n')}\n`;
41
+ }
42
+ export function cmdWhoami(env = process.env) {
43
+ process.stdout.write(renderWhoami(whoami(env)));
44
+ }
@@ -25,8 +25,36 @@ function fromEnv(env) {
25
25
  export function resolveScriptsDir(env = process.env) {
26
26
  return env.AGMSG_SCRIPTS_DIR ?? join(homedir(), '.agents', 'skills', 'agmsg', 'scripts');
27
27
  }
28
+ /**
29
+ * THE credential decision. Every caller that needs to know whether this machine
30
+ * can act — and with what — goes through this one function.
31
+ *
32
+ * It is a function rather than a rule written twice because the two readers
33
+ * disagreed once already: preflight asked `readCredential` alone and reported a
34
+ * headless machine carrying AGMSG_CLOUD_ENDPOINT + AGMSG_CLOUD_SECRET as not
35
+ * signed in, refusing a machine that could run the command — #222's defect
36
+ * facing the other way (raised in review). The first repair merely copied this
37
+ * expression into both readers, which is the same arrangement with the
38
+ * divergence deferred: equal today, free to drift on the next edit (raised in
39
+ * review again). One body, so there is nothing to keep in step.
40
+ */
41
+ function resolveCredential(env) {
42
+ return fromEnv(env) ?? readCredential(null, env);
43
+ }
44
+ /**
45
+ * Whether this machine has a credential to act with — `loadConfig`'s own
46
+ * question, asked of `loadConfig`'s own resolver, so preflight cannot hold a
47
+ * second opinion about a decision `loadConfig` already makes.
48
+ *
49
+ * A half-set pair still THROWS, exactly as it does for `loadConfig`: "endpoint
50
+ * set, secret missing" is a misconfiguration to report, not an absence to
51
+ * quietly call signed-out.
52
+ */
53
+ export function hasCredential(env = process.env) {
54
+ return resolveCredential(env) !== null;
55
+ }
28
56
  export function loadConfig(env = process.env) {
29
- const resolved = fromEnv(env) ?? readCredential(null, env);
57
+ const resolved = resolveCredential(env);
30
58
  if (!resolved) {
31
59
  throw new Error('not signed in on this machine — run `agmsg-cloud login --endpoint <url>` first');
32
60
  }
package/dist/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { cmdWhoami } from './commands/whoami.js';
2
3
  import { loadConfig, resolveScriptsDir } from './config.js';
3
4
  import { cmdApprove } from './commands/approve.js';
4
5
  import { cmdConnect, cmdConnectPreflight } from './commands/connect.js';
@@ -36,6 +37,9 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
36
37
  team this machine's store reports; name one to back up
37
38
  only that team
38
39
  recovery restore <team> (on any approved machine) open the backup and unlock <team>
40
+ whoami which of this account's machines this one is —
41
+ its name, its organization, and the prefix the
42
+ console shows beside it
39
43
  version the version of this CLI (also --version, -v)
40
44
  the OSS scripts it drives are reported by
41
45
  \`connect --preflight\`, which is a separate answer
@@ -54,6 +58,9 @@ environment variable for it.
54
58
  Environment (for CI and headless runs; \`login\` is the normal path):
55
59
  AGMSG_CLOUD_ENDPOINT base URL of the hosted edge
56
60
  AGMSG_CLOUD_SECRET this machine's capability secret
61
+ AGMSG_SCRIPTS_DIR the agmsg install to drive
62
+ (default: ~/.agents/skills/agmsg/scripts)
63
+ \`connect --preflight\` prints the one it resolved
57
64
 
58
65
  Both or neither: a stored secret is only ever sent to the endpoint it was
59
66
  minted for, so one of these without the other is refused rather than mixed
@@ -100,6 +107,13 @@ async function main(argv) {
100
107
  process.stdout.write(`agmsg-cloud/${packageVersion()}\n`);
101
108
  return;
102
109
  }
110
+ // Beside `version` for the same reason it sits there: both answer a
111
+ // question about this machine and neither reaches the network, so both
112
+ // still work at the moment something else has refused.
113
+ case 'whoami': {
114
+ cmdWhoami();
115
+ return;
116
+ }
103
117
  case 'login': {
104
118
  // No endpoint means the official deployment. The flag stays for
105
119
  // self-hosted and development stacks; either way the destination is
@@ -144,7 +158,13 @@ async function main(argv) {
144
158
  const label = rest[0];
145
159
  if (!label)
146
160
  throw new Error('usage: agmsg-cloud request <label>');
147
- return cmdRequest(loadConfig(), { label });
161
+ // The outcome is dropped here on purpose: this is the whole command, and
162
+ // the exit code `request` set is what the shell reads. It is returned for
163
+ // the callers that run it as a STEP and have to decide whether to carry
164
+ // on — `sync` did not, which is how a failed enrollment ended in a
165
+ // message about a missing team.
166
+ await cmdRequest(loadConfig(), { label });
167
+ return;
148
168
  }
149
169
  case 'sync': {
150
170
  // The whole second-machine path. `request`, `fetch` and `pull` remain as
@@ -223,9 +243,17 @@ async function main(argv) {
223
243
  // to add that one on its own. `restore` still requires one — it unlocks a
224
244
  // specific team on this machine.
225
245
  //
226
- // The excuse rides inside the slot, so it cannot apply to anything else.
246
+ // `restore`, written out. It is the only value `sub` can hold here — the
247
+ // branch tests for it — so the interpolation was indirection that printed
248
+ // a constant, and it carried a `printed-commands:` exemption to say so.
249
+ //
250
+ // That exemption was the last one in this file, and #201 is about what it
251
+ // rested on. Measured: nothing. The guard never reached this line, because
252
+ // a command introduced by `usage: ` opened no command extent, so the
253
+ // comment sat over a slot no rule was applied to. Both the comment and the
254
+ // slot are gone, and the line is now inside the guard's scope.
227
255
  if (sub === 'restore' && !team) {
228
- throw new Error(`usage: agmsg-cloud recovery ${ /* printed-commands: a subcommand name this file chooses, narrowed to 'setup' | 'restore' two lines up — not a value anyone supplies, and quoting it would print `recovery 'setup'` */sub} <team>`);
256
+ throw new Error('usage: agmsg-cloud recovery restore <team>');
229
257
  }
230
258
  const config = loadConfig();
231
259
  return sub === 'setup'
@@ -0,0 +1,44 @@
1
+ // Which of an account's machines THIS one is.
2
+ //
3
+ // The console lists an org's machines with a `token_prefix` column, so two
4
+ // machines called `mbp2024` are two visibly different rows. Nothing on the
5
+ // machine printed the matching value, so the distinction could be seen and not
6
+ // resolved — the one person who knows which laptop they are sitting at had
7
+ // nothing to compare against (#199). The revoke dialog names the prefix too
8
+ // (#198), which makes that gap the difference between "these are two rows" and
9
+ // "this one is mine".
10
+ //
11
+ // NOT A SECRET, and worth stating because it is carved out of one. The mint is
12
+ // `agsy_<8 hex>_<43 base64url>` (app/src/edge/capability.ts) and the server
13
+ // stores `agsy_<8 hex>` as the column the console shows to any org member. The
14
+ // capability is the segment AFTER it, and it is what never leaves this file's
15
+ // caller.
16
+ /**
17
+ * The shape the server mints (app/src/edge/capability.ts): `agsy_<8 hex>_<43
18
+ * base64url>`, with the leading two segments captured.
19
+ *
20
+ * ONE definition, exported, because two callers need the same answer for
21
+ * different reasons: `login` refuses to STORE a secret that does not match, and
22
+ * `machinePrefix` refuses to READ a prefix out of one that does not. A second
23
+ * copy would let those drift — updating the mint in one place would leave the
24
+ * other silently answering about a format that no longer exists, and the
25
+ * failure would be a prefix that stops resolving rather than an error
26
+ * (raised in review).
27
+ */
28
+ export const SECRET_RE = /^(agsy_[a-f0-9]{8})_[A-Za-z0-9_-]{43}$/;
29
+ /**
30
+ * The prefix the console shows for this machine, or null if the stored secret
31
+ * is not the shape this version knows.
32
+ *
33
+ * Derived by MATCHING the whole shape rather than by cutting at a fixed length
34
+ * or at the first underscore. A slice would happily return the first 13
35
+ * characters of something that is not a capability at all — including, if the
36
+ * format ever changes, thirteen characters of live secret. It matches against
37
+ * the SAME exported pattern `login` uses to refuse storing a wrong shape —
38
+ * one definition with two callers, rather than two copies a comment claims
39
+ * are equal.
40
+ */
41
+ export function machinePrefix(secret) {
42
+ const m = SECRET_RE.exec(secret);
43
+ return m ? m[1] : null;
44
+ }
package/dist/src/oss.js CHANGED
@@ -249,11 +249,25 @@ export async function unlockAuthenticatedBundle(scriptsDir, team, bundle) {
249
249
  await run('bash', [join(scriptsDir, 'remote.sh'), 'unlock', team, '--authenticated-bundle-stdin'], bundle);
250
250
  }
251
251
  // OSS `remote.sh unlock <team> --bundle <file> [--confirm-digest <digest>]`.
252
+ //
253
+ // RETURNS what the script said, rather than discarding it.
254
+ //
255
+ // `remote.sh unlock` does not print optimistically: it waits for the sync
256
+ // engine to report ready, checks the recorded pidfile matches the process it
257
+ // started, checks that process is alive, and exits non-zero otherwise. Only
258
+ // then does it print `Unlocked '<team>': imported N envelope(s); engine
259
+ // running (pid N).`
260
+ //
261
+ // That sentence is verified against a live pid, and it names the object. The
262
+ // caller used to throw it away and print its own summary instead, which named
263
+ // nothing — so an operator watching a successful join saw "unlocked" with no
264
+ // way to tell WHICH thing had been unlocked, four lines above a remedy telling
265
+ // them to unlock something (#147).
252
266
  export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest) {
253
267
  const args = [join(scriptsDir, 'remote.sh'), 'unlock', team, '--bundle', bundleFile];
254
268
  if (confirmDigest)
255
269
  args.push('--confirm-digest', confirmDigest);
256
- await run('bash', args);
270
+ return (await run('bash', args)).toString();
257
271
  }
258
272
  // The canonical age snapshot, exported locally, and its digest (§3.2, §3.2.1).
259
273
  //
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import { hasCredential } from './config.js';
3
4
  import { join } from 'node:path';
4
5
  import { platform } from 'node:process';
5
6
  // What `connect` needs before it starts, checked all at once.
@@ -150,10 +151,31 @@ function toolsFor(scripts) {
150
151
  python3: scripts.includes('remote.sh'),
151
152
  };
152
153
  }
153
- export function preflight(scriptsDir, needs) {
154
+ export function preflight(scriptsDir, needs, env = process.env) {
154
155
  const { command, scripts } = needs;
155
156
  const requireConnect = needs.requireConnect ?? false;
156
157
  const requirements = [];
158
+ // BEING SIGNED IN IS A PREREQUISITE, so it is one of the things checked.
159
+ //
160
+ // It was not, and the result was a check that said "Everything connect needs
161
+ // is here" immediately before connect stopped for a missing sign-in (#222).
162
+ // A checklist that omits a requirement does not merely fail to help — it
163
+ // states that the requirement is met.
164
+ //
165
+ // Listed first because it is the one whose fix is a different command
166
+ // entirely, and because the others are about this machine's install while
167
+ // this one is about this machine's account.
168
+ requirements.push({
169
+ name: 'signed in',
170
+ // The resolver `loadConfig` uses, not a second opinion about the same
171
+ // question. `readCredential` alone reported a headless machine with
172
+ // AGMSG_CLOUD_ENDPOINT + AGMSG_CLOUD_SECRET as signed out — a checklist
173
+ // refusing a machine that CAN run the command, which is the defect this
174
+ // requirement exists to fix, facing the other way (raised in review).
175
+ ok: hasCredential(env),
176
+ why: `${command} talks to the hosted service as this machine, and this machine has no credential yet.`,
177
+ install: ['Sign in: agmsg-cloud login'],
178
+ });
157
179
  // The scripts come first: without them nothing else matters, and the fix is
158
180
  // a different kind of thing (install agmsg, or point AGMSG_SCRIPTS_DIR at it)
159
181
  // than installing a binary.
@@ -205,6 +227,7 @@ export function preflight(scriptsDir, needs) {
205
227
  requirements,
206
228
  ok: requirements.every((r) => r.ok),
207
229
  command,
230
+ scriptsDir,
208
231
  agmsgVersion: canConnect ? installedVersion(scriptsDir) : null,
209
232
  };
210
233
  }
@@ -230,6 +253,16 @@ export function formatPreflight(result) {
230
253
  for (const r of result.requirements) {
231
254
  lines.push(` ${r.ok ? '[x]' : '[ ]'} ${r.name}`);
232
255
  }
256
+ // THE RESOLVED DIRECTORY, IN FULL, ON ITS OWN LINE.
257
+ //
258
+ // It used to appear only inside a requirement's name — `agmsg with remote
259
+ // sync (in /very/long/path)` — where the walk's terminal cut it off. The
260
+ // operator could not see WHICH install had been checked, which is exactly
261
+ // the disagreement they were trying to diagnose (#222). A path is not a
262
+ // decoration on a label; it is the answer to "which one did you look at".
263
+ lines.push('');
264
+ lines.push(` scripts directory: ${result.scriptsDir}`);
265
+ lines.push(` set AGMSG_SCRIPTS_DIR to check a different install.`);
233
266
  if (result.agmsgVersion !== null) {
234
267
  // Named as what it is. Calling it "agmsg 1.1.11" would invite the reader to
235
268
  // compare it with a release number, which is the thing it cannot be
@@ -281,20 +281,35 @@ export function promptRecoveryKey(prompt) {
281
281
  // can then be re-derived from material this machine holds. It does not exist
282
282
  // yet, so the caller shows first and says so if the backup then fails.
283
283
  //
284
- // The command it prints is the REAL one, built from the team this run was
285
- // given and quoted through `shellArg` (raised in review). It used to read
286
- // `agmsg-cloud recovery setup` with no argument, which the dispatch refuses —
287
- // so the one line telling someone what to do next was a line that could only
288
- // produce a usage error. A `<team>` placeholder would have the same defect in
289
- // a politer form: what is printed here is meant to be pasted, so it has to be
290
- // the command that runs, for the team this failure happened on, including
291
- // when that name is not one shell word.
284
+ // The command it prints is the REAL one — whatever `setupCommand` builds for
285
+ // the team this run was given, or for no team when it was given none. What is
286
+ // printed here is meant to be PASTED, so it has to be a command that runs: a
287
+ // `<team>` placeholder is not, and a name that is not one shell word has to
288
+ // arrive quoted (raised in review).
289
+ //
290
+ // This paragraph used to add that the no-argument form "the dispatch refuses",
291
+ // and that printing it could only produce a usage error. That was true when the
292
+ // team was required. It stopped being true when the argument became optional,
293
+ // and the sentence sat directly above the derivation that says the opposite
294
+ // (#182 review). Both halves are still printable and both still run; which one
295
+ // appears is decided by the caller having a team in hand, not by one of them
296
+ // being invalid.
292
297
  // The command to re-run. The quoting sits ON the interpolating line, which is
293
298
  // what the printed-command checker reads — a ternary hid it, and so did binding
294
299
  // the result to a variable first. Both were still quoted; neither was visible.
295
300
  // The checker is right to demand the stricter form: what it can see is what
296
301
  // survives the next edit.
297
- function setupCommand(team) {
302
+ /**
303
+ * How this tool names the command that creates a vault and backs teams up.
304
+ *
305
+ * One derivation, because the argument convention has already moved once: the
306
+ * team used to be required and is now optional, and the no-argument form is the
307
+ * ordinary one — it covers every team this machine reports rather than the one
308
+ * that happened to be in hand. Every place that prints this remedy asks here,
309
+ * so the next change to the convention moves one line rather than being grepped
310
+ * for (#181).
311
+ */
312
+ export function setupCommand(team) {
298
313
  if (team === undefined)
299
314
  return 'agmsg-cloud recovery setup';
300
315
  return `agmsg-cloud recovery setup ${shellArg(team)}`;