agmsg-cloud 0.1.0-rc.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/api.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { isCanonicalAgeRecipient } from '@agmsg-cloud/sas-core';
2
+ import { DEFAULT_ENDPOINT } from './browser.js';
3
+ import { originOf } from './credentials.js';
4
+ import { shellArg } from './shell-arg.js';
2
5
  // Wire validation.
3
6
  //
4
7
  // Every response is checked here, at the boundary, rather than cast and trusted.
@@ -159,11 +162,81 @@ function requireTranscript(value) {
159
162
  throw new CourierError(200, 'malformed_enrollment');
160
163
  return value;
161
164
  }
165
+ /**
166
+ * What a 401 from this client means, and why it can be said this precisely.
167
+ *
168
+ * `loadConfig` refuses before any request when this machine has no credential
169
+ * ("not signed in on this machine — run `agmsg-cloud login …` first"), so a
170
+ * request from here always carried a credential. A 401 is therefore never "you
171
+ * are not signed in": it is "the credential this machine used was refused".
172
+ *
173
+ * Telling that person to sign in names something they have already done, which
174
+ * is what #347 reported: `recovery restore` printed `courier request failed:
175
+ * 401 unauthenticated` and stopped there. `index.ts` prints `err.message`
176
+ * verbatim, so this string is the terminal output.
177
+ *
178
+ * WHICH CREDENTIAL, AND FOR WHICH ENDPOINT, both change the answer — and the
179
+ * first version of this got both wrong by writing one sentence for every case
180
+ * (review P1 on this PR):
181
+ *
182
+ * - `loadConfig` prefers `AGMSG_CLOUD_ENDPOINT` + `AGMSG_CLOUD_SECRET` over
183
+ * anything on disk. Calling that "this machine's stored credential" names a
184
+ * file that does not exist, and `login` cannot fix it: it writes to disk,
185
+ * the environment keeps winning, and the next command sends the same
186
+ * refused secret. The remedy has to name the variables.
187
+ * - bare `login` goes to `DEFAULT_ENDPOINT` (`commands/login.ts`), not to the
188
+ * endpoint the refused credential belongs to. A message that offers
189
+ * "minted for a different endpoint" as a cause and then drops the endpoint
190
+ * from its own command contradicts itself.
191
+ *
192
+ * So the remedy is built where the source and the base URL are known, and every
193
+ * branch of it is a command that can actually be run.
194
+ *
195
+ * Bound to 401 alone. A 500 that advised re-authenticating would send people to
196
+ * `login` through an outage, where it is the one thing that cannot help.
197
+ *
198
+ * `login` itself does NOT go through this client — it talks to
199
+ * `/v1/device/activate` directly — so its own 401s cannot pick this up and tell
200
+ * someone mid-login to log in.
201
+ */
202
+ export function refusedCredentialRemedy(input) {
203
+ // `originOf` throws on a value that is not a URL. A client built by hand in a
204
+ // test can hold one, and a remedy is not worth turning a 401 into a TypeError.
205
+ let origin;
206
+ try {
207
+ origin = originOf(input.baseUrl);
208
+ }
209
+ catch {
210
+ origin = input.baseUrl;
211
+ }
212
+ if (input.from === 'env') {
213
+ return (` — the credential in AGMSG_CLOUD_SECRET was refused by ${origin}.` +
214
+ ` \`agmsg-cloud login\` will NOT help while that pair is set: it writes to disk and` +
215
+ ` the environment still wins. Update or unset AGMSG_CLOUD_ENDPOINT and AGMSG_CLOUD_SECRET`);
216
+ }
217
+ // The endpoint is carried into the command whenever it is not the one `login`
218
+ // would pick on its own, quoted because a self-hosted origin is not always
219
+ // one shell word (`http://[::1]:5180` is a glob to zsh).
220
+ const flag = origin === originOf(DEFAULT_ENDPOINT) ? '' : ` --endpoint ${shellArg(origin)}`;
221
+ if (input.from === 'stored') {
222
+ return (` — this machine's stored credential for ${origin} was refused (revoked, or the machine was` +
223
+ ` removed). Run \`agmsg-cloud login${flag}\` again`);
224
+ }
225
+ // Source unknown: say what was refused and for where, and claim nothing about
226
+ // where the credential came from. An unknown source is exactly the case the
227
+ // first version got wrong by assuming one.
228
+ return ` — the credential this machine used for ${origin} was refused`;
229
+ }
162
230
  export class CourierError extends Error {
163
231
  status;
164
232
  code;
165
- constructor(status, code) {
166
- super(`courier request failed: ${status} ${code}`);
233
+ constructor(status, code,
234
+ /** Appended after the status and the code; empty for every failure that has none. */
235
+ remedy = '') {
236
+ // The status and the code stay in front. They are what makes a report
237
+ // actionable by someone who is not the person at the terminal, and a
238
+ // remedy that replaced them would trade one audience for the other.
239
+ super(`courier request failed: ${status} ${code}${remedy}`);
167
240
  this.status = status;
168
241
  this.code = code;
169
242
  this.name = 'CourierError';
@@ -173,10 +246,17 @@ export class CourierClient {
173
246
  baseUrl;
174
247
  secret;
175
248
  fetchImpl;
249
+ /**
250
+ * Where the secret came from, when the caller knows. `loadConfig` always
251
+ * knows; a client built by hand in a test usually does not, and the remedy
252
+ * says less rather than guessing (see `refusedCredentialRemedy`).
253
+ */
254
+ credentialFrom;
176
255
  constructor(opts) {
177
256
  this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
178
257
  this.secret = opts.secret;
179
258
  this.fetchImpl = opts.fetchImpl ?? fetch;
259
+ this.credentialFrom = opts.credentialFrom;
180
260
  }
181
261
  async call(method, path, body) {
182
262
  const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
@@ -199,7 +279,9 @@ export class CourierClient {
199
279
  catch {
200
280
  // no/'' JSON body — keep the generic code, never echo the raw response
201
281
  }
202
- throw new CourierError(res.status, code);
282
+ throw new CourierError(res.status, code, res.status === 401
283
+ ? refusedCredentialRemedy({ baseUrl: this.baseUrl, from: this.credentialFrom })
284
+ : '');
203
285
  }
204
286
  return res.status === 200 || res.status === 201 ? res.json() : undefined;
205
287
  }
@@ -6,6 +6,7 @@ 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 { runVersion } from '../self-install.js';
9
10
  import { adviseOnSlotForSilentFiling, renderSlotAdvice } from '../slot-advice.js';
10
11
  import { setupCommand } from '../recovery-key.js';
11
12
  import { shellArg } from '../shell-arg.js';
@@ -149,18 +150,18 @@ export async function cmdConnect(config, opts) {
149
150
  `Already backed up: this team's keys were already in the vault at revision ${filing.revision},\n` +
150
151
  `unchanged, so nothing new was stored. ${teams} in this account's vault.`
151
152
  : 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.
153
+ ? // Account-wide, now that agmsg#650 makes the broad enumeration
154
+ // fail-closed. Passing the team here taught the old per-team recovery
155
+ // model at exactly the moment a second team was about to be added.
155
156
  `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
+ `Run \`${setupCommand()}\` to make one — it shows a recovery key\n` +
157
158
  "once. Until then the only copies of this team's keys are on the machines\n" +
158
159
  'that hold them.'
159
160
  : // Four ways of having no slot, four routes, and only one of them is
160
161
  // "type the key once and this stops happening". Asked rather than
161
162
  // written here, so a machine with no secure store is not sent after a
162
163
  // fix that does not exist for it.
163
- renderSlotAdvice(adviseOnSlotForSilentFiling(filing.slot, { team: opts.team })).trimEnd();
164
+ renderSlotAdvice(adviseOnSlotForSilentFiling(filing.slot)).trimEnd();
164
165
  process.stdout.write(`${backup}\n` +
165
166
  `\nTo add a second machine, run \`agmsg-cloud sync ${shellArg(opts.team)}\` there and answer here.\n` +
166
167
  // The subject is the operator, and it has to be. Something IS carried
@@ -351,8 +352,14 @@ async function forbiddenAdvice(client, team, teamId, ownPubkey) {
351
352
  }
352
353
  // Takes a scripts directory, not a CliConfig: this runs before a credential
353
354
  // exists, which is the point of a dry run.
354
- export function cmdConnectPreflight(scriptsDir) {
355
- const checks = preflight(scriptsDir, NEEDS.connect);
355
+ export function cmdConnectPreflight(scriptsDir,
356
+ /** Injected by the suite, so a check about tools does not spawn the tester's
357
+ * own `agmsg-cloud` and wait on it. */
358
+ run = runVersion) {
359
+ // THE REAL PROBE, HERE AND ONLY HERE (#341). Asking which `agmsg-cloud`
360
+ // answers means running it, and that belongs on the screen someone opens
361
+ // because they are confused — not in front of every connect that is working.
362
+ const checks = preflight(scriptsDir, NEEDS.connect, process.env, undefined, run);
356
363
  process.stdout.write(`\nChecking what connect needs:\n\n${formatPreflight(checks)}`);
357
364
  if (!checks.ok)
358
365
  throw new Error('prerequisites are missing');
@@ -1,7 +1,9 @@
1
1
  import { SECRET_RE } from '../machine-id.js';
2
2
  import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
3
- import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
3
+ import { credentialsForOrigin, isOrgAddress, originOf, writeCredential } from '../credentials.js';
4
4
  import { settleMachineName, validateMachineName } from '../machine-name.js';
5
+ import { resolveScriptsDir } from '../config.js';
6
+ import { teamsBoundTo } from '../oss.js';
5
7
  // `login` — the device-authorization flow, from this machine's side.
6
8
  //
7
9
  // It is the one subcommand that runs with no credential, so it takes its
@@ -112,10 +114,38 @@ export async function cmdLogin(opts) {
112
114
  // rc.2 checked first. #169 moved the prompt to the top of the command to
113
115
  // reuse the name on both screens; nothing in it was about ordering, so
114
116
  // nothing looked at the ordering.
115
- const stored = readCredential(originOf(endpoint), process.env);
117
+ //
118
+ // READ WITHOUT THE AMBIGUITY GUARD, and that is #399's own repair. This used
119
+ // to be `readCredential(originOf(endpoint))`, which fails closed when a host
120
+ // holds more than one credential — the exact state an rc.8 file is in, and
121
+ // the exact state this command exists to end. It threw here, before the
122
+ // grant, before the warning, before the replacing write, and told the person
123
+ // to run `logout` first. So "login replaces what is stored" held only for the
124
+ // single slots the new writer produces, and not for the machines already
125
+ // piled up. The guard was closed against its own remedy.
126
+ //
127
+ // Every other command keeps that guard. Acting on an ambiguous host is the
128
+ // defect; the one command about to collapse the ambiguity is the exemption.
129
+ const existing = credentialsForOrigin(originOf(endpoint), process.env);
130
+ // Only a host with exactly ONE credential can be re-activated: with two there
131
+ // is no answer to "which secret is this machine's", which is what made the
132
+ // reader throw in the first place. Two means a fresh grant, and the warning
133
+ // below says both are going.
134
+ const stored = existing.length === 1 ? existing[0] : null;
116
135
  if (stored && (asked === undefined || stored.machineName === asked)) {
117
136
  const res = await post('/v1/device/activate', {}, stored.secret);
118
137
  if (res.ok) {
138
+ // SAID THE WAY THE SUCCESS PATH SAYS IT. This line used to end with
139
+ // `in organization "<org address>"`, which put a server-minted address
140
+ // where the sentence had promised a name — the surrounding quotes make
141
+ // it read as one, and #329 begins with a reader asking what the value
142
+ // was. The path below that actually signs a machine in prints
143
+ // `Signed in as machine "<name>".` and no org, so the two answers to
144
+ // "am I signed in" now agree rather than differing by an identifier
145
+ // only one of them ever had.
146
+ //
147
+ // Not lost: `agmsg-cloud whoami` exists to answer which account this
148
+ // machine is in, and reads the same stored credential.
119
149
  out(`Already signed in as machine "${stored.machineName}".\n`);
120
150
  return;
121
151
  }
@@ -128,6 +158,93 @@ export async function cmdLogin(opts) {
128
158
  throw new Error(`a credential for ${originOf(endpoint)} is stored but could not be activated (${res.status} ${code}) — not starting a new login on top of it`);
129
159
  }
130
160
  }
161
+ // WHAT THIS RUN IS ABOUT TO DISPLACE, said before it happens (#399).
162
+ //
163
+ // Reaching this line with something stored means a fresh grant is opening on
164
+ // a host that already holds a credential, and a login now keeps ONE per
165
+ // service — so the stored one is going. The ruling is "delete the existing
166
+ // one", and the risk the ruling does not cover is doing that silently: a
167
+ // replace that drops a working setup without naming it is worse than a
168
+ // refusal that names it.
169
+ //
170
+ // SAID, NOT ASKED. A prompt is the obvious answer and it is the wrong one
171
+ // here: this command is handed to an AGENT by the onboarding screens, and an
172
+ // interactive confirm stalls a non-interactive caller with nobody to answer
173
+ // it. A `--force` flag has the mirror problem — the default path stays silent
174
+ // and the flag is the thing nobody types. So the disclosure goes in the
175
+ // output, at the point where there is still time to act on it: what follows
176
+ // is a browser round-trip that only completes when a person approves it, so
177
+ // reading this and stopping costs nothing.
178
+ //
179
+ // The second paragraph is the part that is not obvious. `logout` documents
180
+ // that it keeps the device key and the local team keys, and that holds here —
181
+ // history is not at stake. What IS at stake is subtler: a team's remote
182
+ // binding records the team id and the server instance, NOT which credential
183
+ // connected it. After this run, `readCredential(origin)` answers with the new
184
+ // org for a team that was connected under the old one — same host, different
185
+ // account, binding unchanged. Until now that was masked by the pile-up (both
186
+ // slots existed, so the resolver threw); with one credential per service there
187
+ // is nothing left to throw, so it has to be said.
188
+ // NO ORG ADDRESS ON THIS SCREEN, and the suite pins that. #329 took
189
+ // `stored.org` off the terminal here for a reason that applies twice over to a
190
+ // warning: it is a server-minted `org_<uuid>`, and dressing it as a name is
191
+ // how a reader ends up asking what the value was. A sentence about what you
192
+ // are losing is the worst place to put an identifier nobody can act on. The
193
+ // machine name is a name someone chose, and `whoami` is the command whose
194
+ // whole output is the answer to which account this is.
195
+ if (existing.length > 0) {
196
+ const origin = originOf(endpoint);
197
+ const names = existing.map((c) => `"${c.machineName}"`).join(', ');
198
+ out(existing.length === 1
199
+ ? `\nThis machine is already signed in to ${origin} as ${names}.\n`
200
+ : `\nThis machine holds ${existing.length} credentials for ${origin}, as ${names}, which an older agmsg-cloud allowed.\n`);
201
+ out(`Signing in again REPLACES ${existing.length === 1 ? 'that credential' : 'all of them'} — one per service is all this machine keeps.\n`);
202
+ // NAMED, not described. The decision recorded on #399 is that a
203
+ // replacement says which teams it lands on, and a general sentence about
204
+ // "teams under the current account" is not that — it also is not TRUE, in
205
+ // the precise sense: a binding records the host it was made against and no
206
+ // org at all, so "the outgoing account's teams" is a set this machine
207
+ // cannot compute. What it can compute is the teams bound to this SERVICE,
208
+ // which is the honest superset, and the wording says which one it is
209
+ // rather than implying the narrower claim.
210
+ let teams = null;
211
+ try {
212
+ const lookup = opts.teamsForOrigin ?? ((o) => teamsBoundTo(resolveScriptsDir(process.env), o));
213
+ teams = await lookup(origin);
214
+ }
215
+ catch {
216
+ // A sign-in must not fail because the team store could not be read — this
217
+ // command runs on machines with no teams and can run with no OSS install
218
+ // at all. But "could not read" is reported as itself: silently printing
219
+ // nothing would say "no teams are affected", which is the collapse of
220
+ // unknown into absent that this repository keeps paying for.
221
+ teams = null;
222
+ }
223
+ if (teams === null) {
224
+ out(`This machine's team list could not be read, so the teams this affects are not known here.\n`);
225
+ }
226
+ else {
227
+ // THREE STATES, not two, and the third is why `teamsBoundTo` returns two
228
+ // lists. A binding whose endpoint is missing or unparseable is neither in
229
+ // nor out: it may be bound to this host and this machine cannot tell. The
230
+ // first version dropped those silently, so a store with one unreadable
231
+ // binding printed "No team is bound", asserting an absence it had not
232
+ // measured — inside the change whose whole subject is not doing that.
233
+ if (teams.matched.length > 0) {
234
+ out(`Bound to ${origin}, and so affected: ${teams.matched.join(', ')}.\n`);
235
+ }
236
+ else if (teams.unreadable.length === 0) {
237
+ out(`No team on this machine is bound to ${origin}.\n`);
238
+ }
239
+ if (teams.unreadable.length > 0) {
240
+ out(`These teams could not be placed, so they may be affected too: ${teams.unreadable.join(', ')}.\n`);
241
+ }
242
+ }
243
+ out(`Those teams keep their keys and their history, but cloud commands for them will run as\n`);
244
+ out(`the new account. Reaching them as before means signing back in to the old one — run\n`);
245
+ out(`agmsg-cloud whoami first if you need to know which it is.\n`);
246
+ out(`Stop here if that is not what you meant.\n`);
247
+ }
131
248
  // Only now is the name needed: this run is opening a grant, so there really
132
249
  // is a machine to name.
133
250
  //
@@ -41,6 +41,43 @@ export function logoutEndpoint(argv) {
41
41
  }
42
42
  return value;
43
43
  }
44
+ /**
45
+ * What was signed out, in words the person who typed the command has.
46
+ *
47
+ * THIS LINE USED TO CARRY THE ORG ADDRESS, and the first thing its reader did
48
+ * with it was ask what it was (#329). The shape it came out in, with the
49
+ * machine's name replaced — this file ships to a public registry:
50
+ *
51
+ * Signed out machine "laptop" from https://api.agmsg.cloud (org org_019fea9f-…).
52
+ * Signed out machine "laptop" from https://api.agmsg.cloud (org org_019ff2a4-…).
53
+ *
54
+ * The rule from #275 is that a printed line earns its place by saying what
55
+ * happened, what to do next, or what is now at risk. An org address does none
56
+ * of the three HERE: no subcommand of this CLI takes one — derived from the
57
+ * usage block in `index.ts`, where the only identifier-shaped arguments are
58
+ * `pull --team-id` and `approve [request-id]`. So there is nothing the reader
59
+ * can do with it, and it is not theirs to recognise either.
60
+ *
61
+ * BUT THE COUNT IS NOT DECORATION, and dropping the address without it would
62
+ * have been worse than leaving it. Two credentials for two orgs on one host
63
+ * print the SAME machine name against the SAME origin — the address was the
64
+ * only thing telling those two lines apart. Removed on its own, the output
65
+ * above becomes one sentence printed twice, which reads as a bug rather than
66
+ * as "two accounts went". The number carries that fact and needs no glossary.
67
+ *
68
+ * WHERE A READABLE ORG GOES WHEN THERE IS ONE. #210 / #216 are deciding what a
69
+ * person-facing org identifier looks like; this is the line it belongs on when
70
+ * they land. Named here so that arrives as an edit rather than as a rediscovery.
71
+ */
72
+ export function signedOutLine(removed, origin) {
73
+ // Distinct, because repeating one machine's name once per credential is the
74
+ // duplicate this function exists to avoid.
75
+ const names = [...new Set(removed.map((credential) => credential.machineName))].map((name) => `"${name}"`);
76
+ if (removed.length === 1)
77
+ return `Signed out machine ${names[0]} from ${origin}.\n`;
78
+ const machines = names.length === 1 ? `machine ${names[0]}` : `machines ${names.join(', ')}`;
79
+ return `Signed out ${removed.length} sign-ins from ${origin} (${machines}).\n`;
80
+ }
44
81
  export function cmdLogout(opts = {}) {
45
82
  const env = opts.env ?? process.env;
46
83
  const out = opts.out ?? ((text) => void process.stdout.write(text));
@@ -58,13 +95,7 @@ export function cmdLogout(opts = {}) {
58
95
  out(`Cleared ${pending} pending enrollment record(s).\n`);
59
96
  return;
60
97
  }
61
- // Named, not counted. The person signing out is entitled to know which
62
- // identity just left this machine — one host can hold credentials for
63
- // several orgs, and "signed out" without saying whose is how someone
64
- // discovers later that the wrong one went.
65
- for (const credential of removed) {
66
- out(`Signed out machine "${credential.machineName}" from ${origin} (org ${credential.org}).\n`);
67
- }
98
+ out(signedOutLine(removed, origin));
68
99
  if (pending > 0)
69
100
  out(`Cleared ${pending} pending enrollment record(s).\n`);
70
101
  // Said because the omission is the surprising part: someone who ran this to
@@ -6,9 +6,110 @@ import { recordAuthenticatedDigest } from '../authenticated-digest.js';
6
6
  import { CeremonyError, renderSasBlock, sasFromOpenedTranscript, waitForStatus } from '../ceremony.js';
7
7
  import { generateDeviceIdentity, publicKeyOf } from '../oss.js';
8
8
  import { originOf } from '../credentials.js';
9
+ import { shellArg } from '../shell-arg.js';
9
10
  import { deviceIdentityPath } from '../paths.js';
10
11
  import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
11
12
  import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
13
+ /**
14
+ * The one thing a long wait cannot say for itself: that it is the design.
15
+ *
16
+ * `login` learned this in #279 — an agent held a URL and a code for 42 seconds
17
+ * waiting for a blocking command to return, because nothing told it the block
18
+ * WAS the mechanism. These waits are longer: `login` waits for a browser the
19
+ * operator already has open, and these wait for a person on another machine.
20
+ *
21
+ * Written once and used at every wait, so the three cannot drift into saying
22
+ * different things about the same behaviour. What may be added to it is what
23
+ * stopping costs, and that is NOT the same at every wait — see below.
24
+ */
25
+ function saysItBlocks() {
26
+ return ' This command will not return until then. That is expected, not a failure.\n';
27
+ }
28
+ /**
29
+ * What stopping costs, and it stops being free after the digits are shown.
30
+ *
31
+ * MEASURED, both of them, because the first version of this file asserted the
32
+ * resume in all three places and was wrong in one:
33
+ *
34
+ * interrupted before the answer the next run prints `resuming enrollment
35
+ * <id>` and continues the same ceremony.
36
+ * Budget unchanged: used:1 remaining:4.
37
+ *
38
+ * interrupted at the LAST wait, the row goes terminal while nothing is
39
+ * and the approver then answers watching. `GET /v1/enrollments` lists
40
+ * NONTERMINAL rows only, so the stored
41
+ * record matches nothing, the old attempt is
42
+ * closed as failed, and the next run starts a
43
+ * NEW ceremony. Measured: used:2 remaining:3
44
+ * — one of five, spent.
45
+ *
46
+ * So the resume line belongs at the first two waits and is false at the third.
47
+ * Saying it there would promise a cheap retry for the one interruption that
48
+ * costs something.
49
+ */
50
+ function stoppingIsCheap() {
51
+ return ' Leave it running — if you do stop it, run the same command again to resume.\n';
52
+ }
53
+ /**
54
+ * NOTHING HERE WARNS ABOUT A SECOND TERMINAL, and that is a refusal to guess
55
+ * rather than a finding.
56
+ *
57
+ * The console prompt says "do not start a second one in another terminal", and
58
+ * #344 asked whether that is true before repeating it. It was measured twice
59
+ * and the two measurements disagree:
60
+ *
61
+ * an attempt opened under a commitment no run could plan
62
+ * -> `consumeAttempt` refuses with `attempt_already_open`
63
+ * — but that is not the branch a second terminal takes,
64
+ * because `request` looks for a resumable record first.
65
+ * A rigged input, and it agreed with the warning.
66
+ *
67
+ * a first run interrupted mid-wait, then a second started
68
+ * -> once observed printing `resuming enrollment <id>`
69
+ * and carrying on at no cost; once observed starting a
70
+ * NEW ceremony instead. The two runs differed in their
71
+ * server double, and which difference decided it was
72
+ * not established.
73
+ *
74
+ * So this says nothing about second terminals in either direction. A warning
75
+ * has to be true to be worth a reader's attention, and so does a reassurance.
76
+ * What IS established is on `stoppingIsCheap` above: the cost of stopping, at
77
+ * each wait, measured.
78
+ */
79
+ /**
80
+ * The line that hands the next step to the other machine.
81
+ *
82
+ * TWO HALVES OF ONE DEFECT, and this is the second (#329). The first wait used
83
+ * to print the enrollment id — which nobody types anywhere — and then name
84
+ * `agmsg-cloud approve` WITHOUT the argument it needs. So the value the reader
85
+ * could not use was on screen and the one they had to run was incomplete. In
86
+ * the two-machine walk the agent on the other side filled the gap by guessing
87
+ * the team, and reported its guess beside the id with nothing to say which of
88
+ * the two mattered.
89
+ *
90
+ * The team is threaded in from `sync`, which is the command that knows it.
91
+ * `request <label>` run on its own does NOT: the requester never typed a team,
92
+ * and no credential on that machine names one. That case gets `<team>` — a
93
+ * placeholder, which cannot be pasted by mistake, and which is still more than
94
+ * the bare command said.
95
+ *
96
+ * Quoted through `shellArg` because it is an argument of a line written to be
97
+ * pasted, on the same reasoning as every other printed command here.
98
+ *
99
+ * TWO BRANCHES RATHER THAN ONE ARGUMENT, and the sentence is repeated on
100
+ * purpose. Building `<team>`-or-quoted-team into a local first and
101
+ * interpolating that reads better and is what this function did until
102
+ * `check-printed-commands` refused it: the value at the point of use is not the
103
+ * value the declaration shows, so "it went through shellArg in one branch" is a
104
+ * claim the guard cannot verify. It is right to refuse it — that is #152's
105
+ * shape — so each literal carries a value that is safe where it stands.
106
+ */
107
+ export function approveOnTheOtherMachine(team) {
108
+ const lead = ' The next move is on the other machine, where someone runs ';
109
+ return team === undefined
110
+ ? `${lead}\`agmsg-cloud approve <team>\`.\n`
111
+ : `${lead}\`agmsg-cloud approve ${shellArg(team)}\`.\n`;
112
+ }
12
113
  export async function cmdRequest(config, args, deps = {}) {
13
114
  const out = deps.out ?? ((text) => void process.stdout.write(text));
14
115
  const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
@@ -172,21 +273,42 @@ export async function cmdRequest(config, args, deps = {}) {
172
273
  });
173
274
  requestId = created.request_id;
174
275
  attachRequestId({ serverOrigin: config.baseUrl, commitmentHex: record.commitmentHex, requestId }, env);
175
- out(`enrollment requested: ${requestId} (expires ${created.expires_at})\n`);
276
+ // THE ID IS NOT PRINTED, AND THE DEADLINE IS. #275's test, applied to the
277
+ // two halves of this line separately: the expiry says what is now at risk
278
+ // (this run has a window and it closes), and the request id says nothing
279
+ // the person at THIS terminal can use. `approve [request-id]` does take
280
+ // one — but `approve` runs on the OTHER machine, and the approver's own
281
+ // `watch` prints the id there already, inside the command to paste. So the
282
+ // value was shown to the one person in the ceremony who has no use for it.
283
+ //
284
+ // It is not lost: `attachRequestId` above wrote it to this machine's
285
+ // pending record a line earlier, which is where the resume path reads it
286
+ // from, and where it can be recovered if it is ever needed.
287
+ out(`enrollment requested (expires ${created.expires_at})\n`);
176
288
  }
177
289
  else {
178
- out(`resuming enrollment ${requestId}\n`);
290
+ // Same value, same reason — and saying WHICH enrollment is resumed matters
291
+ // less than saying it is resumed rather than started, because that is the
292
+ // fact §4.1 makes expensive to get wrong (a second ceremony spends another
293
+ // of five attempts).
294
+ out('resuming the enrollment this machine already has open\n');
179
295
  }
180
296
  const done = () => clearRecord({ role: 'requester', serverOrigin: config.baseUrl, key: record.commitmentHex }, env);
181
297
  try {
182
- out('waiting for an approver to commit...\n');
298
+ out('\nwaiting for an approver to commit...\n');
299
+ out(approveOnTheOtherMachine(args.team));
300
+ out(saysItBlocks());
301
+ out(stoppingIsCheap());
183
302
  await waitForStatus(client, requestId, 'both_committed', deps.waitOptions);
184
303
  // Safe to open now: the approver's contribution is fixed and cannot change.
185
304
  await client.submitOpening(requestId, {
186
305
  device_pubkey: pubkey,
187
306
  opening_nonce: record.nonceHex,
188
307
  });
189
- out('waiting for the approver to open...\n');
308
+ out('\nwaiting for the approver to open...\n');
309
+ out(' Nothing to do here yet — the digits to compare appear when this returns.\n');
310
+ out(saysItBlocks());
311
+ out(stoppingIsCheap());
190
312
  const opened = await waitForStatus(client, requestId, 'opened', deps.waitOptions);
191
313
  // Derived here, from the two opened nonces. Nothing displayed below came
192
314
  // from the server as a code.
@@ -205,6 +327,22 @@ export async function cmdRequest(config, args, deps = {}) {
205
327
  // requirement that the requester counts failed and incomplete attempts, and
206
328
  // warns from the second failure, did nothing on this side. Wait for the
207
329
  // server to say which way it went.
330
+ // THE THIRD WAIT, and it said nothing at all — worse than the two the issue
331
+ // named, and the longest silence in the run.
332
+ //
333
+ // No line of its own, unlike the other two. What a reader has to do here is
334
+ // already on the screen directly above: `renderSasBlock` says the approving
335
+ // machine shows eight digits too, that they should check they are the same,
336
+ // and what each answer means. A copy of that here is the repetition #195
337
+ // removed from this very screen — a second copy of the one line that has to
338
+ // be read is how a reader learns to skim it. What was missing was not the
339
+ // instruction. It was that this blocks.
340
+ out('\nwaiting for the approver to answer...\n');
341
+ out(saysItBlocks());
342
+ // NOT `stoppingIsCheap()`. This is the one interruption that costs an
343
+ // attempt — see the note on that function for the measurement.
344
+ out(' Stopping here is the one that costs: if they answer while nothing is\n');
345
+ out(' watching, the next run starts over and spends one of five attempts.\n');
208
346
  const settled = await waitForStatus(client, requestId, 'consumed', deps.waitOptions);
209
347
  // What the eight digits authenticated, kept for `fetch`.
210
348
  //
@@ -32,6 +32,39 @@ export async function cmdSync(config, opts) {
32
32
  const request = d.request ?? cmdRequest;
33
33
  const fetch = d.fetch ?? cmdFetch;
34
34
  const pull = d.pull ?? cmdPull;
35
+ const client = new CourierClient(config);
36
+ // BEFORE THE CEREMONY, because `request` spends one of five attempts before
37
+ // it posts the enrollment. The ceremony is org-scoped and cannot discover
38
+ // that the named team belongs to another org; leaving this to `pull` made a
39
+ // wrong-account run finish the human comparison, spend an attempt, and only
40
+ // then say the team was absent (#345).
41
+ //
42
+ // This lookup is scoped by the current credential on the control plane. A
43
+ // failure to answer is not absence, and ambiguity is not resolved here:
44
+ // neither state is permission to spend a ceremony on a guessed team.
45
+ let matches;
46
+ try {
47
+ matches = opts.teamId === undefined
48
+ ? await client.resolveTeamByName(opts.team)
49
+ : (await client.listTeams()).filter((team) => team.teamId === opts.teamId);
50
+ }
51
+ catch (err) {
52
+ out(`Not started: this account's organization could not be checked for "${opts.team}": ` +
53
+ `${err instanceof Error ? err.message : String(err)}\n` +
54
+ 'No enrollment was started, so this costs none of your attempts.\n');
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ if (matches.length !== 1) {
59
+ out(matches.length === 0
60
+ ? `Not started: this account's organization has no team named "${opts.team}".\n`
61
+ : `Not started: this account's organization has more than one team named "${opts.team}".\n`);
62
+ out('Check `agmsg-cloud whoami`, then sign in to the organization that holds the team.\n');
63
+ out('No enrollment was started, so this costs none of your attempts.\n');
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ const resolvedTeamId = matches[0].teamId;
35
68
  // The approver sees this, and they are looking for a machine they recognise.
36
69
  // A hostname is what a person calls their laptop; a uuid is what they read
37
70
  // aloud wrongly.
@@ -62,7 +95,7 @@ export async function cmdSync(config, opts) {
62
95
  // join; failing the join over a failed courtesy check would be the refusal
63
96
  // this deliberately is not.
64
97
  try {
65
- const existing = await new CourierClient(config).listDevices();
98
+ const existing = await client.listDevices();
66
99
  // THIS MACHINE'S OWN ROW IS NOT A CLASH WITH ITSELF.
67
100
  //
68
101
  // The comparison was on label alone, so a machine already on the account —
@@ -121,7 +154,18 @@ export async function cmdSync(config, opts) {
121
154
  // one that knows there is nothing to do: fetch and pull follow immediately
122
155
  // below. The decision is made HERE, once, rather than inferred inside each
123
156
  // step (raised in review).
124
- const enrolled = await request(config, { label }, { nextStepsFromCaller: true });
157
+ // THE TEAM IS PASSED, and this is the only place that has it. `request` waits
158
+ // for someone on the key-holding machine to run `agmsg-cloud approve <team>`,
159
+ // and it named that command without its argument — so the person on the other
160
+ // end had to supply a value nothing on their screen had given them (#329). A
161
+ // `request <label>` typed on its own genuinely does not know the team and
162
+ // prints a placeholder; this path does know, so it says which one.
163
+ //
164
+ // Separate from `nextStepsFromCaller` below. That decides who tells the
165
+ // OPERATOR OF THIS MACHINE what to do when the ceremony ends, and the answer
166
+ // is this command. The line above is about the OTHER machine, whose next step
167
+ // is the same either way.
168
+ const enrolled = await request(config, { label, team: opts.team }, { nextStepsFromCaller: true });
125
169
  // NOTHING BELOW CAN SUCCEED WITHOUT IT, so a failed ceremony ends the run
126
170
  // here.
127
171
  //
@@ -162,9 +206,11 @@ export async function cmdSync(config, opts) {
162
206
  // It stayed hidden because the second machine in testing pointed at the first
163
207
  // machine's install, where the team was already present — so the second
164
208
  // machine's path had never actually been walked.
165
- await pull(config, opts.teamId === undefined
166
- ? { team: opts.team, nextStepsFromCaller: true }
167
- : { team: opts.team, teamId: opts.teamId, nextStepsFromCaller: true });
209
+ await pull(config, {
210
+ team: opts.team,
211
+ teamId: resolvedTeamId,
212
+ nextStepsFromCaller: true,
213
+ });
168
214
  // And now the key, which opens what arrived above. Only reachable once the
169
215
  // server says the ceremony was approved — `request` waits for that, and
170
216
  // throws otherwise. The bundle is checked against the snapshot those digits