agmsg-cloud 0.1.0-rc.8 → 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,11 +114,39 @@ 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) {
119
- out(`Already signed in as machine "${stored.machineName}" in organization "${stored.org}".\n`);
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.
149
+ out(`Already signed in as machine "${stored.machineName}".\n`);
120
150
  return;
121
151
  }
122
152
  const code = await errorCode(res);
@@ -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,6 +6,7 @@ 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';
@@ -75,6 +76,40 @@ function stoppingIsCheap() {
75
76
  * What IS established is on `stoppingIsCheap` above: the cost of stopping, at
76
77
  * each wait, measured.
77
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
+ }
78
113
  export async function cmdRequest(config, args, deps = {}) {
79
114
  const out = deps.out ?? ((text) => void process.stdout.write(text));
80
115
  const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
@@ -238,15 +273,30 @@ export async function cmdRequest(config, args, deps = {}) {
238
273
  });
239
274
  requestId = created.request_id;
240
275
  attachRequestId({ serverOrigin: config.baseUrl, commitmentHex: record.commitmentHex, requestId }, env);
241
- 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`);
242
288
  }
243
289
  else {
244
- 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');
245
295
  }
246
296
  const done = () => clearRecord({ role: 'requester', serverOrigin: config.baseUrl, key: record.commitmentHex }, env);
247
297
  try {
248
298
  out('\nwaiting for an approver to commit...\n');
249
- out(' The next move is on the other machine, where someone runs `agmsg-cloud approve`.\n');
299
+ out(approveOnTheOtherMachine(args.team));
250
300
  out(saysItBlocks());
251
301
  out(stoppingIsCheap());
252
302
  await waitForStatus(client, requestId, 'both_committed', deps.waitOptions);
@@ -154,7 +154,18 @@ export async function cmdSync(config, opts) {
154
154
  // one that knows there is nothing to do: fetch and pull follow immediately
155
155
  // below. The decision is made HERE, once, rather than inferred inside each
156
156
  // step (raised in review).
157
- 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 });
158
169
  // NOTHING BELOW CAN SUCCEED WITHOUT IT, so a failed ceremony ends the run
159
170
  // here.
160
171
  //