agmsg-cloud 0.1.0-rc.6 → 0.1.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/approve.js +15 -1
- package/dist/src/commands/login.js +33 -13
- package/dist/src/commands/request.js +90 -2
- package/dist/src/commands/sync.js +66 -11
- package/dist/src/config.js +42 -1
- package/dist/src/index.js +18 -3
- package/dist/src/preflight.js +89 -3
- package/dist/src/recovery-key.js +13 -5
- package/dist/src/slot-advice.js +9 -3
- package/dist/src/version.js +21 -22
- package/package.json +4 -2
|
@@ -360,7 +360,21 @@ args, deps = {}) {
|
|
|
360
360
|
done();
|
|
361
361
|
// Closed, not refunded: §4.1 keeps a success counted in the window.
|
|
362
362
|
closeAttempt(scope, requestId, 'succeeded', undefined, env);
|
|
363
|
-
|
|
363
|
+
// The LABEL, not the id.
|
|
364
|
+
//
|
|
365
|
+
// `device_id` is a UUID the approver cannot use: no subcommand of this
|
|
366
|
+
// CLI accepts one (derived from index.ts — approve, connect, fetch,
|
|
367
|
+
// login, logout, pull, recovery, request, sync, watch, whoami, and none
|
|
368
|
+
// take a device). So it fails all three tests for reaching a person: it
|
|
369
|
+
// decides nothing for them, it cannot help them repair anything, and the
|
|
370
|
+
// label they just compared eight digits against is what actually names
|
|
371
|
+
// the machine they approved (#275 — the operator gets the minimum, the
|
|
372
|
+
// rest belongs in a log).
|
|
373
|
+
//
|
|
374
|
+
// Not routed to a log instead, because this CLI has no log to route it
|
|
375
|
+
// to. Building one for a single value would be the wrong size; when a
|
|
376
|
+
// second diagnostic needs a home, that is the moment to give it one.
|
|
377
|
+
out(`approved; "${start.label}" can now read this team\n`);
|
|
364
378
|
}
|
|
365
379
|
finally {
|
|
366
380
|
rmSync(scratch, { recursive: true, force: true });
|
|
@@ -63,6 +63,7 @@ export async function cmdLogin(opts) {
|
|
|
63
63
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
64
64
|
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
65
65
|
const now = opts.now ?? (() => Date.now());
|
|
66
|
+
const out = opts.out ?? ((text) => void process.stdout.write(text));
|
|
66
67
|
const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
67
68
|
// The flag, validated, before anything is asked. `settleMachineName` returns
|
|
68
69
|
// it verbatim when it is given, so nothing below needs the prompt to know
|
|
@@ -73,7 +74,7 @@ export async function cmdLogin(opts) {
|
|
|
73
74
|
// the thing it takes away is the moment the operator typed the destination —
|
|
74
75
|
// so the destination is printed instead. Nobody should have to guess which
|
|
75
76
|
// server their machine is about to be registered with.
|
|
76
|
-
|
|
77
|
+
out(`Connecting to ${new URL(endpoint).host}\n`);
|
|
77
78
|
const post = async (path, body, bearer) => {
|
|
78
79
|
try {
|
|
79
80
|
return await fetchImpl(`${endpoint}${path}`, {
|
|
@@ -115,7 +116,7 @@ export async function cmdLogin(opts) {
|
|
|
115
116
|
if (stored && (asked === undefined || stored.machineName === asked)) {
|
|
116
117
|
const res = await post('/v1/device/activate', {}, stored.secret);
|
|
117
118
|
if (res.ok) {
|
|
118
|
-
|
|
119
|
+
out(`Already signed in as machine "${stored.machineName}" in organization "${stored.org}".\n`);
|
|
119
120
|
return;
|
|
120
121
|
}
|
|
121
122
|
const code = await errorCode(res);
|
|
@@ -148,16 +149,32 @@ export async function cmdLogin(opts) {
|
|
|
148
149
|
throw new Error(`could not start login: ${codeRes.status} ${code}`);
|
|
149
150
|
}
|
|
150
151
|
const grant = (await codeRes.json());
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
out(`\nOpen this page and check the code matches:\n\n`);
|
|
153
|
+
out(` ${grant.verification_uri_complete}\n\n`);
|
|
154
|
+
out(` code: ${grant.user_code}\n`);
|
|
155
|
+
out(` machine: ${machineName}\n\n`);
|
|
155
156
|
// Armed, never awaited: an approval done from a phone, or from a URL typed by
|
|
156
157
|
// hand, must still be noticed — so the poll below runs whether or not any key
|
|
157
158
|
// is ever pressed. `disarm` is bound to every exit path, because a live stdin
|
|
158
159
|
// listener would hold the process open after login has already finished.
|
|
159
160
|
const opener = (opts.armOpener ?? armEnterToOpen)(grant.verification_uri_complete, endpoint);
|
|
160
|
-
|
|
161
|
+
// SAYS THAT THE BLOCKING IS THE DESIGN, and that the two lines above are
|
|
162
|
+
// already usable.
|
|
163
|
+
//
|
|
164
|
+
// An agent held the URL and the code for 42 seconds waiting for this command
|
|
165
|
+
// to return before relaying them — and it cannot return until someone
|
|
166
|
+
// approves, which nobody can do without seeing what it has already printed.
|
|
167
|
+
// The one step that needs a person was the step the person was locked out of
|
|
168
|
+
// (issue 279).
|
|
169
|
+
//
|
|
170
|
+
// The prompt handed to an agent carries four sentences about this. They are a
|
|
171
|
+
// description of behaviour this output can state itself, and prose in a
|
|
172
|
+
// prompt does not reach anyone who did not read that prompt — someone running
|
|
173
|
+
// the command by hand gets nothing.
|
|
174
|
+
out(`Waiting for approval — nothing is granted until you approve it.\n`);
|
|
175
|
+
out(`This command will not return until then. That is expected, not a failure:\n` +
|
|
176
|
+
`the page and the code above are ready to use now — open them, do not wait\n` +
|
|
177
|
+
`for this to finish.\n`);
|
|
161
178
|
try {
|
|
162
179
|
return await pollUntilDecided(grant, {
|
|
163
180
|
post,
|
|
@@ -165,6 +182,7 @@ export async function cmdLogin(opts) {
|
|
|
165
182
|
now,
|
|
166
183
|
endpoint,
|
|
167
184
|
armOpener: opts.armOpener ?? armEnterToOpen,
|
|
185
|
+
out,
|
|
168
186
|
});
|
|
169
187
|
}
|
|
170
188
|
finally {
|
|
@@ -172,7 +190,7 @@ export async function cmdLogin(opts) {
|
|
|
172
190
|
}
|
|
173
191
|
}
|
|
174
192
|
async function pollUntilDecided(grant, ctx) {
|
|
175
|
-
const { post, sleep, now, endpoint, armOpener } = ctx;
|
|
193
|
+
const { post, sleep, now, endpoint, armOpener, out } = ctx;
|
|
176
194
|
// The poll is single-flight by construction: one loop, one request in flight.
|
|
177
195
|
// A concurrent second poll on the same grant would revoke the credential the
|
|
178
196
|
// first poll received.
|
|
@@ -198,7 +216,7 @@ async function pollUntilDecided(grant, ctx) {
|
|
|
198
216
|
if (err instanceof Unreachable) {
|
|
199
217
|
// Said out loud rather than swallowed: an operator watching a long wait
|
|
200
218
|
// should see that contact was lost and regained, not silence.
|
|
201
|
-
|
|
219
|
+
out(` (lost contact with the server, still waiting)\n`);
|
|
202
220
|
continue;
|
|
203
221
|
}
|
|
204
222
|
throw err;
|
|
@@ -227,7 +245,7 @@ async function pollUntilDecided(grant, ctx) {
|
|
|
227
245
|
if (!isOrgAddress(issued.org)) {
|
|
228
246
|
throw new Error('the server answered with an org address this build does not recognise — nothing was stored');
|
|
229
247
|
}
|
|
230
|
-
return finish(issued, endpoint, post);
|
|
248
|
+
return finish(issued, endpoint, post, out);
|
|
231
249
|
}
|
|
232
250
|
const body = await errorBody(res);
|
|
233
251
|
const code = body.code;
|
|
@@ -248,7 +266,9 @@ async function pollUntilDecided(grant, ctx) {
|
|
|
248
266
|
throw new Error(explained ? `login stopped: ${explained}` : `login failed: ${res.status} ${code}`);
|
|
249
267
|
}
|
|
250
268
|
}
|
|
251
|
-
async function finish(issued, endpoint, post
|
|
269
|
+
async function finish(issued, endpoint, post,
|
|
270
|
+
/** The caller's sink, so every line this command prints goes one place. */
|
|
271
|
+
out) {
|
|
252
272
|
const secret = secretFromCapabilityUrl(issued.capability_url);
|
|
253
273
|
// Durable write FIRST. If the process dies between here and activate, the
|
|
254
274
|
// credential is on disk and the machine can be activated by running login
|
|
@@ -270,8 +290,8 @@ async function finish(issued, endpoint, post) {
|
|
|
270
290
|
const code = await errorCode(res);
|
|
271
291
|
throw new Error(`the credential was saved but could not be activated (${res.status} ${code}) — run login again`);
|
|
272
292
|
}
|
|
273
|
-
|
|
274
|
-
|
|
293
|
+
out(`\nSigned in as machine "${issued.machine_name}".\n`);
|
|
294
|
+
out(`Its sync address is saved on this machine; no token to copy.\n`);
|
|
275
295
|
}
|
|
276
296
|
async function errorCode(res) {
|
|
277
297
|
return (await errorBody(res)).code;
|
|
@@ -9,6 +9,72 @@ import { originOf } from '../credentials.js';
|
|
|
9
9
|
import { deviceIdentityPath } from '../paths.js';
|
|
10
10
|
import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
|
|
11
11
|
import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
|
|
12
|
+
/**
|
|
13
|
+
* The one thing a long wait cannot say for itself: that it is the design.
|
|
14
|
+
*
|
|
15
|
+
* `login` learned this in #279 — an agent held a URL and a code for 42 seconds
|
|
16
|
+
* waiting for a blocking command to return, because nothing told it the block
|
|
17
|
+
* WAS the mechanism. These waits are longer: `login` waits for a browser the
|
|
18
|
+
* operator already has open, and these wait for a person on another machine.
|
|
19
|
+
*
|
|
20
|
+
* Written once and used at every wait, so the three cannot drift into saying
|
|
21
|
+
* different things about the same behaviour. What may be added to it is what
|
|
22
|
+
* stopping costs, and that is NOT the same at every wait — see below.
|
|
23
|
+
*/
|
|
24
|
+
function saysItBlocks() {
|
|
25
|
+
return ' This command will not return until then. That is expected, not a failure.\n';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* What stopping costs, and it stops being free after the digits are shown.
|
|
29
|
+
*
|
|
30
|
+
* MEASURED, both of them, because the first version of this file asserted the
|
|
31
|
+
* resume in all three places and was wrong in one:
|
|
32
|
+
*
|
|
33
|
+
* interrupted before the answer the next run prints `resuming enrollment
|
|
34
|
+
* <id>` and continues the same ceremony.
|
|
35
|
+
* Budget unchanged: used:1 remaining:4.
|
|
36
|
+
*
|
|
37
|
+
* interrupted at the LAST wait, the row goes terminal while nothing is
|
|
38
|
+
* and the approver then answers watching. `GET /v1/enrollments` lists
|
|
39
|
+
* NONTERMINAL rows only, so the stored
|
|
40
|
+
* record matches nothing, the old attempt is
|
|
41
|
+
* closed as failed, and the next run starts a
|
|
42
|
+
* NEW ceremony. Measured: used:2 remaining:3
|
|
43
|
+
* — one of five, spent.
|
|
44
|
+
*
|
|
45
|
+
* So the resume line belongs at the first two waits and is false at the third.
|
|
46
|
+
* Saying it there would promise a cheap retry for the one interruption that
|
|
47
|
+
* costs something.
|
|
48
|
+
*/
|
|
49
|
+
function stoppingIsCheap() {
|
|
50
|
+
return ' Leave it running — if you do stop it, run the same command again to resume.\n';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* NOTHING HERE WARNS ABOUT A SECOND TERMINAL, and that is a refusal to guess
|
|
54
|
+
* rather than a finding.
|
|
55
|
+
*
|
|
56
|
+
* The console prompt says "do not start a second one in another terminal", and
|
|
57
|
+
* #344 asked whether that is true before repeating it. It was measured twice
|
|
58
|
+
* and the two measurements disagree:
|
|
59
|
+
*
|
|
60
|
+
* an attempt opened under a commitment no run could plan
|
|
61
|
+
* -> `consumeAttempt` refuses with `attempt_already_open`
|
|
62
|
+
* — but that is not the branch a second terminal takes,
|
|
63
|
+
* because `request` looks for a resumable record first.
|
|
64
|
+
* A rigged input, and it agreed with the warning.
|
|
65
|
+
*
|
|
66
|
+
* a first run interrupted mid-wait, then a second started
|
|
67
|
+
* -> once observed printing `resuming enrollment <id>`
|
|
68
|
+
* and carrying on at no cost; once observed starting a
|
|
69
|
+
* NEW ceremony instead. The two runs differed in their
|
|
70
|
+
* server double, and which difference decided it was
|
|
71
|
+
* not established.
|
|
72
|
+
*
|
|
73
|
+
* So this says nothing about second terminals in either direction. A warning
|
|
74
|
+
* has to be true to be worth a reader's attention, and so does a reassurance.
|
|
75
|
+
* What IS established is on `stoppingIsCheap` above: the cost of stopping, at
|
|
76
|
+
* each wait, measured.
|
|
77
|
+
*/
|
|
12
78
|
export async function cmdRequest(config, args, deps = {}) {
|
|
13
79
|
const out = deps.out ?? ((text) => void process.stdout.write(text));
|
|
14
80
|
const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
|
|
@@ -179,14 +245,20 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
179
245
|
}
|
|
180
246
|
const done = () => clearRecord({ role: 'requester', serverOrigin: config.baseUrl, key: record.commitmentHex }, env);
|
|
181
247
|
try {
|
|
182
|
-
out('
|
|
248
|
+
out('\nwaiting for an approver to commit...\n');
|
|
249
|
+
out(' The next move is on the other machine, where someone runs `agmsg-cloud approve`.\n');
|
|
250
|
+
out(saysItBlocks());
|
|
251
|
+
out(stoppingIsCheap());
|
|
183
252
|
await waitForStatus(client, requestId, 'both_committed', deps.waitOptions);
|
|
184
253
|
// Safe to open now: the approver's contribution is fixed and cannot change.
|
|
185
254
|
await client.submitOpening(requestId, {
|
|
186
255
|
device_pubkey: pubkey,
|
|
187
256
|
opening_nonce: record.nonceHex,
|
|
188
257
|
});
|
|
189
|
-
out('
|
|
258
|
+
out('\nwaiting for the approver to open...\n');
|
|
259
|
+
out(' Nothing to do here yet — the digits to compare appear when this returns.\n');
|
|
260
|
+
out(saysItBlocks());
|
|
261
|
+
out(stoppingIsCheap());
|
|
190
262
|
const opened = await waitForStatus(client, requestId, 'opened', deps.waitOptions);
|
|
191
263
|
// Derived here, from the two opened nonces. Nothing displayed below came
|
|
192
264
|
// from the server as a code.
|
|
@@ -205,6 +277,22 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
205
277
|
// requirement that the requester counts failed and incomplete attempts, and
|
|
206
278
|
// warns from the second failure, did nothing on this side. Wait for the
|
|
207
279
|
// server to say which way it went.
|
|
280
|
+
// THE THIRD WAIT, and it said nothing at all — worse than the two the issue
|
|
281
|
+
// named, and the longest silence in the run.
|
|
282
|
+
//
|
|
283
|
+
// No line of its own, unlike the other two. What a reader has to do here is
|
|
284
|
+
// already on the screen directly above: `renderSasBlock` says the approving
|
|
285
|
+
// machine shows eight digits too, that they should check they are the same,
|
|
286
|
+
// and what each answer means. A copy of that here is the repetition #195
|
|
287
|
+
// removed from this very screen — a second copy of the one line that has to
|
|
288
|
+
// be read is how a reader learns to skim it. What was missing was not the
|
|
289
|
+
// instruction. It was that this blocks.
|
|
290
|
+
out('\nwaiting for the approver to answer...\n');
|
|
291
|
+
out(saysItBlocks());
|
|
292
|
+
// NOT `stoppingIsCheap()`. This is the one interruption that costs an
|
|
293
|
+
// attempt — see the note on that function for the measurement.
|
|
294
|
+
out(' Stopping here is the one that costs: if they answer while nothing is\n');
|
|
295
|
+
out(' watching, the next run starts over and spends one of five attempts.\n');
|
|
208
296
|
const settled = await waitForStatus(client, requestId, 'consumed', deps.waitOptions);
|
|
209
297
|
// What the eight digits authenticated, kept for `fetch`.
|
|
210
298
|
//
|
|
@@ -32,6 +32,39 @@ export async function cmdSync(config, opts) {
|
|
|
32
32
|
const request = d.request ?? cmdRequest;
|
|
33
33
|
const fetch = d.fetch ?? cmdFetch;
|
|
34
34
|
const pull = d.pull ?? cmdPull;
|
|
35
|
+
const client = new CourierClient(config);
|
|
36
|
+
// BEFORE THE CEREMONY, because `request` spends one of five attempts before
|
|
37
|
+
// it posts the enrollment. The ceremony is org-scoped and cannot discover
|
|
38
|
+
// that the named team belongs to another org; leaving this to `pull` made a
|
|
39
|
+
// wrong-account run finish the human comparison, spend an attempt, and only
|
|
40
|
+
// then say the team was absent (#345).
|
|
41
|
+
//
|
|
42
|
+
// This lookup is scoped by the current credential on the control plane. A
|
|
43
|
+
// failure to answer is not absence, and ambiguity is not resolved here:
|
|
44
|
+
// neither state is permission to spend a ceremony on a guessed team.
|
|
45
|
+
let matches;
|
|
46
|
+
try {
|
|
47
|
+
matches = opts.teamId === undefined
|
|
48
|
+
? await client.resolveTeamByName(opts.team)
|
|
49
|
+
: (await client.listTeams()).filter((team) => team.teamId === opts.teamId);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
out(`Not started: this account's organization could not be checked for "${opts.team}": ` +
|
|
53
|
+
`${err instanceof Error ? err.message : String(err)}\n` +
|
|
54
|
+
'No enrollment was started, so this costs none of your attempts.\n');
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (matches.length !== 1) {
|
|
59
|
+
out(matches.length === 0
|
|
60
|
+
? `Not started: this account's organization has no team named "${opts.team}".\n`
|
|
61
|
+
: `Not started: this account's organization has more than one team named "${opts.team}".\n`);
|
|
62
|
+
out('Check `agmsg-cloud whoami`, then sign in to the organization that holds the team.\n');
|
|
63
|
+
out('No enrollment was started, so this costs none of your attempts.\n');
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const resolvedTeamId = matches[0].teamId;
|
|
35
68
|
// The approver sees this, and they are looking for a machine they recognise.
|
|
36
69
|
// A hostname is what a person calls their laptop; a uuid is what they read
|
|
37
70
|
// aloud wrongly.
|
|
@@ -62,7 +95,7 @@ export async function cmdSync(config, opts) {
|
|
|
62
95
|
// join; failing the join over a failed courtesy check would be the refusal
|
|
63
96
|
// this deliberately is not.
|
|
64
97
|
try {
|
|
65
|
-
const existing = await
|
|
98
|
+
const existing = await client.listDevices();
|
|
66
99
|
// THIS MACHINE'S OWN ROW IS NOT A CLASH WITH ITSELF.
|
|
67
100
|
//
|
|
68
101
|
// The comparison was on label alone, so a machine already on the account —
|
|
@@ -87,13 +120,33 @@ export async function cmdSync(config, opts) {
|
|
|
87
120
|
// this machine may join, and a scary line about an unrelated call would
|
|
88
121
|
// compete with the instructions below.
|
|
89
122
|
}
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
123
|
+
// PROSE, NOT A BLOCK TO COPY — and what changes is what this command claims
|
|
124
|
+
// at this moment, not what it knows (#307).
|
|
125
|
+
//
|
|
126
|
+
// Nothing here has established that the team exists, and nothing can. The
|
|
127
|
+
// local store is what `connect` and `fetch` fail closed on, and a second
|
|
128
|
+
// machine does not have one — that absence is the premise of `sync`. The name
|
|
129
|
+
// does not decide it either: `agmsg_validate_team_name` bars `.` `..`, `/`,
|
|
130
|
+
// `\`, a leading `-` and control characters, so `<team>` is a VALID team name.
|
|
131
|
+
// The ceremony below is scoped to the org rather than the team (#148). The one
|
|
132
|
+
// remaining source, asking the service by name, answers nothing for every team
|
|
133
|
+
// today (#250).
|
|
134
|
+
//
|
|
135
|
+
// So this is not a fix, and the issue stays open. What it removes is a
|
|
136
|
+
// misreading: an indented, copyable command reads as "this is the step", and
|
|
137
|
+
// when the person had not filled the placeholder it put a literal `'<team>'`
|
|
138
|
+
// on the other machine, quoted and ready to paste. As a sentence it reads as
|
|
139
|
+
// what it is — something to ask for, on a machine that has the team, which is
|
|
140
|
+
// a condition the reader can check and this process cannot.
|
|
141
|
+
//
|
|
142
|
+
// The command still goes through `shellArg`: the name reaching it is arbitrary
|
|
143
|
+
// text either way.
|
|
144
|
+
out(` Ask a machine that already has "${opts.team}" to run ` +
|
|
145
|
+
`\`agmsg-cloud approve ${shellArg(opts.team)}\`, and compare the eight digits on ` +
|
|
146
|
+
'both screens before answering there.\n\n');
|
|
147
|
+
out(` This machine has not confirmed that "${opts.team}" is on the service — it has no\n` +
|
|
148
|
+
' way to, before the steps below. If that machine has no such team, stop here and\n' +
|
|
149
|
+
' connect it there first.\n\n');
|
|
97
150
|
// Everything the ceremony guarantees happens inside here: the commitment is
|
|
98
151
|
// pinned before anything opens, nothing is sealed or uploaded until the
|
|
99
152
|
// digits match, and this side refuses a transcript it cannot verify.
|
|
@@ -142,9 +195,11 @@ export async function cmdSync(config, opts) {
|
|
|
142
195
|
// It stayed hidden because the second machine in testing pointed at the first
|
|
143
196
|
// machine's install, where the team was already present — so the second
|
|
144
197
|
// machine's path had never actually been walked.
|
|
145
|
-
await pull(config,
|
|
146
|
-
|
|
147
|
-
|
|
198
|
+
await pull(config, {
|
|
199
|
+
team: opts.team,
|
|
200
|
+
teamId: resolvedTeamId,
|
|
201
|
+
nextStepsFromCaller: true,
|
|
202
|
+
});
|
|
148
203
|
// And now the key, which opens what arrived above. Only reachable once the
|
|
149
204
|
// server says the ceremony was approved — `request` waits for that, and
|
|
150
205
|
// throws otherwise. The bundle is checked against the snapshot those digits
|
package/dist/src/config.js
CHANGED
|
@@ -23,7 +23,48 @@ 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');
|
|
27
68
|
}
|
|
28
69
|
/**
|
|
29
70
|
* THE credential decision. Every caller that needs to know whether this machine
|
package/dist/src/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { cmdPull } from './commands/pull.js';
|
|
|
11
11
|
import { cmdRequest } from './commands/request.js';
|
|
12
12
|
import { cmdVaultPut, cmdVaultRestore } from './commands/vault.js';
|
|
13
13
|
import { cmdWatch } from './commands/watch.js';
|
|
14
|
-
import {
|
|
14
|
+
import { packageInstall } from './version.js';
|
|
15
15
|
const USAGE = `agmsg-cloud — hosted agmsg from this machine
|
|
16
16
|
|
|
17
17
|
login sign this machine in; approve it in your own browser
|
|
@@ -40,7 +40,8 @@ const USAGE = `agmsg-cloud — hosted agmsg from this machine
|
|
|
40
40
|
whoami which of this account's machines this one is —
|
|
41
41
|
its name, its organization, and the prefix the
|
|
42
42
|
console shows beside it
|
|
43
|
-
version the version of this CLI
|
|
43
|
+
version the version of this CLI, and the directory it is
|
|
44
|
+
running out of (also --version, -v)
|
|
44
45
|
the OSS scripts it drives are reported by
|
|
45
46
|
\`connect --preflight\`, which is a separate answer
|
|
46
47
|
|
|
@@ -104,7 +105,21 @@ async function main(argv) {
|
|
|
104
105
|
// one it must assume someone will paste. Written without naming a
|
|
105
106
|
// version: an example that has to be bumped is a second place to bump,
|
|
106
107
|
// which is the reason this command reads the manifest at all.
|
|
107
|
-
|
|
108
|
+
//
|
|
109
|
+
// The directory follows on its own line because the version alone does
|
|
110
|
+
// not answer the question people ask this command. `npm i -g agmsg-cloud`
|
|
111
|
+
// can succeed while a copy another package manager put earlier on PATH is
|
|
112
|
+
// what runs, and nothing about that is an error: both installs are valid
|
|
113
|
+
// and the shell picks one. That happened here, and the two defects being
|
|
114
|
+
// chased were already fixed in the copy that was NOT running. Choosing
|
|
115
|
+
// for the user would mean deciding their PATH; saying which one answered
|
|
116
|
+
// costs one line and leaves the choice where it was.
|
|
117
|
+
//
|
|
118
|
+
// A path, not a command: this line must not read as something to paste,
|
|
119
|
+
// which is why it is not shaped like an invocation.
|
|
120
|
+
const install = packageInstall();
|
|
121
|
+
process.stdout.write(`agmsg-cloud/${install.version}\n`);
|
|
122
|
+
process.stdout.write(`running from ${install.directory}\n`);
|
|
108
123
|
return;
|
|
109
124
|
}
|
|
110
125
|
// Beside `version` for the same reason it sits there: both answer a
|
package/dist/src/preflight.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { hasCredential } from './config.js';
|
|
3
|
+
import { defaultScriptsDir, hasCredential, scriptsDirChoice } from './config.js';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { platform } from 'node:process';
|
|
6
6
|
// What `connect` needs before it starts, checked all at once.
|
|
@@ -151,7 +151,17 @@ function toolsFor(scripts) {
|
|
|
151
151
|
python3: scripts.includes('remote.sh'),
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
|
-
export function preflight(scriptsDir, needs, env = process.env
|
|
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) {
|
|
155
165
|
const { command, scripts } = needs;
|
|
156
166
|
const requireConnect = needs.requireConnect ?? false;
|
|
157
167
|
const requirements = [];
|
|
@@ -229,8 +239,43 @@ export function preflight(scriptsDir, needs, env = process.env) {
|
|
|
229
239
|
command,
|
|
230
240
|
scriptsDir,
|
|
231
241
|
agmsgVersion: canConnect ? installedVersion(scriptsDir) : null,
|
|
242
|
+
scriptsDirFrom: provenance(env, home),
|
|
243
|
+
otherInstalls: otherInstalls(scriptsDir, env, home),
|
|
232
244
|
};
|
|
233
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
|
+
}
|
|
234
279
|
// The refusal in front of every command that shells out: one place decides how
|
|
235
280
|
// a missing prerequisite reads, so it reads the same whichever command found
|
|
236
281
|
// it.
|
|
@@ -262,7 +307,39 @@ export function formatPreflight(result) {
|
|
|
262
307
|
// decoration on a label; it is the answer to "which one did you look at".
|
|
263
308
|
lines.push('');
|
|
264
309
|
lines.push(` scripts directory: ${result.scriptsDir}`);
|
|
265
|
-
|
|
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
|
+
}
|
|
266
343
|
if (result.agmsgVersion !== null) {
|
|
267
344
|
// Named as what it is. Calling it "agmsg 1.1.11" would invite the reader to
|
|
268
345
|
// compare it with a release number, which is the thing it cannot be
|
|
@@ -274,6 +351,15 @@ export function formatPreflight(result) {
|
|
|
274
351
|
if (missing.length === 0) {
|
|
275
352
|
lines.push('');
|
|
276
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.`);
|
|
277
363
|
return `${lines.join('\n')}\n`;
|
|
278
364
|
}
|
|
279
365
|
for (const r of missing) {
|
package/dist/src/recovery-key.js
CHANGED
|
@@ -70,8 +70,8 @@ export function generateRecoveryKey() {
|
|
|
70
70
|
// error rather than a silent drop, so a wrong key fails as a wrong key and not
|
|
71
71
|
// as a mangled one.
|
|
72
72
|
export function normalizeRecoveryKey(input) {
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
const entered = input.trim();
|
|
74
|
+
let cleaned = entered
|
|
75
75
|
.toUpperCase()
|
|
76
76
|
.replace(/[\s-]/g, '');
|
|
77
77
|
// The prefix is a label, not key material, and it is stripped by LENGTH
|
|
@@ -87,6 +87,17 @@ export function normalizeRecoveryKey(input) {
|
|
|
87
87
|
if (cleaned.length === PREFIX_SYMBOLS + TOTAL_SYMBOLS && cleaned.startsWith('AGMSG')) {
|
|
88
88
|
cleaned = cleaned.slice(PREFIX_SYMBOLS);
|
|
89
89
|
}
|
|
90
|
+
// Locate a pasted label before diagnosing one character inside it. The
|
|
91
|
+
// prompt masks the input, so "unusable character: :" after a 50-character
|
|
92
|
+
// paste does not tell the operator where the colon was or what a key should
|
|
93
|
+
// look like. Length is the larger fact and can be checked without exposing
|
|
94
|
+
// the secret: the canonical form is 35 characters, while accepted spacing
|
|
95
|
+
// and grouping still reduce to exactly 25 key symbols (or 30 with AGMSG).
|
|
96
|
+
if (cleaned.length !== TOTAL_SYMBOLS) {
|
|
97
|
+
throw new Error(`that entry is ${entered.length} characters; a recovery key is 35 characters ` +
|
|
98
|
+
`in the form ${PREFIX}XXXXX-XXXXX-XXXXX-XXXXX-XXXXX ` +
|
|
99
|
+
`(${TOTAL_SYMBOLS} symbols without the prefix and separators)`);
|
|
100
|
+
}
|
|
90
101
|
cleaned = cleaned
|
|
91
102
|
.replace(/[IL]/g, '1')
|
|
92
103
|
.replace(/O/g, '0')
|
|
@@ -95,9 +106,6 @@ export function normalizeRecoveryKey(input) {
|
|
|
95
106
|
if (!ALPHABET.includes(ch))
|
|
96
107
|
throw new Error(`recovery key contains an unusable character: ${ch}`);
|
|
97
108
|
}
|
|
98
|
-
if (cleaned.length !== TOTAL_SYMBOLS) {
|
|
99
|
-
throw new Error(`recovery key must be ${TOTAL_SYMBOLS} symbols, got ${cleaned.length}`);
|
|
100
|
-
}
|
|
101
109
|
// The check symbol, verified here rather than at the vault. Without it, a
|
|
102
110
|
// mistyped key is indistinguishable from a wrong one until the AEAD refuses
|
|
103
111
|
// it — which is at restore time, the moment there is no way back. Naming the
|
package/dist/src/slot-advice.js
CHANGED
|
@@ -14,12 +14,18 @@ export function adviseOnSlot(result, ctx) {
|
|
|
14
14
|
switch (result.reason) {
|
|
15
15
|
case 'no-slot':
|
|
16
16
|
// The first backup on this machine, or the first after a re-issuance.
|
|
17
|
-
// Worth
|
|
18
|
-
//
|
|
17
|
+
// Worth saying before the next sentence asks for the recovery key. Do
|
|
18
|
+
// not promise "once": knowing that the slot is absent is not knowing
|
|
19
|
+
// that this session may write its replacement. A headless macOS session
|
|
20
|
+
// can read the default keychain name and still have add-generic-password
|
|
21
|
+
// refused with errSecInteractionNotAllowed. That fact is learned only
|
|
22
|
+
// after the vault has opened, when saveDeviceSlot performs the write.
|
|
19
23
|
return {
|
|
20
24
|
tone: 'note',
|
|
21
25
|
lines: [
|
|
22
|
-
'this machine has no key slot for this vault yet, so the recovery key is needed
|
|
26
|
+
'this machine has no key slot for this vault yet, so the recovery key is needed now.',
|
|
27
|
+
'After this succeeds, the CLI will try to keep a slot in the OS secure store. If this',
|
|
28
|
+
'session cannot use that store, the recovery key will be needed every time.',
|
|
23
29
|
],
|
|
24
30
|
};
|
|
25
31
|
case 'no-store':
|
package/dist/src/version.js
CHANGED
|
@@ -5,27 +5,23 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
// version — an identity, and the only thing that tells one package.json from
|
|
6
6
|
// another while walking up a tree that may contain several.
|
|
7
7
|
const PACKAGE_NAME = 'agmsg-cloud';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// stripped of its version is exactly when a monorepo root, an npx cache entry,
|
|
26
|
-
// or a parent workspace supplies its own. Reporting a stranger's version is the
|
|
27
|
-
// failure this command exists to prevent, wearing the shape of a success.
|
|
28
|
-
export function packageVersion(moduleUrl = import.meta.url) {
|
|
8
|
+
/**
|
|
9
|
+
* The install this process is running out of: its version AND where it is.
|
|
10
|
+
*
|
|
11
|
+
* Both come from ONE walk, and that is the point rather than a convenience.
|
|
12
|
+
* `which agmsg-cloud` answers from PATH and a manifest answers from the module
|
|
13
|
+
* graph; when two package managers have both installed this tool, those are two
|
|
14
|
+
* different questions with two different answers, and pairing them would report
|
|
15
|
+
* a directory that need not be where the version came from. The directory
|
|
16
|
+
* returned here is the one whose `package.json` supplied the version, so the
|
|
17
|
+
* two cannot disagree.
|
|
18
|
+
*
|
|
19
|
+
* Node resolves a module's realpath before loading it, so a bin symlink — the
|
|
20
|
+
* usual shape of a global install — has already been followed by the time
|
|
21
|
+
* `import.meta.url` exists. What comes back is the store directory, which is
|
|
22
|
+
* the part that tells a pnpm global install from an npm one.
|
|
23
|
+
*/
|
|
24
|
+
export function packageInstall(moduleUrl = import.meta.url) {
|
|
29
25
|
let dir = dirname(fileURLToPath(moduleUrl));
|
|
30
26
|
const { root } = parse(dir);
|
|
31
27
|
for (;;) {
|
|
@@ -41,7 +37,7 @@ export function packageVersion(moduleUrl = import.meta.url) {
|
|
|
41
37
|
if (manifest?.name === PACKAGE_NAME) {
|
|
42
38
|
const version = manifest.version;
|
|
43
39
|
if (typeof version === 'string' && version !== '')
|
|
44
|
-
return version;
|
|
40
|
+
return { version, directory: dir };
|
|
45
41
|
// Fail closed. This IS the package and it cannot say what it is; climbing
|
|
46
42
|
// past would hand the question to whatever sits above, which is how a
|
|
47
43
|
// broken install comes to announce a stranger's version.
|
|
@@ -55,3 +51,6 @@ export function packageVersion(moduleUrl = import.meta.url) {
|
|
|
55
51
|
// guessed — see the note above.
|
|
56
52
|
throw new Error(`cannot determine the installed version: no ${PACKAGE_NAME} package.json above this module`);
|
|
57
53
|
}
|
|
54
|
+
export function packageVersion(moduleUrl = import.meta.url) {
|
|
55
|
+
return packageInstall(moduleUrl).version;
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agmsg-cloud",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.8",
|
|
4
4
|
"description": "Companion CLI for the agmsg cloud service: connect a team, join from another machine, and back up its keys.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,9 @@
|
|
|
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",
|
|
38
|
+
"verify:shown-install": "tsx scripts/verify-shown-install.ts"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
41
|
"@agmsg-cloud/sas-core": "workspace:*",
|