agmsg-cloud 0.1.0-rc.5 → 0.1.0-rc.7

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.
@@ -9,22 +9,6 @@ import { originOf } from '../credentials.js';
9
9
  import { deviceIdentityPath } from '../paths.js';
10
10
  import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
11
11
  import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
12
- // (c, part 1) B asks to be added.
13
- //
14
- // The order below is the protocol, not a style choice:
15
- //
16
- // 1. commit to (device key, nonce) and PERSIST the nonce
17
- // 2. post only the commitment digest
18
- // 3. wait for the approver's commitment to be fixed
19
- // 4. only then reveal the key and the nonce
20
- // 5. wait for the approver to open, and derive the code locally
21
- //
22
- // Steps 3 and 4 are what stop the other side choosing its contribution after
23
- // learning ours. Doing 4 before 3 would leave a request whose code the server
24
- // could steer, and the human comparison would confirm nothing.
25
- //
26
- // The private key never leaves this machine; only the public recipient is sent,
27
- // and not until step 4.
28
12
  export async function cmdRequest(config, args, deps = {}) {
29
13
  const out = deps.out ?? ((text) => void process.stdout.write(text));
30
14
  const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
@@ -47,11 +31,39 @@ export async function cmdRequest(config, args, deps = {}) {
47
31
  // paid for would charge the same guess twice and let a crash loop exhaust the
48
32
  // budget without a single comparison.
49
33
  const scope = requesterLedgerScope(config.baseUrl, pubkey);
50
- // A record without an id may still have landed: the row can exist on the
51
- // server while the response was lost. Ask, using the only handle available —
52
- // the commitment. Dropping these was what left the ledger's open attempt
53
- // blocking every retry with nothing able to clear it.
54
- if (record && !requestId) {
34
+ // ASKED FOR EVERY STORED RECORD, INCLUDING ONE THAT ALREADY HAS AN ID.
35
+ //
36
+ // Two different questions, one answer:
37
+ //
38
+ // no id did it land at all? The row can exist while the response was
39
+ // lost, and the commitment is the only handle left.
40
+ // with id is it still live? Nothing about the id says so.
41
+ //
42
+ // This used to run only for the first. A record with an id was trusted
43
+ // however old it was, and the first thing the run did with it was
44
+ // `waitForStatus`, which polls for PROGRESS — so a long-dead enrollment was
45
+ // resumed, waited on, and then reported expired. That is the walkthrough
46
+ // sequence in #143: `resuming enrollment <id>` followed by `<id> is expired`.
47
+ //
48
+ // One list answers both, because `GET /v1/enrollments` is scoped to
49
+ // `status = ANY(NONTERMINAL) AND expires_at > now()`
50
+ // (`app/src/handoff/enrollment.ts:306`) — an expired row is absent from it
51
+ // even while its status still reads non-terminal. So "not in the list" already
52
+ // means "cannot be resumed", and the branch below already does the right
53
+ // thing with that: it closes the attempt, drops the record, and falls through
54
+ // to starting a fresh ceremony in this same run.
55
+ //
56
+ // AND THE LIST IS THE ONLY PLACE THAT ANSWERS IT. Reading the id instead would
57
+ // not do: `expireStale` rolls a stale row over on touch, and its callers are
58
+ // the create and claim paths, so a read returns the status the ceremony left
59
+ // behind and says nothing about the deadline. (That the walkthrough saw `<id>
60
+ // is expired` at all means something else had already touched it.) Pinned on
61
+ // the server side in `app/test/handoff-enrollment.test.ts`, because a change
62
+ // there would otherwise leave every test here green.
63
+ //
64
+ // The cost is one extra request per resume. What it buys is that the run
65
+ // either resumes something that can still finish, or starts one that can.
66
+ if (record) {
55
67
  // The list must SUCCEED before its emptiness means anything.
56
68
  //
57
69
  // Swallowing the error and treating it as "no live requests" turned a
@@ -64,12 +76,20 @@ export async function cmdRequest(config, args, deps = {}) {
64
76
  mine = await client.listEnrollments();
65
77
  }
66
78
  catch (err) {
67
- writeErr('cannot tell whether the earlier request reached the server: ' +
79
+ // The two cases have different unknowns and the sentence says which one
80
+ // it is. With no id, whether the request ever landed is open; with an id
81
+ // it landed and what is open is whether it can still finish. Saying "did
82
+ // it reach the server" to someone holding an id describes a doubt they do
83
+ // not have, and the remedy is the same either way — which is what makes
84
+ // the wrong half safe to notice and cheap to fix.
85
+ writeErr((requestId
86
+ ? `cannot tell whether enrollment ${requestId} is still live: `
87
+ : 'cannot tell whether the earlier request reached the server: ') +
68
88
  `${err instanceof Error ? err.message : String(err)}\n` +
69
89
  ' Nothing has been changed — the attempt and its nonce are kept so a\n' +
70
90
  ' later run can finish it. Try again when the server is reachable.\n');
71
91
  process.exitCode = 1;
72
- return;
92
+ return 'not_enrolled';
73
93
  }
74
94
  const match = mine.filter((e) => e.commitment === record.commitmentHex);
75
95
  if (match.length === 1) {
@@ -90,7 +110,7 @@ export async function cmdRequest(config, args, deps = {}) {
90
110
  writeErr('the server reports more than one live request for this commitment; ' +
91
111
  'refusing to guess which one is yours\n');
92
112
  process.exitCode = 1;
93
- return;
113
+ return 'not_enrolled';
94
114
  }
95
115
  }
96
116
  if (!record || !requestId) {
@@ -116,13 +136,13 @@ export async function cmdRequest(config, args, deps = {}) {
116
136
  if (before.remaining === 0) {
117
137
  writeErr(renderBudgetExhausted(before));
118
138
  process.exitCode = 1;
119
- return;
139
+ return 'not_enrolled';
120
140
  }
121
141
  const blocked = await keyAlreadySpokenFor(client, pubkey);
122
142
  if (blocked) {
123
143
  writeErr(blocked);
124
144
  process.exitCode = 1;
125
- return;
145
+ return 'not_enrolled';
126
146
  }
127
147
  const { commitmentHex: plannedCommitment, openingNonce: plannedNonce } = createSasCommitment(pubkey);
128
148
  const charged = consumeAttempt(scope, plannedCommitment, env);
@@ -135,7 +155,7 @@ export async function cmdRequest(config, args, deps = {}) {
135
155
  ' command again to resume it rather than starting a second one.\n\n');
136
156
  }
137
157
  process.exitCode = 1;
138
- return;
158
+ return 'not_enrolled';
139
159
  }
140
160
  out(renderBudgetWarning(charged.budget));
141
161
  // Persisted BEFORE the digest is posted. A crash after the post but before
@@ -220,6 +240,7 @@ export async function cmdRequest(config, args, deps = {}) {
220
240
  // the production walkthrough).
221
241
  out(` Added.${nextStepsAreOurs ? ' Fetch the bundle with `agmsg-cloud fetch <team>`.' : ''} (${settled.status})\n`);
222
242
  done();
243
+ return 'enrolled';
223
244
  }
224
245
  catch (err) {
225
246
  if (err instanceof CeremonyError) {
@@ -227,19 +248,31 @@ export async function cmdRequest(config, args, deps = {}) {
227
248
  // A timeout leaves it, so the next run resumes instead of spending another
228
249
  // attempt on a request that is still live.
229
250
  if (err.reason === 'timed_out') {
230
- // The ceremony may still be live; the next run resumes the same one and
231
- // is not charged again.
251
+ // "May still be live" was printed as though it were "is still live".
252
+ //
253
+ // The wait has a five-minute deadline and an enrollment's window can
254
+ // close inside it — and expiry alone does not move the status, so the
255
+ // poll ends as "did not reach … in time" for a row that is expired and
256
+ // untouched. Both halves of the old sentence were then false: the next
257
+ // run does not resume it, and the new ceremony it starts does cost an
258
+ // attempt. Someone following the remedy spent one having been told it
259
+ // was free (#258).
260
+ //
261
+ // Asked of the same list the resume path asks, which is scoped to
262
+ // `status = ANY(NONTERMINAL) AND expires_at > now()` — so presence
263
+ // there IS resumability, and there is no second definition to keep in
264
+ // step.
232
265
  writeErr(`enrollment did not complete: ${err.message}\n`);
233
- writeErr(' Run the same command again to resume it; this costs no further attempt.\n');
266
+ writeErr(await resumeAdvice(client, record.commitmentHex));
234
267
  process.exitCode = 1;
235
- return;
268
+ return 'not_enrolled';
236
269
  }
237
270
  done();
238
271
  closeAttempt(scope, record.commitmentHex, 'failed', err.reason, env);
239
272
  writeErr(renderBudgetWarning(readBudget(scope, env)));
240
273
  writeErr(`enrollment did not complete: ${err.message}\n`);
241
274
  process.exitCode = 1;
242
- return;
275
+ return 'not_enrolled';
243
276
  }
244
277
  // The two ways this machine's key cannot become a device on this account.
245
278
  //
@@ -257,7 +290,7 @@ export async function cmdRequest(config, args, deps = {}) {
257
290
  writeErr(renderBudgetWarning(readBudget(scope, env)));
258
291
  writeErr(await renderKeyRefusal(err.code, client, pubkey));
259
292
  process.exitCode = 1;
260
- return;
293
+ return 'not_enrolled';
261
294
  }
262
295
  throw err;
263
296
  }
@@ -287,6 +320,37 @@ const NOTHING_COMPARED = ' No digits were shown and no bundle was sent.\n';
287
320
  * about ownership, and reading silence as "none of these are yours" would
288
321
  * refuse every ordinary rejoin.
289
322
  */
323
+ /**
324
+ * What to tell someone whose wait ran out — after asking whether the thing they
325
+ * would be resuming still exists.
326
+ *
327
+ * Three answers, because there are three states and only one of them is the one
328
+ * the message used to assume. Saying nothing about the cost is not an option
329
+ * either: the whole defect was a person spending an attempt they had been told
330
+ * was free, and a sentence that simply omits the price leaves them guessing at
331
+ * the same moment.
332
+ */
333
+ export async function resumeAdvice(client, commitmentHex) {
334
+ let live;
335
+ try {
336
+ live = await client.listEnrollments();
337
+ }
338
+ catch (err) {
339
+ // Unknown, said as unknown. Guessing "still live" is the bug being fixed;
340
+ // guessing "gone" would tell someone their attempt is spent when it may
341
+ // not be.
342
+ return (` Could not check whether it is still live: ${err instanceof Error ? err.message : String(err)}\n` +
343
+ ' Nothing has been changed. Run the command again when the server is reachable:\n' +
344
+ ' if the enrollment is still live it resumes at no further cost, and if it is\n' +
345
+ ' not, that run starts a new ceremony and costs another attempt.\n');
346
+ }
347
+ if (live.some((e) => e.commitment === commitmentHex)) {
348
+ return ' Run the same command again to resume it; this costs no further attempt.\n';
349
+ }
350
+ return (' This attempt is over: the enrollment is no longer live on the server, so it\n' +
351
+ ' cannot be resumed. Running the command again starts a NEW ceremony, which\n' +
352
+ ' costs another attempt.\n');
353
+ }
290
354
  async function keyAlreadySpokenFor(client, pubkey) {
291
355
  let devices;
292
356
  try {
@@ -87,13 +87,33 @@ export async function cmdSync(config, opts) {
87
87
  // this machine may join, and a scary line about an unrelated call would
88
88
  // compete with the instructions below.
89
89
  }
90
- out(' On a machine that already has the team, run:\n\n');
91
- // The real command, with the team quoted for a shell. A placeholder makes
92
- // the reader do the substitution, and the whole point of this arc is to stop
93
- // carrying values by hand between machines — an instruction that ends in
94
- // `<team>` hands the work straight back (raised in review).
95
- out(` agmsg-cloud approve ${shellArg(opts.team)}\n\n`);
96
- out(' Compare the eight digits on both screens before answering there.\n\n');
90
+ // PROSE, NOT A BLOCK TO COPY — and what changes is what this command claims
91
+ // at this moment, not what it knows (#307).
92
+ //
93
+ // Nothing here has established that the team exists, and nothing can. The
94
+ // local store is what `connect` and `fetch` fail closed on, and a second
95
+ // machine does not have one — that absence is the premise of `sync`. The name
96
+ // does not decide it either: `agmsg_validate_team_name` bars `.` `..`, `/`,
97
+ // `\`, a leading `-` and control characters, so `<team>` is a VALID team name.
98
+ // The ceremony below is scoped to the org rather than the team (#148). The one
99
+ // remaining source, asking the service by name, answers nothing for every team
100
+ // today (#250).
101
+ //
102
+ // So this is not a fix, and the issue stays open. What it removes is a
103
+ // misreading: an indented, copyable command reads as "this is the step", and
104
+ // when the person had not filled the placeholder it put a literal `'<team>'`
105
+ // on the other machine, quoted and ready to paste. As a sentence it reads as
106
+ // what it is — something to ask for, on a machine that has the team, which is
107
+ // a condition the reader can check and this process cannot.
108
+ //
109
+ // The command still goes through `shellArg`: the name reaching it is arbitrary
110
+ // text either way.
111
+ out(` Ask a machine that already has "${opts.team}" to run ` +
112
+ `\`agmsg-cloud approve ${shellArg(opts.team)}\`, and compare the eight digits on ` +
113
+ 'both screens before answering there.\n\n');
114
+ out(` This machine has not confirmed that "${opts.team}" is on the service — it has no\n` +
115
+ ' way to, before the steps below. If that machine has no such team, stop here and\n' +
116
+ ' connect it there first.\n\n');
97
117
  // Everything the ceremony guarantees happens inside here: the commitment is
98
118
  // pinned before anything opens, nothing is sealed or uploaded until the
99
119
  // digits match, and this side refuses a transcript it cannot verify.
@@ -101,7 +121,27 @@ export async function cmdSync(config, opts) {
101
121
  // one that knows there is nothing to do: fetch and pull follow immediately
102
122
  // below. The decision is made HERE, once, rather than inferred inside each
103
123
  // step (raised in review).
104
- await request(config, { label }, { nextStepsFromCaller: true });
124
+ const enrolled = await request(config, { label }, { nextStepsFromCaller: true });
125
+ // NOTHING BELOW CAN SUCCEED WITHOUT IT, so a failed ceremony ends the run
126
+ // here.
127
+ //
128
+ // It used to continue. `cmdRequest` reports failure by setting
129
+ // `process.exitCode` and returning normally — it does not throw — and this
130
+ // line ignored the return value, so `pull` ran next and resolved the team by
131
+ // name against an account this machine had not joined. What the operator was
132
+ // left holding was `no team named "<team>" in this organization`: true, and
133
+ // not the reason anything failed. The enrollment message had scrolled past
134
+ // two steps earlier.
135
+ //
136
+ // Nothing is printed here. `request` has already said what happened and what
137
+ // to do about it, in the words that fit the case it hit; a summary from this
138
+ // side would either repeat it or, worse, generalise over cases it cannot
139
+ // tell apart.
140
+ //
141
+ // Compared against the success value rather than the failure one, so a
142
+ // future outcome that is neither stops the run as well.
143
+ if (enrolled !== 'enrolled')
144
+ return;
105
145
  // PULL BEFORE FETCH, and the old order was the wrong way round.
106
146
  //
107
147
  // The comment that used to sit below said the messages "needed the key that
@@ -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
+ }
@@ -23,10 +23,79 @@ function fromEnv(env) {
23
23
  // `connect --preflight` is a dry run whose whole purpose is to be usable before
24
24
  // anything is set up, so it must not be refused for not being signed in.
25
25
  export function resolveScriptsDir(env = process.env) {
26
- return env.AGMSG_SCRIPTS_DIR ?? join(homedir(), '.agents', 'skills', 'agmsg', 'scripts');
26
+ return scriptsDirChoice(env).dir;
27
+ }
28
+ /**
29
+ * The resolution, with its PROVENANCE — because the path alone does not answer
30
+ * the question an operator is actually asking (issue 282).
31
+ *
32
+ * An empty value is treated as unset. `??` passes the empty string through, so
33
+ * `export AGMSG_SCRIPTS_DIR=` resolved to `""` and every script path became a
34
+ * bare filename — measured, not supposed:
35
+ *
36
+ * resolveScriptsDir({ AGMSG_SCRIPTS_DIR: '' }) -> ""
37
+ *
38
+ * That is the shape someone reaches for when they are trying to undo the
39
+ * override, and it left them further from the default rather than back at it.
40
+ */
41
+ export function scriptsDirChoice(env = process.env) {
42
+ const given = env.AGMSG_SCRIPTS_DIR;
43
+ if (given !== undefined && given !== '')
44
+ return { dir: given, from: 'env' };
45
+ return { dir: defaultScriptsDir(), from: 'default' };
46
+ }
47
+ /**
48
+ * The install used when `AGMSG_SCRIPTS_DIR` says nothing.
49
+ *
50
+ * Split out because it is also the answer to "what is the OTHER install"
51
+ * (issue 279), and a second copy of the path would be free to drift from the
52
+ * one the resolver uses — which is exactly the disagreement being reported.
53
+ *
54
+ * `home` is a PARAMETER, not `env.HOME`. The first version read
55
+ * `env.HOME ?? homedir()` and called it injectable-but-equivalent, on the
56
+ * grounds that `homedir()` reads `$HOME` on POSIX. That does not close: this
57
+ * CLI targets Windows, where a POSIX-compatible shell sets `HOME` to something
58
+ * like `/c/Users/x` while `homedir()` returns the native path — so the
59
+ * "equivalent" version would have moved the default install location for every
60
+ * Git Bash user (raised in review).
61
+ *
62
+ * Production never passes it. A seam a caller must opt into cannot change what
63
+ * anyone runs; an environment variable that production already has is not a
64
+ * seam at all.
65
+ */
66
+ export function defaultScriptsDir(home = homedir()) {
67
+ return join(home, '.agents', 'skills', 'agmsg', 'scripts');
68
+ }
69
+ /**
70
+ * THE credential decision. Every caller that needs to know whether this machine
71
+ * can act — and with what — goes through this one function.
72
+ *
73
+ * It is a function rather than a rule written twice because the two readers
74
+ * disagreed once already: preflight asked `readCredential` alone and reported a
75
+ * headless machine carrying AGMSG_CLOUD_ENDPOINT + AGMSG_CLOUD_SECRET as not
76
+ * signed in, refusing a machine that could run the command — #222's defect
77
+ * facing the other way (raised in review). The first repair merely copied this
78
+ * expression into both readers, which is the same arrangement with the
79
+ * divergence deferred: equal today, free to drift on the next edit (raised in
80
+ * review again). One body, so there is nothing to keep in step.
81
+ */
82
+ function resolveCredential(env) {
83
+ return fromEnv(env) ?? readCredential(null, env);
84
+ }
85
+ /**
86
+ * Whether this machine has a credential to act with — `loadConfig`'s own
87
+ * question, asked of `loadConfig`'s own resolver, so preflight cannot hold a
88
+ * second opinion about a decision `loadConfig` already makes.
89
+ *
90
+ * A half-set pair still THROWS, exactly as it does for `loadConfig`: "endpoint
91
+ * set, secret missing" is a misconfiguration to report, not an absence to
92
+ * quietly call signed-out.
93
+ */
94
+ export function hasCredential(env = process.env) {
95
+ return resolveCredential(env) !== null;
27
96
  }
28
97
  export function loadConfig(env = process.env) {
29
- const resolved = fromEnv(env) ?? readCredential(null, env);
98
+ const resolved = resolveCredential(env);
30
99
  if (!resolved) {
31
100
  throw new Error('not signed in on this machine — run `agmsg-cloud login --endpoint <url>` first');
32
101
  }