agmsg-cloud 0.0.1 → 0.1.0-rc.4
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/README.md +39 -2
- package/dist/src/api.js +517 -0
- package/dist/src/authenticated-digest.js +234 -0
- package/dist/src/browser.js +241 -0
- package/dist/src/ceremony.js +181 -0
- package/dist/src/commands/approve.js +392 -0
- package/dist/src/commands/connect.js +273 -0
- package/dist/src/commands/fetch.js +249 -0
- package/dist/src/commands/login.js +334 -0
- package/dist/src/commands/logout.js +74 -0
- package/dist/src/commands/pull.js +80 -0
- package/dist/src/commands/request.js +371 -0
- package/dist/src/commands/sync.js +138 -0
- package/dist/src/commands/vault.js +478 -0
- package/dist/src/commands/watch.js +47 -0
- package/dist/src/config.js +34 -0
- package/dist/src/credentials.js +374 -0
- package/dist/src/device-slot.js +148 -0
- package/dist/src/filelock.js +167 -0
- package/dist/src/index.js +242 -0
- package/dist/src/ledger.js +296 -0
- package/dist/src/machine-name.js +90 -0
- package/dist/src/oss-env.js +49 -0
- package/dist/src/oss.js +289 -0
- package/dist/src/paths.js +8 -0
- package/dist/src/pending.js +330 -0
- package/dist/src/pick-request.js +56 -0
- package/dist/src/preflight.js +257 -0
- package/dist/src/recovery-key.js +386 -0
- package/dist/src/sas.js +18 -0
- package/dist/src/secure-store.js +176 -0
- package/dist/src/shell-arg.js +18 -0
- package/dist/src/slot-advice.js +74 -0
- package/dist/src/vault-container.js +115 -0
- package/dist/src/vault-crypto.js +190 -0
- package/dist/src/vault-protocol.js +358 -0
- package/dist/src/version.js +57 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
- package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
- package/package.json +50 -7
- package/bin/agmsg-cloud.js +0 -4
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Which request an argument-free `approve` acts on.
|
|
2
|
+
//
|
|
3
|
+
// Its own function, and pure, because the rule it encodes is the one thing
|
|
4
|
+
// that must not bend: a machine that picks for you is a machine that can
|
|
5
|
+
// approve the wrong device while the person watching believes they approved
|
|
6
|
+
// the one they were told about. The eight digits authenticate a device; they
|
|
7
|
+
// do not tell you WHICH pending request you are answering, so choosing has to
|
|
8
|
+
// be unambiguous before the ceremony starts rather than after.
|
|
9
|
+
/**
|
|
10
|
+
* Statuses an approval can START from.
|
|
11
|
+
*
|
|
12
|
+
* NOT every status a request is alive in. `requester_opened` means this
|
|
13
|
+
* approver already committed and the other side has opened — a ceremony in
|
|
14
|
+
* flight, which `cmdApprove` refuses to begin because beginning it again would
|
|
15
|
+
* mean committing twice. Including it here selected a request that the very
|
|
16
|
+
* next call rejected, after an attempt had been charged: the operator paid for
|
|
17
|
+
* "nothing to approve" (raised in review).
|
|
18
|
+
*
|
|
19
|
+
* Resuming an interrupted ceremony IS this, and `opened` is here for it.
|
|
20
|
+
*
|
|
21
|
+
* That sentence used to say the opposite — that resuming "is done by naming the
|
|
22
|
+
* id". It was not true: naming the id still hit approve's status gate, which
|
|
23
|
+
* accepted neither `opened` nor anything past it, so the documented way out was
|
|
24
|
+
* closed. An approver who saw the digits and did not answer could not finish
|
|
25
|
+
* from any command, and the enrollment sat unanswerable until it expired.
|
|
26
|
+
*
|
|
27
|
+
* `opened` is selectable because it is still a live request awaiting exactly
|
|
28
|
+
* one thing: this machine's answer. It is not a second attempt — §4.1 charged
|
|
29
|
+
* the run that opened it, and the ledger is keyed on the request id so the
|
|
30
|
+
* resumed run continues that same attempt rather than buying another.
|
|
31
|
+
*/
|
|
32
|
+
const SELECTABLE = ['requester_committed', 'both_committed', 'opened'];
|
|
33
|
+
/**
|
|
34
|
+
* Choose the one live request, or refuse.
|
|
35
|
+
*
|
|
36
|
+
* Never picks the newest, and never picks the first. Two people enrolling at
|
|
37
|
+
* once is an ordinary thing — a laptop and a phone, or a colleague starting
|
|
38
|
+
* while you are mid-flow — and in that moment the two requests are
|
|
39
|
+
* indistinguishable to everyone except the people holding the devices.
|
|
40
|
+
* Selecting silently would make the approver's screen say one thing and the
|
|
41
|
+
* requester's another, with the digits still matching, because the digits are
|
|
42
|
+
* about a device and not about a queue position.
|
|
43
|
+
*
|
|
44
|
+
* Expiry is not consulted here. A request whose window has closed is not live,
|
|
45
|
+
* and the server is what decides that; treating a locally-computed clock as
|
|
46
|
+
* authoritative would let a slow machine refuse a request the server would
|
|
47
|
+
* still accept, or the reverse.
|
|
48
|
+
*/
|
|
49
|
+
export function pickLiveRequest(transcripts) {
|
|
50
|
+
const live = transcripts.filter((t) => SELECTABLE.includes(t.status));
|
|
51
|
+
if (live.length === 0)
|
|
52
|
+
return { kind: 'none' };
|
|
53
|
+
if (live.length > 1)
|
|
54
|
+
return { kind: 'many', requests: [...live] };
|
|
55
|
+
return { kind: 'one', request: live[0] };
|
|
56
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { platform } from 'node:process';
|
|
5
|
+
// What `connect` needs before it starts, checked all at once.
|
|
6
|
+
//
|
|
7
|
+
// Everything here is reported together. Finding one missing thing, installing
|
|
8
|
+
// it, and being told about the next one is the shape this exists to avoid — and
|
|
9
|
+
// it is worse than it sounds, because the operator has already spent the
|
|
10
|
+
// browser approval by the time `connect` runs.
|
|
11
|
+
//
|
|
12
|
+
// The OSS `remote.sh doctor` covers the same ground for the OSS CLI and
|
|
13
|
+
// deliberately reports `age` as OPTIONAL: plain remote sync defaults to cipher
|
|
14
|
+
// "none", so refusing without age would tell a new user they were unfit for a
|
|
15
|
+
// feature they had not asked for. **That is correct there and wrong here.**
|
|
16
|
+
// Hosted teams always connect with `--e2ee` (the gateway refuses plaintext with
|
|
17
|
+
// `e2ee_required`), so on this path age is not optional at all. Shelling out to
|
|
18
|
+
// doctor would print "optional" next to a prerequisite this command cannot run
|
|
19
|
+
// without.
|
|
20
|
+
// The installed agmsg's own provenance string, obtained through the accessor it
|
|
21
|
+
// publishes rather than by reading its files: `version.sh` is the OSS side's
|
|
22
|
+
// documented answer to "what is installed here", and reaching past it would
|
|
23
|
+
// bind us to a layout that is not ours.
|
|
24
|
+
//
|
|
25
|
+
// Reported, not compared. The string is a git-describe (`v1.1.11-320-gfbc90db`)
|
|
26
|
+
// for an install taken from a branch, and a plain tag for a released one — and
|
|
27
|
+
// those two do not order against each other. The dogfood install measures as
|
|
28
|
+
// `v1.1.11-320-…` while the released `v1.1.13` has no remote sync at all, so a
|
|
29
|
+
// numeric floor would reject the only install that can currently connect.
|
|
30
|
+
// Until there is a released version to compare against, this is shown for
|
|
31
|
+
// diagnosis and nothing is gated on it.
|
|
32
|
+
export function installedVersion(scriptsDir) {
|
|
33
|
+
try {
|
|
34
|
+
const out = execFileSync('bash', [join(scriptsDir, 'version.sh')], {
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
+
}).trim();
|
|
38
|
+
return out.length > 0 ? out : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Does the installed remote.sh offer `connect`? Asked of the script itself: run
|
|
45
|
+
// with no arguments it prints its usage, which names the subcommands it has.
|
|
46
|
+
//
|
|
47
|
+
// This is capability detection, not version detection, and the difference is
|
|
48
|
+
// the point — see the note above `installedVersion`.
|
|
49
|
+
//
|
|
50
|
+
// What is deliberately NOT probed: whether this install carries the fix for the
|
|
51
|
+
// store-migration data-loss path. There is no way to ask a script "do you have
|
|
52
|
+
// this guard" without reading its innards and guessing, and a check that cannot
|
|
53
|
+
// really answer is worse than none — it reads as a guarantee. The release plan
|
|
54
|
+
// covers it instead: the fix ships before remote sync is public.
|
|
55
|
+
function remoteSupportsConnect(scriptsDir) {
|
|
56
|
+
try {
|
|
57
|
+
const out = execFileSync('bash', [join(scriptsDir, 'remote.sh')], {
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
return /\bconnect\b/.test(out);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
// Usage goes to stderr and exits non-zero in some versions; that output is
|
|
65
|
+
// just as good an answer as a clean exit.
|
|
66
|
+
const e = err;
|
|
67
|
+
const text = `${String(e.stdout ?? '')}${String(e.stderr ?? '')}`;
|
|
68
|
+
return /\bconnect\b/.test(text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function onPath(command) {
|
|
72
|
+
try {
|
|
73
|
+
// `command -v` through the shell would re-interpret the name; execFile with
|
|
74
|
+
// a fixed argv does not.
|
|
75
|
+
execFileSync(platform === 'win32' ? 'where' : 'which', [command], { stdio: 'ignore' });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Install lines follow the OSS doctor's, so an operator who has seen one is not
|
|
83
|
+
// told something different by the other.
|
|
84
|
+
const AGE_INSTALL = [
|
|
85
|
+
'macOS (Homebrew): brew install age',
|
|
86
|
+
'Debian/Ubuntu: sudo apt install age',
|
|
87
|
+
'Windows (winget): winget install FiloSottile.age',
|
|
88
|
+
'See https://github.com/FiloSottile/age for other install methods.',
|
|
89
|
+
];
|
|
90
|
+
const NODE_INSTALL = [
|
|
91
|
+
'macOS (Homebrew): brew install node',
|
|
92
|
+
'Debian/Ubuntu: sudo apt install nodejs',
|
|
93
|
+
'Windows (winget): winget install OpenJS.NodeJS',
|
|
94
|
+
'See https://nodejs.org for other install methods.',
|
|
95
|
+
];
|
|
96
|
+
const PYTHON_INSTALL = [
|
|
97
|
+
'macOS (Homebrew): brew install python3',
|
|
98
|
+
'macOS (Xcode tools): xcode-select --install',
|
|
99
|
+
'Debian/Ubuntu: sudo apt install python3',
|
|
100
|
+
'Windows (winget): winget install Python.Python.3',
|
|
101
|
+
];
|
|
102
|
+
// Measured at the call sites, not inferred:
|
|
103
|
+
// connect remote.sh connect (commands/connect.ts) + remote.sh status
|
|
104
|
+
// via remoteTeamId
|
|
105
|
+
// recovery setup remote.sh status via remoteTeamId + key.sh handoff via
|
|
106
|
+
// keyHandoff
|
|
107
|
+
// recovery restore remote.sh status via remoteTeamId + remote.sh unlock —
|
|
108
|
+
// no key.sh
|
|
109
|
+
// fetch remote-sync.sh verify-age-handoff via
|
|
110
|
+
// verifyHandoffDigest + remote.sh unlock via unlockBundle
|
|
111
|
+
// — no key.sh
|
|
112
|
+
// approve key.sh handoff via keyHandoff + remote-sync.sh via
|
|
113
|
+
// verifyHandoffDigest — no remote.sh
|
|
114
|
+
// pull remote.sh pull (commands/pull.ts) — no key.sh: the team
|
|
115
|
+
// arrives sealed and is opened by fetch or
|
|
116
|
+
// recovery restore
|
|
117
|
+
export const NEEDS = {
|
|
118
|
+
connect: { command: 'connect', scripts: ['remote.sh'], requireConnect: true },
|
|
119
|
+
vaultPut: { command: 'recovery setup', scripts: ['remote.sh', 'key.sh'] },
|
|
120
|
+
vaultRestore: { command: 'recovery restore', scripts: ['remote.sh'] },
|
|
121
|
+
fetch: { command: 'fetch', scripts: ['remote.sh', 'remote-sync.sh'] },
|
|
122
|
+
approve: { command: 'approve', scripts: ['key.sh', 'remote-sync.sh'] },
|
|
123
|
+
pull: { command: 'pull', scripts: ['remote.sh'] },
|
|
124
|
+
// sync runs request -> pull -> fetch, so it needs the UNION of what they
|
|
125
|
+
// need, checked once at the start. Derived from the entries above rather
|
|
126
|
+
// than listed again: a `sync` that probed less than the steps it runs would
|
|
127
|
+
// pass its check and then fail at the third one — after the ceremony had
|
|
128
|
+
// happened, the approver had been interrupted, and the attempt had been
|
|
129
|
+
// spent on a machine that could never have finished.
|
|
130
|
+
//
|
|
131
|
+
// `request` has no entry because it shells out to nothing; the ceremony is
|
|
132
|
+
// HTTP and local age. Adding one here would claim a requirement that does
|
|
133
|
+
// not exist.
|
|
134
|
+
sync: {
|
|
135
|
+
command: 'sync',
|
|
136
|
+
scripts: ['remote.sh', 'remote-sync.sh'],
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
// The tools follow from the scripts rather than being listed per command, so
|
|
140
|
+
// a command cannot end up demanding a binary it never reaches:
|
|
141
|
+
// remote.sh talks to the control plane through python3, and starts the
|
|
142
|
+
// sync engine, which runs on node
|
|
143
|
+
// remote-sync.sh is itself node
|
|
144
|
+
// any of them handles age-encrypted key material; hosted teams are
|
|
145
|
+
// always end-to-end encrypted, so age is required here even
|
|
146
|
+
// though the OSS doctor reports it as optional (see above)
|
|
147
|
+
function toolsFor(scripts) {
|
|
148
|
+
return {
|
|
149
|
+
node: scripts.includes('remote.sh') || scripts.includes('remote-sync.sh'),
|
|
150
|
+
python3: scripts.includes('remote.sh'),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function preflight(scriptsDir, needs) {
|
|
154
|
+
const { command, scripts } = needs;
|
|
155
|
+
const requireConnect = needs.requireConnect ?? false;
|
|
156
|
+
const requirements = [];
|
|
157
|
+
// The scripts come first: without them nothing else matters, and the fix is
|
|
158
|
+
// a different kind of thing (install agmsg, or point AGMSG_SCRIPTS_DIR at it)
|
|
159
|
+
// than installing a binary.
|
|
160
|
+
const missingScripts = scripts.filter((s) => !existsSync(join(scriptsDir, s)));
|
|
161
|
+
// Present AND able to do the thing. A version number would be the obvious
|
|
162
|
+
// check and is the wrong one: `version.sh` returns a git-describe string, so
|
|
163
|
+
// the install that can connect measures as v1.1.11-320-g… while a released
|
|
164
|
+
// v1.1.13 without remote sync measures as newer. A numeric floor would reject
|
|
165
|
+
// the only install that works. So the capability is asked for directly.
|
|
166
|
+
const scriptsPresent = missingScripts.length === 0;
|
|
167
|
+
const canConnect = scriptsPresent && (!requireConnect || remoteSupportsConnect(scriptsDir));
|
|
168
|
+
requirements.push({
|
|
169
|
+
name: requireConnect
|
|
170
|
+
? `agmsg with remote sync (in ${scriptsDir})`
|
|
171
|
+
: `agmsg ${scripts.join(', ')} (in ${scriptsDir})`,
|
|
172
|
+
ok: canConnect,
|
|
173
|
+
why: !scriptsPresent
|
|
174
|
+
? `${command} runs ${missingScripts.join(' and ')}, and this machine has no copy there.`
|
|
175
|
+
: 'the agmsg installed here does not offer `remote.sh connect`, so it predates remote sync.',
|
|
176
|
+
install: [
|
|
177
|
+
'Install or update: npx agmsg install',
|
|
178
|
+
'Elsewhere? point AGMSG_SCRIPTS_DIR at that install\'s scripts directory.',
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
const tools = toolsFor(scripts);
|
|
182
|
+
requirements.push({
|
|
183
|
+
name: 'age and age-keygen',
|
|
184
|
+
ok: onPath('age') && onPath('age-keygen'),
|
|
185
|
+
why: 'hosted teams are end-to-end encrypted, and the keys are generated with age. The server refuses messages that are not encrypted, so this is required rather than optional here.',
|
|
186
|
+
install: AGE_INSTALL,
|
|
187
|
+
});
|
|
188
|
+
if (tools.node) {
|
|
189
|
+
requirements.push({
|
|
190
|
+
name: 'node',
|
|
191
|
+
ok: onPath('node'),
|
|
192
|
+
why: 'the sync engine that carries messages to and from the server runs on node.',
|
|
193
|
+
install: NODE_INSTALL,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (tools.python3) {
|
|
197
|
+
requirements.push({
|
|
198
|
+
name: 'python3',
|
|
199
|
+
ok: onPath('python3'),
|
|
200
|
+
why: 'remote.sh talks to the control plane through python3.',
|
|
201
|
+
install: PYTHON_INSTALL,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
requirements,
|
|
206
|
+
ok: requirements.every((r) => r.ok),
|
|
207
|
+
command,
|
|
208
|
+
agmsgVersion: canConnect ? installedVersion(scriptsDir) : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// The refusal in front of every command that shells out: one place decides how
|
|
212
|
+
// a missing prerequisite reads, so it reads the same whichever command found
|
|
213
|
+
// it.
|
|
214
|
+
//
|
|
215
|
+
// It takes the RESULT rather than calling `preflight` itself. A wrapper that
|
|
216
|
+
// called it would reach the real check even where a test has replaced the
|
|
217
|
+
// module's `preflight` — the internal call does not go through the module's
|
|
218
|
+
// exports — and the suites would then depend on whether the machine running
|
|
219
|
+
// them happens to have age, node and python3.
|
|
220
|
+
export function ensurePreflight(checks) {
|
|
221
|
+
if (checks.ok)
|
|
222
|
+
return;
|
|
223
|
+
process.stdout.write(`\n${checks.command} cannot run yet:\n\n${formatPreflight(checks)}`);
|
|
224
|
+
throw new Error('prerequisites are missing');
|
|
225
|
+
}
|
|
226
|
+
// Printed the same way whether this was `--preflight` or the check in front of a
|
|
227
|
+
// real connect, so an operator sees one format.
|
|
228
|
+
export function formatPreflight(result) {
|
|
229
|
+
const lines = [];
|
|
230
|
+
for (const r of result.requirements) {
|
|
231
|
+
lines.push(` ${r.ok ? '[x]' : '[ ]'} ${r.name}`);
|
|
232
|
+
}
|
|
233
|
+
if (result.agmsgVersion !== null) {
|
|
234
|
+
// Named as what it is. Calling it "agmsg 1.1.11" would invite the reader to
|
|
235
|
+
// compare it with a release number, which is the thing it cannot be
|
|
236
|
+
// compared with.
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push(` agmsg reports itself as: ${result.agmsgVersion}`);
|
|
239
|
+
}
|
|
240
|
+
const missing = result.requirements.filter((r) => !r.ok);
|
|
241
|
+
if (missing.length === 0) {
|
|
242
|
+
lines.push('');
|
|
243
|
+
lines.push(`Everything ${result.command} needs is here.`);
|
|
244
|
+
return `${lines.join('\n')}\n`;
|
|
245
|
+
}
|
|
246
|
+
for (const r of missing) {
|
|
247
|
+
lines.push('');
|
|
248
|
+
lines.push(`${r.name} is missing. ${r.why}`);
|
|
249
|
+
for (const line of r.install)
|
|
250
|
+
lines.push(` ${line}`);
|
|
251
|
+
}
|
|
252
|
+
lines.push('');
|
|
253
|
+
// Every refusal says what comes next. Listing what is missing without saying
|
|
254
|
+
// how to resume leaves the operator where they started.
|
|
255
|
+
lines.push('Install what is listed above, then run the same command again.');
|
|
256
|
+
return `${lines.join('\n')}\n`;
|
|
257
|
+
}
|