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/api.js +20 -1
- package/dist/src/commands/approve.js +15 -1
- package/dist/src/commands/connect.js +110 -24
- package/dist/src/commands/fetch.js +36 -2
- package/dist/src/commands/login.js +39 -14
- package/dist/src/commands/pull.js +60 -2
- package/dist/src/commands/request.js +97 -33
- package/dist/src/commands/sync.js +48 -8
- package/dist/src/commands/vault.js +117 -24
- package/dist/src/commands/whoami.js +44 -0
- package/dist/src/config.js +71 -2
- package/dist/src/index.js +31 -3
- package/dist/src/machine-id.js +44 -0
- package/dist/src/oss.js +15 -1
- package/dist/src/preflight.js +120 -1
- package/dist/src/recovery-key.js +24 -9
- package/dist/src/slot-advice.js +85 -0
- package/dist/src/vault-container.js +39 -0
- package/dist/src/vault-filing.js +62 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
//
|
package/dist/src/preflight.js
CHANGED
|
@@ -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) {
|
package/dist/src/recovery-key.js
CHANGED
|
@@ -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
|
|
285
|
-
// given
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
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
|
-
|
|
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)}`;
|
package/dist/src/slot-advice.js
CHANGED
|
@@ -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.
|
|
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:*",
|