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

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.
@@ -116,7 +116,7 @@ export async function cmdLogin(opts) {
116
116
  if (stored && (asked === undefined || stored.machineName === asked)) {
117
117
  const res = await post('/v1/device/activate', {}, stored.secret);
118
118
  if (res.ok) {
119
- out(`Already signed in as machine "${stored.machineName}".\n`);
119
+ out(`Already signed in as machine "${stored.machineName}" in organization "${stored.org}".\n`);
120
120
  return;
121
121
  }
122
122
  const code = await errorCode(res);
@@ -9,6 +9,72 @@ import { originOf } from '../credentials.js';
9
9
  import { deviceIdentityPath } from '../paths.js';
10
10
  import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
11
11
  import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
12
+ /**
13
+ * The one thing a long wait cannot say for itself: that it is the design.
14
+ *
15
+ * `login` learned this in #279 — an agent held a URL and a code for 42 seconds
16
+ * waiting for a blocking command to return, because nothing told it the block
17
+ * WAS the mechanism. These waits are longer: `login` waits for a browser the
18
+ * operator already has open, and these wait for a person on another machine.
19
+ *
20
+ * Written once and used at every wait, so the three cannot drift into saying
21
+ * different things about the same behaviour. What may be added to it is what
22
+ * stopping costs, and that is NOT the same at every wait — see below.
23
+ */
24
+ function saysItBlocks() {
25
+ return ' This command will not return until then. That is expected, not a failure.\n';
26
+ }
27
+ /**
28
+ * What stopping costs, and it stops being free after the digits are shown.
29
+ *
30
+ * MEASURED, both of them, because the first version of this file asserted the
31
+ * resume in all three places and was wrong in one:
32
+ *
33
+ * interrupted before the answer the next run prints `resuming enrollment
34
+ * <id>` and continues the same ceremony.
35
+ * Budget unchanged: used:1 remaining:4.
36
+ *
37
+ * interrupted at the LAST wait, the row goes terminal while nothing is
38
+ * and the approver then answers watching. `GET /v1/enrollments` lists
39
+ * NONTERMINAL rows only, so the stored
40
+ * record matches nothing, the old attempt is
41
+ * closed as failed, and the next run starts a
42
+ * NEW ceremony. Measured: used:2 remaining:3
43
+ * — one of five, spent.
44
+ *
45
+ * So the resume line belongs at the first two waits and is false at the third.
46
+ * Saying it there would promise a cheap retry for the one interruption that
47
+ * costs something.
48
+ */
49
+ function stoppingIsCheap() {
50
+ return ' Leave it running — if you do stop it, run the same command again to resume.\n';
51
+ }
52
+ /**
53
+ * NOTHING HERE WARNS ABOUT A SECOND TERMINAL, and that is a refusal to guess
54
+ * rather than a finding.
55
+ *
56
+ * The console prompt says "do not start a second one in another terminal", and
57
+ * #344 asked whether that is true before repeating it. It was measured twice
58
+ * and the two measurements disagree:
59
+ *
60
+ * an attempt opened under a commitment no run could plan
61
+ * -> `consumeAttempt` refuses with `attempt_already_open`
62
+ * — but that is not the branch a second terminal takes,
63
+ * because `request` looks for a resumable record first.
64
+ * A rigged input, and it agreed with the warning.
65
+ *
66
+ * a first run interrupted mid-wait, then a second started
67
+ * -> once observed printing `resuming enrollment <id>`
68
+ * and carrying on at no cost; once observed starting a
69
+ * NEW ceremony instead. The two runs differed in their
70
+ * server double, and which difference decided it was
71
+ * not established.
72
+ *
73
+ * So this says nothing about second terminals in either direction. A warning
74
+ * has to be true to be worth a reader's attention, and so does a reassurance.
75
+ * What IS established is on `stoppingIsCheap` above: the cost of stopping, at
76
+ * each wait, measured.
77
+ */
12
78
  export async function cmdRequest(config, args, deps = {}) {
13
79
  const out = deps.out ?? ((text) => void process.stdout.write(text));
14
80
  const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
@@ -179,14 +245,20 @@ export async function cmdRequest(config, args, deps = {}) {
179
245
  }
180
246
  const done = () => clearRecord({ role: 'requester', serverOrigin: config.baseUrl, key: record.commitmentHex }, env);
181
247
  try {
182
- out('waiting for an approver to commit...\n');
248
+ 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');
250
+ out(saysItBlocks());
251
+ out(stoppingIsCheap());
183
252
  await waitForStatus(client, requestId, 'both_committed', deps.waitOptions);
184
253
  // Safe to open now: the approver's contribution is fixed and cannot change.
185
254
  await client.submitOpening(requestId, {
186
255
  device_pubkey: pubkey,
187
256
  opening_nonce: record.nonceHex,
188
257
  });
189
- out('waiting for the approver to open...\n');
258
+ out('\nwaiting for the approver to open...\n');
259
+ out(' Nothing to do here yet — the digits to compare appear when this returns.\n');
260
+ out(saysItBlocks());
261
+ out(stoppingIsCheap());
190
262
  const opened = await waitForStatus(client, requestId, 'opened', deps.waitOptions);
191
263
  // Derived here, from the two opened nonces. Nothing displayed below came
192
264
  // from the server as a code.
@@ -205,6 +277,22 @@ export async function cmdRequest(config, args, deps = {}) {
205
277
  // requirement that the requester counts failed and incomplete attempts, and
206
278
  // warns from the second failure, did nothing on this side. Wait for the
207
279
  // server to say which way it went.
280
+ // THE THIRD WAIT, and it said nothing at all — worse than the two the issue
281
+ // named, and the longest silence in the run.
282
+ //
283
+ // No line of its own, unlike the other two. What a reader has to do here is
284
+ // already on the screen directly above: `renderSasBlock` says the approving
285
+ // machine shows eight digits too, that they should check they are the same,
286
+ // and what each answer means. A copy of that here is the repetition #195
287
+ // removed from this very screen — a second copy of the one line that has to
288
+ // be read is how a reader learns to skim it. What was missing was not the
289
+ // instruction. It was that this blocks.
290
+ out('\nwaiting for the approver to answer...\n');
291
+ out(saysItBlocks());
292
+ // NOT `stoppingIsCheap()`. This is the one interruption that costs an
293
+ // attempt — see the note on that function for the measurement.
294
+ out(' Stopping here is the one that costs: if they answer while nothing is\n');
295
+ out(' watching, the next run starts over and spends one of five attempts.\n');
208
296
  const settled = await waitForStatus(client, requestId, 'consumed', deps.waitOptions);
209
297
  // What the eight digits authenticated, kept for `fetch`.
210
298
  //
@@ -32,6 +32,39 @@ export async function cmdSync(config, opts) {
32
32
  const request = d.request ?? cmdRequest;
33
33
  const fetch = d.fetch ?? cmdFetch;
34
34
  const pull = d.pull ?? cmdPull;
35
+ const client = new CourierClient(config);
36
+ // BEFORE THE CEREMONY, because `request` spends one of five attempts before
37
+ // it posts the enrollment. The ceremony is org-scoped and cannot discover
38
+ // that the named team belongs to another org; leaving this to `pull` made a
39
+ // wrong-account run finish the human comparison, spend an attempt, and only
40
+ // then say the team was absent (#345).
41
+ //
42
+ // This lookup is scoped by the current credential on the control plane. A
43
+ // failure to answer is not absence, and ambiguity is not resolved here:
44
+ // neither state is permission to spend a ceremony on a guessed team.
45
+ let matches;
46
+ try {
47
+ matches = opts.teamId === undefined
48
+ ? await client.resolveTeamByName(opts.team)
49
+ : (await client.listTeams()).filter((team) => team.teamId === opts.teamId);
50
+ }
51
+ catch (err) {
52
+ out(`Not started: this account's organization could not be checked for "${opts.team}": ` +
53
+ `${err instanceof Error ? err.message : String(err)}\n` +
54
+ 'No enrollment was started, so this costs none of your attempts.\n');
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ if (matches.length !== 1) {
59
+ out(matches.length === 0
60
+ ? `Not started: this account's organization has no team named "${opts.team}".\n`
61
+ : `Not started: this account's organization has more than one team named "${opts.team}".\n`);
62
+ out('Check `agmsg-cloud whoami`, then sign in to the organization that holds the team.\n');
63
+ out('No enrollment was started, so this costs none of your attempts.\n');
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ const resolvedTeamId = matches[0].teamId;
35
68
  // The approver sees this, and they are looking for a machine they recognise.
36
69
  // A hostname is what a person calls their laptop; a uuid is what they read
37
70
  // aloud wrongly.
@@ -62,7 +95,7 @@ export async function cmdSync(config, opts) {
62
95
  // join; failing the join over a failed courtesy check would be the refusal
63
96
  // this deliberately is not.
64
97
  try {
65
- const existing = await new CourierClient(config).listDevices();
98
+ const existing = await client.listDevices();
66
99
  // THIS MACHINE'S OWN ROW IS NOT A CLASH WITH ITSELF.
67
100
  //
68
101
  // The comparison was on label alone, so a machine already on the account —
@@ -162,9 +195,11 @@ export async function cmdSync(config, opts) {
162
195
  // It stayed hidden because the second machine in testing pointed at the first
163
196
  // machine's install, where the team was already present — so the second
164
197
  // machine's path had never actually been walked.
165
- await pull(config, opts.teamId === undefined
166
- ? { team: opts.team, nextStepsFromCaller: true }
167
- : { team: opts.team, teamId: opts.teamId, nextStepsFromCaller: true });
198
+ await pull(config, {
199
+ team: opts.team,
200
+ teamId: resolvedTeamId,
201
+ nextStepsFromCaller: true,
202
+ });
168
203
  // And now the key, which opens what arrived above. Only reachable once the
169
204
  // server says the ceremony was approved — `request` waits for that, and
170
205
  // throws otherwise. The bundle is checked against the snapshot those digits
package/dist/src/index.js CHANGED
@@ -11,7 +11,7 @@ import { cmdPull } from './commands/pull.js';
11
11
  import { cmdRequest } from './commands/request.js';
12
12
  import { cmdVaultPut, cmdVaultRestore } from './commands/vault.js';
13
13
  import { cmdWatch } from './commands/watch.js';
14
- import { packageVersion } from './version.js';
14
+ import { packageInstall } from './version.js';
15
15
  const USAGE = `agmsg-cloud — hosted agmsg from this machine
16
16
 
17
17
  login sign this machine in; approve it in your own browser
@@ -40,7 +40,8 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
40
40
  whoami which of this account's machines this one is —
41
41
  its name, its organization, and the prefix the
42
42
  console shows beside it
43
- version the version of this CLI (also --version, -v)
43
+ version the version of this CLI, and the directory it is
44
+ running out of (also --version, -v)
44
45
  the OSS scripts it drives are reported by
45
46
  \`connect --preflight\`, which is a separate answer
46
47
 
@@ -104,7 +105,21 @@ async function main(argv) {
104
105
  // one it must assume someone will paste. Written without naming a
105
106
  // version: an example that has to be bumped is a second place to bump,
106
107
  // which is the reason this command reads the manifest at all.
107
- process.stdout.write(`agmsg-cloud/${packageVersion()}\n`);
108
+ //
109
+ // The directory follows on its own line because the version alone does
110
+ // not answer the question people ask this command. `npm i -g agmsg-cloud`
111
+ // can succeed while a copy another package manager put earlier on PATH is
112
+ // what runs, and nothing about that is an error: both installs are valid
113
+ // and the shell picks one. That happened here, and the two defects being
114
+ // chased were already fixed in the copy that was NOT running. Choosing
115
+ // for the user would mean deciding their PATH; saying which one answered
116
+ // costs one line and leaves the choice where it was.
117
+ //
118
+ // A path, not a command: this line must not read as something to paste,
119
+ // which is why it is not shaped like an invocation.
120
+ const install = packageInstall();
121
+ process.stdout.write(`agmsg-cloud/${install.version}\n`);
122
+ process.stdout.write(`running from ${install.directory}\n`);
108
123
  return;
109
124
  }
110
125
  // Beside `version` for the same reason it sits there: both answer a
@@ -70,8 +70,8 @@ export function generateRecoveryKey() {
70
70
  // error rather than a silent drop, so a wrong key fails as a wrong key and not
71
71
  // as a mangled one.
72
72
  export function normalizeRecoveryKey(input) {
73
- let cleaned = input
74
- .trim()
73
+ const entered = input.trim();
74
+ let cleaned = entered
75
75
  .toUpperCase()
76
76
  .replace(/[\s-]/g, '');
77
77
  // The prefix is a label, not key material, and it is stripped by LENGTH
@@ -87,6 +87,17 @@ export function normalizeRecoveryKey(input) {
87
87
  if (cleaned.length === PREFIX_SYMBOLS + TOTAL_SYMBOLS && cleaned.startsWith('AGMSG')) {
88
88
  cleaned = cleaned.slice(PREFIX_SYMBOLS);
89
89
  }
90
+ // Locate a pasted label before diagnosing one character inside it. The
91
+ // prompt masks the input, so "unusable character: :" after a 50-character
92
+ // paste does not tell the operator where the colon was or what a key should
93
+ // look like. Length is the larger fact and can be checked without exposing
94
+ // the secret: the canonical form is 35 characters, while accepted spacing
95
+ // and grouping still reduce to exactly 25 key symbols (or 30 with AGMSG).
96
+ if (cleaned.length !== TOTAL_SYMBOLS) {
97
+ throw new Error(`that entry is ${entered.length} characters; a recovery key is 35 characters ` +
98
+ `in the form ${PREFIX}XXXXX-XXXXX-XXXXX-XXXXX-XXXXX ` +
99
+ `(${TOTAL_SYMBOLS} symbols without the prefix and separators)`);
100
+ }
90
101
  cleaned = cleaned
91
102
  .replace(/[IL]/g, '1')
92
103
  .replace(/O/g, '0')
@@ -95,9 +106,6 @@ export function normalizeRecoveryKey(input) {
95
106
  if (!ALPHABET.includes(ch))
96
107
  throw new Error(`recovery key contains an unusable character: ${ch}`);
97
108
  }
98
- if (cleaned.length !== TOTAL_SYMBOLS) {
99
- throw new Error(`recovery key must be ${TOTAL_SYMBOLS} symbols, got ${cleaned.length}`);
100
- }
101
109
  // The check symbol, verified here rather than at the vault. Without it, a
102
110
  // mistyped key is indistinguishable from a wrong one until the AEAD refuses
103
111
  // it — which is at restore time, the moment there is no way back. Naming the
@@ -14,12 +14,18 @@ export function adviseOnSlot(result, ctx) {
14
14
  switch (result.reason) {
15
15
  case 'no-slot':
16
16
  // The first backup on this machine, or the first after a re-issuance.
17
- // Worth one line, because the next sentence asks for the recovery key and
18
- // the person deserves to know why this time and not next time.
17
+ // Worth saying before the next sentence asks for the recovery key. Do
18
+ // not promise "once": knowing that the slot is absent is not knowing
19
+ // that this session may write its replacement. A headless macOS session
20
+ // can read the default keychain name and still have add-generic-password
21
+ // refused with errSecInteractionNotAllowed. That fact is learned only
22
+ // after the vault has opened, when saveDeviceSlot performs the write.
19
23
  return {
20
24
  tone: 'note',
21
25
  lines: [
22
- 'this machine has no key slot for this vault yet, so the recovery key is needed once.',
26
+ 'this machine has no key slot for this vault yet, so the recovery key is needed now.',
27
+ 'After this succeeds, the CLI will try to keep a slot in the OS secure store. If this',
28
+ 'session cannot use that store, the recovery key will be needed every time.',
23
29
  ],
24
30
  };
25
31
  case 'no-store':
@@ -5,27 +5,23 @@ import { fileURLToPath } from 'node:url';
5
5
  // version — an identity, and the only thing that tells one package.json from
6
6
  // another while walking up a tree that may contain several.
7
7
  const PACKAGE_NAME = 'agmsg-cloud';
8
- // The version this build was published as.
9
- //
10
- // Read from the package's own `package.json` at runtime rather than baked into
11
- // a constant, because a constant is a second place to update and the one that
12
- // gets forgotten: a build that says the wrong version is worse than one that
13
- // says none, since the answer looks authoritative either way. npm always
14
- // includes package.json in a tarball, so it is there for a published install.
15
- //
16
- // Found by walking UP from this module rather than by a fixed relative path,
17
- // because the depth differs between the two places this file runs from:
18
- // `src/version.ts` sits one level under the package root, `dist/src/version.js`
19
- // sits two. A path correct in one is wrong in the other, and the one that would
20
- // be wrong is the published one.
21
- //
22
- // The walk answers only from a manifest that NAMES this package. Without that
23
- // check the first non-empty version above the module wins, and the installs
24
- // most likely to hit it are the broken ones — a package.json missing or
25
- // stripped of its version is exactly when a monorepo root, an npx cache entry,
26
- // or a parent workspace supplies its own. Reporting a stranger's version is the
27
- // failure this command exists to prevent, wearing the shape of a success.
28
- export function packageVersion(moduleUrl = import.meta.url) {
8
+ /**
9
+ * The install this process is running out of: its version AND where it is.
10
+ *
11
+ * Both come from ONE walk, and that is the point rather than a convenience.
12
+ * `which agmsg-cloud` answers from PATH and a manifest answers from the module
13
+ * graph; when two package managers have both installed this tool, those are two
14
+ * different questions with two different answers, and pairing them would report
15
+ * a directory that need not be where the version came from. The directory
16
+ * returned here is the one whose `package.json` supplied the version, so the
17
+ * two cannot disagree.
18
+ *
19
+ * Node resolves a module's realpath before loading it, so a bin symlink — the
20
+ * usual shape of a global install — has already been followed by the time
21
+ * `import.meta.url` exists. What comes back is the store directory, which is
22
+ * the part that tells a pnpm global install from an npm one.
23
+ */
24
+ export function packageInstall(moduleUrl = import.meta.url) {
29
25
  let dir = dirname(fileURLToPath(moduleUrl));
30
26
  const { root } = parse(dir);
31
27
  for (;;) {
@@ -41,7 +37,7 @@ export function packageVersion(moduleUrl = import.meta.url) {
41
37
  if (manifest?.name === PACKAGE_NAME) {
42
38
  const version = manifest.version;
43
39
  if (typeof version === 'string' && version !== '')
44
- return version;
40
+ return { version, directory: dir };
45
41
  // Fail closed. This IS the package and it cannot say what it is; climbing
46
42
  // past would hand the question to whatever sits above, which is how a
47
43
  // broken install comes to announce a stranger's version.
@@ -55,3 +51,6 @@ export function packageVersion(moduleUrl = import.meta.url) {
55
51
  // guessed — see the note above.
56
52
  throw new Error(`cannot determine the installed version: no ${PACKAGE_NAME} package.json above this module`);
57
53
  }
54
+ export function packageVersion(moduleUrl = import.meta.url) {
55
+ return packageInstall(moduleUrl).version;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agmsg-cloud",
3
- "version": "0.1.0-rc.7",
3
+ "version": "0.1.0-rc.8",
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",
@@ -34,7 +34,8 @@
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit",
35
35
  "lint": "tsx scripts/check-printed-commands.ts && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts",
36
36
  "prepack": "pnpm run build && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts",
37
- "verify:published": "tsx scripts/verify-published.ts"
37
+ "verify:published": "tsx scripts/verify-published.ts",
38
+ "verify:shown-install": "tsx scripts/verify-shown-install.ts"
38
39
  },
39
40
  "dependencies": {
40
41
  "@agmsg-cloud/sas-core": "workspace:*",