agmsg-cloud 0.1.0-rc.5 → 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.
package/dist/src/api.js CHANGED
@@ -324,7 +324,26 @@ export class CourierClient {
324
324
  // machine resolves the name here and pulls by id, rather than handing a
325
325
  // name to the data plane.
326
326
  async resolveTeamByName(name) {
327
- const out = await this.call('GET', `/v1/edge/teams?name=${encodeURIComponent(name)}`);
327
+ return this.teams(`/v1/edge/teams?name=${encodeURIComponent(name)}`);
328
+ }
329
+ /**
330
+ * Every team in the caller's organization.
331
+ *
332
+ * The same endpoint without the `name` filter, so it discloses nothing the
333
+ * lookup above does not: the control plane scopes both to the caller's org
334
+ * (`WHERE org_id = $1`), which is the property that lets the name lookup live
335
+ * here instead of on the data plane.
336
+ *
337
+ * It exists so a failed lookup can tell two situations apart. "No team by
338
+ * that name" is the same answer whether nothing has ever been connected to
339
+ * this organization or the operator is signed in to the wrong one, and those
340
+ * need opposite next steps.
341
+ */
342
+ async listTeams() {
343
+ return this.teams('/v1/edge/teams');
344
+ }
345
+ async teams(path) {
346
+ const out = await this.call('GET', path);
328
347
  // A response this cannot read is not an answer about which teams exist,
329
348
  // and it must not be turned into one. `teams` must be PRESENT and an
330
349
  // array, with an empty array the only way to say "none" — the same rule
@@ -6,7 +6,10 @@ import { originOf, readCredential } from '../credentials.js';
6
6
  import { generateDeviceIdentity, publicKeyOf, remoteTeamId } from '../oss.js';
7
7
  import { deviceIdentityPath } from '../paths.js';
8
8
  import { NEEDS, ensurePreflight, formatPreflight, preflight } from '../preflight.js';
9
+ import { adviseOnSlotForSilentFiling, renderSlotAdvice } from '../slot-advice.js';
10
+ import { setupCommand } from '../recovery-key.js';
9
11
  import { shellArg } from '../shell-arg.js';
12
+ import { fileTeamInVaultSilently } from '../vault-filing.js';
10
13
  function runInherit(command, args) {
11
14
  return new Promise((resolve, reject) => {
12
15
  // stdio inherited: `remote.sh connect` prints its own progress, and
@@ -120,9 +123,45 @@ export async function cmdConnect(config, opts) {
120
123
  // opening it — which needs the recovery key nobody has typed here. A
121
124
  // sentence about where the only copy is would be a guess whichever way it
122
125
  // went. The condition is named instead, and it is true in every case.
123
- process.stdout.write(`\nBack these keys up: \`agmsg-cloud recovery setup ${shellArg(opts.team)}\`.\n` +
124
- 'Until a team is in your recovery vault, the only copies of its keys are on\n' +
125
- 'the machines that hold them; once it is there, the recovery key opens it again.\n' +
126
+ // Filed here rather than left to an instruction. The key was made moments
127
+ // ago by the step above, and until it is in the vault the only copies are on
128
+ // the machines holding them — a window that used to last until someone acted
129
+ // on a line that had already scrolled away (#181).
130
+ //
131
+ // Silently or not at all. `connect` does not ask for a recovery key: a
132
+ // command that stopped to collect one would be a ceremony, and the person
133
+ // running it did not come here for that. Both ways of being unable — no
134
+ // vault, no slot on this machine — fall back to naming the route, which is
135
+ // what this printed before.
136
+ //
137
+ // And the two are told apart, because "nothing happened, silently" is the
138
+ // outcome that leaves someone believing they are backed up.
139
+ const filing = await fileTeamInVaultSilently(config, new CourierClient(config), opts.team);
140
+ const teams = `${filing.filed ? filing.teamCount : 0} team${(filing.filed ? filing.teamCount : 0) === 1 ? '' : 's'}`;
141
+ const backup = filing.filed
142
+ ? filing.wrote
143
+ ? `Backed up: revision ${filing.revision}, ${teams} in this account's vault.\n` +
144
+ 'Losing this machine no longer loses this team — the recovery key opens it again.'
145
+ : // Already there, with the same keys. Said as its own sentence rather
146
+ // than as a write, because claiming a revision this run did not produce
147
+ // is the same kind of false as claiming a backup that never happened —
148
+ // and a re-run of `connect` reaches here every time.
149
+ `Already backed up: this team's keys were already in the vault at revision ${filing.revision},\n` +
150
+ `unchanged, so nothing new was stored. ${teams} in this account's vault.`
151
+ : filing.reason === 'no-vault'
152
+ ? // NAMED, for the same reason as the four below: creating the vault and
153
+ // backing up THIS team is what the sentence above promises, and the
154
+ // broad form can refuse over a team that has nothing to do with it.
155
+ `Not backed up: this account has no recovery vault yet.\n` +
156
+ `Run \`${setupCommand(opts.team)}\` to make one — it shows a recovery key\n` +
157
+ "once. Until then the only copies of this team's keys are on the machines\n" +
158
+ 'that hold them.'
159
+ : // Four ways of having no slot, four routes, and only one of them is
160
+ // "type the key once and this stops happening". Asked rather than
161
+ // written here, so a machine with no secure store is not sent after a
162
+ // fix that does not exist for it.
163
+ renderSlotAdvice(adviseOnSlotForSilentFiling(filing.slot, { team: opts.team })).trimEnd();
164
+ process.stdout.write(`${backup}\n` +
126
165
  `\nTo add a second machine, run \`agmsg-cloud sync ${shellArg(opts.team)}\` there and answer here.\n` +
127
166
  // The subject is the operator, and it has to be. Something IS carried
128
167
  // between the machines after the yes — the sealed bundle, which is what
@@ -132,11 +171,33 @@ export async function cmdConnect(config, opts) {
132
171
  'You do not carry any value between the machines yourself: both screens show\n' +
133
172
  'eight digits and you check they are the same.\n');
134
173
  if (code !== 0) {
135
- // Now that it is true, the operator can be told the failure needs nothing
136
- // further — but only about the case where that is so. The error itself is
137
- // still theirs to read; this does not summarise it.
138
- process.stdout.write('Read the error above: if it was a uniqueness conflict, the team was already connected\n' +
139
- 'and there is nothing else to do.\n');
174
+ // THE CASE THIS USED TO NAME NO LONGER REACHES HERE.
175
+ //
176
+ // It said: "if it was a uniqueness conflict, the team was already connected
177
+ // and there is nothing else to do". That conflict was the OSS step refusing
178
+ // a second connect — `a team_id registers once ... not a transient error to
179
+ // retry` — and it is gone. Searched at the pin this repo carries
180
+ // (`92ddbe5`): no match in `scripts/`. It was removed by `8c2bd4b`, "a
181
+ // connect that already registered resumes instead of dead-ending", which is
182
+ // an ancestor of that pin. The script now adopts the registration and
183
+ // carries on, so a duplicate does not end non-zero at all.
184
+ //
185
+ // Leaving the sentence would have been worse than saying nothing: it asks
186
+ // the reader to look for a message that cannot appear, and the one it
187
+ // describes was the reassuring case, so a real failure would be read as the
188
+ // harmless one.
189
+ //
190
+ // Said as a property of this command rather than as a diagnosis of an error
191
+ // this side has not read — and only the part that was checked. Re-running
192
+ // adds nothing: the OSS step adopts a registration that is already there,
193
+ // and `bootstrapDevice` returns the existing device when the key matches
194
+ // (`devices.ts:309`). It is NOT "it will work": this same machine can be
195
+ // refused for reasons a second run cannot change, and the key having
196
+ // changed is refused rather than registered twice — which is why the
197
+ // sentence stops at "adds nothing".
198
+ process.stdout.write('Read the error above. Running connect again adds nothing that is already\n' +
199
+ 'there: a registered team is adopted rather than refused, and this machine\n' +
200
+ 'is registered once. It is worth doing if the error looks transient.\n');
140
201
  }
141
202
  }
142
203
  async function registerThisMachine(config, opts, machineName) {
@@ -192,7 +253,12 @@ async function registerThisMachine(config, opts, machineName) {
192
253
  // that is not the claimant never becomes one. Connect from here returns to
193
254
  // this same 403 forever.
194
255
  if (err instanceof CourierError && err.status === 403) {
195
- throw new Error(await forbiddenAdvice(client, opts.team, pubkey));
256
+ // The id goes in, so the message can PRINT it. The release is asked for
257
+ // by team id, and the console shows teams by slug (`Connections.tsx:154`)
258
+ // — telling someone to go and find it is the placeholder problem this
259
+ // file already refuses elsewhere. It is in hand here; it is the value
260
+ // `bootstrapDevice` was just called with.
261
+ throw new Error(await forbiddenAdvice(client, opts.team, teamId, pubkey));
196
262
  }
197
263
  throw err;
198
264
  }
@@ -205,19 +271,30 @@ async function registerThisMachine(config, opts, machineName) {
205
271
  * account have a machine that could approve? Enrollment needs an approver, and
206
272
  * an approver is a registered device.
207
273
  *
208
- * The case with no approver is a real one and it has no way out today. That is
209
- * written plainly instead of offering a command that will wait forever, because
210
- * a person who is told there is no recovery stops spending time looking for one.
274
+ * The case with no approver has no way out THIS MACHINE CAN TAKE, which is not
275
+ * the same as no way out — and the message used to say the second one.
276
+ *
277
+ * There is a route: releasing the team's claim (`app/scripts/release-claim.ts`)
278
+ * frees the id, and a later connect claims it again. It is run by whoever
279
+ * operates the service, not by the person holding this terminal, and that is
280
+ * the decision rather than an omission — releases are rare enough to be worth a
281
+ * human checkpoint each time, and a self-service release would need an
282
+ * owner-authenticated mutation surface that does not exist. Both are recorded
283
+ * on #71, with the condition that would reopen the question.
284
+ *
285
+ * So the sentence to print is what to ASK FOR, not a command to run. A person
286
+ * told there is no recovery stops looking for one — which is right when there
287
+ * is none and expensive when there is, and #143 is the case where there was.
211
288
  *
212
- * WHY NOT BUILD A WAY BACK. Reassigning a team's claim to a live capability
213
- * would fix it, and it is a change nobody should make inside a bug fix: it
214
- * decides who may take over a team whose first machine is gone, and the obvious
215
- * answer — any live capability in the org — turns a revoked machine into "any
216
- * machine may become device-0 for that team", which is part of what revoking
217
- * was for. Named here so the next person who finds this message unhelpful
218
- * reaches the same question rather than the same guess.
289
+ * WHY NOT BUILD A WAY BACK HERE. Reassigning a team's claim to a live
290
+ * capability would also fix it, and it is a change nobody should make inside a
291
+ * bug fix: it decides who may take over a team whose first machine is gone, and
292
+ * the obvious answer — any live capability in the org — turns a revoked machine
293
+ * into "any machine may become device-0 for that team", which is part of what
294
+ * revoking was for. Named here so the next person who finds this message
295
+ * unhelpful reaches the same question rather than the same guess.
219
296
  */
220
- async function forbiddenAdvice(client, team, ownPubkey) {
297
+ async function forbiddenAdvice(client, team, teamId, ownPubkey) {
221
298
  const head = `the server would not register this machine for '${team}'.\n\n` +
222
299
  'It is not the machine that first connected this team, and only that one can register\n' +
223
300
  'without an approver. That is recorded once and never moves, so running connect again\n' +
@@ -253,10 +330,19 @@ async function forbiddenAdvice(client, team, ownPubkey) {
253
330
  if (approvers === 0) {
254
331
  return (head +
255
332
  'There is also no OTHER machine on this account that could approve a request — and a\n' +
256
- 'machine cannot approve its own — so joining is not open either. THERE IS NO WAY TO\n' +
257
- 'RECOVER THIS TEAM FROM THIS ACCOUNT TODAY: the machine that claimed it can no longer\n' +
258
- 'register, and enrollment needs an approver that does not exist. Reported rather than\n' +
259
- 'dressed up as a command, so you do not spend the evening on one that cannot work.');
333
+ 'machine cannot approve its own — so joining is not open either. NOTHING YOU CAN RUN\n' +
334
+ 'FROM HERE RECOVERS THIS TEAM: the machine that claimed it can no longer register, and\n' +
335
+ 'enrollment needs an approver that does not exist.\n\n' +
336
+ 'What does recover it is releasing the team, which frees the id so a connect can claim\n' +
337
+ 'it again. That is run by whoever operates this service, not from this terminal. Ask\n' +
338
+ 'them to release this team, and say why:\n\n' +
339
+ ` team ${team}\n` +
340
+ ` id ${teamId}\n\n` +
341
+ 'Releasing DELETES EVERYTHING THIS SERVICE HOLDS FOR THE TEAM — its messages, its\n' +
342
+ 'member list and their agent registrations, and its policy and identity history. That\n' +
343
+ 'is the point of it: the id is freed for a fresh start, not handed over with its past.\n' +
344
+ 'Your machines stay signed in and nothing on them is touched; it is this team on the\n' +
345
+ 'service that goes. If any of it matters, say so before asking.');
260
346
  }
261
347
  return (head +
262
348
  'Join it from here instead — this account has another machine that can approve the\n' +
@@ -222,7 +222,25 @@ env = process.env) {
222
222
  // The choice is made from the VERIFIED matching set, never from the raw
223
223
  // queue: picking an unverified blob by position would not be identification.
224
224
  const { blob, file } = matches[0];
225
- await unlockBundle(config.scriptsDir, args.team, file, expected);
225
+ const unlockSaid = await unlockBundle(config.scriptsDir, args.team, file, expected);
226
+ // Relayed HERE, before the ack, because it is already true here.
227
+ //
228
+ // The script has finished: the team is unlocked on this machine and its
229
+ // sync engine is running under a pid the script verified. Printing it after
230
+ // `ackBlob` meant a failed ack threw that away — the local half had
231
+ // succeeded, the operator was shown only the error, and the machine they
232
+ // were about to debug was already working. That is this issue's own defect
233
+ // one step later (raised in review).
234
+ //
235
+ // NOT verbatim, and the difference is worth naming rather than glossing:
236
+ // `trimEnd()` drops trailing whitespace and a single '\n' is re-appended.
237
+ // The body is untouched; only the tail is normalised. A script that says
238
+ // nothing prints nothing, because a blank line would read as an empty
239
+ // answer (raised in review — the comment said "verbatim", which is wider
240
+ // than what this does).
241
+ const said = (unlockSaid ?? '').trimEnd();
242
+ if (said !== '')
243
+ process.stdout.write(said + '\n');
226
244
  await client.ackBlob(blob.id);
227
245
  // Consumed only now, and the order is load-bearing.
228
246
  //
@@ -238,7 +256,23 @@ env = process.env) {
238
256
  clearAuthenticatedDigest(originOf(config.baseUrl), consumed.requestId, config.secret, devicePubkey, env);
239
257
  const others = blobs.length - 1;
240
258
  const duplicates = matches.length - 1;
241
- process.stdout.write(`unlocked and acked the confirmed bundle` +
259
+ // What `remote.sh unlock` said — its body, with the trailing newline
260
+ // normalised to exactly one — and then what THIS command did.
261
+ //
262
+ // The script's line is the stronger one and it was being thrown away: it
263
+ // waits for the sync engine to report ready, checks the pidfile it recorded
264
+ // matches the process it started, checks that process is alive, and exits
265
+ // non-zero otherwise — so `engine running (pid N)` is a claim about a live
266
+ // pid, and it names the team.
267
+ //
268
+ // In its place this printed `unlocked and acked the confirmed bundle`,
269
+ // which names no object at all. Four lines later the operator was told to
270
+ // "unlock" — a DIFFERENT object, the team's history — with the same verb.
271
+ // Two objects, one word, and nothing on screen to tell them apart (#147).
272
+ //
273
+ // The ack is reported separately because it is this command's own act
274
+ // against the server, not something the script did.
275
+ process.stdout.write(`acked the confirmed bundle` +
242
276
  (others > 0 ? `; ${others} other bundle(s) left queued` : '') +
243
277
  (duplicates > 0 ? ` (${duplicates} of them carry the same digest)` : '') +
244
278
  '\n');
@@ -1,3 +1,4 @@
1
+ import { SECRET_RE } from '../machine-id.js';
1
2
  import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
2
3
  import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
3
4
  import { settleMachineName, validateMachineName } from '../machine-name.js';
@@ -13,7 +14,11 @@ import { settleMachineName, validateMachineName } from '../machine-name.js';
13
14
  // out of the capability URL's last path segment, so a response that does not
14
15
  // carry the expected shape is refused rather than stored: a wrong value here
15
16
  // would be sent as this machine's Bearer credential on every later command.
16
- const SECRET_RE = /^agsy_[a-f0-9]{8}_[A-Za-z0-9_-]{43}$/;
17
+ //
18
+ // Imported, not restated. `machine-id.ts` reads the prefix out of the same
19
+ // shape, and a second copy here would let the two drift: changing the mint in
20
+ // one place would leave the other answering about a format that no longer
21
+ // exists (raised in review).
17
22
  // The binary is `agmsg-cloud`, and the approval screen shows the name the
18
23
  // SERVER holds for this client id — so the id the CLI sends is what decides
19
24
  // whether the person sees "agmsg CLI" (a different program, which never does
@@ -58,6 +58,57 @@ export async function cmdPull(config, opts) {
58
58
  'it asks a machine that already has the team, and the two of you compare eight digits.\n');
59
59
  }
60
60
  }
61
+ /**
62
+ * The advice that follows "no team named X", chosen from what this
63
+ * organization actually holds.
64
+ *
65
+ * The observation and the advice are separated, because only the observation
66
+ * differs. **No answer this command can get establishes whether the team was
67
+ * connected anywhere** — the lookup is scoped to the caller's organization, so
68
+ * it cannot see the rest of the world at all:
69
+ *
70
+ * - A populated organization does not mean the team was connected elsewhere.
71
+ * An org holding `billing` and not `ops` is equally consistent with `ops`
72
+ * never having been connected.
73
+ * - An empty organization does not mean it was not. The case in #224 had it
74
+ * connected into a different org, and that leaves this one empty.
75
+ * - A list that could not be fetched says nothing in either direction.
76
+ *
77
+ * So the advice is one block, the same in all three, and names both
78
+ * possibilities as conditions. The original defect was a remedy stated as an
79
+ * instruction — "connect it from the machine that runs it" — which asserted the
80
+ * connect had not happened. Stating it unconditionally in ANY branch brings the
81
+ * defect back, which is why the regression pins the conditional form rather
82
+ * than the presence of new words.
83
+ *
84
+ * The list is printed in full. A "…and N more" would make the interesting case
85
+ * — the team IS here under a spelling the operator did not expect — the one
86
+ * most likely to be cut off, and an operator who cannot see it reads the
87
+ * message as saying it is not there. That is a deliberate trade against very
88
+ * large organizations, not an oversight.
89
+ */
90
+ const WHERE_TO_LOOK = 'Where the team stands outside this organization is not visible from here.\n' +
91
+ 'Check which account and organization this machine is signed in to, then:\n' +
92
+ ' - if another machine already connected it, sign in to the organization it\n' +
93
+ ' was connected into, or pass --team-id if you know the id;\n' +
94
+ ' - if it has not been connected anywhere yet, connect it from the machine\n' +
95
+ ' that runs it.';
96
+ async function whyNot(client, team) {
97
+ let teams;
98
+ try {
99
+ teams = await client.listTeams();
100
+ }
101
+ catch {
102
+ return `This machine could not list the organization's teams, so what it holds is unknown.\n\n${WHERE_TO_LOOK}`;
103
+ }
104
+ if (teams.length === 0) {
105
+ return `This organization holds no teams, so nothing has been connected into it.\n\n${WHERE_TO_LOOK}`;
106
+ }
107
+ const named = teams.map((t) => ` ${t.teamName ?? '(unnamed)'} ${t.teamId}`).join('\n');
108
+ return (`This organization holds ${teams.length === 1 ? '1 team' : `${teams.length} teams`}, and none of them is "${team}":\n\n` +
109
+ `${named}\n\n` +
110
+ `Check the spelling against that list.\n\n${WHERE_TO_LOOK}`);
111
+ }
61
112
  async function resolveTeamId(config, opts) {
62
113
  const client = opts.client ?? new CourierClient(config);
63
114
  const matches = await client.resolveTeamByName(opts.team);
@@ -65,8 +116,15 @@ async function resolveTeamId(config, opts) {
65
116
  // Named as what it is: this org has no such team. The alternative reading
66
117
  // — that the team exists under someone else — is not ours to confirm or
67
118
  // deny, and the answer is the same either way.
68
- throw new Error(`no team named "${opts.team}" in this organization.\n\n` +
69
- 'Connect it from the machine that runs it first, or pass --team-id if you have it.');
119
+ //
120
+ // What is ours to say is whether this organization holds ANY team, and the
121
+ // two cases need opposite moves. An empty organization means the connect
122
+ // has not happened; a populated one means it happened somewhere else, and
123
+ // "connect it from the machine that runs it" then points at work that is
124
+ // already done — which is what #224 reported. Asking the org for its own
125
+ // list adds no disclosure: it is the same org-scoped endpoint, without the
126
+ // filter.
127
+ throw new Error(`no team named "${opts.team}" in this organization.\n\n${await whyNot(client, opts.team)}`);
70
128
  }
71
129
  if (matches.length > 1) {
72
130
  // A name is not unique by construction, so this stops rather than picking.
@@ -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 {
@@ -101,7 +101,27 @@ export async function cmdSync(config, opts) {
101
101
  // one that knows there is nothing to do: fetch and pull follow immediately
102
102
  // below. The decision is made HERE, once, rather than inferred inside each
103
103
  // step (raised in review).
104
- await request(config, { label }, { nextStepsFromCaller: true });
104
+ const enrolled = await request(config, { label }, { nextStepsFromCaller: true });
105
+ // NOTHING BELOW CAN SUCCEED WITHOUT IT, so a failed ceremony ends the run
106
+ // here.
107
+ //
108
+ // It used to continue. `cmdRequest` reports failure by setting
109
+ // `process.exitCode` and returning normally — it does not throw — and this
110
+ // line ignored the return value, so `pull` ran next and resolved the team by
111
+ // name against an account this machine had not joined. What the operator was
112
+ // left holding was `no team named "<team>" in this organization`: true, and
113
+ // not the reason anything failed. The enrollment message had scrolled past
114
+ // two steps earlier.
115
+ //
116
+ // Nothing is printed here. `request` has already said what happened and what
117
+ // to do about it, in the words that fit the case it hit; a summary from this
118
+ // side would either repeat it or, worse, generalise over cases it cannot
119
+ // tell apart.
120
+ //
121
+ // Compared against the success value rather than the failure one, so a
122
+ // future outcome that is neither stops the run as well.
123
+ if (enrolled !== 'enrolled')
124
+ return;
105
125
  // PULL BEFORE FETCH, and the old order was the wrong way round.
106
126
  //
107
127
  // The comment that used to sit below said the messages "needed the key that