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

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/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { cmdWhoami } from './commands/whoami.js';
2
3
  import { loadConfig, resolveScriptsDir } from './config.js';
3
4
  import { cmdApprove } from './commands/approve.js';
4
5
  import { cmdConnect, cmdConnectPreflight } from './commands/connect.js';
@@ -36,6 +37,9 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
36
37
  team this machine's store reports; name one to back up
37
38
  only that team
38
39
  recovery restore <team> (on any approved machine) open the backup and unlock <team>
40
+ whoami which of this account's machines this one is —
41
+ its name, its organization, and the prefix the
42
+ console shows beside it
39
43
  version the version of this CLI (also --version, -v)
40
44
  the OSS scripts it drives are reported by
41
45
  \`connect --preflight\`, which is a separate answer
@@ -54,6 +58,9 @@ environment variable for it.
54
58
  Environment (for CI and headless runs; \`login\` is the normal path):
55
59
  AGMSG_CLOUD_ENDPOINT base URL of the hosted edge
56
60
  AGMSG_CLOUD_SECRET this machine's capability secret
61
+ AGMSG_SCRIPTS_DIR the agmsg install to drive
62
+ (default: ~/.agents/skills/agmsg/scripts)
63
+ \`connect --preflight\` prints the one it resolved
57
64
 
58
65
  Both or neither: a stored secret is only ever sent to the endpoint it was
59
66
  minted for, so one of these without the other is refused rather than mixed
@@ -100,6 +107,13 @@ async function main(argv) {
100
107
  process.stdout.write(`agmsg-cloud/${packageVersion()}\n`);
101
108
  return;
102
109
  }
110
+ // Beside `version` for the same reason it sits there: both answer a
111
+ // question about this machine and neither reaches the network, so both
112
+ // still work at the moment something else has refused.
113
+ case 'whoami': {
114
+ cmdWhoami();
115
+ return;
116
+ }
103
117
  case 'login': {
104
118
  // No endpoint means the official deployment. The flag stays for
105
119
  // self-hosted and development stacks; either way the destination is
@@ -144,7 +158,13 @@ async function main(argv) {
144
158
  const label = rest[0];
145
159
  if (!label)
146
160
  throw new Error('usage: agmsg-cloud request <label>');
147
- return cmdRequest(loadConfig(), { label });
161
+ // The outcome is dropped here on purpose: this is the whole command, and
162
+ // the exit code `request` set is what the shell reads. It is returned for
163
+ // the callers that run it as a STEP and have to decide whether to carry
164
+ // on — `sync` did not, which is how a failed enrollment ended in a
165
+ // message about a missing team.
166
+ await cmdRequest(loadConfig(), { label });
167
+ return;
148
168
  }
149
169
  case 'sync': {
150
170
  // The whole second-machine path. `request`, `fetch` and `pull` remain as
@@ -223,9 +243,17 @@ async function main(argv) {
223
243
  // to add that one on its own. `restore` still requires one — it unlocks a
224
244
  // specific team on this machine.
225
245
  //
226
- // The excuse rides inside the slot, so it cannot apply to anything else.
246
+ // `restore`, written out. It is the only value `sub` can hold here — the
247
+ // branch tests for it — so the interpolation was indirection that printed
248
+ // a constant, and it carried a `printed-commands:` exemption to say so.
249
+ //
250
+ // That exemption was the last one in this file, and #201 is about what it
251
+ // rested on. Measured: nothing. The guard never reached this line, because
252
+ // a command introduced by `usage: ` opened no command extent, so the
253
+ // comment sat over a slot no rule was applied to. Both the comment and the
254
+ // slot are gone, and the line is now inside the guard's scope.
227
255
  if (sub === 'restore' && !team) {
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>`);
256
+ throw new Error('usage: agmsg-cloud recovery restore <team>');
229
257
  }
230
258
  const config = loadConfig();
231
259
  return sub === 'setup'
@@ -0,0 +1,44 @@
1
+ // Which of an account's machines THIS one is.
2
+ //
3
+ // The console lists an org's machines with a `token_prefix` column, so two
4
+ // machines called `mbp2024` are two visibly different rows. Nothing on the
5
+ // machine printed the matching value, so the distinction could be seen and not
6
+ // resolved — the one person who knows which laptop they are sitting at had
7
+ // nothing to compare against (#199). The revoke dialog names the prefix too
8
+ // (#198), which makes that gap the difference between "these are two rows" and
9
+ // "this one is mine".
10
+ //
11
+ // NOT A SECRET, and worth stating because it is carved out of one. The mint is
12
+ // `agsy_<8 hex>_<43 base64url>` (app/src/edge/capability.ts) and the server
13
+ // stores `agsy_<8 hex>` as the column the console shows to any org member. The
14
+ // capability is the segment AFTER it, and it is what never leaves this file's
15
+ // caller.
16
+ /**
17
+ * The shape the server mints (app/src/edge/capability.ts): `agsy_<8 hex>_<43
18
+ * base64url>`, with the leading two segments captured.
19
+ *
20
+ * ONE definition, exported, because two callers need the same answer for
21
+ * different reasons: `login` refuses to STORE a secret that does not match, and
22
+ * `machinePrefix` refuses to READ a prefix out of one that does not. A second
23
+ * copy would let those drift — updating the mint in one place would leave the
24
+ * other silently answering about a format that no longer exists, and the
25
+ * failure would be a prefix that stops resolving rather than an error
26
+ * (raised in review).
27
+ */
28
+ export const SECRET_RE = /^(agsy_[a-f0-9]{8})_[A-Za-z0-9_-]{43}$/;
29
+ /**
30
+ * The prefix the console shows for this machine, or null if the stored secret
31
+ * is not the shape this version knows.
32
+ *
33
+ * Derived by MATCHING the whole shape rather than by cutting at a fixed length
34
+ * or at the first underscore. A slice would happily return the first 13
35
+ * characters of something that is not a capability at all — including, if the
36
+ * format ever changes, thirteen characters of live secret. It matches against
37
+ * the SAME exported pattern `login` uses to refuse storing a wrong shape —
38
+ * one definition with two callers, rather than two copies a comment claims
39
+ * are equal.
40
+ */
41
+ export function machinePrefix(secret) {
42
+ const m = SECRET_RE.exec(secret);
43
+ return m ? m[1] : null;
44
+ }
package/dist/src/oss.js CHANGED
@@ -249,11 +249,25 @@ export async function unlockAuthenticatedBundle(scriptsDir, team, bundle) {
249
249
  await run('bash', [join(scriptsDir, 'remote.sh'), 'unlock', team, '--authenticated-bundle-stdin'], bundle);
250
250
  }
251
251
  // OSS `remote.sh unlock <team> --bundle <file> [--confirm-digest <digest>]`.
252
+ //
253
+ // RETURNS what the script said, rather than discarding it.
254
+ //
255
+ // `remote.sh unlock` does not print optimistically: it waits for the sync
256
+ // engine to report ready, checks the recorded pidfile matches the process it
257
+ // started, checks that process is alive, and exits non-zero otherwise. Only
258
+ // then does it print `Unlocked '<team>': imported N envelope(s); engine
259
+ // running (pid N).`
260
+ //
261
+ // That sentence is verified against a live pid, and it names the object. The
262
+ // caller used to throw it away and print its own summary instead, which named
263
+ // nothing — so an operator watching a successful join saw "unlocked" with no
264
+ // way to tell WHICH thing had been unlocked, four lines above a remedy telling
265
+ // them to unlock something (#147).
252
266
  export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest) {
253
267
  const args = [join(scriptsDir, 'remote.sh'), 'unlock', team, '--bundle', bundleFile];
254
268
  if (confirmDigest)
255
269
  args.push('--confirm-digest', confirmDigest);
256
- await run('bash', args);
270
+ return (await run('bash', args)).toString();
257
271
  }
258
272
  // The canonical age snapshot, exported locally, and its digest (§3.2, §3.2.1).
259
273
  //
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import { defaultScriptsDir, hasCredential, scriptsDirChoice } from './config.js';
3
4
  import { join } from 'node:path';
4
5
  import { platform } from 'node:process';
5
6
  // What `connect` needs before it starts, checked all at once.
@@ -150,10 +151,41 @@ function toolsFor(scripts) {
150
151
  python3: scripts.includes('remote.sh'),
151
152
  };
152
153
  }
153
- export function preflight(scriptsDir, needs) {
154
+ export function preflight(scriptsDir, needs, env = process.env,
155
+ /**
156
+ * The home directory the default install would be under.
157
+ *
158
+ * Only ever passed by a check that needs to put a second install somewhere it
159
+ * owns. It is a parameter rather than `env.HOME` because this CLI runs on
160
+ * Windows too, where a POSIX-compatible shell sets `HOME` to a path
161
+ * `homedir()` does not return — reading it would have moved the default
162
+ * install for every Git Bash user in exchange for a testable line.
163
+ */
164
+ home) {
154
165
  const { command, scripts } = needs;
155
166
  const requireConnect = needs.requireConnect ?? false;
156
167
  const requirements = [];
168
+ // BEING SIGNED IN IS A PREREQUISITE, so it is one of the things checked.
169
+ //
170
+ // It was not, and the result was a check that said "Everything connect needs
171
+ // is here" immediately before connect stopped for a missing sign-in (#222).
172
+ // A checklist that omits a requirement does not merely fail to help — it
173
+ // states that the requirement is met.
174
+ //
175
+ // Listed first because it is the one whose fix is a different command
176
+ // entirely, and because the others are about this machine's install while
177
+ // this one is about this machine's account.
178
+ requirements.push({
179
+ name: 'signed in',
180
+ // The resolver `loadConfig` uses, not a second opinion about the same
181
+ // question. `readCredential` alone reported a headless machine with
182
+ // AGMSG_CLOUD_ENDPOINT + AGMSG_CLOUD_SECRET as signed out — a checklist
183
+ // refusing a machine that CAN run the command, which is the defect this
184
+ // requirement exists to fix, facing the other way (raised in review).
185
+ ok: hasCredential(env),
186
+ why: `${command} talks to the hosted service as this machine, and this machine has no credential yet.`,
187
+ install: ['Sign in: agmsg-cloud login'],
188
+ });
157
189
  // The scripts come first: without them nothing else matters, and the fix is
158
190
  // a different kind of thing (install agmsg, or point AGMSG_SCRIPTS_DIR at it)
159
191
  // than installing a binary.
@@ -205,9 +237,45 @@ export function preflight(scriptsDir, needs) {
205
237
  requirements,
206
238
  ok: requirements.every((r) => r.ok),
207
239
  command,
240
+ scriptsDir,
208
241
  agmsgVersion: canConnect ? installedVersion(scriptsDir) : null,
242
+ scriptsDirFrom: provenance(env, home),
243
+ otherInstalls: otherInstalls(scriptsDir, env, home),
209
244
  };
210
245
  }
246
+ /**
247
+ * The install that exists at the location this command is NOT using.
248
+ *
249
+ * Only ever one, and only when `AGMSG_SCRIPTS_DIR` points somewhere other than
250
+ * the default: those are the two places `resolveScriptsDir` can name, so they
251
+ * are the two places worth reporting. `version.sh` is the marker rather than
252
+ * the directory itself — an empty `~/.agents/skills/agmsg/scripts` is not a
253
+ * second install, and calling it one would send someone to look at nothing.
254
+ */
255
+ /**
256
+ * Which of the three states the resolution is in.
257
+ *
258
+ * Three, not two: the variable can be set AND point at the default, which is
259
+ * what someone has on the way to undoing an override. Collapsing it into "env"
260
+ * makes the output say "not the default location" about the default location.
261
+ */
262
+ function provenance(env, home) {
263
+ const choice = scriptsDirChoice(env);
264
+ if (choice.from === 'default')
265
+ return 'default';
266
+ return choice.dir === defaultScriptsDir(home) ? 'env-at-default' : 'env';
267
+ }
268
+ function otherInstalls(scriptsDir, env, home) {
269
+ // Through the resolver's own accessor, so the two can never disagree about
270
+ // where "the default" is — a disagreement here would report the wrong
271
+ // directory as the one not in use.
272
+ const fallback = defaultScriptsDir(home);
273
+ if (scriptsDir === fallback)
274
+ return [];
275
+ if (!env.AGMSG_SCRIPTS_DIR)
276
+ return [];
277
+ return existsSync(join(fallback, 'version.sh')) ? [fallback] : [];
278
+ }
211
279
  // The refusal in front of every command that shells out: one place decides how
212
280
  // a missing prerequisite reads, so it reads the same whichever command found
213
281
  // it.
@@ -230,6 +298,48 @@ export function formatPreflight(result) {
230
298
  for (const r of result.requirements) {
231
299
  lines.push(` ${r.ok ? '[x]' : '[ ]'} ${r.name}`);
232
300
  }
301
+ // THE RESOLVED DIRECTORY, IN FULL, ON ITS OWN LINE.
302
+ //
303
+ // It used to appear only inside a requirement's name — `agmsg with remote
304
+ // sync (in /very/long/path)` — where the walk's terminal cut it off. The
305
+ // operator could not see WHICH install had been checked, which is exactly
306
+ // the disagreement they were trying to diagnose (#222). A path is not a
307
+ // decoration on a label; it is the answer to "which one did you look at".
308
+ lines.push('');
309
+ lines.push(` scripts directory: ${result.scriptsDir}`);
310
+ // WHAT PUT IT THERE, when something did.
311
+ //
312
+ // The hint below is advice to redirect the install. Printed while
313
+ // AGMSG_SCRIPTS_DIR is already set, it invites the reader to do the thing
314
+ // that is currently confusing them — "you could redirect this" when the truth
315
+ // is "this is redirected". So the two are alternatives, not a pair.
316
+ //
317
+ // Nothing extra when the default is in force: "this is the default" on every
318
+ // ordinary run is noise, and the sentence is only missing when something
319
+ // non-default is deciding.
320
+ if (result.scriptsDirFrom === 'env') {
321
+ lines.push(` chosen by AGMSG_SCRIPTS_DIR, not the default location.`);
322
+ }
323
+ else if (result.scriptsDirFrom === 'env-at-default') {
324
+ // The variable is set AND points at the default. Saying "not the default
325
+ // location" here would be false, and it is the state someone reaches while
326
+ // trying to undo an override — the one moment the sentence has to be exact.
327
+ lines.push(` chosen by AGMSG_SCRIPTS_DIR, which points at the default location.`);
328
+ }
329
+ else {
330
+ lines.push(` set AGMSG_SCRIPTS_DIR to check a different install.`);
331
+ }
332
+ // NAMES THE OTHER ONE, and says which of the two is in force.
333
+ //
334
+ // Without this the reader has to decide, and an agent that finds two installs
335
+ // stops and asks — the incident four lines of the handed-over prompt exist to
336
+ // prevent (issue 279). The answer is not a preference to be settled; it is
337
+ // already settled by the resolver, and this is it being said out loud.
338
+ for (const other of result.otherInstalls) {
339
+ lines.push('');
340
+ lines.push(` another install is here: ${other}`);
341
+ lines.push(` it is NOT the one in use — AGMSG_SCRIPTS_DIR decides, and it points above.`);
342
+ }
233
343
  if (result.agmsgVersion !== null) {
234
344
  // Named as what it is. Calling it "agmsg 1.1.11" would invite the reader to
235
345
  // compare it with a release number, which is the thing it cannot be
@@ -241,6 +351,15 @@ export function formatPreflight(result) {
241
351
  if (missing.length === 0) {
242
352
  lines.push('');
243
353
  lines.push(`Everything ${result.command} needs is here.`);
354
+ // SAYS THAT THIS IS THE CHECK.
355
+ //
356
+ // An agent told "make sure the prerequisites are met" checks them again
357
+ // unless something says not to — it has no way to know this output was the
358
+ // check rather than a summary of one. The prompt carries a sentence for
359
+ // exactly that ("do not check for tools yourself"), which is a property
360
+ // this output should have rather than a warning the reader must be given
361
+ // separately (issue 279).
362
+ lines.push(`This was the check. Nothing else needs testing before you run it.`);
244
363
  return `${lines.join('\n')}\n`;
245
364
  }
246
365
  for (const r of missing) {
@@ -281,20 +281,35 @@ export function promptRecoveryKey(prompt) {
281
281
  // can then be re-derived from material this machine holds. It does not exist
282
282
  // yet, so the caller shows first and says so if the backup then fails.
283
283
  //
284
- // The command it prints is the REAL one, built from the team this run was
285
- // given and quoted through `shellArg` (raised in review). It used to read
286
- // `agmsg-cloud recovery setup` with no argument, which the dispatch refuses —
287
- // so the one line telling someone what to do next was a line that could only
288
- // produce a usage error. A `<team>` placeholder would have the same defect in
289
- // a politer form: what is printed here is meant to be pasted, so it has to be
290
- // the command that runs, for the team this failure happened on, including
291
- // when that name is not one shell word.
284
+ // The command it prints is the REAL one — whatever `setupCommand` builds for
285
+ // the team this run was given, or for no team when it was given none. What is
286
+ // printed here is meant to be PASTED, so it has to be a command that runs: a
287
+ // `<team>` placeholder is not, and a name that is not one shell word has to
288
+ // arrive quoted (raised in review).
289
+ //
290
+ // This paragraph used to add that the no-argument form "the dispatch refuses",
291
+ // and that printing it could only produce a usage error. That was true when the
292
+ // team was required. It stopped being true when the argument became optional,
293
+ // and the sentence sat directly above the derivation that says the opposite
294
+ // (#182 review). Both halves are still printable and both still run; which one
295
+ // appears is decided by the caller having a team in hand, not by one of them
296
+ // being invalid.
292
297
  // The command to re-run. The quoting sits ON the interpolating line, which is
293
298
  // what the printed-command checker reads — a ternary hid it, and so did binding
294
299
  // the result to a variable first. Both were still quoted; neither was visible.
295
300
  // The checker is right to demand the stricter form: what it can see is what
296
301
  // survives the next edit.
297
- function setupCommand(team) {
302
+ /**
303
+ * How this tool names the command that creates a vault and backs teams up.
304
+ *
305
+ * One derivation, because the argument convention has already moved once: the
306
+ * team used to be required and is now optional, and the no-argument form is the
307
+ * ordinary one — it covers every team this machine reports rather than the one
308
+ * that happened to be in hand. Every place that prints this remedy asks here,
309
+ * so the next change to the convention moves one line rather than being grepped
310
+ * for (#181).
311
+ */
312
+ export function setupCommand(team) {
298
313
  if (team === undefined)
299
314
  return 'agmsg-cloud recovery setup';
300
315
  return `agmsg-cloud recovery setup ${shellArg(team)}`;
@@ -1,3 +1,4 @@
1
+ import { setupCommand } from './recovery-key.js';
1
2
  import { shellArg } from './shell-arg.js';
2
3
  /**
3
4
  * Explain a slot that did not open, given the command that will now ask for the
@@ -65,6 +66,90 @@ export function adviseOnSlot(result, ctx) {
65
66
  };
66
67
  }
67
68
  }
69
+ /**
70
+ * Explain a slot that did not open, given a command that will NOT ask for the
71
+ * recovery key — `connect`, which files the team's key into the vault silently
72
+ * or not at all.
73
+ *
74
+ * A second mapping rather than a second wording of the first: the sentences
75
+ * above all end in "the recovery key is typed and this run continues", and
76
+ * every one of them is false here. Nothing continues; the team is not in the
77
+ * vault, and what the person needs is the route that puts it there.
78
+ *
79
+ * The two live in one file, side by side, because they are one judgement read
80
+ * twice. `OpenResult['reason']` is a union and both switches are exhaustive, so
81
+ * a fifth reason cannot be answered in one of them and forgotten in the other —
82
+ * it fails to compile until both have looked at it.
83
+ *
84
+ * The `no-store` branch is why this exists at all. The prompting caller can say
85
+ * "the key is needed for each backup" and be done; here, the honest sentence is
86
+ * that this machine will never file silently, and a remedy that said "run
87
+ * `recovery setup` once and this stops happening" would be a route that does
88
+ * not exist on Windows or Linux.
89
+ */
90
+ export function adviseOnSlotForSilentFiling(result, ctx) {
91
+ if (result.ok)
92
+ return { tone: 'silent', lines: [] };
93
+ // NAMED, and the argument is the point rather than a detail.
94
+ //
95
+ // `recovery setup` takes the team optionally now, and the no-argument form is
96
+ // the one its own documentation calls ordinary — which is why this printed it
97
+ // at first. But the broad form's success depends on every OTHER team the
98
+ // machine reports: one pass, no per-team isolation, so a `key.sh handoff`
99
+ // that fails for an unrelated team throws out of the loop and nothing is
100
+ // written. And if `remote.sh status --json` comes back short (agmsg#650) the
101
+ // target list can be empty, which refuses outright.
102
+ //
103
+ // Either way the person is sent to a command that can fail for a reason that
104
+ // has nothing to do with the team they just connected — and this is the one
105
+ // route they are given. Naming the team makes the remedy reach exactly the
106
+ // thing the sentence above it is about (raised in review).
107
+ const setup = `\`${setupCommand(ctx.team)}\``;
108
+ switch (result.reason) {
109
+ case 'no-slot':
110
+ return {
111
+ tone: 'note',
112
+ lines: [
113
+ 'this machine has no key slot for this vault, so the team was not backed up.',
114
+ `Run ${setup} and type the recovery key once; after that this machine`,
115
+ 'files new teams itself.',
116
+ ],
117
+ };
118
+ case 'no-store':
119
+ // The one that must not promise a fix. There is no secure store to keep a
120
+ // slot in, so every future `connect` on this machine lands here too.
121
+ return {
122
+ tone: 'note',
123
+ lines: [
124
+ 'this machine has no secure store, so it cannot back up a team without the recovery',
125
+ `key: the team was not backed up. Run ${setup} to back it up now.`,
126
+ 'This machine will need the key each time; a machine with a secure store will not.',
127
+ ],
128
+ };
129
+ case 'store-locked':
130
+ // Order matters here and only here: running setup against a locked store
131
+ // backs the team up but keeps no slot, so the next connect asks again.
132
+ return {
133
+ tone: 'note',
134
+ lines: [
135
+ 'this machine has a secure store but it refused: ' + result.detail,
136
+ `The team was not backed up. Unlock the store, then run ${setup} —`,
137
+ 'in that order, so a key slot is kept and later teams are filed without asking.',
138
+ ],
139
+ };
140
+ case 'unusable':
141
+ return {
142
+ tone: 'warning',
143
+ lines: [
144
+ 'this machine has a key slot for this vault and its contents did not hold up: ' +
145
+ result.detail,
146
+ `The team was not backed up. Run ${setup}, which asks for the recovery`,
147
+ 'key and replaces the slot. Until then this team exists only on the machines',
148
+ 'holding it.',
149
+ ],
150
+ };
151
+ }
152
+ }
68
153
  /** Render advice for a terminal. Empty string when there is nothing to say. */
69
154
  export function renderSlotAdvice(advice) {
70
155
  if (advice.lines.length === 0)
@@ -87,6 +87,45 @@ export function upsertTeam(container, entry) {
87
87
  const teams = [...others, entry].sort((a, b) => (identityOf(a) < identityOf(b) ? -1 : 1));
88
88
  return { format: CONTAINER_FORMAT, version: CONTAINER_VERSION, teams };
89
89
  }
90
+ /**
91
+ * The same two key epochs, however they are ordered.
92
+ *
93
+ * A set comparison, not a sequence one: the epochs come out of a bundle the OSS
94
+ * side produced, and nothing promises the order is stable between two runs.
95
+ * Comparing them in order would report a change on a re-ordering and append a
96
+ * version identical to the one before it.
97
+ */
98
+ export function sameEpochs(a, b) {
99
+ if (a.length !== b.length)
100
+ return false;
101
+ const left = [...a].sort();
102
+ const right = [...b].sort();
103
+ return left.every((v, i) => v === right[i]);
104
+ }
105
+ /**
106
+ * Put one team into the container, unless its epochs are already the ones in
107
+ * there.
108
+ *
109
+ * The `written` half is the point. `recovery setup` documents itself as
110
+ * idempotent — "a run where no team's epochs have moved writes nothing and
111
+ * leaves the revision where it was" — and that promise belongs to the container,
112
+ * not to the command that happened to make it first. `connect` files a team the
113
+ * moment it is created and is re-runnable, so without this a second `connect`
114
+ * on a team already in the vault appends a version identical to the one before
115
+ * it, and the revision history stops meaning "something changed here".
116
+ *
117
+ * Kept beside `upsertTeam` rather than in either caller, because two copies of
118
+ * "has this actually moved" that drifted would disagree about whether a write
119
+ * was needed — and the one that said yes would silently win.
120
+ */
121
+ export function upsertTeamIfMoved(container, entry) {
122
+ const key = identityOf(entry);
123
+ const already = container.teams.find((t) => identityOf(t) === key);
124
+ if (already && sameEpochs(already.key_ids, entry.key_ids)) {
125
+ return { container, written: false };
126
+ }
127
+ return { container: upsertTeam(container, entry), written: true };
128
+ }
90
129
  export function serializeContainer(container) {
91
130
  return Buffer.from(JSON.stringify(container), 'utf8');
92
131
  }
@@ -0,0 +1,62 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { openDeviceSlot } from './device-slot.js';
5
+ import { keyHandoff, remoteBinding } from './oss.js';
6
+ import { parseContainer, serializeContainer, upsertTeamIfMoved } from './vault-container.js';
7
+ import { appendVaultVersionWithVdk, openVaultWithVdk, readAccountVault } from './vault-protocol.js';
8
+ import { bundleKeyIds, slotAddress } from './commands/vault.js';
9
+ /**
10
+ * File one team's handoff bundle into this account's vault, if that can be done
11
+ * silently.
12
+ *
13
+ * Fails closed in the sense that matters: it never invents a vault, never asks
14
+ * for a key, and never reports success for a write it did not make.
15
+ */
16
+ export async function fileTeamInVaultSilently(config, client, team) {
17
+ const { identity, version } = await readAccountVault(client);
18
+ // No vault means there is no recovery key either — creating one here would
19
+ // mint a key and show it during a command nobody ran for that purpose.
20
+ if (!version)
21
+ return { filed: false, reason: 'no-vault' };
22
+ const opened = await openDeviceSlot(slotAddress(identity, version.vault_id, version.recovery_generation));
23
+ // Every way of not having a slot lands here, and none of them is an error:
24
+ // a machine that has never run `recovery setup`, a platform with no secure
25
+ // store, a locked keychain. The caller names the route instead.
26
+ if (!opened.ok)
27
+ return { filed: false, reason: 'no-slot', slot: opened };
28
+ const binding = await remoteBinding(config.scriptsDir, team);
29
+ const current = openVaultWithVdk(identity, version, opened.vdk);
30
+ const container = parseContainer(current.content);
31
+ const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-filing-'));
32
+ try {
33
+ const bundleFile = join(scratch, 'handoff.bundle');
34
+ await keyHandoff(config.scriptsDir, team, bundleFile);
35
+ const bundle = readFileSync(bundleFile);
36
+ // The same decision `recovery setup` makes, from the same function.
37
+ //
38
+ // `connect` is re-runnable — a second run on a team that is already
39
+ // connected continues to the registration rather than refusing — so
40
+ // without this it would append a version identical to the one before it
41
+ // every time. `recovery setup` documents itself as idempotent; a second
42
+ // writer that is not makes that sentence false about the account.
43
+ const { container: next, written } = upsertTeamIfMoved(container, {
44
+ server_instance_id: binding.serverInstanceId,
45
+ team_id: binding.teamId,
46
+ key_ids: bundleKeyIds(bundle),
47
+ bundle: bundle.toString('base64'),
48
+ });
49
+ if (!written) {
50
+ // Backed up, and this run is not what did it. Told apart from a write
51
+ // because the caller says "revision N" and there is no new N to say.
52
+ return { filed: true, wrote: false, revision: version.revision, teamCount: next.teams.length };
53
+ }
54
+ const result = await appendVaultVersionWithVdk(client, identity, version, opened.vdk, serializeContainer(next));
55
+ return { filed: true, wrote: true, revision: result.revision, teamCount: next.teams.length };
56
+ }
57
+ finally {
58
+ // The bundle is key material. Removed on every path this process controls,
59
+ // the same way the ceremony's snapshot is.
60
+ rmSync(scratch, { recursive: true, force: true });
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agmsg-cloud",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.0-rc.7",
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",
@@ -33,7 +33,8 @@
33
33
  "pretypecheck": "pnpm run build:deps",
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
- "prepack": "pnpm run build && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts"
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
38
  },
38
39
  "dependencies": {
39
40
  "@agmsg-cloud/sas-core": "workspace:*",