agmsg-cloud 0.1.0-rc.8 → 0.1.0
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 +85 -3
- package/dist/src/commands/connect.js +14 -7
- package/dist/src/commands/login.js +120 -3
- package/dist/src/commands/logout.js +38 -7
- package/dist/src/commands/request.js +53 -3
- package/dist/src/commands/sync.js +12 -1
- package/dist/src/commands/vault.js +345 -78
- package/dist/src/commands/watch.js +25 -7
- package/dist/src/config.js +11 -2
- package/dist/src/credentials.js +149 -17
- package/dist/src/data-plane.js +67 -0
- package/dist/src/index.js +45 -24
- package/dist/src/oss.js +30 -1
- package/dist/src/preflight.js +140 -15
- package/dist/src/recovery-key.js +11 -16
- package/dist/src/self-install.js +152 -0
- package/dist/src/slot-advice.js +7 -19
- package/dist/src/vault-inventory.js +118 -0
- package/dist/src/vault-placement.js +94 -0
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { isCanonicalAgeRecipient } from '@agmsg-cloud/sas-core';
|
|
2
|
+
import { DEFAULT_ENDPOINT } from './browser.js';
|
|
3
|
+
import { originOf } from './credentials.js';
|
|
4
|
+
import { shellArg } from './shell-arg.js';
|
|
2
5
|
// Wire validation.
|
|
3
6
|
//
|
|
4
7
|
// Every response is checked here, at the boundary, rather than cast and trusted.
|
|
@@ -159,11 +162,81 @@ function requireTranscript(value) {
|
|
|
159
162
|
throw new CourierError(200, 'malformed_enrollment');
|
|
160
163
|
return value;
|
|
161
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* What a 401 from this client means, and why it can be said this precisely.
|
|
167
|
+
*
|
|
168
|
+
* `loadConfig` refuses before any request when this machine has no credential
|
|
169
|
+
* ("not signed in on this machine — run `agmsg-cloud login …` first"), so a
|
|
170
|
+
* request from here always carried a credential. A 401 is therefore never "you
|
|
171
|
+
* are not signed in": it is "the credential this machine used was refused".
|
|
172
|
+
*
|
|
173
|
+
* Telling that person to sign in names something they have already done, which
|
|
174
|
+
* is what #347 reported: `recovery restore` printed `courier request failed:
|
|
175
|
+
* 401 unauthenticated` and stopped there. `index.ts` prints `err.message`
|
|
176
|
+
* verbatim, so this string is the terminal output.
|
|
177
|
+
*
|
|
178
|
+
* WHICH CREDENTIAL, AND FOR WHICH ENDPOINT, both change the answer — and the
|
|
179
|
+
* first version of this got both wrong by writing one sentence for every case
|
|
180
|
+
* (review P1 on this PR):
|
|
181
|
+
*
|
|
182
|
+
* - `loadConfig` prefers `AGMSG_CLOUD_ENDPOINT` + `AGMSG_CLOUD_SECRET` over
|
|
183
|
+
* anything on disk. Calling that "this machine's stored credential" names a
|
|
184
|
+
* file that does not exist, and `login` cannot fix it: it writes to disk,
|
|
185
|
+
* the environment keeps winning, and the next command sends the same
|
|
186
|
+
* refused secret. The remedy has to name the variables.
|
|
187
|
+
* - bare `login` goes to `DEFAULT_ENDPOINT` (`commands/login.ts`), not to the
|
|
188
|
+
* endpoint the refused credential belongs to. A message that offers
|
|
189
|
+
* "minted for a different endpoint" as a cause and then drops the endpoint
|
|
190
|
+
* from its own command contradicts itself.
|
|
191
|
+
*
|
|
192
|
+
* So the remedy is built where the source and the base URL are known, and every
|
|
193
|
+
* branch of it is a command that can actually be run.
|
|
194
|
+
*
|
|
195
|
+
* Bound to 401 alone. A 500 that advised re-authenticating would send people to
|
|
196
|
+
* `login` through an outage, where it is the one thing that cannot help.
|
|
197
|
+
*
|
|
198
|
+
* `login` itself does NOT go through this client — it talks to
|
|
199
|
+
* `/v1/device/activate` directly — so its own 401s cannot pick this up and tell
|
|
200
|
+
* someone mid-login to log in.
|
|
201
|
+
*/
|
|
202
|
+
export function refusedCredentialRemedy(input) {
|
|
203
|
+
// `originOf` throws on a value that is not a URL. A client built by hand in a
|
|
204
|
+
// test can hold one, and a remedy is not worth turning a 401 into a TypeError.
|
|
205
|
+
let origin;
|
|
206
|
+
try {
|
|
207
|
+
origin = originOf(input.baseUrl);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
origin = input.baseUrl;
|
|
211
|
+
}
|
|
212
|
+
if (input.from === 'env') {
|
|
213
|
+
return (` — the credential in AGMSG_CLOUD_SECRET was refused by ${origin}.` +
|
|
214
|
+
` \`agmsg-cloud login\` will NOT help while that pair is set: it writes to disk and` +
|
|
215
|
+
` the environment still wins. Update or unset AGMSG_CLOUD_ENDPOINT and AGMSG_CLOUD_SECRET`);
|
|
216
|
+
}
|
|
217
|
+
// The endpoint is carried into the command whenever it is not the one `login`
|
|
218
|
+
// would pick on its own, quoted because a self-hosted origin is not always
|
|
219
|
+
// one shell word (`http://[::1]:5180` is a glob to zsh).
|
|
220
|
+
const flag = origin === originOf(DEFAULT_ENDPOINT) ? '' : ` --endpoint ${shellArg(origin)}`;
|
|
221
|
+
if (input.from === 'stored') {
|
|
222
|
+
return (` — this machine's stored credential for ${origin} was refused (revoked, or the machine was` +
|
|
223
|
+
` removed). Run \`agmsg-cloud login${flag}\` again`);
|
|
224
|
+
}
|
|
225
|
+
// Source unknown: say what was refused and for where, and claim nothing about
|
|
226
|
+
// where the credential came from. An unknown source is exactly the case the
|
|
227
|
+
// first version got wrong by assuming one.
|
|
228
|
+
return ` — the credential this machine used for ${origin} was refused`;
|
|
229
|
+
}
|
|
162
230
|
export class CourierError extends Error {
|
|
163
231
|
status;
|
|
164
232
|
code;
|
|
165
|
-
constructor(status, code
|
|
166
|
-
|
|
233
|
+
constructor(status, code,
|
|
234
|
+
/** Appended after the status and the code; empty for every failure that has none. */
|
|
235
|
+
remedy = '') {
|
|
236
|
+
// The status and the code stay in front. They are what makes a report
|
|
237
|
+
// actionable by someone who is not the person at the terminal, and a
|
|
238
|
+
// remedy that replaced them would trade one audience for the other.
|
|
239
|
+
super(`courier request failed: ${status} ${code}${remedy}`);
|
|
167
240
|
this.status = status;
|
|
168
241
|
this.code = code;
|
|
169
242
|
this.name = 'CourierError';
|
|
@@ -173,10 +246,17 @@ export class CourierClient {
|
|
|
173
246
|
baseUrl;
|
|
174
247
|
secret;
|
|
175
248
|
fetchImpl;
|
|
249
|
+
/**
|
|
250
|
+
* Where the secret came from, when the caller knows. `loadConfig` always
|
|
251
|
+
* knows; a client built by hand in a test usually does not, and the remedy
|
|
252
|
+
* says less rather than guessing (see `refusedCredentialRemedy`).
|
|
253
|
+
*/
|
|
254
|
+
credentialFrom;
|
|
176
255
|
constructor(opts) {
|
|
177
256
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
178
257
|
this.secret = opts.secret;
|
|
179
258
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
259
|
+
this.credentialFrom = opts.credentialFrom;
|
|
180
260
|
}
|
|
181
261
|
async call(method, path, body) {
|
|
182
262
|
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
@@ -199,7 +279,9 @@ export class CourierClient {
|
|
|
199
279
|
catch {
|
|
200
280
|
// no/'' JSON body — keep the generic code, never echo the raw response
|
|
201
281
|
}
|
|
202
|
-
throw new CourierError(res.status, code
|
|
282
|
+
throw new CourierError(res.status, code, res.status === 401
|
|
283
|
+
? refusedCredentialRemedy({ baseUrl: this.baseUrl, from: this.credentialFrom })
|
|
284
|
+
: '');
|
|
203
285
|
}
|
|
204
286
|
return res.status === 200 || res.status === 201 ? res.json() : undefined;
|
|
205
287
|
}
|
|
@@ -6,6 +6,7 @@ import { originOf, readCredential } from '../credentials.js';
|
|
|
6
6
|
import { generateDeviceIdentity, publicKeyOf, remoteTeamId } from '../oss.js';
|
|
7
7
|
import { deviceIdentityPath } from '../paths.js';
|
|
8
8
|
import { NEEDS, ensurePreflight, formatPreflight, preflight } from '../preflight.js';
|
|
9
|
+
import { runVersion } from '../self-install.js';
|
|
9
10
|
import { adviseOnSlotForSilentFiling, renderSlotAdvice } from '../slot-advice.js';
|
|
10
11
|
import { setupCommand } from '../recovery-key.js';
|
|
11
12
|
import { shellArg } from '../shell-arg.js';
|
|
@@ -149,18 +150,18 @@ export async function cmdConnect(config, opts) {
|
|
|
149
150
|
`Already backed up: this team's keys were already in the vault at revision ${filing.revision},\n` +
|
|
150
151
|
`unchanged, so nothing new was stored. ${teams} in this account's vault.`
|
|
151
152
|
: filing.reason === 'no-vault'
|
|
152
|
-
? //
|
|
153
|
-
//
|
|
154
|
-
//
|
|
153
|
+
? // Account-wide, now that agmsg#650 makes the broad enumeration
|
|
154
|
+
// fail-closed. Passing the team here taught the old per-team recovery
|
|
155
|
+
// model at exactly the moment a second team was about to be added.
|
|
155
156
|
`Not backed up: this account has no recovery vault yet.\n` +
|
|
156
|
-
`Run \`${setupCommand(
|
|
157
|
+
`Run \`${setupCommand()}\` to make one — it shows a recovery key\n` +
|
|
157
158
|
"once. Until then the only copies of this team's keys are on the machines\n" +
|
|
158
159
|
'that hold them.'
|
|
159
160
|
: // Four ways of having no slot, four routes, and only one of them is
|
|
160
161
|
// "type the key once and this stops happening". Asked rather than
|
|
161
162
|
// written here, so a machine with no secure store is not sent after a
|
|
162
163
|
// fix that does not exist for it.
|
|
163
|
-
renderSlotAdvice(adviseOnSlotForSilentFiling(filing.slot
|
|
164
|
+
renderSlotAdvice(adviseOnSlotForSilentFiling(filing.slot)).trimEnd();
|
|
164
165
|
process.stdout.write(`${backup}\n` +
|
|
165
166
|
`\nTo add a second machine, run \`agmsg-cloud sync ${shellArg(opts.team)}\` there and answer here.\n` +
|
|
166
167
|
// The subject is the operator, and it has to be. Something IS carried
|
|
@@ -351,8 +352,14 @@ async function forbiddenAdvice(client, team, teamId, ownPubkey) {
|
|
|
351
352
|
}
|
|
352
353
|
// Takes a scripts directory, not a CliConfig: this runs before a credential
|
|
353
354
|
// exists, which is the point of a dry run.
|
|
354
|
-
export function cmdConnectPreflight(scriptsDir
|
|
355
|
-
|
|
355
|
+
export function cmdConnectPreflight(scriptsDir,
|
|
356
|
+
/** Injected by the suite, so a check about tools does not spawn the tester's
|
|
357
|
+
* own `agmsg-cloud` and wait on it. */
|
|
358
|
+
run = runVersion) {
|
|
359
|
+
// THE REAL PROBE, HERE AND ONLY HERE (#341). Asking which `agmsg-cloud`
|
|
360
|
+
// answers means running it, and that belongs on the screen someone opens
|
|
361
|
+
// because they are confused — not in front of every connect that is working.
|
|
362
|
+
const checks = preflight(scriptsDir, NEEDS.connect, process.env, undefined, run);
|
|
356
363
|
process.stdout.write(`\nChecking what connect needs:\n\n${formatPreflight(checks)}`);
|
|
357
364
|
if (!checks.ok)
|
|
358
365
|
throw new Error('prerequisites are missing');
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { SECRET_RE } from '../machine-id.js';
|
|
2
2
|
import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../browser.js';
|
|
3
|
-
import { isOrgAddress, originOf,
|
|
3
|
+
import { credentialsForOrigin, isOrgAddress, originOf, writeCredential } from '../credentials.js';
|
|
4
4
|
import { settleMachineName, validateMachineName } from '../machine-name.js';
|
|
5
|
+
import { resolveScriptsDir } from '../config.js';
|
|
6
|
+
import { teamsBoundTo } from '../oss.js';
|
|
5
7
|
// `login` — the device-authorization flow, from this machine's side.
|
|
6
8
|
//
|
|
7
9
|
// It is the one subcommand that runs with no credential, so it takes its
|
|
@@ -112,11 +114,39 @@ export async function cmdLogin(opts) {
|
|
|
112
114
|
// rc.2 checked first. #169 moved the prompt to the top of the command to
|
|
113
115
|
// reuse the name on both screens; nothing in it was about ordering, so
|
|
114
116
|
// nothing looked at the ordering.
|
|
115
|
-
|
|
117
|
+
//
|
|
118
|
+
// READ WITHOUT THE AMBIGUITY GUARD, and that is #399's own repair. This used
|
|
119
|
+
// to be `readCredential(originOf(endpoint))`, which fails closed when a host
|
|
120
|
+
// holds more than one credential — the exact state an rc.8 file is in, and
|
|
121
|
+
// the exact state this command exists to end. It threw here, before the
|
|
122
|
+
// grant, before the warning, before the replacing write, and told the person
|
|
123
|
+
// to run `logout` first. So "login replaces what is stored" held only for the
|
|
124
|
+
// single slots the new writer produces, and not for the machines already
|
|
125
|
+
// piled up. The guard was closed against its own remedy.
|
|
126
|
+
//
|
|
127
|
+
// Every other command keeps that guard. Acting on an ambiguous host is the
|
|
128
|
+
// defect; the one command about to collapse the ambiguity is the exemption.
|
|
129
|
+
const existing = credentialsForOrigin(originOf(endpoint), process.env);
|
|
130
|
+
// Only a host with exactly ONE credential can be re-activated: with two there
|
|
131
|
+
// is no answer to "which secret is this machine's", which is what made the
|
|
132
|
+
// reader throw in the first place. Two means a fresh grant, and the warning
|
|
133
|
+
// below says both are going.
|
|
134
|
+
const stored = existing.length === 1 ? existing[0] : null;
|
|
116
135
|
if (stored && (asked === undefined || stored.machineName === asked)) {
|
|
117
136
|
const res = await post('/v1/device/activate', {}, stored.secret);
|
|
118
137
|
if (res.ok) {
|
|
119
|
-
|
|
138
|
+
// SAID THE WAY THE SUCCESS PATH SAYS IT. This line used to end with
|
|
139
|
+
// `in organization "<org address>"`, which put a server-minted address
|
|
140
|
+
// where the sentence had promised a name — the surrounding quotes make
|
|
141
|
+
// it read as one, and #329 begins with a reader asking what the value
|
|
142
|
+
// was. The path below that actually signs a machine in prints
|
|
143
|
+
// `Signed in as machine "<name>".` and no org, so the two answers to
|
|
144
|
+
// "am I signed in" now agree rather than differing by an identifier
|
|
145
|
+
// only one of them ever had.
|
|
146
|
+
//
|
|
147
|
+
// Not lost: `agmsg-cloud whoami` exists to answer which account this
|
|
148
|
+
// machine is in, and reads the same stored credential.
|
|
149
|
+
out(`Already signed in as machine "${stored.machineName}".\n`);
|
|
120
150
|
return;
|
|
121
151
|
}
|
|
122
152
|
const code = await errorCode(res);
|
|
@@ -128,6 +158,93 @@ export async function cmdLogin(opts) {
|
|
|
128
158
|
throw new Error(`a credential for ${originOf(endpoint)} is stored but could not be activated (${res.status} ${code}) — not starting a new login on top of it`);
|
|
129
159
|
}
|
|
130
160
|
}
|
|
161
|
+
// WHAT THIS RUN IS ABOUT TO DISPLACE, said before it happens (#399).
|
|
162
|
+
//
|
|
163
|
+
// Reaching this line with something stored means a fresh grant is opening on
|
|
164
|
+
// a host that already holds a credential, and a login now keeps ONE per
|
|
165
|
+
// service — so the stored one is going. The ruling is "delete the existing
|
|
166
|
+
// one", and the risk the ruling does not cover is doing that silently: a
|
|
167
|
+
// replace that drops a working setup without naming it is worse than a
|
|
168
|
+
// refusal that names it.
|
|
169
|
+
//
|
|
170
|
+
// SAID, NOT ASKED. A prompt is the obvious answer and it is the wrong one
|
|
171
|
+
// here: this command is handed to an AGENT by the onboarding screens, and an
|
|
172
|
+
// interactive confirm stalls a non-interactive caller with nobody to answer
|
|
173
|
+
// it. A `--force` flag has the mirror problem — the default path stays silent
|
|
174
|
+
// and the flag is the thing nobody types. So the disclosure goes in the
|
|
175
|
+
// output, at the point where there is still time to act on it: what follows
|
|
176
|
+
// is a browser round-trip that only completes when a person approves it, so
|
|
177
|
+
// reading this and stopping costs nothing.
|
|
178
|
+
//
|
|
179
|
+
// The second paragraph is the part that is not obvious. `logout` documents
|
|
180
|
+
// that it keeps the device key and the local team keys, and that holds here —
|
|
181
|
+
// history is not at stake. What IS at stake is subtler: a team's remote
|
|
182
|
+
// binding records the team id and the server instance, NOT which credential
|
|
183
|
+
// connected it. After this run, `readCredential(origin)` answers with the new
|
|
184
|
+
// org for a team that was connected under the old one — same host, different
|
|
185
|
+
// account, binding unchanged. Until now that was masked by the pile-up (both
|
|
186
|
+
// slots existed, so the resolver threw); with one credential per service there
|
|
187
|
+
// is nothing left to throw, so it has to be said.
|
|
188
|
+
// NO ORG ADDRESS ON THIS SCREEN, and the suite pins that. #329 took
|
|
189
|
+
// `stored.org` off the terminal here for a reason that applies twice over to a
|
|
190
|
+
// warning: it is a server-minted `org_<uuid>`, and dressing it as a name is
|
|
191
|
+
// how a reader ends up asking what the value was. A sentence about what you
|
|
192
|
+
// are losing is the worst place to put an identifier nobody can act on. The
|
|
193
|
+
// machine name is a name someone chose, and `whoami` is the command whose
|
|
194
|
+
// whole output is the answer to which account this is.
|
|
195
|
+
if (existing.length > 0) {
|
|
196
|
+
const origin = originOf(endpoint);
|
|
197
|
+
const names = existing.map((c) => `"${c.machineName}"`).join(', ');
|
|
198
|
+
out(existing.length === 1
|
|
199
|
+
? `\nThis machine is already signed in to ${origin} as ${names}.\n`
|
|
200
|
+
: `\nThis machine holds ${existing.length} credentials for ${origin}, as ${names}, which an older agmsg-cloud allowed.\n`);
|
|
201
|
+
out(`Signing in again REPLACES ${existing.length === 1 ? 'that credential' : 'all of them'} — one per service is all this machine keeps.\n`);
|
|
202
|
+
// NAMED, not described. The decision recorded on #399 is that a
|
|
203
|
+
// replacement says which teams it lands on, and a general sentence about
|
|
204
|
+
// "teams under the current account" is not that — it also is not TRUE, in
|
|
205
|
+
// the precise sense: a binding records the host it was made against and no
|
|
206
|
+
// org at all, so "the outgoing account's teams" is a set this machine
|
|
207
|
+
// cannot compute. What it can compute is the teams bound to this SERVICE,
|
|
208
|
+
// which is the honest superset, and the wording says which one it is
|
|
209
|
+
// rather than implying the narrower claim.
|
|
210
|
+
let teams = null;
|
|
211
|
+
try {
|
|
212
|
+
const lookup = opts.teamsForOrigin ?? ((o) => teamsBoundTo(resolveScriptsDir(process.env), o));
|
|
213
|
+
teams = await lookup(origin);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// A sign-in must not fail because the team store could not be read — this
|
|
217
|
+
// command runs on machines with no teams and can run with no OSS install
|
|
218
|
+
// at all. But "could not read" is reported as itself: silently printing
|
|
219
|
+
// nothing would say "no teams are affected", which is the collapse of
|
|
220
|
+
// unknown into absent that this repository keeps paying for.
|
|
221
|
+
teams = null;
|
|
222
|
+
}
|
|
223
|
+
if (teams === null) {
|
|
224
|
+
out(`This machine's team list could not be read, so the teams this affects are not known here.\n`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// THREE STATES, not two, and the third is why `teamsBoundTo` returns two
|
|
228
|
+
// lists. A binding whose endpoint is missing or unparseable is neither in
|
|
229
|
+
// nor out: it may be bound to this host and this machine cannot tell. The
|
|
230
|
+
// first version dropped those silently, so a store with one unreadable
|
|
231
|
+
// binding printed "No team is bound", asserting an absence it had not
|
|
232
|
+
// measured — inside the change whose whole subject is not doing that.
|
|
233
|
+
if (teams.matched.length > 0) {
|
|
234
|
+
out(`Bound to ${origin}, and so affected: ${teams.matched.join(', ')}.\n`);
|
|
235
|
+
}
|
|
236
|
+
else if (teams.unreadable.length === 0) {
|
|
237
|
+
out(`No team on this machine is bound to ${origin}.\n`);
|
|
238
|
+
}
|
|
239
|
+
if (teams.unreadable.length > 0) {
|
|
240
|
+
out(`These teams could not be placed, so they may be affected too: ${teams.unreadable.join(', ')}.\n`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
out(`Those teams keep their keys and their history, but cloud commands for them will run as\n`);
|
|
244
|
+
out(`the new account. Reaching them as before means signing back in to the old one — run\n`);
|
|
245
|
+
out(`agmsg-cloud whoami first if you need to know which it is.\n`);
|
|
246
|
+
out(`Stop here if that is not what you meant.\n`);
|
|
247
|
+
}
|
|
131
248
|
// Only now is the name needed: this run is opening a grant, so there really
|
|
132
249
|
// is a machine to name.
|
|
133
250
|
//
|
|
@@ -41,6 +41,43 @@ export function logoutEndpoint(argv) {
|
|
|
41
41
|
}
|
|
42
42
|
return value;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* What was signed out, in words the person who typed the command has.
|
|
46
|
+
*
|
|
47
|
+
* THIS LINE USED TO CARRY THE ORG ADDRESS, and the first thing its reader did
|
|
48
|
+
* with it was ask what it was (#329). The shape it came out in, with the
|
|
49
|
+
* machine's name replaced — this file ships to a public registry:
|
|
50
|
+
*
|
|
51
|
+
* Signed out machine "laptop" from https://api.agmsg.cloud (org org_019fea9f-…).
|
|
52
|
+
* Signed out machine "laptop" from https://api.agmsg.cloud (org org_019ff2a4-…).
|
|
53
|
+
*
|
|
54
|
+
* The rule from #275 is that a printed line earns its place by saying what
|
|
55
|
+
* happened, what to do next, or what is now at risk. An org address does none
|
|
56
|
+
* of the three HERE: no subcommand of this CLI takes one — derived from the
|
|
57
|
+
* usage block in `index.ts`, where the only identifier-shaped arguments are
|
|
58
|
+
* `pull --team-id` and `approve [request-id]`. So there is nothing the reader
|
|
59
|
+
* can do with it, and it is not theirs to recognise either.
|
|
60
|
+
*
|
|
61
|
+
* BUT THE COUNT IS NOT DECORATION, and dropping the address without it would
|
|
62
|
+
* have been worse than leaving it. Two credentials for two orgs on one host
|
|
63
|
+
* print the SAME machine name against the SAME origin — the address was the
|
|
64
|
+
* only thing telling those two lines apart. Removed on its own, the output
|
|
65
|
+
* above becomes one sentence printed twice, which reads as a bug rather than
|
|
66
|
+
* as "two accounts went". The number carries that fact and needs no glossary.
|
|
67
|
+
*
|
|
68
|
+
* WHERE A READABLE ORG GOES WHEN THERE IS ONE. #210 / #216 are deciding what a
|
|
69
|
+
* person-facing org identifier looks like; this is the line it belongs on when
|
|
70
|
+
* they land. Named here so that arrives as an edit rather than as a rediscovery.
|
|
71
|
+
*/
|
|
72
|
+
export function signedOutLine(removed, origin) {
|
|
73
|
+
// Distinct, because repeating one machine's name once per credential is the
|
|
74
|
+
// duplicate this function exists to avoid.
|
|
75
|
+
const names = [...new Set(removed.map((credential) => credential.machineName))].map((name) => `"${name}"`);
|
|
76
|
+
if (removed.length === 1)
|
|
77
|
+
return `Signed out machine ${names[0]} from ${origin}.\n`;
|
|
78
|
+
const machines = names.length === 1 ? `machine ${names[0]}` : `machines ${names.join(', ')}`;
|
|
79
|
+
return `Signed out ${removed.length} sign-ins from ${origin} (${machines}).\n`;
|
|
80
|
+
}
|
|
44
81
|
export function cmdLogout(opts = {}) {
|
|
45
82
|
const env = opts.env ?? process.env;
|
|
46
83
|
const out = opts.out ?? ((text) => void process.stdout.write(text));
|
|
@@ -58,13 +95,7 @@ export function cmdLogout(opts = {}) {
|
|
|
58
95
|
out(`Cleared ${pending} pending enrollment record(s).\n`);
|
|
59
96
|
return;
|
|
60
97
|
}
|
|
61
|
-
|
|
62
|
-
// identity just left this machine — one host can hold credentials for
|
|
63
|
-
// several orgs, and "signed out" without saying whose is how someone
|
|
64
|
-
// discovers later that the wrong one went.
|
|
65
|
-
for (const credential of removed) {
|
|
66
|
-
out(`Signed out machine "${credential.machineName}" from ${origin} (org ${credential.org}).\n`);
|
|
67
|
-
}
|
|
98
|
+
out(signedOutLine(removed, origin));
|
|
68
99
|
if (pending > 0)
|
|
69
100
|
out(`Cleared ${pending} pending enrollment record(s).\n`);
|
|
70
101
|
// Said because the omission is the surprising part: someone who ran this to
|
|
@@ -6,6 +6,7 @@ import { recordAuthenticatedDigest } from '../authenticated-digest.js';
|
|
|
6
6
|
import { CeremonyError, renderSasBlock, sasFromOpenedTranscript, waitForStatus } from '../ceremony.js';
|
|
7
7
|
import { generateDeviceIdentity, publicKeyOf } from '../oss.js';
|
|
8
8
|
import { originOf } from '../credentials.js';
|
|
9
|
+
import { shellArg } from '../shell-arg.js';
|
|
9
10
|
import { deviceIdentityPath } from '../paths.js';
|
|
10
11
|
import { closeAttempt, consumeAttempt, readBudget, renderBudgetExhausted, renderBudgetWarning, requesterLedgerScope, } from '../ledger.js';
|
|
11
12
|
import { attachRequestId, clearRecord, listResumableRequesterRecords, reserveRequesterNonce, } from '../pending.js';
|
|
@@ -75,6 +76,40 @@ function stoppingIsCheap() {
|
|
|
75
76
|
* What IS established is on `stoppingIsCheap` above: the cost of stopping, at
|
|
76
77
|
* each wait, measured.
|
|
77
78
|
*/
|
|
79
|
+
/**
|
|
80
|
+
* The line that hands the next step to the other machine.
|
|
81
|
+
*
|
|
82
|
+
* TWO HALVES OF ONE DEFECT, and this is the second (#329). The first wait used
|
|
83
|
+
* to print the enrollment id — which nobody types anywhere — and then name
|
|
84
|
+
* `agmsg-cloud approve` WITHOUT the argument it needs. So the value the reader
|
|
85
|
+
* could not use was on screen and the one they had to run was incomplete. In
|
|
86
|
+
* the two-machine walk the agent on the other side filled the gap by guessing
|
|
87
|
+
* the team, and reported its guess beside the id with nothing to say which of
|
|
88
|
+
* the two mattered.
|
|
89
|
+
*
|
|
90
|
+
* The team is threaded in from `sync`, which is the command that knows it.
|
|
91
|
+
* `request <label>` run on its own does NOT: the requester never typed a team,
|
|
92
|
+
* and no credential on that machine names one. That case gets `<team>` — a
|
|
93
|
+
* placeholder, which cannot be pasted by mistake, and which is still more than
|
|
94
|
+
* the bare command said.
|
|
95
|
+
*
|
|
96
|
+
* Quoted through `shellArg` because it is an argument of a line written to be
|
|
97
|
+
* pasted, on the same reasoning as every other printed command here.
|
|
98
|
+
*
|
|
99
|
+
* TWO BRANCHES RATHER THAN ONE ARGUMENT, and the sentence is repeated on
|
|
100
|
+
* purpose. Building `<team>`-or-quoted-team into a local first and
|
|
101
|
+
* interpolating that reads better and is what this function did until
|
|
102
|
+
* `check-printed-commands` refused it: the value at the point of use is not the
|
|
103
|
+
* value the declaration shows, so "it went through shellArg in one branch" is a
|
|
104
|
+
* claim the guard cannot verify. It is right to refuse it — that is #152's
|
|
105
|
+
* shape — so each literal carries a value that is safe where it stands.
|
|
106
|
+
*/
|
|
107
|
+
export function approveOnTheOtherMachine(team) {
|
|
108
|
+
const lead = ' The next move is on the other machine, where someone runs ';
|
|
109
|
+
return team === undefined
|
|
110
|
+
? `${lead}\`agmsg-cloud approve <team>\`.\n`
|
|
111
|
+
: `${lead}\`agmsg-cloud approve ${shellArg(team)}\`.\n`;
|
|
112
|
+
}
|
|
78
113
|
export async function cmdRequest(config, args, deps = {}) {
|
|
79
114
|
const out = deps.out ?? ((text) => void process.stdout.write(text));
|
|
80
115
|
const writeErr = deps.err ?? ((text) => void process.stderr.write(text));
|
|
@@ -238,15 +273,30 @@ export async function cmdRequest(config, args, deps = {}) {
|
|
|
238
273
|
});
|
|
239
274
|
requestId = created.request_id;
|
|
240
275
|
attachRequestId({ serverOrigin: config.baseUrl, commitmentHex: record.commitmentHex, requestId }, env);
|
|
241
|
-
|
|
276
|
+
// THE ID IS NOT PRINTED, AND THE DEADLINE IS. #275's test, applied to the
|
|
277
|
+
// two halves of this line separately: the expiry says what is now at risk
|
|
278
|
+
// (this run has a window and it closes), and the request id says nothing
|
|
279
|
+
// the person at THIS terminal can use. `approve [request-id]` does take
|
|
280
|
+
// one — but `approve` runs on the OTHER machine, and the approver's own
|
|
281
|
+
// `watch` prints the id there already, inside the command to paste. So the
|
|
282
|
+
// value was shown to the one person in the ceremony who has no use for it.
|
|
283
|
+
//
|
|
284
|
+
// It is not lost: `attachRequestId` above wrote it to this machine's
|
|
285
|
+
// pending record a line earlier, which is where the resume path reads it
|
|
286
|
+
// from, and where it can be recovered if it is ever needed.
|
|
287
|
+
out(`enrollment requested (expires ${created.expires_at})\n`);
|
|
242
288
|
}
|
|
243
289
|
else {
|
|
244
|
-
|
|
290
|
+
// Same value, same reason — and saying WHICH enrollment is resumed matters
|
|
291
|
+
// less than saying it is resumed rather than started, because that is the
|
|
292
|
+
// fact §4.1 makes expensive to get wrong (a second ceremony spends another
|
|
293
|
+
// of five attempts).
|
|
294
|
+
out('resuming the enrollment this machine already has open\n');
|
|
245
295
|
}
|
|
246
296
|
const done = () => clearRecord({ role: 'requester', serverOrigin: config.baseUrl, key: record.commitmentHex }, env);
|
|
247
297
|
try {
|
|
248
298
|
out('\nwaiting for an approver to commit...\n');
|
|
249
|
-
out(
|
|
299
|
+
out(approveOnTheOtherMachine(args.team));
|
|
250
300
|
out(saysItBlocks());
|
|
251
301
|
out(stoppingIsCheap());
|
|
252
302
|
await waitForStatus(client, requestId, 'both_committed', deps.waitOptions);
|
|
@@ -154,7 +154,18 @@ export async function cmdSync(config, opts) {
|
|
|
154
154
|
// one that knows there is nothing to do: fetch and pull follow immediately
|
|
155
155
|
// below. The decision is made HERE, once, rather than inferred inside each
|
|
156
156
|
// step (raised in review).
|
|
157
|
-
|
|
157
|
+
// THE TEAM IS PASSED, and this is the only place that has it. `request` waits
|
|
158
|
+
// for someone on the key-holding machine to run `agmsg-cloud approve <team>`,
|
|
159
|
+
// and it named that command without its argument — so the person on the other
|
|
160
|
+
// end had to supply a value nothing on their screen had given them (#329). A
|
|
161
|
+
// `request <label>` typed on its own genuinely does not know the team and
|
|
162
|
+
// prints a placeholder; this path does know, so it says which one.
|
|
163
|
+
//
|
|
164
|
+
// Separate from `nextStepsFromCaller` below. That decides who tells the
|
|
165
|
+
// OPERATOR OF THIS MACHINE what to do when the ceremony ends, and the answer
|
|
166
|
+
// is this command. The line above is about the OTHER machine, whose next step
|
|
167
|
+
// is the same either way.
|
|
168
|
+
const enrolled = await request(config, { label, team: opts.team }, { nextStepsFromCaller: true });
|
|
158
169
|
// NOTHING BELOW CAN SUCCEED WITHOUT IT, so a failed ceremony ends the run
|
|
159
170
|
// here.
|
|
160
171
|
//
|