agmsg-cloud 0.1.2 → 0.1.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/dist/src/authenticated-digest.js +143 -5
- package/dist/src/bash-path.js +50 -0
- package/dist/src/commands/connect.js +2 -1
- package/dist/src/commands/fetch.js +6 -1
- package/dist/src/commands/pull.js +11 -2
- package/dist/src/commands/sync.js +13 -1
- package/dist/src/credentials.js +15 -8
- package/dist/src/durable-dir.js +129 -0
- package/dist/src/oss.js +30 -10
- package/dist/src/preflight.js +33 -24
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, chmodSync, rmSync, writeSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
+
import { syncDirectoryEntryIfSupported } from './durable-dir.js';
|
|
5
6
|
/**
|
|
6
7
|
* Identify the (credential, device identity) pair without storing either.
|
|
7
8
|
*
|
|
@@ -77,6 +78,35 @@ export function writeAll(fd, bytes, what, write = writeSync) {
|
|
|
77
78
|
* temp file, fsync, rename, fsync the directory — because a half-written
|
|
78
79
|
* record is one `fetch` would either reject a good bundle over or, worse, read
|
|
79
80
|
* a truncated digest from.
|
|
81
|
+
*
|
|
82
|
+
* Also clears any OTHER record already held for this same (origin, scope)
|
|
83
|
+
* (#523). A machine that stalls after this ceremony — the bundle never
|
|
84
|
+
* fetched — and then connects again performs a SECOND ceremony for the same
|
|
85
|
+
* credential and device, and without this the old approval sat beside the
|
|
86
|
+
* new one forever: `fetch` finds two records for one scope and, correctly,
|
|
87
|
+
* refuses to guess which bundle either belongs to. There is exactly one live
|
|
88
|
+
* approval per (credential, device) at a time, so the new one replacing the
|
|
89
|
+
* old is not a loss — the old one was already superseded the moment this
|
|
90
|
+
* ceremony ran, since it authenticates the account's current snapshot, not a
|
|
91
|
+
* fixed past one.
|
|
92
|
+
*
|
|
93
|
+
* Two ceremonies for the SAME credential and device, running at once, only
|
|
94
|
+
* clear records OLDER than the one each just wrote (raised in review) —
|
|
95
|
+
* never the newest, and never each other. Without that qualifier, two
|
|
96
|
+
* concurrent calls could each write their own record and then each delete
|
|
97
|
+
* the OTHER's, leaving zero records for a scope that just ran two
|
|
98
|
+
* successful ceremonies — worse than the pile-up this exists to fix. This
|
|
99
|
+
* guarantees the scope is never left with zero records, however two
|
|
100
|
+
* genuinely concurrent calls interleave; it does not by itself guarantee
|
|
101
|
+
* they converge to exactly one (a pair racing closely enough can both
|
|
102
|
+
* survive, since each only knows what predates it, not what is still being
|
|
103
|
+
* written elsewhere). Running two ceremonies for one scope at once is out
|
|
104
|
+
* of scope to serialize against, deliberately — nobody starts a second
|
|
105
|
+
* `request` on top of one already in flight. What this fix exists to
|
|
106
|
+
* guarantee, and does, is the case that actually happens: from a stuck
|
|
107
|
+
* two-record state, one ordinary retry always recovers to exactly one,
|
|
108
|
+
* because that retry's own record is newer than both existing ones and
|
|
109
|
+
* clears them both in the same pass.
|
|
80
110
|
*/
|
|
81
111
|
export function recordAuthenticatedDigest(input, env = process.env) {
|
|
82
112
|
if (!HEX64.test(input.handoffDigest)) {
|
|
@@ -93,10 +123,11 @@ export function recordAuthenticatedDigest(input, env = process.env) {
|
|
|
93
123
|
// from the first one — the same reason credentials.ts does it (raised in review).
|
|
94
124
|
enforceMode(dir, 0o700);
|
|
95
125
|
const path = pathFor(dir, input.serverOrigin, input.requestId);
|
|
126
|
+
const scope = fingerprintScope(input.secret, input.devicePubkey);
|
|
96
127
|
const record = {
|
|
97
128
|
requestId: input.requestId,
|
|
98
129
|
handoffDigest: input.handoffDigest,
|
|
99
|
-
scopeFingerprint:
|
|
130
|
+
scopeFingerprint: scope,
|
|
100
131
|
recordedAt: new Date().toISOString(),
|
|
101
132
|
};
|
|
102
133
|
const tmp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
|
|
@@ -138,12 +169,119 @@ export function recordAuthenticatedDigest(input, env = process.env) {
|
|
|
138
169
|
throw err;
|
|
139
170
|
}
|
|
140
171
|
enforceMode(path, 0o600);
|
|
141
|
-
|
|
172
|
+
// The same POSIX-only directory flush as `credentials.ts`, and the same
|
|
173
|
+
// reason it is now probed rather than assumed (#512). Fixing only the other
|
|
174
|
+
// site would have moved the EPERM here instead of removing it.
|
|
175
|
+
syncDirectoryEntryIfSupported(dirname(path));
|
|
176
|
+
// AFTER the new record is durably in place, never before: deleting the old
|
|
177
|
+
// one first would leave a window with zero records for this scope if the
|
|
178
|
+
// write above failed partway. Best-effort from here — see
|
|
179
|
+
// removeOlderRecordsForScope — because the new record above is already
|
|
180
|
+
// durable, and nothing below may throw back to a caller who would only
|
|
181
|
+
// retry and pile up yet another new record (raised in review).
|
|
182
|
+
removeOlderRecordsForScope(dir, input.serverOrigin, scope, input.requestId, record.recordedAt);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Best-effort cleanup of every OTHER, OLDER record this (origin, scope)
|
|
186
|
+
* still holds — older BY `recordedAt`, strictly, never equal or newer
|
|
187
|
+
* (raised in review). That qualifier is what keeps this safe under two
|
|
188
|
+
* concurrent ceremonies for the same scope: each of the two only deletes
|
|
189
|
+
* what it can already tell predates it, so neither ever deletes the other
|
|
190
|
+
* when both are genuinely new, and whichever of the two IS the newest is
|
|
191
|
+
* never deleted by the other. What this does NOT guarantee: running two
|
|
192
|
+
* ceremonies for one scope AT THE SAME TIME can still leave both of their
|
|
193
|
+
* records behind — deliberately out of scope, since nobody runs a second
|
|
194
|
+
* `request` on top of one already in flight, and this never claims a
|
|
195
|
+
* concurrent pair converges to one on its own. What it does guarantee is
|
|
196
|
+
* the case that matters: from that stuck two-record state, ONE ordinary
|
|
197
|
+
* retry always recovers it, because the retry's own record is newer than
|
|
198
|
+
* both and this cleanup removes them both in that single pass. Never zero,
|
|
199
|
+
* either way, is unconditional.
|
|
200
|
+
*
|
|
201
|
+
* Best-effort, not authoritative, in two more ways. A neighbour this cannot
|
|
202
|
+
* parse, or whose `recordedAt` this cannot confidently compare, is left
|
|
203
|
+
* alone rather than guessed at, because a wrongly-deleted approval is a
|
|
204
|
+
* fetch nobody can complete, while a wrongly-kept one is only a state
|
|
205
|
+
* `fetch`'s own multiple-record refusal already handles. And a deletion that
|
|
206
|
+
* FAILS is caught rather than thrown, for the same reason: this runs after
|
|
207
|
+
* the new record is already durable, so throwing here would report failure
|
|
208
|
+
* for a call that mostly succeeded, and a caller who retries on that error
|
|
209
|
+
* would only record ANOTHER new approval on top (raised in review). Either
|
|
210
|
+
* way, `fetch`'s refusal stays the backstop for whatever this pass cannot
|
|
211
|
+
* confidently remove.
|
|
212
|
+
*/
|
|
213
|
+
function removeOlderRecordsForScope(dir, origin, scope, keepRequestId, keepRecordedAt) {
|
|
214
|
+
const prefix = `${Buffer.from(origin, 'utf8').toString('base64url')}.`;
|
|
215
|
+
let names;
|
|
142
216
|
try {
|
|
143
|
-
|
|
217
|
+
names = readdirSync(dir);
|
|
144
218
|
}
|
|
145
|
-
|
|
146
|
-
|
|
219
|
+
catch {
|
|
220
|
+
// Same reasoning as a failed deletion below: the new record is already
|
|
221
|
+
// durable, so a directory this cannot even list must not fail the call.
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let removedAny = false;
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
if (!name.startsWith(prefix) || !name.endsWith('.json'))
|
|
227
|
+
continue;
|
|
228
|
+
const candidate = join(dir, name);
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
const fd = openSync(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
232
|
+
try {
|
|
233
|
+
parsed = JSON.parse(readFileSync(fd, 'utf8'));
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
closeSync(fd);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const r = parsed;
|
|
243
|
+
if (typeof r?.['requestId'] !== 'string' || r['requestId'] === keepRequestId)
|
|
244
|
+
continue;
|
|
245
|
+
if (r['scopeFingerprint'] !== scope)
|
|
246
|
+
continue;
|
|
247
|
+
// Strictly older, by ISO-8601 string comparison — valid because
|
|
248
|
+
// `recordedAt` is always written by `toISOString()` here, a fixed-width
|
|
249
|
+
// format lexical order agrees with chronological order on. Equal or
|
|
250
|
+
// unreadable is NOT older: a missing/malformed timestamp cannot be
|
|
251
|
+
// confidently placed before this one, and a tie (two ceremonies stamped
|
|
252
|
+
// in the same millisecond) must not delete either — that is what keeps
|
|
253
|
+
// a concurrent pair from being able to empty the scope between them.
|
|
254
|
+
if (typeof r['recordedAt'] !== 'string' || !(r['recordedAt'] < keepRecordedAt))
|
|
255
|
+
continue;
|
|
256
|
+
try {
|
|
257
|
+
rmSync(candidate, { force: true });
|
|
258
|
+
removedAny = true;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Once, after every deletion in this pass, not per file: one flush covers
|
|
265
|
+
// everything this call removed. Without it, a crash right after could roll
|
|
266
|
+
// a removal back on disk while this pass has already moved on — putting the
|
|
267
|
+
// old record back and reintroducing the exact stuck state this cleanup
|
|
268
|
+
// exists to prevent (raised in review).
|
|
269
|
+
//
|
|
270
|
+
// Caught, not thrown (raised in review): this whole function runs after
|
|
271
|
+
// the caller's own new record is already durable, so a failure HERE must
|
|
272
|
+
// not be reported back as the call having failed — that would tell a
|
|
273
|
+
// caller whose write actually succeeded to retry, and a retry only
|
|
274
|
+
// records yet another new approval on top.
|
|
275
|
+
if (removedAny) {
|
|
276
|
+
try {
|
|
277
|
+
syncDirectoryEntryIfSupported(dir);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Best-effort, like the deletions themselves: the files are gone from
|
|
281
|
+
// the directory listing either way, and `fetch`'s own multiple-record
|
|
282
|
+
// refusal is what protects a machine if this particular flush is what
|
|
283
|
+
// a crash rolls back.
|
|
284
|
+
}
|
|
147
285
|
}
|
|
148
286
|
}
|
|
149
287
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
/**
|
|
3
|
+
* Git for Windows' usual homes, most specific first.
|
|
4
|
+
*
|
|
5
|
+
* `System32\bash.exe` is deliberately NOT on this list and is never chosen
|
|
6
|
+
* explicitly: it reaches PATH on its own, and if it is the only thing there
|
|
7
|
+
* then that is what gets reported rather than silently substituted.
|
|
8
|
+
*/
|
|
9
|
+
function gitBashCandidates(env) {
|
|
10
|
+
const local = env['LOCALAPPDATA'];
|
|
11
|
+
const programFiles = env['ProgramFiles'] ?? 'C:\\Program Files';
|
|
12
|
+
const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
|
|
13
|
+
return [
|
|
14
|
+
...(local ? [`${local}\\Programs\\Git\\bin\\bash.exe`] : []),
|
|
15
|
+
`${programFiles}\\Git\\bin\\bash.exe`,
|
|
16
|
+
`${programFilesX86}\\Git\\bin\\bash.exe`,
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The interpreter to run the OSS shell scripts with.
|
|
21
|
+
*
|
|
22
|
+
* `AGMSG_BASH` wins outright and is not checked for existence — an operator
|
|
23
|
+
* naming a path is making a statement, and silently ignoring it when the path
|
|
24
|
+
* is wrong would put us back to guessing. A bad value fails at the spawn, with
|
|
25
|
+
* that value named.
|
|
26
|
+
*
|
|
27
|
+
* `deps` exists so a test can drive the Windows branches from any platform.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveBash(env = process.env, deps = {}) {
|
|
30
|
+
const override = env['AGMSG_BASH'];
|
|
31
|
+
if (override)
|
|
32
|
+
return { command: override, source: 'AGMSG_BASH' };
|
|
33
|
+
const platform = deps.platform ?? process.platform;
|
|
34
|
+
if (platform === 'win32') {
|
|
35
|
+
const exists = deps.exists ?? existsSync;
|
|
36
|
+
for (const candidate of gitBashCandidates(env)) {
|
|
37
|
+
if (exists(candidate))
|
|
38
|
+
return { command: candidate, source: 'git-bash' };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { command: 'bash', source: 'PATH' };
|
|
42
|
+
}
|
|
43
|
+
/** One clause naming the interpreter, for a message a person has to act on. */
|
|
44
|
+
export function describeBash(r) {
|
|
45
|
+
if (r.source === 'AGMSG_BASH')
|
|
46
|
+
return `${r.command} (from AGMSG_BASH)`;
|
|
47
|
+
if (r.source === 'git-bash')
|
|
48
|
+
return `${r.command} (Git for Windows)`;
|
|
49
|
+
return `${r.command} (first on PATH)`;
|
|
50
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnOssInherit } from '../oss-env.js';
|
|
2
|
+
import { resolveBash } from '../bash-path.js';
|
|
2
3
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { CourierClient, CourierError } from '../api.js';
|
|
@@ -35,7 +36,7 @@ export async function cmdConnect(config, opts) {
|
|
|
35
36
|
}
|
|
36
37
|
process.stdout.write(`\nConnecting "${opts.team}" as machine "${credential.machineName}".\n`);
|
|
37
38
|
const run = opts.runner ?? runInherit;
|
|
38
|
-
const code = await run(
|
|
39
|
+
const code = await run(resolveBash().command, [
|
|
39
40
|
join(config.scriptsDir, 'remote.sh'),
|
|
40
41
|
'connect',
|
|
41
42
|
'--endpoint',
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { CourierClient } from '../api.js';
|
|
5
5
|
import { clearAuthenticatedDigest, readAuthenticatedDigests } from '../authenticated-digest.js';
|
|
6
6
|
import { originOf } from '../credentials.js';
|
|
7
|
+
import { shellArg } from '../shell-arg.js';
|
|
7
8
|
import { decryptWithIdentity, localTeamLookup, publicKeyOf, unlockBundle, verifyHandoffDigest, } from '../oss.js';
|
|
8
9
|
import { deviceIdentityPath } from '../paths.js';
|
|
9
10
|
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
@@ -107,7 +108,11 @@ env = process.env) {
|
|
|
107
108
|
// not a weaker check — it is no check, in the shape of one.
|
|
108
109
|
const ids = authenticated.map((a) => ` ${a.requestId} (${a.recordedAt})`).join('\n');
|
|
109
110
|
throw new Error(`${authenticated.length} approved enrollments are recorded for this service:\n\n${ids}\n\n` +
|
|
110
|
-
'Which one this bundle belongs to cannot be decided from here
|
|
111
|
+
'Which one this bundle belongs to cannot be decided from here.\n\n' +
|
|
112
|
+
`Run \`agmsg-cloud sync ${shellArg(args.team)}\` again and have it approved: a\n` +
|
|
113
|
+
'fresh approval replaces every other one recorded here for this machine, leaving\n' +
|
|
114
|
+
'exactly one. (Run `agmsg-cloud request <label>` instead if this machine got here\n' +
|
|
115
|
+
'through `request` on its own rather than through `sync`.)');
|
|
111
116
|
}
|
|
112
117
|
const consumed = authenticated[0];
|
|
113
118
|
const expected = consumed.handoffDigest;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnOssInherit } from '../oss-env.js';
|
|
2
2
|
import { shellArg } from '../shell-arg.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { resolveBash } from '../bash-path.js';
|
|
4
5
|
import { CourierClient, isUuid } from '../api.js';
|
|
5
6
|
import { originOf, readCredential } from '../credentials.js';
|
|
6
7
|
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
@@ -30,7 +31,7 @@ export async function cmdPull(config, opts) {
|
|
|
30
31
|
const teamId = opts.teamId ?? (await resolveTeamId(config, opts));
|
|
31
32
|
process.stdout.write(`\nPulling "${opts.team}" onto machine "${credential.machineName}".\n`);
|
|
32
33
|
const run = opts.runner ?? runInherit;
|
|
33
|
-
const code = await run(
|
|
34
|
+
const code = await run(resolveBash().command, [
|
|
34
35
|
join(config.scriptsDir, 'remote.sh'),
|
|
35
36
|
'pull',
|
|
36
37
|
'--endpoint',
|
|
@@ -52,8 +53,16 @@ export async function cmdPull(config, opts) {
|
|
|
52
53
|
// What it adds is the route, which the OSS script no longer names because
|
|
53
54
|
// its route is not ours. Phrased as a condition rather than a claim, so it
|
|
54
55
|
// is true after either line above.
|
|
55
|
-
|
|
56
|
+
//
|
|
57
|
+
// BOTH lines withheld under `nextStepsFromCaller`, not just the route
|
|
58
|
+
// (#523). `sync` runs `fetch` immediately after this, and "is on this
|
|
59
|
+
// machine" reads as this step's own success — which it is — right before a
|
|
60
|
+
// failing `fetch` throws with nothing to say the two are different steps.
|
|
61
|
+
// The caller owns saying what happened once every one of its steps has
|
|
62
|
+
// run; this side saying its own half early is what let the two read as one
|
|
63
|
+
// outcome.
|
|
56
64
|
if (opts.nextStepsFromCaller !== true) {
|
|
65
|
+
process.stdout.write(`\n"${opts.team}" is on this machine.\n`);
|
|
57
66
|
process.stdout.write(`If it is still locked, \`agmsg-cloud sync ${shellArg(opts.team)}\` completes the key handoff:\n` +
|
|
58
67
|
'it asks a machine that already has the team, and the two of you compare eight digits.\n');
|
|
59
68
|
}
|
|
@@ -216,7 +216,19 @@ export async function cmdSync(config, opts) {
|
|
|
216
216
|
// server says the ceremony was approved — `request` waits for that, and
|
|
217
217
|
// throws otherwise. The bundle is checked against the snapshot those digits
|
|
218
218
|
// authenticated, by machine.
|
|
219
|
-
|
|
219
|
+
//
|
|
220
|
+
// `pull` no longer says anything on its own when it is a step of this
|
|
221
|
+
// command (#523), so if this throws, nothing above has claimed success yet
|
|
222
|
+
// — the operator has only been told the ceremony finished. Said here,
|
|
223
|
+
// before the error propagates, so a failure at this LAST step still reads
|
|
224
|
+
// as a failure and not as a silent stop after what looked like the finish.
|
|
225
|
+
try {
|
|
226
|
+
await fetch(config, { team: opts.team });
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
out(`\n"${opts.team}" is on this machine, but the key has not arrived — it cannot be used yet.\n`);
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
220
232
|
// The last thing said, because "what now" is the question the screen leaves
|
|
221
233
|
// otherwise: someone who has just run five steps and watched a code
|
|
222
234
|
// comparison has every reason to expect a sixth.
|
package/dist/src/credentials.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, chmodSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
+
import { syncDirectoryEntryIfSupported } from './durable-dir.js';
|
|
5
6
|
/**
|
|
6
7
|
* A server-minted org address, in either of the two forms the server mints.
|
|
7
8
|
*
|
|
@@ -489,14 +490,20 @@ function commit(path, dir, next) {
|
|
|
489
490
|
enforceFileMode(path);
|
|
490
491
|
// Rename is atomic but not durable until the DIRECTORY entry is flushed. The
|
|
491
492
|
// whole point of writing before activating is that a crash here still leaves
|
|
492
|
-
// the credential on disk, so this fsync is load-bearing, not hygiene
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
493
|
+
// the credential on disk, so this fsync is load-bearing, not hygiene —
|
|
494
|
+
// ON POSIX. That qualifier is new and it is the whole of #512: Windows has
|
|
495
|
+
// no directory fsync, the call failed with EPERM, and `login` died after the
|
|
496
|
+
// server exchange had already succeeded. The comment as it stood said
|
|
497
|
+
// "load-bearing" without a platform and would have told the next reader that
|
|
498
|
+
// Windows was getting a guarantee it never had.
|
|
499
|
+
//
|
|
500
|
+
// Where the operation does not exist, the credential is still written and
|
|
501
|
+
// renamed; what is missing is the flush. THIS CALL DOES NOT TELL US WHICH
|
|
502
|
+
// HAPPENED, and the comment here used to say it did — the helper returned an
|
|
503
|
+
// outcome and this line dropped it, so "the caller can tell the difference"
|
|
504
|
+
// was true of the test suite and false of the product. It is best-effort now
|
|
505
|
+
// and says so. Anything that is not a capability answer still throws.
|
|
506
|
+
syncDirectoryEntryIfSupported(dir);
|
|
500
507
|
}
|
|
501
508
|
// Used only by the tests that need a file on disk without going through a
|
|
502
509
|
// login; kept here so the 0600/0700 rules live in exactly one place.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { closeSync, constants, fsyncSync, openSync } from 'node:fs';
|
|
2
|
+
// FLUSHING A DIRECTORY ENTRY, WHERE THAT IS A THING (#512).
|
|
3
|
+
//
|
|
4
|
+
// After `rename`, POSIX gives atomicity but not durability: the entry is not on
|
|
5
|
+
// disk until the containing DIRECTORY is fsynced. Both call sites write a file,
|
|
6
|
+
// rename it into place, and then flush the directory, and the comment at one of
|
|
7
|
+
// them called that "load-bearing, not hygiene". On POSIX it is.
|
|
8
|
+
//
|
|
9
|
+
// Windows has no such operation, and a user hit it: `agmsg-cloud login` printed
|
|
10
|
+
// its device code, completed the exchange, and then died with
|
|
11
|
+
//
|
|
12
|
+
// EPERM: operation not permitted, fsync
|
|
13
|
+
//
|
|
14
|
+
// on the last line of storing the credential — after the part that talks to the
|
|
15
|
+
// server, before the part that makes it usable.
|
|
16
|
+
//
|
|
17
|
+
// READ THE SYSCALL IN THAT MESSAGE. It says `fsync`, so on that machine the
|
|
18
|
+
// open SUCCEEDED and the flush was refused. An earlier version of this file
|
|
19
|
+
// asserted the opposite in a comment — "Windows refuses at the OPEN, not at the
|
|
20
|
+
// fsync" — two paragraphs below the error text that contradicts it. Nobody
|
|
21
|
+
// measured it; it was inferred from how directories open elsewhere and then
|
|
22
|
+
// written down as fact. The open is guarded too, but as a precaution, and it is
|
|
23
|
+
// labelled as one below.
|
|
24
|
+
//
|
|
25
|
+
// THE FIX IS NOT AN EQUIVALENT. There is no Windows call that buys the same
|
|
26
|
+
// guarantee here; `MoveFileEx` reasons about durability differently. So this
|
|
27
|
+
// does not "do the same thing another way" — where the guarantee is
|
|
28
|
+
// unavailable, the entry is written and renamed and the flush does not happen,
|
|
29
|
+
// and that is a downgrade we accept rather than a success we claim.
|
|
30
|
+
/**
|
|
31
|
+
* Refusals that mean "this filesystem does not offer the operation", whatever
|
|
32
|
+
* the platform.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately small. A directory fsync is refused with `EINVAL` or `ENOTSUP`
|
|
35
|
+
* by filesystems that do not implement it — some network and FUSE mounts — and
|
|
36
|
+
* those answers are not ambiguous. Adding an errno here needs the same
|
|
37
|
+
* argument: that it CANNOT also be a real failure of this particular write.
|
|
38
|
+
*/
|
|
39
|
+
const NOT_OFFERED_ANYWHERE = new Set(['EINVAL', 'ENOTSUP']);
|
|
40
|
+
/**
|
|
41
|
+
* Refusals that mean "unsupported" ONLY on Windows, because on POSIX they are
|
|
42
|
+
* how a real failure of a real operation arrives.
|
|
43
|
+
*
|
|
44
|
+
* `EPERM` is the one the reported crash produced. On POSIX the same errno is a
|
|
45
|
+
* genuine refusal, so reading it as a capability answer everywhere would turn
|
|
46
|
+
* real failures into silent successes. It is gated on the platform for exactly
|
|
47
|
+
* that reason — see the note on `deps.platform`.
|
|
48
|
+
*/
|
|
49
|
+
const NOT_OFFERED_ON_WINDOWS = new Set(['EPERM', 'EISDIR']);
|
|
50
|
+
/**
|
|
51
|
+
* `EACCES` is in NEITHER set, and this is the line most likely to be
|
|
52
|
+
* "simplified" later.
|
|
53
|
+
*
|
|
54
|
+
* It is a permission answer, not a capability answer: a directory whose search
|
|
55
|
+
* permission was dropped, an ACL, a sandbox policy. Swallowing it would report
|
|
56
|
+
* a successful sign-in over a filesystem that is refusing us — which is the
|
|
57
|
+
* failure this whole file exists to remove, pointing the other way. Same for
|
|
58
|
+
* `ENOENT`: the directory the caller just renamed into being gone is not a
|
|
59
|
+
* portability question.
|
|
60
|
+
*/
|
|
61
|
+
function isUnsupported(err, phase, platform) {
|
|
62
|
+
const code = err.code ?? '';
|
|
63
|
+
if (phase === 'fsync' && NOT_OFFERED_ANYWHERE.has(code))
|
|
64
|
+
return true;
|
|
65
|
+
return platform === 'win32' && NOT_OFFERED_ON_WINDOWS.has(code);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Flush a directory entry where the platform offers it, and do nothing where it
|
|
69
|
+
* does not.
|
|
70
|
+
*
|
|
71
|
+
* Returns `void`, and the contract is the honest version of that: **this is
|
|
72
|
+
* best-effort durability.** An earlier draft returned `'flushed' | 'unsupported'`
|
|
73
|
+
* so that a caller "could tell the difference" — and neither caller looked at
|
|
74
|
+
* it. A union nobody reads is not a report; it only makes the prose around it
|
|
75
|
+
* sound like one. If we ever need to tell a user that their filesystem cannot
|
|
76
|
+
* promise this, that is a change at the call sites, and the return type comes
|
|
77
|
+
* back with it.
|
|
78
|
+
*
|
|
79
|
+
* Everything that is not a capability answer still throws.
|
|
80
|
+
*
|
|
81
|
+
* `deps.platform` exists because two errnos are ambiguous, not because the
|
|
82
|
+
* platform decides whether the call works. The call itself still answers that —
|
|
83
|
+
* probed, never assumed — and a filesystem refusing with `EINVAL` is honoured on
|
|
84
|
+
* any platform. What the platform gates is how to READ `EPERM`, which means one
|
|
85
|
+
* thing on Windows and another on Linux.
|
|
86
|
+
*
|
|
87
|
+
* The rest of `deps` is there so a test can drive these branches from any
|
|
88
|
+
* platform: there is no way to make a real POSIX kernel refuse this on demand.
|
|
89
|
+
*/
|
|
90
|
+
export function syncDirectoryEntryIfSupported(dir, deps = {}) {
|
|
91
|
+
const open = deps.open ?? openSync;
|
|
92
|
+
const fsync = deps.fsync ?? fsyncSync;
|
|
93
|
+
const close = deps.close ?? closeSync;
|
|
94
|
+
const platform = deps.platform ?? process.platform;
|
|
95
|
+
let dirFd;
|
|
96
|
+
try {
|
|
97
|
+
dirFd = open(dir, constants.O_RDONLY);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
// PRECAUTION, NOT THE REPORTED CRASH. The machine that reported this got
|
|
101
|
+
// past the open; guarding here covers a Windows build or a mount that
|
|
102
|
+
// refuses earlier, and costs nothing when it does not.
|
|
103
|
+
if (isUnsupported(err, 'open', platform))
|
|
104
|
+
return;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
fsync(dirFd);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// Close before deciding, and do not let the close's own failure become the
|
|
112
|
+
// error the caller sees: it would replace a diagnosis with a symptom.
|
|
113
|
+
try {
|
|
114
|
+
close(dirFd);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Intentionally dropped. The fsync error below is the one that explains
|
|
118
|
+
// what happened; a close error on a descriptor we are abandoning is not.
|
|
119
|
+
}
|
|
120
|
+
if (isUnsupported(err, 'fsync', platform))
|
|
121
|
+
return;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
// Success path only, so this close is the sole thing that can still fail —
|
|
125
|
+
// and a close reporting a deferred write error is a real failure of this
|
|
126
|
+
// write, not a portability answer. It throws, matching how both call sites
|
|
127
|
+
// already treat `closeSync` on the file itself.
|
|
128
|
+
close(dirFd);
|
|
129
|
+
}
|
package/dist/src/oss.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { closeSync, constants, openSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { spawnOssPiped } from './oss-env.js';
|
|
5
|
+
import { describeBash, resolveBash } from './bash-path.js';
|
|
5
6
|
// Thin wrappers over the OSS tooling. The proprietary courier never touches key
|
|
6
7
|
// material: sealing/opening the bundle and the handoff/unlock steps all run
|
|
7
8
|
// through `age` and the OSS `key.sh` / `remote.sh` on the operator's machine.
|
|
@@ -66,13 +67,32 @@ export function runAllowingFailure(cmd, args) {
|
|
|
66
67
|
* not get presented to the operator as a mistyped name.
|
|
67
68
|
*/
|
|
68
69
|
export async function localTeamLookup(scriptsDir, team) {
|
|
69
|
-
const
|
|
70
|
+
const bash = resolveBash();
|
|
71
|
+
const r = await runAllowingFailure(bash.command, [
|
|
70
72
|
join(scriptsDir, 'remote.sh'),
|
|
71
73
|
'status',
|
|
72
74
|
team,
|
|
73
75
|
'--json',
|
|
74
76
|
]);
|
|
75
|
-
|
|
77
|
+
if (r.code === 0)
|
|
78
|
+
return { known: true, said: '' };
|
|
79
|
+
// THE DEFENCE IS EMPTY EXACTLY WHEN IT IS NEEDED (#507).
|
|
80
|
+
//
|
|
81
|
+
// `fetch.ts` prints `said` so the operator can tell "no such team" from "the
|
|
82
|
+
// store could not be read". That works while the script runs and complains.
|
|
83
|
+
// A process that never started writes nothing to stderr, so `said` is empty
|
|
84
|
+
// in the one case the two readings are hardest to tell apart — and the empty
|
|
85
|
+
// string is dropped from the message, leaving only the ambiguity with no
|
|
86
|
+
// evidence attached.
|
|
87
|
+
//
|
|
88
|
+
// So when the store said nothing, name the interpreter instead. It is not a
|
|
89
|
+
// diagnosis: it is the one fact this layer holds that the operator does not,
|
|
90
|
+
// and on Windows it is usually the whole answer (`System32\bash.exe` is the
|
|
91
|
+
// WSL launcher, not a shell that can run these scripts — #505).
|
|
92
|
+
return {
|
|
93
|
+
known: false,
|
|
94
|
+
said: r.stderr === '' ? `nothing. Interpreter tried: ${describeBash(bash)}` : r.stderr,
|
|
95
|
+
};
|
|
76
96
|
}
|
|
77
97
|
// Generate a device age identity at `identityPath`; returns its public recipient.
|
|
78
98
|
export async function generateDeviceIdentity(identityPath) {
|
|
@@ -95,7 +115,7 @@ export function decryptWithIdentity(identityPath, ciphertext) {
|
|
|
95
115
|
// OSS `key.sh handoff <team> --out <file>` — export the one secret handoff bundle
|
|
96
116
|
// (confirmed snapshot chain + every epoch identity) for `team`.
|
|
97
117
|
export async function keyHandoff(scriptsDir, team, outFile) {
|
|
98
|
-
await run(
|
|
118
|
+
await run(resolveBash().command, [join(scriptsDir, 'key.sh'), 'handoff', team, '--out', outFile]);
|
|
99
119
|
}
|
|
100
120
|
// OSS `remote-sync.sh verify-age-handoff` — the digest the joiner's `unlock`
|
|
101
121
|
// will compare the human-carried value against.
|
|
@@ -112,7 +132,7 @@ export async function keyHandoff(scriptsDir, team, outFile) {
|
|
|
112
132
|
// and is already on disk beside it — but the directory holds private key
|
|
113
133
|
// material and must be treated exactly like the bundle.
|
|
114
134
|
export async function verifyHandoffDigest(scriptsDir, team, bundleFile, outDir) {
|
|
115
|
-
const out = await run(
|
|
135
|
+
const out = await run(resolveBash().command, [
|
|
116
136
|
join(scriptsDir, 'remote-sync.sh'),
|
|
117
137
|
'verify-age-handoff',
|
|
118
138
|
'--team',
|
|
@@ -153,7 +173,7 @@ export async function verifyHandoffDigest(scriptsDir, team, bundleFile, outDir)
|
|
|
153
173
|
// material came from. `remoteTeamId` stays for callers that only address the
|
|
154
174
|
// team, so neither has to know about the other's fields.
|
|
155
175
|
export async function remoteBinding(scriptsDir, team) {
|
|
156
|
-
const out = await run(
|
|
176
|
+
const out = await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
|
|
157
177
|
const text = out.toString().trim();
|
|
158
178
|
if (!text)
|
|
159
179
|
throw new Error(`team '${team}' has never been connected to a remote`);
|
|
@@ -173,7 +193,7 @@ export async function remoteBinding(scriptsDir, team) {
|
|
|
173
193
|
return { teamId: status.remote_team_id, serverInstanceId: status.server_instance_id };
|
|
174
194
|
}
|
|
175
195
|
export async function connectedTeams(scriptsDir) {
|
|
176
|
-
const out = await run(
|
|
196
|
+
const out = await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'status', '--json']);
|
|
177
197
|
const teams = [];
|
|
178
198
|
for (const line of out.toString().split('\n')) {
|
|
179
199
|
const text = line.trim();
|
|
@@ -245,7 +265,7 @@ export async function teamsBoundTo(scriptsDir, origin) {
|
|
|
245
265
|
return { matched: matched.sort(), unreadable: unreadable.sort() };
|
|
246
266
|
}
|
|
247
267
|
export async function remoteTeamId(scriptsDir, team) {
|
|
248
|
-
const out = await run(
|
|
268
|
+
const out = await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
|
|
249
269
|
const text = out.toString().trim();
|
|
250
270
|
if (!text)
|
|
251
271
|
throw new Error(`team '${team}' has never been connected to a remote`);
|
|
@@ -275,7 +295,7 @@ export async function remoteTeamId(scriptsDir, team) {
|
|
|
275
295
|
// courier path must keep using unlockBundle with an out-of-band digest, because
|
|
276
296
|
// age's recipient encryption does not authenticate the sender.
|
|
277
297
|
export async function unlockAuthenticatedBundle(scriptsDir, team, bundle) {
|
|
278
|
-
await run(
|
|
298
|
+
await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'unlock', team, '--authenticated-bundle-stdin'], bundle);
|
|
279
299
|
}
|
|
280
300
|
// OSS `remote.sh unlock <team> --bundle <file> [--confirm-digest <digest>]`.
|
|
281
301
|
//
|
|
@@ -296,7 +316,7 @@ export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest)
|
|
|
296
316
|
const args = [join(scriptsDir, 'remote.sh'), 'unlock', team, '--bundle', bundleFile];
|
|
297
317
|
if (confirmDigest)
|
|
298
318
|
args.push('--confirm-digest', confirmDigest);
|
|
299
|
-
return (await run(
|
|
319
|
+
return (await run(resolveBash().command, args)).toString();
|
|
300
320
|
}
|
|
301
321
|
// The canonical age snapshot, exported locally, and its digest (§3.2, §3.2.1).
|
|
302
322
|
//
|
|
@@ -311,7 +331,7 @@ export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest)
|
|
|
311
331
|
// without anything failing. Reading the definition rather than the message
|
|
312
332
|
// also means A and B compute the same value from the same rule.
|
|
313
333
|
export async function exportSnapshotDigest(scriptsDir, team, outPath) {
|
|
314
|
-
await run(
|
|
334
|
+
await run(resolveBash().command, [
|
|
315
335
|
join(scriptsDir, 'remote-sync.sh'),
|
|
316
336
|
'export-age-snapshot',
|
|
317
337
|
'--team',
|
package/dist/src/preflight.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { defaultScriptsDir, hasCredential, scriptsDirChoice } from './config.js';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { describeBash, resolveBash } from './bash-path.js';
|
|
5
6
|
import { platform } from 'node:process';
|
|
6
7
|
import { selfInstall } from './self-install.js';
|
|
7
8
|
// What `connect` needs before it starts, checked all at once.
|
|
@@ -33,7 +34,7 @@ import { selfInstall } from './self-install.js';
|
|
|
33
34
|
// cannot establish that a silently short team list is impossible.
|
|
34
35
|
export function installedVersion(scriptsDir) {
|
|
35
36
|
try {
|
|
36
|
-
const out = execFileSync(
|
|
37
|
+
const out = execFileSync(resolveBash().command, [join(scriptsDir, 'version.sh')], {
|
|
37
38
|
encoding: 'utf8',
|
|
38
39
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
40
|
}).trim();
|
|
@@ -70,31 +71,28 @@ export function supportsFailClosedTeamStatus(version) {
|
|
|
70
71
|
return patch > 0;
|
|
71
72
|
return rc === null || rc >= 4;
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// This is capability detection, not version detection, and the difference is
|
|
77
|
-
// the point — see the note above `installedVersion`.
|
|
78
|
-
//
|
|
79
|
-
// What is deliberately NOT probed: whether this install carries the fix for the
|
|
80
|
-
// store-migration data-loss path. There is no way to ask a script "do you have
|
|
81
|
-
// this guard" without reading its innards and guessing, and a check that cannot
|
|
82
|
-
// really answer is worse than none — it reads as a guarantee. The release plan
|
|
83
|
-
// covers it instead: the fix ships before remote sync is public.
|
|
84
|
-
function remoteSupportsConnect(scriptsDir) {
|
|
74
|
+
export function remoteSupportsConnect(scriptsDir, deps = {}) {
|
|
75
|
+
const bash = deps.bash ?? resolveBash();
|
|
76
|
+
const run = deps.run ?? execFileSync;
|
|
85
77
|
try {
|
|
86
|
-
const out =
|
|
78
|
+
const out = run(bash.command, [join(scriptsDir, 'remote.sh')], {
|
|
87
79
|
encoding: 'utf8',
|
|
88
80
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
81
|
});
|
|
90
|
-
return /\bconnect\b/.test(out);
|
|
82
|
+
return /\bconnect\b/.test(out) ? 'yes' : 'no';
|
|
91
83
|
}
|
|
92
84
|
catch (err) {
|
|
93
|
-
// Usage goes to stderr and exits non-zero in some versions; that output is
|
|
94
|
-
// just as good an answer as a clean exit.
|
|
95
85
|
const e = err;
|
|
96
86
|
const text = `${String(e.stdout ?? '')}${String(e.stderr ?? '')}`;
|
|
97
|
-
|
|
87
|
+
// Usage on stderr with a non-zero exit is a real answer — the script ran.
|
|
88
|
+
if (/\bconnect\b/.test(text))
|
|
89
|
+
return 'yes';
|
|
90
|
+
// It ran and said nothing about connect: that IS evidence about the script.
|
|
91
|
+
// Distinguished from never having run by whether either stream produced
|
|
92
|
+
// anything at all. A spawn failure has neither, and ENOENT names itself.
|
|
93
|
+
if (e.code === 'ENOENT' || text.length === 0)
|
|
94
|
+
return 'unknown';
|
|
95
|
+
return 'no';
|
|
98
96
|
}
|
|
99
97
|
}
|
|
100
98
|
/**
|
|
@@ -256,7 +254,9 @@ versionRunner) {
|
|
|
256
254
|
// than inferred from a version; account-wide recovery separately applies the
|
|
257
255
|
// released producer floor described above `installedVersion`.
|
|
258
256
|
const scriptsPresent = missingScripts.length === 0;
|
|
259
|
-
const
|
|
257
|
+
const bash = resolveBash();
|
|
258
|
+
const connectSupport = scriptsPresent && requireConnect ? remoteSupportsConnect(scriptsDir, { bash }) : 'yes';
|
|
259
|
+
const canConnect = scriptsPresent && connectSupport === 'yes';
|
|
260
260
|
const agmsgVersion = scriptsPresent ? installedVersion(scriptsDir) : null;
|
|
261
261
|
requirements.push({
|
|
262
262
|
name: requireConnect
|
|
@@ -265,11 +265,20 @@ versionRunner) {
|
|
|
265
265
|
ok: canConnect,
|
|
266
266
|
why: !scriptsPresent
|
|
267
267
|
? `${command} runs ${missingScripts.join(' and ')}, and this machine has no copy there.`
|
|
268
|
-
:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
268
|
+
: connectSupport === 'unknown'
|
|
269
|
+
? // NOT a claim about the install (#505). Nothing was learned about it.
|
|
270
|
+
`could not run ${describeBash(bash)}, so nothing here was checked — this says nothing about your agmsg install.`
|
|
271
|
+
: 'the agmsg installed here does not offer `remote.sh connect`, so it predates remote sync.',
|
|
272
|
+
install: connectSupport === 'unknown'
|
|
273
|
+
? [
|
|
274
|
+
`Interpreter tried: ${describeBash(bash)}`,
|
|
275
|
+
'On Windows the first `bash` on PATH is usually WSL, which cannot run these scripts.',
|
|
276
|
+
'Point at Git Bash: set AGMSG_BASH to ...\\Git\\bin\\bash.exe',
|
|
277
|
+
]
|
|
278
|
+
: [
|
|
279
|
+
'Install or update: npx agmsg install',
|
|
280
|
+
'Elsewhere? point AGMSG_SCRIPTS_DIR at that install\'s scripts directory.',
|
|
281
|
+
],
|
|
273
282
|
});
|
|
274
283
|
if (requireFailClosedTeamStatus) {
|
|
275
284
|
requirements.push({
|
package/package.json
CHANGED