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
|
@@ -9,22 +9,6 @@ import { originOf } from '../credentials.js';
|
|
|
9
9
|
import { deviceIdentityPath } from '../paths.js';
|
|
10
10
|
import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
|
|
11
11
|
import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
|
|
12
|
-
// (c, part 1) B asks to be added.
|
|
13
|
-
//
|
|
14
|
-
// The order below is the protocol, not a style choice:
|
|
15
|
-
//
|
|
16
|
-
// 1. commit to (device key, nonce) and PERSIST the nonce
|
|
17
|
-
// 2. post only the commitment digest
|
|
18
|
-
// 3. wait for the approver's commitment to be fixed
|
|
19
|
-
// 4. only then reveal the key and the nonce
|
|
20
|
-
// 5. wait for the approver to open, and derive the code locally
|
|
21
|
-
//
|
|
22
|
-
// Steps 3 and 4 are what stop the other side choosing its contribution after
|
|
23
|
-
// learning ours. Doing 4 before 3 would leave a request whose code the server
|
|
24
|
-
// could steer, and the human comparison would confirm nothing.
|
|
25
|
-
//
|
|
26
|
-
// The private key never leaves this machine; only the public recipient is sent,
|
|
27
|
-
// and not until step 4.
|
|
28
12
|
export async function cmdRequest(config, args, deps = {}) {
|
|
29
13
|
const out = deps.out ?? ((text) => void process.stdout.write(text));
|
|
30
14
|
const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
|
|
@@ -47,11 +31,39 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
47
31
|
// paid for would charge the same guess twice and let a crash loop exhaust the
|
|
48
32
|
// budget without a single comparison.
|
|
49
33
|
const scope = requesterLedgerScope(config.baseUrl, pubkey);
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
34
|
+
// ASKED FOR EVERY STORED RECORD, INCLUDING ONE THAT ALREADY HAS AN ID.
|
|
35
|
+
//
|
|
36
|
+
// Two different questions, one answer:
|
|
37
|
+
//
|
|
38
|
+
// no id did it land at all? The row can exist while the response was
|
|
39
|
+
// lost, and the commitment is the only handle left.
|
|
40
|
+
// with id is it still live? Nothing about the id says so.
|
|
41
|
+
//
|
|
42
|
+
// This used to run only for the first. A record with an id was trusted
|
|
43
|
+
// however old it was, and the first thing the run did with it was
|
|
44
|
+
// `waitForStatus`, which polls for PROGRESS — so a long-dead enrollment was
|
|
45
|
+
// resumed, waited on, and then reported expired. That is the walkthrough
|
|
46
|
+
// sequence in #143: `resuming enrollment <id>` followed by `<id> is expired`.
|
|
47
|
+
//
|
|
48
|
+
// One list answers both, because `GET /v1/enrollments` is scoped to
|
|
49
|
+
// `status = ANY(NONTERMINAL) AND expires_at > now()`
|
|
50
|
+
// (`app/src/handoff/enrollment.ts:306`) — an expired row is absent from it
|
|
51
|
+
// even while its status still reads non-terminal. So "not in the list" already
|
|
52
|
+
// means "cannot be resumed", and the branch below already does the right
|
|
53
|
+
// thing with that: it closes the attempt, drops the record, and falls through
|
|
54
|
+
// to starting a fresh ceremony in this same run.
|
|
55
|
+
//
|
|
56
|
+
// AND THE LIST IS THE ONLY PLACE THAT ANSWERS IT. Reading the id instead would
|
|
57
|
+
// not do: `expireStale` rolls a stale row over on touch, and its callers are
|
|
58
|
+
// the create and claim paths, so a read returns the status the ceremony left
|
|
59
|
+
// behind and says nothing about the deadline. (That the walkthrough saw `<id>
|
|
60
|
+
// is expired` at all means something else had already touched it.) Pinned on
|
|
61
|
+
// the server side in `app/test/handoff-enrollment.test.ts`, because a change
|
|
62
|
+
// there would otherwise leave every test here green.
|
|
63
|
+
//
|
|
64
|
+
// The cost is one extra request per resume. What it buys is that the run
|
|
65
|
+
// either resumes something that can still finish, or starts one that can.
|
|
66
|
+
if (record) {
|
|
55
67
|
// The list must SUCCEED before its emptiness means anything.
|
|
56
68
|
//
|
|
57
69
|
// Swallowing the error and treating it as "no live requests" turned a
|
|
@@ -64,12 +76,20 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
64
76
|
mine = await client.listEnrollments();
|
|
65
77
|
}
|
|
66
78
|
catch (err) {
|
|
67
|
-
|
|
79
|
+
// The two cases have different unknowns and the sentence says which one
|
|
80
|
+
// it is. With no id, whether the request ever landed is open; with an id
|
|
81
|
+
// it landed and what is open is whether it can still finish. Saying "did
|
|
82
|
+
// it reach the server" to someone holding an id describes a doubt they do
|
|
83
|
+
// not have, and the remedy is the same either way — which is what makes
|
|
84
|
+
// the wrong half safe to notice and cheap to fix.
|
|
85
|
+
writeErr((requestId
|
|
86
|
+
? `cannot tell whether enrollment ${requestId} is still live: `
|
|
87
|
+
: 'cannot tell whether the earlier request reached the server: ') +
|
|
68
88
|
`${err instanceof Error ? err.message : String(err)}\n` +
|
|
69
89
|
' Nothing has been changed — the attempt and its nonce are kept so a\n' +
|
|
70
90
|
' later run can finish it. Try again when the server is reachable.\n');
|
|
71
91
|
process.exitCode = 1;
|
|
72
|
-
return;
|
|
92
|
+
return 'not_enrolled';
|
|
73
93
|
}
|
|
74
94
|
const match = mine.filter((e) => e.commitment === record.commitmentHex);
|
|
75
95
|
if (match.length === 1) {
|
|
@@ -90,7 +110,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
90
110
|
writeErr('the server reports more than one live request for this commitment; ' +
|
|
91
111
|
'refusing to guess which one is yours\n');
|
|
92
112
|
process.exitCode = 1;
|
|
93
|
-
return;
|
|
113
|
+
return 'not_enrolled';
|
|
94
114
|
}
|
|
95
115
|
}
|
|
96
116
|
if (!record || !requestId) {
|
|
@@ -116,13 +136,13 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
116
136
|
if (before.remaining === 0) {
|
|
117
137
|
writeErr(renderBudgetExhausted(before));
|
|
118
138
|
process.exitCode = 1;
|
|
119
|
-
return;
|
|
139
|
+
return 'not_enrolled';
|
|
120
140
|
}
|
|
121
141
|
const blocked = await keyAlreadySpokenFor(client, pubkey);
|
|
122
142
|
if (blocked) {
|
|
123
143
|
writeErr(blocked);
|
|
124
144
|
process.exitCode = 1;
|
|
125
|
-
return;
|
|
145
|
+
return 'not_enrolled';
|
|
126
146
|
}
|
|
127
147
|
const { commitmentHex: plannedCommitment, openingNonce: plannedNonce } = createSasCommitment(pubkey);
|
|
128
148
|
const charged = consumeAttempt(scope, plannedCommitment, env);
|
|
@@ -135,7 +155,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
135
155
|
' command again to resume it rather than starting a second one.\n\n');
|
|
136
156
|
}
|
|
137
157
|
process.exitCode = 1;
|
|
138
|
-
return;
|
|
158
|
+
return 'not_enrolled';
|
|
139
159
|
}
|
|
140
160
|
out(renderBudgetWarning(charged.budget));
|
|
141
161
|
// Persisted BEFORE the digest is posted. A crash after the post but before
|
|
@@ -220,6 +240,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
220
240
|
// the production walkthrough).
|
|
221
241
|
out(` Added.${nextStepsAreOurs ? ' Fetch the bundle with `agmsg-cloud fetch <team>`.' : ''} (${settled.status})\n`);
|
|
222
242
|
done();
|
|
243
|
+
return 'enrolled';
|
|
223
244
|
}
|
|
224
245
|
catch (err) {
|
|
225
246
|
if (err instanceof CeremonyError) {
|
|
@@ -227,19 +248,31 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
227
248
|
// A timeout leaves it, so the next run resumes instead of spending another
|
|
228
249
|
// attempt on a request that is still live.
|
|
229
250
|
if (err.reason === 'timed_out') {
|
|
230
|
-
//
|
|
231
|
-
//
|
|
251
|
+
// "May still be live" was printed as though it were "is still live".
|
|
252
|
+
//
|
|
253
|
+
// The wait has a five-minute deadline and an enrollment's window can
|
|
254
|
+
// close inside it — and expiry alone does not move the status, so the
|
|
255
|
+
// poll ends as "did not reach … in time" for a row that is expired and
|
|
256
|
+
// untouched. Both halves of the old sentence were then false: the next
|
|
257
|
+
// run does not resume it, and the new ceremony it starts does cost an
|
|
258
|
+
// attempt. Someone following the remedy spent one having been told it
|
|
259
|
+
// was free (#258).
|
|
260
|
+
//
|
|
261
|
+
// Asked of the same list the resume path asks, which is scoped to
|
|
262
|
+
// `status = ANY(NONTERMINAL) AND expires_at > now()` — so presence
|
|
263
|
+
// there IS resumability, and there is no second definition to keep in
|
|
264
|
+
// step.
|
|
232
265
|
writeErr(`enrollment did not complete: ${err.message}\n`);
|
|
233
|
-
writeErr(
|
|
266
|
+
writeErr(await resumeAdvice(client, record.commitmentHex));
|
|
234
267
|
process.exitCode = 1;
|
|
235
|
-
return;
|
|
268
|
+
return 'not_enrolled';
|
|
236
269
|
}
|
|
237
270
|
done();
|
|
238
271
|
closeAttempt(scope, record.commitmentHex, 'failed', err.reason, env);
|
|
239
272
|
writeErr(renderBudgetWarning(readBudget(scope, env)));
|
|
240
273
|
writeErr(`enrollment did not complete: ${err.message}\n`);
|
|
241
274
|
process.exitCode = 1;
|
|
242
|
-
return;
|
|
275
|
+
return 'not_enrolled';
|
|
243
276
|
}
|
|
244
277
|
// The two ways this machine's key cannot become a device on this account.
|
|
245
278
|
//
|
|
@@ -257,7 +290,7 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
257
290
|
writeErr(renderBudgetWarning(readBudget(scope, env)));
|
|
258
291
|
writeErr(await renderKeyRefusal(err.code, client, pubkey));
|
|
259
292
|
process.exitCode = 1;
|
|
260
|
-
return;
|
|
293
|
+
return 'not_enrolled';
|
|
261
294
|
}
|
|
262
295
|
throw err;
|
|
263
296
|
}
|
|
@@ -287,6 +320,37 @@ const NOTHING_COMPARED = ' No digits were shown and no bundle was sent.\n';
|
|
|
287
320
|
* about ownership, and reading silence as "none of these are yours" would
|
|
288
321
|
* refuse every ordinary rejoin.
|
|
289
322
|
*/
|
|
323
|
+
/**
|
|
324
|
+
* What to tell someone whose wait ran out — after asking whether the thing they
|
|
325
|
+
* would be resuming still exists.
|
|
326
|
+
*
|
|
327
|
+
* Three answers, because there are three states and only one of them is the one
|
|
328
|
+
* the message used to assume. Saying nothing about the cost is not an option
|
|
329
|
+
* either: the whole defect was a person spending an attempt they had been told
|
|
330
|
+
* was free, and a sentence that simply omits the price leaves them guessing at
|
|
331
|
+
* the same moment.
|
|
332
|
+
*/
|
|
333
|
+
export async function resumeAdvice(client, commitmentHex) {
|
|
334
|
+
let live;
|
|
335
|
+
try {
|
|
336
|
+
live = await client.listEnrollments();
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
// Unknown, said as unknown. Guessing "still live" is the bug being fixed;
|
|
340
|
+
// guessing "gone" would tell someone their attempt is spent when it may
|
|
341
|
+
// not be.
|
|
342
|
+
return (` Could not check whether it is still live: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
343
|
+
' Nothing has been changed. Run the command again when the server is reachable:\n' +
|
|
344
|
+
' if the enrollment is still live it resumes at no further cost, and if it is\n' +
|
|
345
|
+
' not, that run starts a new ceremony and costs another attempt.\n');
|
|
346
|
+
}
|
|
347
|
+
if (live.some((e) => e.commitment === commitmentHex)) {
|
|
348
|
+
return ' Run the same command again to resume it; this costs no further attempt.\n';
|
|
349
|
+
}
|
|
350
|
+
return (' This attempt is over: the enrollment is no longer live on the server, so it\n' +
|
|
351
|
+
' cannot be resumed. Running the command again starts a NEW ceremony, which\n' +
|
|
352
|
+
' costs another attempt.\n');
|
|
353
|
+
}
|
|
290
354
|
async function keyAlreadySpokenFor(client, pubkey) {
|
|
291
355
|
let devices;
|
|
292
356
|
try {
|
|
@@ -87,13 +87,33 @@ export async function cmdSync(config, opts) {
|
|
|
87
87
|
// this machine may join, and a scary line about an unrelated call would
|
|
88
88
|
// compete with the instructions below.
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
// PROSE, NOT A BLOCK TO COPY — and what changes is what this command claims
|
|
91
|
+
// at this moment, not what it knows (#307).
|
|
92
|
+
//
|
|
93
|
+
// Nothing here has established that the team exists, and nothing can. The
|
|
94
|
+
// local store is what `connect` and `fetch` fail closed on, and a second
|
|
95
|
+
// machine does not have one — that absence is the premise of `sync`. The name
|
|
96
|
+
// does not decide it either: `agmsg_validate_team_name` bars `.` `..`, `/`,
|
|
97
|
+
// `\`, a leading `-` and control characters, so `<team>` is a VALID team name.
|
|
98
|
+
// The ceremony below is scoped to the org rather than the team (#148). The one
|
|
99
|
+
// remaining source, asking the service by name, answers nothing for every team
|
|
100
|
+
// today (#250).
|
|
101
|
+
//
|
|
102
|
+
// So this is not a fix, and the issue stays open. What it removes is a
|
|
103
|
+
// misreading: an indented, copyable command reads as "this is the step", and
|
|
104
|
+
// when the person had not filled the placeholder it put a literal `'<team>'`
|
|
105
|
+
// on the other machine, quoted and ready to paste. As a sentence it reads as
|
|
106
|
+
// what it is — something to ask for, on a machine that has the team, which is
|
|
107
|
+
// a condition the reader can check and this process cannot.
|
|
108
|
+
//
|
|
109
|
+
// The command still goes through `shellArg`: the name reaching it is arbitrary
|
|
110
|
+
// text either way.
|
|
111
|
+
out(` Ask a machine that already has "${opts.team}" to run ` +
|
|
112
|
+
`\`agmsg-cloud approve ${shellArg(opts.team)}\`, and compare the eight digits on ` +
|
|
113
|
+
'both screens before answering there.\n\n');
|
|
114
|
+
out(` This machine has not confirmed that "${opts.team}" is on the service — it has no\n` +
|
|
115
|
+
' way to, before the steps below. If that machine has no such team, stop here and\n' +
|
|
116
|
+
' connect it there first.\n\n');
|
|
97
117
|
// Everything the ceremony guarantees happens inside here: the commitment is
|
|
98
118
|
// pinned before anything opens, nothing is sealed or uploaded until the
|
|
99
119
|
// digits match, and this side refuses a transcript it cannot verify.
|
|
@@ -101,7 +121,27 @@ export async function cmdSync(config, opts) {
|
|
|
101
121
|
// one that knows there is nothing to do: fetch and pull follow immediately
|
|
102
122
|
// below. The decision is made HERE, once, rather than inferred inside each
|
|
103
123
|
// step (raised in review).
|
|
104
|
-
await request(config, { label }, { nextStepsFromCaller: true });
|
|
124
|
+
const enrolled = await request(config, { label }, { nextStepsFromCaller: true });
|
|
125
|
+
// NOTHING BELOW CAN SUCCEED WITHOUT IT, so a failed ceremony ends the run
|
|
126
|
+
// here.
|
|
127
|
+
//
|
|
128
|
+
// It used to continue. `cmdRequest` reports failure by setting
|
|
129
|
+
// `process.exitCode` and returning normally — it does not throw — and this
|
|
130
|
+
// line ignored the return value, so `pull` ran next and resolved the team by
|
|
131
|
+
// name against an account this machine had not joined. What the operator was
|
|
132
|
+
// left holding was `no team named "<team>" in this organization`: true, and
|
|
133
|
+
// not the reason anything failed. The enrollment message had scrolled past
|
|
134
|
+
// two steps earlier.
|
|
135
|
+
//
|
|
136
|
+
// Nothing is printed here. `request` has already said what happened and what
|
|
137
|
+
// to do about it, in the words that fit the case it hit; a summary from this
|
|
138
|
+
// side would either repeat it or, worse, generalise over cases it cannot
|
|
139
|
+
// tell apart.
|
|
140
|
+
//
|
|
141
|
+
// Compared against the success value rather than the failure one, so a
|
|
142
|
+
// future outcome that is neither stops the run as well.
|
|
143
|
+
if (enrolled !== 'enrolled')
|
|
144
|
+
return;
|
|
105
145
|
// PULL BEFORE FETCH, and the old order was the wrong way round.
|
|
106
146
|
//
|
|
107
147
|
// The comment that used to sit below said the messages "needed the key that
|
|
@@ -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
|
@@ -23,10 +23,79 @@ function fromEnv(env) {
|
|
|
23
23
|
// `connect --preflight` is a dry run whose whole purpose is to be usable before
|
|
24
24
|
// anything is set up, so it must not be refused for not being signed in.
|
|
25
25
|
export function resolveScriptsDir(env = process.env) {
|
|
26
|
-
return env
|
|
26
|
+
return scriptsDirChoice(env).dir;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The resolution, with its PROVENANCE — because the path alone does not answer
|
|
30
|
+
* the question an operator is actually asking (issue 282).
|
|
31
|
+
*
|
|
32
|
+
* An empty value is treated as unset. `??` passes the empty string through, so
|
|
33
|
+
* `export AGMSG_SCRIPTS_DIR=` resolved to `""` and every script path became a
|
|
34
|
+
* bare filename — measured, not supposed:
|
|
35
|
+
*
|
|
36
|
+
* resolveScriptsDir({ AGMSG_SCRIPTS_DIR: '' }) -> ""
|
|
37
|
+
*
|
|
38
|
+
* That is the shape someone reaches for when they are trying to undo the
|
|
39
|
+
* override, and it left them further from the default rather than back at it.
|
|
40
|
+
*/
|
|
41
|
+
export function scriptsDirChoice(env = process.env) {
|
|
42
|
+
const given = env.AGMSG_SCRIPTS_DIR;
|
|
43
|
+
if (given !== undefined && given !== '')
|
|
44
|
+
return { dir: given, from: 'env' };
|
|
45
|
+
return { dir: defaultScriptsDir(), from: 'default' };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The install used when `AGMSG_SCRIPTS_DIR` says nothing.
|
|
49
|
+
*
|
|
50
|
+
* Split out because it is also the answer to "what is the OTHER install"
|
|
51
|
+
* (issue 279), and a second copy of the path would be free to drift from the
|
|
52
|
+
* one the resolver uses — which is exactly the disagreement being reported.
|
|
53
|
+
*
|
|
54
|
+
* `home` is a PARAMETER, not `env.HOME`. The first version read
|
|
55
|
+
* `env.HOME ?? homedir()` and called it injectable-but-equivalent, on the
|
|
56
|
+
* grounds that `homedir()` reads `$HOME` on POSIX. That does not close: this
|
|
57
|
+
* CLI targets Windows, where a POSIX-compatible shell sets `HOME` to something
|
|
58
|
+
* like `/c/Users/x` while `homedir()` returns the native path — so the
|
|
59
|
+
* "equivalent" version would have moved the default install location for every
|
|
60
|
+
* Git Bash user (raised in review).
|
|
61
|
+
*
|
|
62
|
+
* Production never passes it. A seam a caller must opt into cannot change what
|
|
63
|
+
* anyone runs; an environment variable that production already has is not a
|
|
64
|
+
* seam at all.
|
|
65
|
+
*/
|
|
66
|
+
export function defaultScriptsDir(home = homedir()) {
|
|
67
|
+
return join(home, '.agents', 'skills', 'agmsg', 'scripts');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* THE credential decision. Every caller that needs to know whether this machine
|
|
71
|
+
* can act — and with what — goes through this one function.
|
|
72
|
+
*
|
|
73
|
+
* It is a function rather than a rule written twice because the two readers
|
|
74
|
+
* disagreed once already: preflight asked `readCredential` alone and reported a
|
|
75
|
+
* headless machine carrying AGMSG_CLOUD_ENDPOINT + AGMSG_CLOUD_SECRET as not
|
|
76
|
+
* signed in, refusing a machine that could run the command — #222's defect
|
|
77
|
+
* facing the other way (raised in review). The first repair merely copied this
|
|
78
|
+
* expression into both readers, which is the same arrangement with the
|
|
79
|
+
* divergence deferred: equal today, free to drift on the next edit (raised in
|
|
80
|
+
* review again). One body, so there is nothing to keep in step.
|
|
81
|
+
*/
|
|
82
|
+
function resolveCredential(env) {
|
|
83
|
+
return fromEnv(env) ?? readCredential(null, env);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether this machine has a credential to act with — `loadConfig`'s own
|
|
87
|
+
* question, asked of `loadConfig`'s own resolver, so preflight cannot hold a
|
|
88
|
+
* second opinion about a decision `loadConfig` already makes.
|
|
89
|
+
*
|
|
90
|
+
* A half-set pair still THROWS, exactly as it does for `loadConfig`: "endpoint
|
|
91
|
+
* set, secret missing" is a misconfiguration to report, not an absence to
|
|
92
|
+
* quietly call signed-out.
|
|
93
|
+
*/
|
|
94
|
+
export function hasCredential(env = process.env) {
|
|
95
|
+
return resolveCredential(env) !== null;
|
|
27
96
|
}
|
|
28
97
|
export function loadConfig(env = process.env) {
|
|
29
|
-
const resolved =
|
|
98
|
+
const resolved = resolveCredential(env);
|
|
30
99
|
if (!resolved) {
|
|
31
100
|
throw new Error('not signed in on this machine — run `agmsg-cloud login --endpoint <url>` first');
|
|
32
101
|
}
|