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

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/README.md CHANGED
@@ -23,7 +23,9 @@ agmsg-cloud login sign this machine in
23
23
  agmsg-cloud connect <team> put a team this machine runs onto the service
24
24
  agmsg-cloud sync <team> (on a new machine) ask to join, and wait
25
25
  agmsg-cloud approve <team> (on a machine that has the keys) answer that request
26
- agmsg-cloud recovery setup <team> create the team's recovery key and back the keys up
26
+ agmsg-cloud recovery setup create the account's recovery key and back up every
27
+ active team this machine's store reports
28
+
27
29
  ```
28
30
 
29
31
  `sync` and `approve` are two halves of one ceremony: each screen shows eight
package/dist/src/api.js CHANGED
@@ -21,6 +21,7 @@ const STATUSES = [
21
21
  'consumed',
22
22
  'expired',
23
23
  'failed',
24
+ 'refused',
24
25
  ];
25
26
  // Ranges, not types.
26
27
  //
@@ -365,6 +366,22 @@ export class CourierClient {
365
366
  if (!isObject(d) || !isRecipient(d['devicePubkey']) || !isUuid(d['id'])) {
366
367
  throw new CourierError(200, 'malformed_devices_response');
367
368
  }
369
+ // `thisMachine` DECIDES A REFUSAL, so it is checked like one.
370
+ //
371
+ // Absent or boolean, nothing else. `"true"`, `null` and `0` are all
372
+ // truthy-or-falsy in some reading, and any of them would make the
373
+ // pre-flight below treat a row as "not this machine" while ALSO treating
374
+ // the response as coming from a server that answers the question — so an
375
+ // ordinary rejoin would be refused locally, without a single request
376
+ // reaching the server that could have corrected it.
377
+ //
378
+ // Refused here rather than coerced: this value gates a decision no other
379
+ // check re-derives, and the pre-flight's own catch turns a malformed
380
+ // response into "go ahead and run the ceremony", which is the safe
381
+ // direction (raised in review).
382
+ if (d['thisMachine'] !== undefined && typeof d['thisMachine'] !== 'boolean') {
383
+ throw new CourierError(200, 'malformed_devices_response');
384
+ }
368
385
  }
369
386
  return out['devices'];
370
387
  }
@@ -30,7 +30,11 @@ export class CeremonyError extends Error {
30
30
  const PROGRESSION = ['requester_committed', 'both_committed', 'requester_opened', 'opened', 'consumed'];
31
31
  // Ends the ceremony without reaching the end. Not the same as 'consumed', which
32
32
  // is the end.
33
- const TERMINAL_FAILURE = ['expired', 'failed'];
33
+ // 'refused' belongs here even though it costs no attempt: a waiter's question
34
+ // is "can this still finish", and the answer is no. What it costs is a separate
35
+ // question, answered by the ledger and by the server's counters — a waiter that
36
+ // left it out would poll a dead request until it timed out.
37
+ const TERMINAL_FAILURE = ['expired', 'failed', 'refused'];
34
38
  function rank(status) {
35
39
  return PROGRESSION.indexOf(status);
36
40
  }
@@ -137,9 +141,22 @@ export function renderSasBlock(sas, role) {
137
141
  // Only the approver is asked anything. Telling the joining machine's
138
142
  // operator to "answer no" pointed at a prompt that does not exist on their
139
143
  // side, one line above the sentence explaining that the approver decides.
140
- role === 'approver'
141
- ? ' If they differ at all, answer no. That is what this check is for.'
142
- : ' The approver decides. If they confirm, this machine is added.',
144
+ //
145
+ // SAID HERE AND NOWHERE ELSE. `request` printed its own version of the
146
+ // requester's sentence straight after this block, so the screen read: who
147
+ // decides -> how to compare remotely -> who decides (#195). The block is
148
+ // the one place that knows which side it is rendering for, so it is the
149
+ // one place that says it.
150
+ //
151
+ // The wording is the LONGER of the two that existed, because it carried
152
+ // something this one did not: what a `no` means. Deduplicating by keeping
153
+ // the shorter line would have deleted that with it.
154
+ ...(role === 'approver'
155
+ ? [' If they differ at all, answer no. That is what this check is for.']
156
+ : [
157
+ ' The approver decides. If they confirm, this machine is added and can',
158
+ ' fetch its bundle; if not, nothing is sent and this request is over.',
159
+ ]),
143
160
  '',
144
161
  // Addressed to the people it applies to, and to nobody else.
145
162
  //
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { CourierClient } from '../api.js';
5
5
  import { clearAuthenticatedDigest, readAuthenticatedDigests } from '../authenticated-digest.js';
6
6
  import { originOf } from '../credentials.js';
7
- import { decryptWithIdentity, publicKeyOf, unlockBundle, verifyHandoffDigest } from '../oss.js';
7
+ import { decryptWithIdentity, localTeamLookup, publicKeyOf, unlockBundle, verifyHandoffDigest, } from '../oss.js';
8
8
  import { deviceIdentityPath } from '../paths.js';
9
9
  import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
10
10
  // (c, part 2) B fetches the sealed bundles addressed to its device, opens each
@@ -23,9 +23,51 @@ env = process.env) {
23
23
  // approval was recorded under, so a regenerated identity does not inherit
24
24
  // the previous one's ceremonies (raised in review).
25
25
  const devicePubkey = await publicKeyOf(idPath);
26
+ // ASKED BEFORE THE QUEUE IS REPORTED ON (#145).
27
+ //
28
+ // `fetchBlobs` is scoped to the DEVICE, not to the team, and the team name is
29
+ // not read until `unlock` at the far end — so an empty queue and a mistyped
30
+ // name produced the same sentence. Measured on one machine with one
31
+ // credential:
32
+ //
33
+ // fetch walkfresh1 -> no pending bundles (real team, empty queue)
34
+ // fetch zzz-no-such-team -> no pending bundles (never existed)
35
+ //
36
+ // "no pending bundles" was true of the second and useless: someone waits for
37
+ // a bundle that will never arrive under that name. It is the same argument
38
+ // the `--confirm-digest` branch below already makes out loud — a thing
39
+ // silently ignored is how someone keeps believing they are the check.
40
+ //
41
+ // The queue is unchanged and so is every gate after it. What changes is that
42
+ // a name this machine does not have is refused BY NAME instead of answered.
43
+ // STOPS AT WHAT WAS OBSERVED, which is that the lookup did not succeed.
44
+ //
45
+ // It said "this machine has no team called X" — an assertion about absence,
46
+ // from a non-zero exit that has more than one cause: an unknown name, a
47
+ // config that could not be read, a store lock, the script failing to start.
48
+ // This repository has already MEASURED that the first two are
49
+ // indistinguishable from here — a corrupt team and an unconnected one give
50
+ // the same exit code and the same sentence (agmsg#650, found while reviewing
51
+ // #192). Naming a cause that was ruled indistinguishable, and then printing
52
+ // the store's own words underneath, does not undo the first line: the first
53
+ // line is what gets believed.
54
+ //
55
+ // So the two readings are given, and the store's words carry whichever it is
56
+ // (raised in review).
57
+ const lookup = await localTeamLookup(config.scriptsDir, args.team);
58
+ if (!lookup.known) {
59
+ throw new Error(`this machine could not confirm a team called ${JSON.stringify(args.team)}, so there is\n` +
60
+ ' nothing here it can unlock.\n' +
61
+ (lookup.said ? `\n The store said: ${lookup.said}\n` : '') +
62
+ '\n That reads two ways and this machine cannot tell them apart: there may be no\n' +
63
+ ' team by that name here, or its state could not be read.\n' +
64
+ '\n If the name is right, `agmsg-cloud pull` is what puts a team on a machine, and\n' +
65
+ ' `agmsg-cloud sync` does that and this in one go.');
66
+ }
26
67
  const client = new CourierClient(config);
27
68
  const blobs = await client.fetchBlobs();
28
69
  if (blobs.length === 0) {
70
+ // Now this says something: the team IS here, and nothing is waiting.
29
71
  process.stdout.write('no pending bundles\n');
30
72
  return;
31
73
  }
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
2
2
  import { isOrgAddress, originOf, readCredential, writeCredential } from '../credentials.js';
3
- import { settleMachineName } from '../machine-name.js';
3
+ import { settleMachineName, validateMachineName } from '../machine-name.js';
4
4
  // `login` — the device-authorization flow, from this machine's side.
5
5
  //
6
6
  // It is the one subcommand that runs with no credential, so it takes its
@@ -59,14 +59,11 @@ export async function cmdLogin(opts) {
59
59
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
60
60
  const now = opts.now ?? (() => Date.now());
61
61
  const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
62
- // Asked for, not assumed. The name is what an approver reads to tell one
63
- // machine from another, and defaulting to the hostname without showing it is
64
- // how two machines came to be called mbp2024 in production. --machine-name
65
- // still wins outright; a terminal gets a pre-filled question; a headless run
66
- // gets the hostname and is told so.
67
- const machineName = await settleMachineName(opts.machineName, {
68
- ...(opts.nameDeps ?? {}),
69
- });
62
+ // The flag, validated, before anything is asked. `settleMachineName` returns
63
+ // it verbatim when it is given, so nothing below needs the prompt to know
64
+ // what the caller wants — and a malformed value should be refused before the
65
+ // command starts talking to a server.
66
+ const asked = opts.machineName === undefined ? undefined : validateMachineName(opts.machineName);
70
67
  // Said out loud on EVERY run, default or not. A default is convenient, and
71
68
  // the thing it takes away is the moment the operator typed the destination —
72
69
  // so the destination is printed instead. Nobody should have to guess which
@@ -98,8 +95,19 @@ export async function cmdLogin(opts) {
98
95
  // are repaired by retrying the IDEMPOTENT activate with what is already
99
96
  // stored, before opening a new grant — a fresh grant would instead collide
100
97
  // with the machine name the stored credential is holding.
98
+ //
99
+ // Read BEFORE the machine name is asked for. The prompt used to run first, so
100
+ // a machine that was already signed in was asked to name itself and only then
101
+ // told the question had no bearing — and the asking is not a wasted
102
+ // keystroke, it is a claim. Two people read it as evidence that the stored
103
+ // credential had been deleted, and went looking for a file that was on disk
104
+ // the whole time (#193).
105
+ //
106
+ // rc.2 checked first. #169 moved the prompt to the top of the command to
107
+ // reuse the name on both screens; nothing in it was about ordering, so
108
+ // nothing looked at the ordering.
101
109
  const stored = readCredential(originOf(endpoint), process.env);
102
- if (stored && (opts.machineName === undefined || stored.machineName === machineName)) {
110
+ if (stored && (asked === undefined || stored.machineName === asked)) {
103
111
  const res = await post('/v1/device/activate', {}, stored.secret);
104
112
  if (res.ok) {
105
113
  process.stdout.write(`Already signed in as machine "${stored.machineName}".\n`);
@@ -114,6 +122,17 @@ export async function cmdLogin(opts) {
114
122
  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`);
115
123
  }
116
124
  }
125
+ // Only now is the name needed: this run is opening a grant, so there really
126
+ // is a machine to name.
127
+ //
128
+ // Asked for, not assumed. The name is what an approver reads to tell one
129
+ // machine from another, and defaulting to the hostname without showing it is
130
+ // how two machines came to be called the same thing in production.
131
+ // --machine-name still wins outright; a terminal gets a pre-filled question;
132
+ // a headless run gets the hostname and is told so.
133
+ const machineName = await settleMachineName(opts.machineName, {
134
+ ...(opts.nameDeps ?? {}),
135
+ });
117
136
  const codeRes = await post('/v1/device/code', {
118
137
  client_id: CLIENT_ID,
119
138
  scope: 'login',
@@ -0,0 +1,74 @@
1
+ import { DEFAULT_ENDPOINT } from '../browser.js';
2
+ import { originOf, removeCredentials } from '../credentials.js';
3
+ import { clearRecordsForOrigin } from '../pending.js';
4
+ /**
5
+ * The endpoint `logout` was asked for, or a refusal.
6
+ *
7
+ * The argv this command accepts is CLOSED — `[]`, or exactly
8
+ * `--endpoint <url>` — rather than filtered for the one flag it reads.
9
+ *
10
+ * Because it is destructive AND defaulted. Reading only `--endpoint` and
11
+ * ignoring the rest means `logout --endpont https://self.test` finds no flag,
12
+ * falls back to the hosted service, and deletes the production credential
13
+ * while the person believed they had named their own stack. So does
14
+ * `logout https://self.test`. Neither is an exotic input: they are a typo and
15
+ * a reasonable guess at the syntax.
16
+ *
17
+ * A command that removes a secret must not act on an argv it did not
18
+ * understand.
19
+ */
20
+ export function logoutEndpoint(argv) {
21
+ const refuse = () => {
22
+ throw new Error('usage: agmsg-cloud logout [--endpoint <url>]');
23
+ };
24
+ if (argv.length === 0)
25
+ return undefined;
26
+ if (argv.length !== 2 || argv[0] !== '--endpoint')
27
+ return refuse();
28
+ const value = argv[1];
29
+ // A missing value would otherwise swallow the next token; there is no next
30
+ // token here, but the same shape (`--endpoint --force`) is what the flag
31
+ // reader elsewhere in this file guards against.
32
+ if (value.length === 0 || value.startsWith('--'))
33
+ return refuse();
34
+ try {
35
+ // Parsed here rather than at the point of deletion: an unparseable address
36
+ // should be refused before anything is read or removed.
37
+ void new URL(value);
38
+ }
39
+ catch {
40
+ return refuse();
41
+ }
42
+ return value;
43
+ }
44
+ export function cmdLogout(opts = {}) {
45
+ const env = opts.env ?? process.env;
46
+ const out = opts.out ?? ((text) => void process.stdout.write(text));
47
+ const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
48
+ const origin = originOf(endpoint);
49
+ const removed = removeCredentials(origin, env);
50
+ const pending = clearRecordsForOrigin(origin, env);
51
+ if (removed.length === 0) {
52
+ // Not an error. "There was nothing to remove" is the state someone running
53
+ // this twice is in, and the state someone checking is in — and it is the
54
+ // same end state they asked for. Exiting non-zero would make a script that
55
+ // signs out defensively fail on the run where it worked.
56
+ out(`No credential stored for ${origin}.\n`);
57
+ if (pending > 0)
58
+ out(`Cleared ${pending} pending enrollment record(s).\n`);
59
+ return;
60
+ }
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
+ }
68
+ if (pending > 0)
69
+ out(`Cleared ${pending} pending enrollment record(s).\n`);
70
+ // Said because the omission is the surprising part: someone who ran this to
71
+ // clean up a machine should not have to wonder whether their history went
72
+ // with it.
73
+ out(`This machine's device key and local team keys were not touched.\n`);
74
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { createSasCommitment } from '@agmsg-cloud/sas-core';
4
- import { CourierClient } from '../api.js';
4
+ import { CourierClient, CourierError } from '../api.js';
5
5
  import { recordAuthenticatedDigest } from '../authenticated-digest.js';
6
6
  import { CeremonyError, renderSasBlock, sasFromOpenedTranscript, waitForStatus } from '../ceremony.js';
7
7
  import { generateDeviceIdentity, publicKeyOf } from '../oss.js';
@@ -94,6 +94,36 @@ export async function cmdRequest(config, args, deps = {}) {
94
94
  }
95
95
  }
96
96
  if (!record || !requestId) {
97
+ // ASKED BEFORE THE ATTEMPT IS CHARGED, and that placement is the whole
98
+ // point. A device key that can never become a device on this account makes
99
+ // this ceremony unwinnable, and #194 is about such a path costing one of
100
+ // five. The server refuses it too — that check is the authority and stays
101
+ // — but it can only speak at the opening, by which time §4.1 has already
102
+ // spent the attempt.
103
+ //
104
+ // A REFUSAL BEFORE STARTING, NOT A REFUND AFTER. The difference matters:
105
+ // giving an attempt back because the server said the run did not count
106
+ // would let a hostile server hand out unlimited free ceremonies, which is
107
+ // exactly the budget an attacker wants while searching for a SAS
108
+ // collision. Declining to start costs the attacker nothing they cannot
109
+ // already do by refusing every request.
110
+ // The budget is READ before the lookup below, so a machine with nothing
111
+ // left still reaches the server for nothing. `consumeAttempt` remains the
112
+ // authority and re-checks under the lock — this only decides whether it is
113
+ // worth asking a question, and a sixth attempt must not reach the server
114
+ // at all, not even to read.
115
+ const before = readBudget(scope, env);
116
+ if (before.remaining === 0) {
117
+ writeErr(renderBudgetExhausted(before));
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ const blocked = await keyAlreadySpokenFor(client, pubkey);
122
+ if (blocked) {
123
+ writeErr(blocked);
124
+ process.exitCode = 1;
125
+ return;
126
+ }
97
127
  const { commitmentHex: plannedCommitment, openingNonce: plannedNonce } = createSasCommitment(pubkey);
98
128
  const charged = consumeAttempt(scope, plannedCommitment, env);
99
129
  if (!charged.ok) {
@@ -141,9 +171,15 @@ export async function cmdRequest(config, args, deps = {}) {
141
171
  // Derived here, from the two opened nonces. Nothing displayed below came
142
172
  // from the server as a code.
143
173
  const sas = sasFromOpenedTranscript(opened);
174
+ // The block says who decides, and says it once (#195). This printed a
175
+ // second copy immediately after it, so the screen read: who decides -> how
176
+ // to compare on a call you trust -> who decides. Repetition in the one
177
+ // screen where a single line has to be read is how the reader learns to
178
+ // skim it.
179
+ //
180
+ // The sentence was not deleted, it MOVED: what this copy carried and the
181
+ // block's did not — what a `no` means — is now the block's wording.
144
182
  out(renderSasBlock(sas, 'requester'));
145
- out(' The approver decides. If they confirm, this machine is added and can\n' +
146
- ' fetch its bundle; if not, nothing is sent and this request is over.\n\n');
147
183
  // The outcome is NOT known yet. Closing as succeeded here — which is what
148
184
  // this did — recorded every rejected ceremony as a success, so §4.1's
149
185
  // requirement that the requester counts failed and incomplete attempts, and
@@ -205,6 +241,131 @@ export async function cmdRequest(config, args, deps = {}) {
205
241
  process.exitCode = 1;
206
242
  return;
207
243
  }
244
+ // The two ways this machine's key cannot become a device on this account.
245
+ //
246
+ // They arrive at the OPENING — before any comparison — because that is the
247
+ // first moment the server has the key at all. They used to arrive as one
248
+ // `already_registered` at the very end, after an approver had been
249
+ // interrupted and had answered (#194), and it named neither case.
250
+ //
251
+ // A machine already on this account joining a second team is NOT here: that
252
+ // one succeeds now, which is what #194 was about.
253
+ if (err instanceof CourierError &&
254
+ (err.code === 'device_key_in_use' || err.code === 'device_key_changed')) {
255
+ done();
256
+ closeAttempt(scope, record.commitmentHex, 'failed', err.code, env);
257
+ writeErr(renderBudgetWarning(readBudget(scope, env)));
258
+ writeErr(await renderKeyRefusal(err.code, client, pubkey));
259
+ process.exitCode = 1;
260
+ return;
261
+ }
208
262
  throw err;
209
263
  }
210
264
  }
265
+ // WHAT IS TRUE AT THIS POINT, and no more.
266
+ //
267
+ // This used to say "no approver was asked", which is false: reaching the
268
+ // opening means the requester waited for `both_committed`, so an approver had
269
+ // already run `approve` and posted a commitment over the bundle digest. They
270
+ // were interrupted — what did NOT happen is the comparison and the upload
271
+ // (raised in review). The test that blessed the old wording started its
272
+ // scripted server at `both_committed` and so never ran the order it was
273
+ // asserting about.
274
+ const NOTHING_COMPARED = ' No digits were shown and no bundle was sent.\n';
275
+ /**
276
+ * Whether this machine's key is already spoken for by a DIFFERENT machine, or
277
+ * this machine already holds a different key — asked before an attempt is spent.
278
+ *
279
+ * Returns the message to print, or null to go ahead. Best effort in one
280
+ * direction only: a lookup that fails, or a server too old to say which row is
281
+ * this machine's, returns null and lets the ceremony run. The server's own
282
+ * refusal is the gate; this exists to keep an unwinnable run from costing one
283
+ * of five, and a check that cannot answer must not become a second refusal on
284
+ * its own.
285
+ *
286
+ * `thisMachine === undefined` is not `false`. An older server says nothing
287
+ * about ownership, and reading silence as "none of these are yours" would
288
+ * refuse every ordinary rejoin.
289
+ */
290
+ async function keyAlreadySpokenFor(client, pubkey) {
291
+ let devices;
292
+ try {
293
+ devices = await client.listDevices();
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ if (!devices.some((d) => d.thisMachine !== undefined))
299
+ return null;
300
+ const mine = devices.find((d) => d.thisMachine === true);
301
+ const holdingMyKey = devices.find((d) => d.devicePubkey === pubkey);
302
+ // The ordinary second-team case: this machine is on the account and the key
303
+ // it presents is its own. It still runs the full ceremony — what that
304
+ // establishes is this team's bundle digest, not the registration.
305
+ if (holdingMyKey && holdingMyKey.thisMachine === true)
306
+ return null;
307
+ if (holdingMyKey) {
308
+ return (`not started: this machine's device key is already registered to a different machine on\n` +
309
+ ` this account. It is listed as "${holdingMyKey.label}".\n\n` +
310
+ ' Two things look like this, and the machine you are sitting at tells them apart:\n\n' +
311
+ ' the same machine, signed in again under another name\n' +
312
+ ' -> an owner removes the OTHER entry in the console (Machines -> Remove).\n\n' +
313
+ " a different machine, using a copy of that one's identity file\n" +
314
+ ' -> this machine needs its own. Move ~/.agmsg-cloud/device.key aside and\n' +
315
+ ' run this again; a new identity is generated when none is present.\n\n' +
316
+ ' No enrollment was started, so this costs none of your attempts.\n');
317
+ }
318
+ if (mine) {
319
+ return ('not started: this machine is already listed on the account under a different device key.\n' +
320
+ ' That happens when the key at ~/.agmsg-cloud/device.key was replaced or lost —\n' +
321
+ ' the listed one cannot be recovered from here, and the account still points at it.\n\n' +
322
+ ' To clear it, an owner removes this machine in the console (Machines -> Remove),\n' +
323
+ ' and then this machine signs in again with `agmsg-cloud login`.\n\n' +
324
+ ' No enrollment was started, so this costs none of your attempts.\n');
325
+ }
326
+ return null;
327
+ }
328
+ /**
329
+ * Why this machine's device key was refused, and what actually clears it.
330
+ *
331
+ * Both remedies were followed to the end before being printed. Removing a
332
+ * machine in the console revokes its capability URL AND its device rows
333
+ * (`revokeCapabilityRows`) and releases the machine slot, and both unique
334
+ * indexes are partial on active rows — so the next sign-in registers cleanly.
335
+ * It is an OWNER action, which is why the wording does not tell a member to go
336
+ * and do it.
337
+ */
338
+ async function renderKeyRefusal(code, client, pubkey) {
339
+ if (code === 'device_key_changed') {
340
+ return ('not added: this machine is already listed on the account under a different device key.\n' +
341
+ ' That happens when the key at ~/.agmsg-cloud/device.key was replaced or lost —\n' +
342
+ ' the listed one cannot be recovered from here, and the account still points at it.\n\n' +
343
+ ' To clear it, an owner removes this machine in the console (Machines -> Remove),\n' +
344
+ ' and then this machine signs in again with `agmsg-cloud login`.\n' +
345
+ NOTHING_COMPARED);
346
+ }
347
+ // Naming the machine that holds the key turns "something is wrong" into one
348
+ // decision. It discloses nothing new: `listDevices` already answers any
349
+ // capability of this account with every device's label and public key.
350
+ //
351
+ // Best effort — a failed lookup must not replace the reason with an error
352
+ // about the lookup.
353
+ let holder = '';
354
+ try {
355
+ const held = (await client.listDevices()).find((d) => d.devicePubkey === pubkey);
356
+ if (held)
357
+ holder = ` It is listed as "${held.label}".`;
358
+ }
359
+ catch {
360
+ holder = '';
361
+ }
362
+ return (`not added: this machine's device key is already registered to a different machine on\n` +
363
+ ` this account.${holder}\n\n` +
364
+ ' Two things look like this, and the machine you are sitting at tells them apart:\n\n' +
365
+ ' the same machine, signed in again under another name\n' +
366
+ ' -> an owner removes the OTHER entry in the console (Machines -> Remove).\n\n' +
367
+ ' a different machine, using a copy of that one\'s identity file\n' +
368
+ ' -> this machine needs its own. Move ~/.agmsg-cloud/device.key aside and\n' +
369
+ ' run this again; a new identity is generated when none is present.\n\n' +
370
+ NOTHING_COMPARED);
371
+ }
@@ -1,12 +1,11 @@
1
- import { shellArg } from '../shell-arg.js';
2
- // Re-exported: the sync tests pinned the quoting rule through this module
3
- // before it had a home of its own, and those cases are still the ones that
4
- // prove the printed command survives a team name with a space in it.
5
- export { shellArg };
1
+ import { existsSync } from 'node:fs';
6
2
  import { hostname } from 'node:os';
7
3
  import { CourierClient } from '../api.js';
8
4
  import { originOf, readCredential } from '../credentials.js';
5
+ import { publicKeyOf } from '../oss.js';
6
+ import { deviceIdentityPath } from '../paths.js';
9
7
  import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
8
+ import { shellArg } from '../shell-arg.js';
10
9
  import { cmdFetch } from './fetch.js';
11
10
  import { cmdPull } from './pull.js';
12
11
  import { cmdRequest } from './request.js';
@@ -64,7 +63,19 @@ export async function cmdSync(config, opts) {
64
63
  // this deliberately is not.
65
64
  try {
66
65
  const existing = await new CourierClient(config).listDevices();
67
- const clash = existing.filter((d) => d.label === label);
66
+ // THIS MACHINE'S OWN ROW IS NOT A CLASH WITH ITSELF.
67
+ //
68
+ // The comparison was on label alone, so a machine already on the account —
69
+ // joining a second team — always matched itself and was warned that "this
70
+ // account already has a machine called <its own name>". That is the line
71
+ // read during the production walkthrough (#194), and acting on it means
72
+ // renaming a machine to avoid a collision with nothing.
73
+ //
74
+ // Identity is the device key, not the name. Absent before the first join,
75
+ // which is exactly when there is no own row to exclude.
76
+ const idPath = deviceIdentityPath();
77
+ const mine = existsSync(idPath) ? await publicKeyOf(idPath) : null;
78
+ const clash = existing.filter((d) => d.label === label && d.devicePubkey !== mine);
68
79
  if (clash.length > 0) {
69
80
  out(` NOTE: this account already has ${clash.length === 1 ? 'a machine' : `${clash.length} machines`} called "${label}".\n`);
70
81
  out(' The approver sees names, so identical ones are indistinguishable on their screen.\n');
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { CourierClient } from '../api.js';
5
- import { keyHandoff, remoteBinding, unlockAuthenticatedBundle } from '../oss.js';
5
+ import { connectedTeams, keyHandoff, remoteBinding, unlockAuthenticatedBundle } from '../oss.js';
6
6
  import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
7
7
  import { generateRecoveryKey, normalizeRecoveryKey, promptRecoveryKey, showRecoveryKey, } from '../recovery-key.js';
8
8
  import { appendVaultVersionWithVdk, createVault, openVaultWithVdk, readAccountVault, vdkFromRecoveryKey, } from '../vault-protocol.js';
@@ -93,16 +93,87 @@ async function keepSlot(address, vdk) {
93
93
  process.stdout.write(`note: this machine could not keep a key slot (${saved.reason}), so the recovery ` +
94
94
  'key will be needed again next time.\n');
95
95
  }
96
- // `agmsg-cloud recovery setup <team>` — export the team's handoff bundle and store it
97
- // wrapped under the recovery key. The first run creates the vault and mints the
98
- // key; later runs append a version to the same vault under the same key.
96
+ // Whether a team's material is the same material, judged on the key epochs the
97
+ // bundle declares rather than on its bytes.
98
+ //
99
+ // The ciphertext differs every run (nonce), and whether the bundle itself is
100
+ // byte-stable is not something this side has established — so neither can carry
101
+ // the comparison. `key_ids` are the epochs, they are already stored per entry,
102
+ // and they are exactly what "the key situation has not changed" means. Compared
103
+ // as a set: the order the exporter lists them in is not part of the fact.
104
+ /**
105
+ * The key that decides whether two records name the same team.
106
+ *
107
+ * One function, so both sides of every comparison are built the same way — a
108
+ * separator chosen twice is a separator that eventually differs. Framed rather
109
+ * than joined: `('a|b','c')` and `('a','b|c')` are different pairs and must not
110
+ * produce one key, which is the collision the request digest had to be fixed
111
+ * for. These two values are uuids today, so no pair can collide — the framing
112
+ * is here so that stays true if either ever stops being one.
113
+ *
114
+ * Written after the first version put a literal separator inside a template and
115
+ * a NUL character ended up in the source, which made the whole file binary to
116
+ * `grep`. Both sides still agreed, so nothing failed; the file simply stopped
117
+ * answering searches.
118
+ */
119
+ function vaultTeamKey(serverInstanceId, teamId) {
120
+ return JSON.stringify([serverInstanceId, teamId]);
121
+ }
122
+ function sameEpochs(a, b) {
123
+ if (a.length !== b.length)
124
+ return false;
125
+ const left = [...a].sort();
126
+ const right = [...b].sort();
127
+ return left.every((v, i) => v === right[i]);
128
+ }
129
+ /**
130
+ * `recovery setup` — put the keys of every team THIS MACHINE reports into the
131
+ * account's vault.
132
+ *
133
+ * NO ARGUMENT IS THE ORDINARY FORM. The vault holds one recovery key for the
134
+ * whole account, so "set up recovery" is an account-level act; naming a team
135
+ * made it something to repeat every time a team was added, which is the folding
136
+ * this design exists to do. A team may still be named, for adding one on its
137
+ * own.
138
+ *
139
+ * NOT "every team on the account", and the difference is load-bearing until
140
+ * fujibee/agmsg#650 lands. The set comes from `remote.sh status --json`, which
141
+ * drops a team it could not read and exits 0 — so an active team that has never
142
+ * been backed up can be missed with nothing said. Every sentence describing this
143
+ * command, here and in `--help` and in the README, stops at what the machine
144
+ * reported for exactly that reason. Widening them is the change that closes
145
+ * #182, and it comes after #650, not before.
146
+ *
147
+ * IDEMPOTENT. A run where no team's epochs have moved writes nothing and leaves
148
+ * the revision where it was. `setup` that appended an identical version every
149
+ * time was a name promising one thing and a behaviour doing another — and the
150
+ * cost is not storage, it is that a version history exists to show when
151
+ * something changed, and identical versions make it unreadable.
152
+ */
99
153
  export async function cmdVaultPut(config, args) {
100
154
  // Same check connect gets. Without it a machine with no agmsg install runs
101
155
  // straight into `bash exited 127: …/remote.sh: No such file or directory` —
102
156
  // the same absence connect reports as a named checklist.
103
157
  ensurePreflight(preflight(config.scriptsDir, NEEDS.vaultPut));
104
158
  const client = new CourierClient(config);
105
- const binding = await remoteBinding(config.scriptsDir, args.team);
159
+ // Two questions, and they are not the same one.
160
+ //
161
+ // observed every team whose status this machine could READ, whatever it
162
+ // said. This is what makes "the vault holds a team that did not
163
+ // appear" mean something: without it, a team disconnected here
164
+ // is indistinguishable from one whose status could not be read.
165
+ // targets those with an ACTIVE binding. Only these may be filed — a
166
+ // disconnected team's ids are a leftover, and a vault entry
167
+ // under one records a backup against a remote this machine is no
168
+ // longer bound to.
169
+ const observed = args.team === undefined ? await connectedTeams(config.scriptsDir) : [];
170
+ const targets = args.team === undefined
171
+ ? observed.filter((t) => t.state === 'active')
172
+ : [{ team: args.team, ...(await remoteBinding(config.scriptsDir, args.team)) }];
173
+ if (targets.length === 0) {
174
+ throw new Error('no team on this machine is connected to the hosted service, so there are no keys to back up.\n' +
175
+ 'Run `agmsg-cloud connect <team>` first, or name a team if one is connected under a different name.');
176
+ }
106
177
  // The account's vault, not this team's: one vault, one recovery key. A second
107
178
  // team finds the vault that already exists and is added to it.
108
179
  const { identity, version } = await readAccountVault(client);
@@ -125,7 +196,7 @@ export async function cmdVaultPut(config, args) {
125
196
  // X" was true when a team had its own vault; saying it now would teach
126
197
  // someone to expect a different key per team, which is the belief the
127
198
  // account vault exists to remove.
128
- const obtained = await vdkForVault(identity, version, args.team);
199
+ const obtained = await vdkForVault(identity, version, args.team ?? targets[0].team);
129
200
  vdk = obtained.vdk;
130
201
  askedForTheKey = obtained.usedRecoveryKey;
131
202
  // Open before writing. The container has to be read to add a team to it,
@@ -165,17 +236,127 @@ export async function cmdVaultPut(config, args) {
165
236
  await showRecoveryKey(minted, args.team);
166
237
  container = emptyContainer();
167
238
  }
239
+ // THE ENUMERATION IS NOT FAIL-CLOSED, AND THIS DOES NOT PRETEND TO FIX THAT.
240
+ //
241
+ // `remote.sh status --json` with no team DROPS a team it could not read and
242
+ // still exits 0, and the single-team form cannot tell "never connected" from
243
+ // "could not be read" either — same message, same exit code. Measured, and
244
+ // filed as fujibee/agmsg#650.
245
+ //
246
+ // The first version of this REFUSED when the vault held a team the run did
247
+ // not see. That was wrong, and the reason is worth keeping: a team
248
+ // deliberately disconnected here disappears from the enumeration too, and the
249
+ // vault keeps its entry forever by design — so the refusal fired every run,
250
+ // for the one reason that is not a problem, with no way out. Refusing on a
251
+ // distinction the system cannot draw treats "unknown" as "wrong".
252
+ //
253
+ // So it reports instead. What was not seen is put on the screen by name and
254
+ // the judgement goes back to the operator, who is the only party that knows
255
+ // whether they disconnected it. That leaves a real gap — nobody who does not
256
+ // read the output is protected — and the gap is smaller than a command that
257
+ // cannot be run.
258
+ //
259
+ // When #650 lands this can become a refusal again, because by then a team
260
+ // that could not be read will say so.
261
+ const seenThisRun = new Set(observed.map((t) => vaultTeamKey(t.serverInstanceId, t.teamId)));
262
+ const unseen = args.team === undefined
263
+ ? container.teams.filter((e) => !seenThisRun.has(vaultTeamKey(e.server_instance_id, e.team_id)))
264
+ : [];
265
+ // Seen, and deliberately not filed. Kept apart from `unseen` because they are
266
+ // different facts: this machine HEARD about these, and the ones above it did
267
+ // not hear about at all.
268
+ const disconnected = observed.filter((t) => t.state === 'disconnected');
269
+ // WHAT THIS RUN COVERED, BY NAME. Written once, so both exits below say the
270
+ // same thing — a sentence written twice is one that eventually disagrees with
271
+ // itself, and these two paths differ in everything else.
272
+ //
273
+ // NO COUNT WITHOUT ITS MEMBERS. What the operator saw on the production
274
+ // walkthrough was `stored revision 3 — team 'rc3walk' backed up; 2 teams`,
275
+ // and the "2 teams" never said which two. A count reads as "the account is
276
+ // backed up", which is wider than what was checked: the set came from what
277
+ // the local store reported, and that store can come back short without
278
+ // saying so (agmsg#650).
279
+ //
280
+ // The first version of this got it wrong in its own way — it printed the
281
+ // heading "Covered" and then listed only the DISCONNECTED teams, because the
282
+ // backed-up ones are not known until the loop below has run. Hence the
283
+ // parameters: the groups are passed in rather than closed over, so the
284
+ // heading cannot outrun what is under it.
285
+ const coverage = (written, unchanged) => {
286
+ const group = (title, members) => members.length === 0 ? [] : [`\n ${title}\n`, ...members.map((m) => ` ${m}\n`)];
287
+ // The first two hold for either form — a run that names one team still has
288
+ // to say what it did with it. The rest are about the ENUMERATION, so they
289
+ // only mean anything when the enumeration is what chose the set.
290
+ const enumerated = args.team === undefined;
291
+ const lines = [
292
+ ...group('Backed up in this revision:', written),
293
+ ...group('Already current, nothing to write:', unchanged),
294
+ ...(!enumerated
295
+ ? []
296
+ : group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team))),
297
+ // Ids, because a team that never reported has no local name here to
298
+ // print — the same absence that put it on this list. Said plainly rather
299
+ // than dressed up as an instruction: this machine does not know whether
300
+ // these were disconnected on purpose, and the operator does.
301
+ ...(!enumerated
302
+ ? []
303
+ : group('In the vault, but not reported by this machine on this run:', unseen.map((e) => `${e.team_id} (server ${e.server_instance_id})`))),
304
+ ];
305
+ if (unseen.length > 0) {
306
+ lines.push('\n Those entries are untouched. If you disconnected them here, nothing is\n', ' wrong. If you did not, this machine could not read their state and they\n', ' were not re-checked — that is agmsg#650, and it cannot be told apart\n', ' from here.\n');
307
+ }
308
+ // Said only by the form that DERIVED its set. A run given a team covered
309
+ // what it was told to; claiming it covered what the store reported would
310
+ // be a statement about an enumeration that never ran.
311
+ if (enumerated) {
312
+ lines.push("\n That is every team this machine's store reported. A team connected only\n", ' on another machine is backed up by running this there.\n');
313
+ }
314
+ return lines.join('');
315
+ };
168
316
  const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-'));
169
317
  try {
170
- const bundleFile = join(scratch, 'handoff.bundle');
171
- await keyHandoff(config.scriptsDir, args.team, bundleFile);
172
- const bundle = readFileSync(bundleFile);
173
- const next = upsertTeam(container, {
174
- server_instance_id: binding.serverInstanceId,
175
- team_id: binding.teamId,
176
- key_ids: bundleKeyIds(bundle),
177
- bundle: bundle.toString('base64'),
178
- });
318
+ // One pass over every target. A team whose declared epochs already match
319
+ // what the vault holds is left exactly as it is — not re-sealed under a
320
+ // fresh nonce, which would be a new version saying nothing new.
321
+ let next = container;
322
+ const written = [];
323
+ const unchanged = [];
324
+ for (const [i, target] of targets.entries()) {
325
+ const bundleFile = join(scratch, `handoff-${i}.bundle`);
326
+ await keyHandoff(config.scriptsDir, target.team, bundleFile);
327
+ const bundle = readFileSync(bundleFile);
328
+ const keyIds = bundleKeyIds(bundle);
329
+ const already = next.teams.find((e) => e.server_instance_id === target.serverInstanceId && e.team_id === target.teamId);
330
+ if (already && sameEpochs(already.key_ids, keyIds)) {
331
+ unchanged.push(target.team);
332
+ continue;
333
+ }
334
+ next = upsertTeam(next, {
335
+ server_instance_id: target.serverInstanceId,
336
+ team_id: target.teamId,
337
+ key_ids: keyIds,
338
+ bundle: bundle.toString('base64'),
339
+ });
340
+ written.push(target.team);
341
+ }
342
+ // Nothing moved, and the vault already exists: there is no version to
343
+ // write. Saying "stored revision N+1" here would be true of the server and
344
+ // false about the account — the point of a revision is that something is
345
+ // different in it.
346
+ if (written.length === 0 && version && vdk) {
347
+ // The headline carries no bare count: what "up to date" covers is the
348
+ // list below it, and a number on its own is the thing this was reported
349
+ // for.
350
+ process.stdout.write(`already up to date — nothing has new keys. Revision stays at ${version.revision}.\n`);
351
+ process.stdout.write(coverage(written, unchanged));
352
+ // A run that had to type the key still earns a slot: the key is in hand,
353
+ // and the next run should not ask again just because this one had nothing
354
+ // to store.
355
+ if (askedForTheKey) {
356
+ await keepSlot(slotAddress(identity, version.vault_id, version.recovery_generation), vdk);
357
+ }
358
+ return;
359
+ }
179
360
  const content = serializeContainer(next);
180
361
  // The address this machine's slot is kept at, decided by which write
181
362
  // happened: an append addresses the vault that already exists, a create
@@ -196,10 +377,19 @@ export async function cmdVaultPut(config, args) {
196
377
  vdk = created.vdk;
197
378
  address = slotAddress(identity, created.vaultId, created.recoveryGeneration);
198
379
  }
199
- const teamCount = next.teams.length;
200
- process.stdout.write(`stored revision ${revision} — team '${args.team}' backed up` +
201
- `${version ? '' : ' (vault created)'}; ` +
202
- `${teamCount} team${teamCount === 1 ? '' : 's'} in this account's vault\n`);
380
+ // `; 2 teams in this account's vault` used to end this line. It is the
381
+ // count the walkthrough operator read and could not act on — it never said
382
+ // WHICH two, and a bare number here reads as a statement about the account
383
+ // rather than about what this run saw. The groups below say both.
384
+ process.stdout.write(`stored revision ${revision}${version ? '' : ' (vault created)'}\n`);
385
+ // WHAT WAS COVERED IS WHAT THIS MACHINE REPORTED, and that is what the line
386
+ // says. It used to end at the count above, which reads as "the account is
387
+ // backed up" — a claim wider than the thing that was checked, since the
388
+ // enumeration this ran on can come back short without saying so
389
+ // (agmsg#650). Teams already in the vault are covered by the refusal
390
+ // higher up; a team that has never been in it cannot be seen from here at
391
+ // all, so the sentence stops at the machine rather than the account.
392
+ process.stdout.write(coverage(written, unchanged));
203
393
  // After the write, and only when this run had to reach the key. A run the
204
394
  // slot already answered has a working slot at this address; saving again
205
395
  // would mint a fresh KEK, replace it, and touch the keychain on every
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, chmodSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
2
+ import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, chmodSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  /**
@@ -297,6 +297,48 @@ function writeLocked(path, dir, credential) {
297
297
  active: key,
298
298
  credentials: { ...file.credentials, [key]: credential },
299
299
  };
300
+ commit(path, dir, next);
301
+ }
302
+ /**
303
+ * Remove every credential this origin holds, and say which they were.
304
+ *
305
+ * Same lock and same durable write as a login, for the same reason: two
306
+ * processes that read before locking both write a stale base, and the loser's
307
+ * entry comes back from the dead. Removal has to be as careful as writing —
308
+ * a sign-out that half-worked leaves a secret on disk that its owner believes
309
+ * is gone.
310
+ *
311
+ * Returns what it removed rather than a count. The caller names the machine on
312
+ * screen, and the person signing out is entitled to know which identity just
313
+ * left this machine.
314
+ */
315
+ export function removeCredentials(origin, env = process.env) {
316
+ const path = credentialsPath(env);
317
+ if (!existsSync(path))
318
+ return [];
319
+ const dir = dirname(path);
320
+ const release = acquireLock(path);
321
+ try {
322
+ const file = readFile(path);
323
+ const doomed = Object.entries(file.credentials).filter(([key]) => key.startsWith(`${origin} `));
324
+ if (doomed.length === 0)
325
+ return [];
326
+ const kept = Object.fromEntries(Object.entries(file.credentials).filter(([key]) => !key.startsWith(`${origin} `)));
327
+ // `active` is a KEY, and the key it names may be one of the ones going. A
328
+ // stale pointer would make `readCredential(null)` answer with a credential
329
+ // that is no longer in the file — null, not the first survivor: which org
330
+ // becomes current is the person's to say, and guessing it here would sign
331
+ // them into an account they did not choose.
332
+ const active = file.active && file.active in kept ? file.active : null;
333
+ commit(path, dir, { version: 1, active, credentials: kept });
334
+ return doomed.map(([, credential]) => credential);
335
+ }
336
+ finally {
337
+ release();
338
+ }
339
+ }
340
+ /** The atomic, durable write both paths share. Caller holds the lock. */
341
+ function commit(path, dir, next) {
300
342
  // Unpredictable temp name: a name an attacker can guess is a name they can
301
343
  // pre-create as a symlink pointing somewhere else.
302
344
  const tmp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
package/dist/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { cmdConnect, cmdConnectPreflight } from './commands/connect.js';
5
5
  import { cmdFetch } from './commands/fetch.js';
6
6
  import { cmdSync } from './commands/sync.js';
7
7
  import { cmdLogin } from './commands/login.js';
8
+ import { cmdLogout, logoutEndpoint } from './commands/logout.js';
8
9
  import { cmdPull } from './commands/pull.js';
9
10
  import { cmdRequest } from './commands/request.js';
10
11
  import { cmdVaultPut, cmdVaultRestore } from './commands/vault.js';
@@ -16,6 +17,9 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
16
17
  [--endpoint <url>] a self-hosted or development stack (default: the hosted service)
17
18
  [--machine-name <n>] the name this machine is registered under (default: hostname)
18
19
 
20
+ logout remove this machine's stored credential for the service
21
+ [--endpoint <url>] the same address login used (default: the hosted service)
22
+
19
23
  connect <team> put a team this machine already runs onto the hosted service
20
24
  connect --preflight check what connect needs, without connecting anything
21
25
 
@@ -28,15 +32,21 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
28
32
  it is matched against the snapshot your code authenticated
29
33
  approve <team> [request-id] (on a key-holding machine) approve the waiting request after the codes match
30
34
  watch [--interval-ms <n>] (on a key-holding machine) print pending requests as they arrive
31
- recovery setup <team> create <team>'s recovery key and back the keys up under it
35
+ recovery setup [team] create this account's recovery key and back up every active
36
+ team this machine's store reports; name one to back up
37
+ only that team
32
38
  recovery restore <team> (on any approved machine) open the backup and unlock <team>
33
39
  version the version of this CLI (also --version, -v)
34
40
  the OSS scripts it drives are reported by
35
41
  \`connect --preflight\`, which is a separate answer
36
42
 
37
43
  The recovery key is generated by \`recovery setup\` on the first run, shown once,
38
- and never stored anywhere. It is the only thing that opens the backup — if it is
39
- lost, so is the backup. After the first run, a machine with an OS secure store
44
+ and never stored anywhere. There is ONE for the account, not one per team — which
45
+ is why it is not something to repeat each time a team is added. What one run
46
+ covers is every active team THIS MACHINE's store reports, and it names them when
47
+ it finishes; a team connected only on another machine is backed up by running it
48
+ there. It is the only thing that opens the backup — if it is lost, so is the
49
+ backup. After the first run, a machine with an OS secure store
40
50
  keeps a key slot and does not ask for it again; a machine without one asks every
41
51
  time, and says so. It can only be typed at a terminal: there is no flag and no
42
52
  environment variable for it.
@@ -101,6 +111,16 @@ async function main(argv) {
101
111
  ...(machineName === undefined ? {} : { machineName }),
102
112
  });
103
113
  }
114
+ case 'logout': {
115
+ // Local only, and its own subcommand rather than a flag on `login`: the
116
+ // two do opposite things, and a flag that inverts a command is how
117
+ // someone signs out while meaning to sign in.
118
+ // Closed argv, not a filtered read: this removes a secret and defaults
119
+ // to the hosted service, so an argument it does not understand must not
120
+ // become "the default one".
121
+ const endpoint = logoutEndpoint(rest);
122
+ return cmdLogout(endpoint === undefined ? {} : { endpoint });
123
+ }
104
124
  case 'connect': {
105
125
  // The dry run resolves no credential: its job is to be usable before
106
126
  // anything is set up.
@@ -191,14 +211,26 @@ async function main(argv) {
191
211
  case 'recovery': {
192
212
  const [sub, team] = rest;
193
213
  if (sub !== 'setup' && sub !== 'restore') {
194
- throw new Error('usage: agmsg-cloud recovery <setup|restore> <team>');
214
+ // The two take different arguments, so one line covering both said the
215
+ // wrong thing about each: `setup` does not need a team and `restore`
216
+ // does. A usage message is read as the contract (raised in review).
217
+ throw new Error('usage: agmsg-cloud recovery setup [team]\n' +
218
+ ' agmsg-cloud recovery restore <team>');
195
219
  }
220
+ // `setup` takes no team in its ordinary form: the vault is the ACCOUNT's,
221
+ // so setting it up covers every connected team, and naming one made it a
222
+ // thing to repeat each time a team was added. A team may still be given,
223
+ // to add that one on its own. `restore` still requires one — it unlocks a
224
+ // specific team on this machine.
225
+ //
196
226
  // The excuse rides inside the slot, so it cannot apply to anything else.
197
- if (!team) {
227
+ if (sub === 'restore' && !team) {
198
228
  throw new Error(`usage: agmsg-cloud recovery ${ /* printed-commands: a subcommand name this file chooses, narrowed to 'setup' | 'restore' two lines up — not a value anyone supplies, and quoting it would print `recovery 'setup'` */sub} <team>`);
199
229
  }
200
230
  const config = loadConfig();
201
- return sub === 'setup' ? cmdVaultPut(config, { team }) : cmdVaultRestore(config, { team });
231
+ return sub === 'setup'
232
+ ? cmdVaultPut(config, team === undefined ? {} : { team })
233
+ : cmdVaultRestore(config, { team: team });
202
234
  }
203
235
  default:
204
236
  throw new Error(`unknown command: ${cmd}\n\n${USAGE}`);
package/dist/src/oss.js CHANGED
@@ -28,6 +28,52 @@ function run(cmd, args, input) {
28
28
  child.stdin.end();
29
29
  });
30
30
  }
31
+ /** A finished run, whatever its exit code. For callers where non-zero is an ANSWER. */
32
+ function runAllowingFailure(cmd, args) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawnOssPiped(cmd, args);
35
+ const out = [];
36
+ const err = [];
37
+ child.stdout.on('data', (d) => out.push(d));
38
+ child.stderr.on('data', (d) => err.push(d));
39
+ child.on('error', reject);
40
+ child.on('close', (code) => resolve({
41
+ code: code ?? -1,
42
+ stdout: Buffer.concat(out).toString(),
43
+ stderr: Buffer.concat(err).toString().trim(),
44
+ }));
45
+ child.stdin.end();
46
+ });
47
+ }
48
+ /**
49
+ * Whether this machine has a team by this name — the question `unlock` asks.
50
+ *
51
+ * NOT `remoteBinding`, and the difference was measured rather than assumed.
52
+ * `remoteBinding` additionally requires the binding to be ACTIVE, and against
53
+ * the real scripts:
54
+ *
55
+ * team status <team> --json unlock <team>
56
+ * connected exit 0, state active past the team check
57
+ * DISCONNECTED exit 0, state disconnected past the team check
58
+ * never existed exit 1 "agmsg: team not found"
59
+ *
60
+ * So a disconnected team is one `unlock` accepts and `remoteBinding` refuses.
61
+ * A caller that used the stricter check to decide whether a name is known would
62
+ * turn a working command into a refusal — which is the shape of defect this arc
63
+ * has already paid for twice. The exit code is the answer; the state is not.
64
+ *
65
+ * A non-zero exit is reported WITH what the store said, so a broken script does
66
+ * not get presented to the operator as a mistyped name.
67
+ */
68
+ export async function localTeamLookup(scriptsDir, team) {
69
+ const r = await runAllowingFailure('bash', [
70
+ join(scriptsDir, 'remote.sh'),
71
+ 'status',
72
+ team,
73
+ '--json',
74
+ ]);
75
+ return r.code === 0 ? { known: true, said: '' } : { known: false, said: r.stderr };
76
+ }
31
77
  // Generate a device age identity at `identityPath`; returns its public recipient.
32
78
  export async function generateDeviceIdentity(identityPath) {
33
79
  await run('age-keygen', ['-o', identityPath]);
@@ -126,6 +172,49 @@ export async function remoteBinding(scriptsDir, team) {
126
172
  }
127
173
  return { teamId: status.remote_team_id, serverInstanceId: status.server_instance_id };
128
174
  }
175
+ export async function connectedTeams(scriptsDir) {
176
+ const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', '--json']);
177
+ const teams = [];
178
+ for (const line of out.toString().split('\n')) {
179
+ const text = line.trim();
180
+ if (text === '')
181
+ continue;
182
+ let status;
183
+ try {
184
+ status = JSON.parse(text);
185
+ }
186
+ catch {
187
+ // One unreadable line must not decide the whole answer either way:
188
+ // skipping it silently would file a backup that quietly omits a team, so
189
+ // it is refused instead. A status output this client cannot parse is not
190
+ // a shorter list of teams.
191
+ // Worded so it does not read as a command to paste: it quotes DATA, and
192
+ // the checker is right that an interpolation inside something
193
+ // command-shaped is worth refusing on sight.
194
+ throw new Error(`the team status this machine printed contains a line this version cannot read: ${JSON.stringify(text.slice(0, 80))}`);
195
+ }
196
+ // A state this version does not know is not quietly dropped and not quietly
197
+ // treated as filable: an unknown word here is the same shape as a line that
198
+ // did not parse, and it gets the same refusal.
199
+ if (status.state !== 'active' && status.state !== 'disconnected') {
200
+ throw new Error(`the team status this machine printed describes a state this version cannot read: ${JSON.stringify(String(status.state).slice(0, 40))}`);
201
+ }
202
+ if (typeof status.local_team !== 'string' ||
203
+ typeof status.remote_team_id !== 'string' ||
204
+ !status.remote_team_id ||
205
+ typeof status.server_instance_id !== 'string' ||
206
+ !status.server_instance_id) {
207
+ throw new Error(`remote.sh status --json described a team without the ids a vault entry needs`);
208
+ }
209
+ teams.push({
210
+ team: status.local_team,
211
+ teamId: status.remote_team_id,
212
+ serverInstanceId: status.server_instance_id,
213
+ state: status.state,
214
+ });
215
+ }
216
+ return teams;
217
+ }
129
218
  export async function remoteTeamId(scriptsDir, team) {
130
219
  const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
131
220
  const text = out.toString().trim();
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { chmodSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { deriveApproverCommitment, isCanonicalAgeRecipient } from '@agmsg-cloud/sas-core';
@@ -296,3 +296,35 @@ export function listResumableRequesterRecords(serverOrigin, env = process.env) {
296
296
  export function clearRecord(input, env = process.env) {
297
297
  rmSync(recordPath(pendingDir(env), input.role, input.serverOrigin, input.key), { force: true });
298
298
  }
299
+ /**
300
+ * Drop every pending record for one server, and say how many.
301
+ *
302
+ * For sign-out: a ceremony half-started against a service this machine is
303
+ * leaving has nothing left to complete, and its nonce is the opening of a
304
+ * commitment nobody will ever ask about again.
305
+ *
306
+ * Selected by the origin TAG that is already part of every filename, so this
307
+ * cannot drift from the naming above the way a second list of origins would.
308
+ * Records for other servers are untouched: signing out of one deployment is
309
+ * not signing out of the others.
310
+ */
311
+ export function clearRecordsForOrigin(serverOrigin, env = process.env) {
312
+ const dir = pendingDir(env);
313
+ if (!existsSync(dir))
314
+ return 0;
315
+ const tag = originTag(serverOrigin);
316
+ let removed = 0;
317
+ for (const name of readdirSync(dir)) {
318
+ if (!name.endsWith('.json'))
319
+ continue;
320
+ // `<role>-<tag>-<key>.json`. Matching the tag as its own dash-delimited
321
+ // field, not as a substring: a key that happened to contain the tag would
322
+ // otherwise take another server's record with it.
323
+ const parts = name.slice(0, -'.json'.length).split('-');
324
+ if (parts[1] !== tag)
325
+ continue;
326
+ rmSync(join(dir, name), { force: true });
327
+ removed += 1;
328
+ }
329
+ return removed;
330
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomInt } from 'node:crypto';
2
- import { shellArg } from './commands/sync.js';
2
+ import { shellArg } from './shell-arg.js';
3
3
  // The recovery key (K7) is the only thing that opens the vault. It is generated
4
4
  // here, shown once, and never stored — not by us and not by the server. Losing it
5
5
  // means the vault is unopenable, which is the property that makes the vault worth
@@ -289,12 +289,22 @@ export function promptRecoveryKey(prompt) {
289
289
  // a politer form: what is printed here is meant to be pasted, so it has to be
290
290
  // the command that runs, for the team this failure happened on, including
291
291
  // when that name is not one shell word.
292
+ // The command to re-run. The quoting sits ON the interpolating line, which is
293
+ // what the printed-command checker reads — a ternary hid it, and so did binding
294
+ // the result to a variable first. Both were still quoted; neither was visible.
295
+ // The checker is right to demand the stricter form: what it can see is what
296
+ // survives the next edit.
297
+ function setupCommand(team) {
298
+ if (team === undefined)
299
+ return 'agmsg-cloud recovery setup';
300
+ return `agmsg-cloud recovery setup ${shellArg(team)}`;
301
+ }
292
302
  export class NeedsTerminalError extends Error {
293
303
  constructor(team) {
294
304
  super('this needs a terminal, and stdin is not one.\n\n' +
295
305
  'Nothing was created and no recovery key exists yet. Run this yourself,\n' +
296
306
  'in your own terminal:\n\n' +
297
- ` agmsg-cloud recovery setup ${shellArg(team)}\n\n` +
307
+ ` ${setupCommand(team)}\n\n` +
298
308
  'It is refused here on purpose: the recovery key is shown once and stored\n' +
299
309
  'nowhere, so writing it into a redirected or captured stream would be this\n' +
300
310
  'tool breaking that promise itself.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agmsg-cloud",
3
- "version": "0.1.0-rc.3",
3
+ "version": "0.1.0-rc.4",
4
4
  "description": "Companion CLI for the agmsg cloud service: connect a team, join from another machine, and back up its keys.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",