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.
Files changed (44) hide show
  1. package/README.md +39 -2
  2. package/dist/src/api.js +517 -0
  3. package/dist/src/authenticated-digest.js +234 -0
  4. package/dist/src/browser.js +241 -0
  5. package/dist/src/ceremony.js +181 -0
  6. package/dist/src/commands/approve.js +392 -0
  7. package/dist/src/commands/connect.js +273 -0
  8. package/dist/src/commands/fetch.js +249 -0
  9. package/dist/src/commands/login.js +334 -0
  10. package/dist/src/commands/logout.js +74 -0
  11. package/dist/src/commands/pull.js +80 -0
  12. package/dist/src/commands/request.js +371 -0
  13. package/dist/src/commands/sync.js +138 -0
  14. package/dist/src/commands/vault.js +478 -0
  15. package/dist/src/commands/watch.js +47 -0
  16. package/dist/src/config.js +34 -0
  17. package/dist/src/credentials.js +374 -0
  18. package/dist/src/device-slot.js +148 -0
  19. package/dist/src/filelock.js +167 -0
  20. package/dist/src/index.js +242 -0
  21. package/dist/src/ledger.js +296 -0
  22. package/dist/src/machine-name.js +90 -0
  23. package/dist/src/oss-env.js +49 -0
  24. package/dist/src/oss.js +289 -0
  25. package/dist/src/paths.js +8 -0
  26. package/dist/src/pending.js +330 -0
  27. package/dist/src/pick-request.js +56 -0
  28. package/dist/src/preflight.js +257 -0
  29. package/dist/src/recovery-key.js +386 -0
  30. package/dist/src/sas.js +18 -0
  31. package/dist/src/secure-store.js +176 -0
  32. package/dist/src/shell-arg.js +18 -0
  33. package/dist/src/slot-advice.js +74 -0
  34. package/dist/src/vault-container.js +115 -0
  35. package/dist/src/vault-crypto.js +190 -0
  36. package/dist/src/vault-protocol.js +358 -0
  37. package/dist/src/version.js +57 -0
  38. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
  39. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
  40. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
  41. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
  42. package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
  43. package/package.json +50 -7
  44. 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
+ }