agmsg-cloud 0.1.0-rc.4 → 0.1.0-rc.6
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/connect.js +110 -24
- package/dist/src/commands/fetch.js +36 -2
- package/dist/src/commands/login.js +6 -1
- package/dist/src/commands/pull.js +60 -2
- package/dist/src/commands/request.js +97 -33
- package/dist/src/commands/sync.js +21 -1
- package/dist/src/commands/vault.js +117 -24
- package/dist/src/commands/whoami.js +44 -0
- package/dist/src/config.js +29 -1
- 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 +34 -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 +1 -1
|
@@ -8,7 +8,7 @@ import { generateRecoveryKey, normalizeRecoveryKey, promptRecoveryKey, showRecov
|
|
|
8
8
|
import { appendVaultVersionWithVdk, createVault, openVaultWithVdk, readAccountVault, vdkFromRecoveryKey, } from '../vault-protocol.js';
|
|
9
9
|
import { openDeviceSlot, saveDeviceSlot } from '../device-slot.js';
|
|
10
10
|
import { adviseOnSlot, renderSlotAdvice } from '../slot-advice.js';
|
|
11
|
-
import { emptyContainer, parseContainer, selectTeam, serializeContainer,
|
|
11
|
+
import { emptyContainer, parseContainer, selectTeam, serializeContainer, upsertTeamIfMoved, } from '../vault-container.js';
|
|
12
12
|
// The two recovery commands: resolve the team, obtain the bundle from the OSS
|
|
13
13
|
// side, get the recovery key from the terminal, and hand off to
|
|
14
14
|
// vault-protocol.ts for everything that talks to the server.
|
|
@@ -27,7 +27,10 @@ import { emptyContainer, parseContainer, selectTeam, serializeContainer, upsertT
|
|
|
27
27
|
// omits the field name generation 1 by omission, and — worse — a slot saved
|
|
28
28
|
// under a made-up 1 would be indistinguishable from a legitimate generation-1
|
|
29
29
|
// slot once a re-issuance produced one.
|
|
30
|
-
|
|
30
|
+
// Exported for vault-filing.ts, which addresses the same slot from connect.
|
|
31
|
+
// One derivation, because this decides WHICH slot is read: a second copy that
|
|
32
|
+
// drifted would read a slot belonging to another vault or generation.
|
|
33
|
+
export function slotAddress(identity, vaultId, generation) {
|
|
31
34
|
return {
|
|
32
35
|
vaultServiceId: identity.vaultServiceId,
|
|
33
36
|
accountId: identity.accountId,
|
|
@@ -119,13 +122,6 @@ async function keepSlot(address, vdk) {
|
|
|
119
122
|
function vaultTeamKey(serverInstanceId, teamId) {
|
|
120
123
|
return JSON.stringify([serverInstanceId, teamId]);
|
|
121
124
|
}
|
|
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
125
|
/**
|
|
130
126
|
* `recovery setup` — put the keys of every team THIS MACHINE reports into the
|
|
131
127
|
* account's vault.
|
|
@@ -282,7 +278,13 @@ export async function cmdVaultPut(config, args) {
|
|
|
282
278
|
// backed-up ones are not known until the loop below has run. Hence the
|
|
283
279
|
// parameters: the groups are passed in rather than closed over, so the
|
|
284
280
|
// heading cannot outrun what is under it.
|
|
285
|
-
|
|
281
|
+
// `stranded` joins written/unchanged as a PARAMETER for the reason the two
|
|
282
|
+
// above are parameters: this function is defined before the loop fills any
|
|
283
|
+
// of them, and closing over a list that is still empty here prints a
|
|
284
|
+
// heading with nothing under it. The first version of this got that wrong
|
|
285
|
+
// in the other direction and listed the disconnected teams under
|
|
286
|
+
// "Covered".
|
|
287
|
+
const coverage = (written, unchanged, stranded) => {
|
|
286
288
|
const group = (title, members) => members.length === 0 ? [] : [`\n ${title}\n`, ...members.map((m) => ` ${m}\n`)];
|
|
287
289
|
// The first two hold for either form — a run that names one team still has
|
|
288
290
|
// to say what it did with it. The rest are about the ENUMERATION, so they
|
|
@@ -291,6 +293,12 @@ export async function cmdVaultPut(config, args) {
|
|
|
291
293
|
const lines = [
|
|
292
294
|
...group('Backed up in this revision:', written),
|
|
293
295
|
...group('Already current, nothing to write:', unchanged),
|
|
296
|
+
// FIRST among the things that did not happen, and with the reason
|
|
297
|
+
// attached. This is the only group whose members are a contradiction in
|
|
298
|
+
// the store rather than a choice the operator made, so it is the one
|
|
299
|
+
// that needs acting on — and the reason is what says which team's state
|
|
300
|
+
// to look at, rather than leaving "something failed" for them to locate.
|
|
301
|
+
...group('Recorded as connected here, but this machine could not open them:', stranded.map((f) => `${f.team} — ${f.reason}`)),
|
|
294
302
|
...(!enumerated
|
|
295
303
|
? []
|
|
296
304
|
: group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team))),
|
|
@@ -321,34 +329,96 @@ export async function cmdVaultPut(config, args) {
|
|
|
321
329
|
let next = container;
|
|
322
330
|
const written = [];
|
|
323
331
|
const unchanged = [];
|
|
332
|
+
// Recorded as connected by this machine, and not openable by it. Kept
|
|
333
|
+
// apart from `unseen` and `disconnected` because it is a third fact: the
|
|
334
|
+
// machine reported this team AND could not produce its keys, which is the
|
|
335
|
+
// store disagreeing with itself rather than a team the operator retired.
|
|
336
|
+
const couldNotOpen = [];
|
|
324
337
|
for (const [i, target] of targets.entries()) {
|
|
325
338
|
const bundleFile = join(scratch, `handoff-${i}.bundle`);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
339
|
+
// ONE TEAM THIS MACHINE CANNOT OPEN DOES NOT DECIDE THE ACCOUNT (#276).
|
|
340
|
+
//
|
|
341
|
+
// The enumeration and this loop read DIFFERENT sources of truth, and
|
|
342
|
+
// that is the whole defect. `remote.sh status --json` reads only
|
|
343
|
+
// `teams/<team>/config`: a binding with a `connected_at` and no
|
|
344
|
+
// `disconnected_at` is reported active, and that read never opens
|
|
345
|
+
// `db/remote-sync/<team>.json`. The key handoff does. So a team whose
|
|
346
|
+
// binding record outlived its sync state arrives here looking ordinary
|
|
347
|
+
// and throws — measured on the 2026-08-11 walk, where a leftover named
|
|
348
|
+
// `oss-rc2` stopped the backup of four intact teams.
|
|
349
|
+
//
|
|
350
|
+
// The failure was TOTAL, and its blast radius was set by the oldest
|
|
351
|
+
// leftover on the machine rather than by anything the operator was
|
|
352
|
+
// doing. Skipping and naming is the answer, not refusing to start: this
|
|
353
|
+
// command already reports what it did not cover — `unseen`,
|
|
354
|
+
// `disconnected` — instead of refusing, for the same reason. A backup of
|
|
355
|
+
// four teams that says which fifth is missing leaves someone strictly
|
|
356
|
+
// better off than a backup of none; a backup of four that said nothing
|
|
357
|
+
// would not, which is why this is a report and not a silence.
|
|
358
|
+
//
|
|
359
|
+
// Any failure, not a matched message. Parsing `ENOENT` would name one
|
|
360
|
+
// shape of one version of one script, and the honest claim here is
|
|
361
|
+
// narrower: this machine could not produce this team's keys. The reason
|
|
362
|
+
// is carried through verbatim for whoever reads it.
|
|
363
|
+
let bundle;
|
|
364
|
+
try {
|
|
365
|
+
await keyHandoff(config.scriptsDir, target.team, bundleFile);
|
|
366
|
+
bundle = readFileSync(bundleFile);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
couldNotOpen.push({
|
|
370
|
+
team: target.team,
|
|
371
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
372
|
+
});
|
|
332
373
|
continue;
|
|
333
374
|
}
|
|
334
|
-
|
|
375
|
+
const keyIds = bundleKeyIds(bundle);
|
|
376
|
+
// The decision and the write are one call, in the container module, so
|
|
377
|
+
// `connect` cannot make the same decision differently (#181).
|
|
378
|
+
const result = upsertTeamIfMoved(next, {
|
|
335
379
|
server_instance_id: target.serverInstanceId,
|
|
336
380
|
team_id: target.teamId,
|
|
337
381
|
key_ids: keyIds,
|
|
338
382
|
bundle: bundle.toString('base64'),
|
|
339
383
|
});
|
|
340
|
-
|
|
384
|
+
next = result.container;
|
|
385
|
+
(result.written ? written : unchanged).push(target.team);
|
|
341
386
|
}
|
|
342
387
|
// Nothing moved, and the vault already exists: there is no version to
|
|
343
388
|
// write. Saying "stored revision N+1" here would be true of the server and
|
|
344
389
|
// false about the account — the point of a revision is that something is
|
|
345
390
|
// different in it.
|
|
391
|
+
// NOTHING OPENED IS NOT A PARTIAL BACKUP. Skipping a team that cannot be
|
|
392
|
+
// opened is right while others can; when the whole set fails there is no
|
|
393
|
+
// backup to report, and writing here would store a container holding no
|
|
394
|
+
// teams — a recovery key that opens an empty vault, which reads as success
|
|
395
|
+
// and is worse than the abort this change replaced.
|
|
396
|
+
//
|
|
397
|
+
// Found by the test for this change, not by reading it back: the skip made
|
|
398
|
+
// the loop finish, and every path after the loop assumed finishing meant
|
|
399
|
+
// something had been filed.
|
|
400
|
+
if (written.length === 0 && unchanged.length === 0 && couldNotOpen.length > 0) {
|
|
401
|
+
process.stdout.write(coverage(written, unchanged, couldNotOpen));
|
|
402
|
+
throw new Error('no team on this machine could be opened, so nothing was backed up.\n' +
|
|
403
|
+
'Each team above is recorded here as connected while the state its keys ' +
|
|
404
|
+
'come from is missing.');
|
|
405
|
+
}
|
|
346
406
|
if (written.length === 0 && version && vdk) {
|
|
347
407
|
// The headline carries no bare count: what "up to date" covers is the
|
|
348
408
|
// list below it, and a number on its own is the thing this was reported
|
|
349
409
|
// for.
|
|
350
|
-
|
|
351
|
-
|
|
410
|
+
//
|
|
411
|
+
// AND IT NARROWS TO WHAT WAS OPENED. "nothing has new keys" is a claim
|
|
412
|
+
// over every team on the machine, but a team that could not be opened
|
|
413
|
+
// had its bundle and key epoch read zero times — whether it has new keys
|
|
414
|
+
// is not a thing this run knows. Unqualified, the line contradicts the
|
|
415
|
+
// coverage printed two statements later, which names that same team as
|
|
416
|
+
// one this run could not open (review P1-contract).
|
|
417
|
+
process.stdout.write(couldNotOpen.length > 0
|
|
418
|
+
? `the teams this machine could open have no new keys; the ones below ` +
|
|
419
|
+
`it could not open were not checked. Revision stays at ${version.revision}.\n`
|
|
420
|
+
: `already up to date — nothing has new keys. Revision stays at ${version.revision}.\n`);
|
|
421
|
+
process.stdout.write(coverage(written, unchanged, couldNotOpen));
|
|
352
422
|
// A run that had to type the key still earns a slot: the key is in hand,
|
|
353
423
|
// and the next run should not ask again just because this one had nothing
|
|
354
424
|
// to store.
|
|
@@ -389,7 +459,7 @@ export async function cmdVaultPut(config, args) {
|
|
|
389
459
|
// (agmsg#650). Teams already in the vault are covered by the refusal
|
|
390
460
|
// higher up; a team that has never been in it cannot be seen from here at
|
|
391
461
|
// all, so the sentence stops at the machine rather than the account.
|
|
392
|
-
process.stdout.write(coverage(written, unchanged));
|
|
462
|
+
process.stdout.write(coverage(written, unchanged, couldNotOpen));
|
|
393
463
|
// After the write, and only when this run had to reach the key. A run the
|
|
394
464
|
// slot already answered has a working slot at this address; saving again
|
|
395
465
|
// would mint a fresh KEK, replace it, and touch the keychain on every
|
|
@@ -406,9 +476,28 @@ export async function cmdVaultPut(config, args) {
|
|
|
406
476
|
// filing a key that opens nothing and trusting it for years. A re-run
|
|
407
477
|
// mints a new one, which is correct — and only correct if they know to
|
|
408
478
|
// discard this one.
|
|
479
|
+
// A REMEDY THAT IS PRINTED HAS TO END SOMEWHERE (#276).
|
|
480
|
+
//
|
|
481
|
+
// This used to say "running this command again will show you a new key",
|
|
482
|
+
// full stop. It does show a new key — and then fails in the same place,
|
|
483
|
+
// because re-running creates nothing that was missing. On the
|
|
484
|
+
// 2026-08-11 walk that sentence was followed each time, and each attempt
|
|
485
|
+
// burned a key and arrived back here, with the failure still advising
|
|
486
|
+
// the retry.
|
|
487
|
+
//
|
|
488
|
+
// Discarding the key is still right and still first: it opens nothing,
|
|
489
|
+
// and someone who files it will trust it for years. What changed is the
|
|
490
|
+
// second half — a bare retry is only the answer when the cause was
|
|
491
|
+
// transient, and this exit does not know that it was. So it names the
|
|
492
|
+
// condition to check instead, and offers the narrow form, which is the
|
|
493
|
+
// one thing that lets an account with a broken leftover back up the team
|
|
494
|
+
// actually in use.
|
|
409
495
|
process.stdout.write('\nThe backup did NOT complete, so the recovery key above was never used\n' +
|
|
410
|
-
'and opens nothing. Discard it
|
|
411
|
-
'a
|
|
496
|
+
'and opens nothing. Discard it.\n\n' +
|
|
497
|
+
'Re-running is only worth it if the cause above was temporary. If a team\n' +
|
|
498
|
+
'could not be opened, re-running will stop at the same place and cost you\n' +
|
|
499
|
+
'another key — back up the one you are using instead:\n\n' +
|
|
500
|
+
' agmsg-cloud recovery setup <team>\n\n');
|
|
412
501
|
}
|
|
413
502
|
throw err;
|
|
414
503
|
}
|
|
@@ -419,7 +508,11 @@ export async function cmdVaultPut(config, args) {
|
|
|
419
508
|
// The key epochs the bundle declares, for the entry's binding. This is the only
|
|
420
509
|
// part of the bundle the cloud side reads: everything else is carried opaquely
|
|
421
510
|
// and handed back to the OSS unlock path, so the bundle's format stays theirs.
|
|
422
|
-
|
|
511
|
+
// Exported alongside slotAddress, and for the same reason: vault-filing.ts
|
|
512
|
+
// writes the same entry shape from connect, and a second reading of the
|
|
513
|
+
// bundle that disagreed would record a binding to key epochs the bundle does
|
|
514
|
+
// not declare.
|
|
515
|
+
export function bundleKeyIds(bundle) {
|
|
423
516
|
try {
|
|
424
517
|
const parsed = JSON.parse(bundle.toString('utf8'));
|
|
425
518
|
const ids = (parsed.identities ?? [])
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readCredential } from '../credentials.js';
|
|
2
|
+
import { machinePrefix } from '../machine-id.js';
|
|
3
|
+
export function whoami(env = process.env) {
|
|
4
|
+
// The ACTIVE credential, which is what every other command sends. Asking by
|
|
5
|
+
// origin would answer for a host this machine may not be using, and the
|
|
6
|
+
// question is "which machine am I", not "what do I have for X".
|
|
7
|
+
const credential = readCredential(null, env);
|
|
8
|
+
if (!credential)
|
|
9
|
+
return { signedIn: false };
|
|
10
|
+
return {
|
|
11
|
+
signedIn: true,
|
|
12
|
+
endpoint: credential.endpoint,
|
|
13
|
+
org: credential.org,
|
|
14
|
+
machineName: credential.machineName,
|
|
15
|
+
prefix: machinePrefix(credential.secret),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function renderWhoami(found) {
|
|
19
|
+
if (!found.signedIn) {
|
|
20
|
+
return ('This machine is not signed in, so it is not one of the machines in any account yet.\n' +
|
|
21
|
+
'Run `agmsg-cloud login` first.\n');
|
|
22
|
+
}
|
|
23
|
+
const lines = [
|
|
24
|
+
`endpoint ${found.endpoint}`,
|
|
25
|
+
`organization ${found.org}`,
|
|
26
|
+
`machine name ${found.machineName}`,
|
|
27
|
+
];
|
|
28
|
+
if (found.prefix === null) {
|
|
29
|
+
// Said rather than omitted. A missing line reads as "this machine has no
|
|
30
|
+
// prefix", and the console will be showing one for it — so the person would
|
|
31
|
+
// be comparing against a row that does exist, with nothing to compare.
|
|
32
|
+
lines.push('', 'This version cannot read the prefix out of the stored credential: it is not', 'the shape this build knows. The machine still works — every other command', 'sends the secret as it is — but matching it against the console has to be', 'done another way. Upgrading `agmsg-cloud` is the thing to try first.');
|
|
33
|
+
return `${lines.join('\n')}\n`;
|
|
34
|
+
}
|
|
35
|
+
lines.push(`prefix ${found.prefix}`, '',
|
|
36
|
+
// Why the prefix is here at all, in the place someone reads it.
|
|
37
|
+
'The prefix is how you tell this machine apart from the others in the account:', 'it is the value shown beside the name on the console\'s machines screen, and', 'machine names are not unique. Match this one before revoking anything.', '',
|
|
38
|
+
// Pre-empting the obvious worry about seeing part of a credential.
|
|
39
|
+
'It is not a secret — the console shows it to everyone in the organization.', 'The credential itself is the part that is not printed here, and never is.');
|
|
40
|
+
return `${lines.join('\n')}\n`;
|
|
41
|
+
}
|
|
42
|
+
export function cmdWhoami(env = process.env) {
|
|
43
|
+
process.stdout.write(renderWhoami(whoami(env)));
|
|
44
|
+
}
|
package/dist/src/config.js
CHANGED
|
@@ -25,8 +25,36 @@ function fromEnv(env) {
|
|
|
25
25
|
export function resolveScriptsDir(env = process.env) {
|
|
26
26
|
return env.AGMSG_SCRIPTS_DIR ?? join(homedir(), '.agents', 'skills', 'agmsg', 'scripts');
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* THE credential decision. Every caller that needs to know whether this machine
|
|
30
|
+
* can act — and with what — goes through this one function.
|
|
31
|
+
*
|
|
32
|
+
* It is a function rather than a rule written twice because the two readers
|
|
33
|
+
* disagreed once already: preflight asked `readCredential` alone and reported a
|
|
34
|
+
* headless machine carrying AGMSG_CLOUD_ENDPOINT + AGMSG_CLOUD_SECRET as not
|
|
35
|
+
* signed in, refusing a machine that could run the command — #222's defect
|
|
36
|
+
* facing the other way (raised in review). The first repair merely copied this
|
|
37
|
+
* expression into both readers, which is the same arrangement with the
|
|
38
|
+
* divergence deferred: equal today, free to drift on the next edit (raised in
|
|
39
|
+
* review again). One body, so there is nothing to keep in step.
|
|
40
|
+
*/
|
|
41
|
+
function resolveCredential(env) {
|
|
42
|
+
return fromEnv(env) ?? readCredential(null, env);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether this machine has a credential to act with — `loadConfig`'s own
|
|
46
|
+
* question, asked of `loadConfig`'s own resolver, so preflight cannot hold a
|
|
47
|
+
* second opinion about a decision `loadConfig` already makes.
|
|
48
|
+
*
|
|
49
|
+
* A half-set pair still THROWS, exactly as it does for `loadConfig`: "endpoint
|
|
50
|
+
* set, secret missing" is a misconfiguration to report, not an absence to
|
|
51
|
+
* quietly call signed-out.
|
|
52
|
+
*/
|
|
53
|
+
export function hasCredential(env = process.env) {
|
|
54
|
+
return resolveCredential(env) !== null;
|
|
55
|
+
}
|
|
28
56
|
export function loadConfig(env = process.env) {
|
|
29
|
-
const resolved =
|
|
57
|
+
const resolved = resolveCredential(env);
|
|
30
58
|
if (!resolved) {
|
|
31
59
|
throw new Error('not signed in on this machine — run `agmsg-cloud login --endpoint <url>` first');
|
|
32
60
|
}
|
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 { hasCredential } 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,31 @@ 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) {
|
|
154
155
|
const { command, scripts } = needs;
|
|
155
156
|
const requireConnect = needs.requireConnect ?? false;
|
|
156
157
|
const requirements = [];
|
|
158
|
+
// BEING SIGNED IN IS A PREREQUISITE, so it is one of the things checked.
|
|
159
|
+
//
|
|
160
|
+
// It was not, and the result was a check that said "Everything connect needs
|
|
161
|
+
// is here" immediately before connect stopped for a missing sign-in (#222).
|
|
162
|
+
// A checklist that omits a requirement does not merely fail to help — it
|
|
163
|
+
// states that the requirement is met.
|
|
164
|
+
//
|
|
165
|
+
// Listed first because it is the one whose fix is a different command
|
|
166
|
+
// entirely, and because the others are about this machine's install while
|
|
167
|
+
// this one is about this machine's account.
|
|
168
|
+
requirements.push({
|
|
169
|
+
name: 'signed in',
|
|
170
|
+
// The resolver `loadConfig` uses, not a second opinion about the same
|
|
171
|
+
// question. `readCredential` alone reported a headless machine with
|
|
172
|
+
// AGMSG_CLOUD_ENDPOINT + AGMSG_CLOUD_SECRET as signed out — a checklist
|
|
173
|
+
// refusing a machine that CAN run the command, which is the defect this
|
|
174
|
+
// requirement exists to fix, facing the other way (raised in review).
|
|
175
|
+
ok: hasCredential(env),
|
|
176
|
+
why: `${command} talks to the hosted service as this machine, and this machine has no credential yet.`,
|
|
177
|
+
install: ['Sign in: agmsg-cloud login'],
|
|
178
|
+
});
|
|
157
179
|
// The scripts come first: without them nothing else matters, and the fix is
|
|
158
180
|
// a different kind of thing (install agmsg, or point AGMSG_SCRIPTS_DIR at it)
|
|
159
181
|
// than installing a binary.
|
|
@@ -205,6 +227,7 @@ export function preflight(scriptsDir, needs) {
|
|
|
205
227
|
requirements,
|
|
206
228
|
ok: requirements.every((r) => r.ok),
|
|
207
229
|
command,
|
|
230
|
+
scriptsDir,
|
|
208
231
|
agmsgVersion: canConnect ? installedVersion(scriptsDir) : null,
|
|
209
232
|
};
|
|
210
233
|
}
|
|
@@ -230,6 +253,16 @@ export function formatPreflight(result) {
|
|
|
230
253
|
for (const r of result.requirements) {
|
|
231
254
|
lines.push(` ${r.ok ? '[x]' : '[ ]'} ${r.name}`);
|
|
232
255
|
}
|
|
256
|
+
// THE RESOLVED DIRECTORY, IN FULL, ON ITS OWN LINE.
|
|
257
|
+
//
|
|
258
|
+
// It used to appear only inside a requirement's name — `agmsg with remote
|
|
259
|
+
// sync (in /very/long/path)` — where the walk's terminal cut it off. The
|
|
260
|
+
// operator could not see WHICH install had been checked, which is exactly
|
|
261
|
+
// the disagreement they were trying to diagnose (#222). A path is not a
|
|
262
|
+
// decoration on a label; it is the answer to "which one did you look at".
|
|
263
|
+
lines.push('');
|
|
264
|
+
lines.push(` scripts directory: ${result.scriptsDir}`);
|
|
265
|
+
lines.push(` set AGMSG_SCRIPTS_DIR to check a different install.`);
|
|
233
266
|
if (result.agmsgVersion !== null) {
|
|
234
267
|
// Named as what it is. Calling it "agmsg 1.1.11" would invite the reader to
|
|
235
268
|
// compare it with a release number, which is the thing it cannot be
|
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)}`;
|