@homeflare/alchemy 0.25.1 → 0.26.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/launchd/index.js +51 -47
- package/dist/launchd/index.js.map +5 -4
- package/dist/launchd/sudo-guard-mode.d.ts +62 -0
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +1 -38
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts +26 -0
- package/dist/linux/fake-sudo-root.d.ts.map +1 -0
- package/dist/linux/fake-sudo.d.ts +56 -0
- package/dist/linux/fake-sudo.d.ts.map +1 -0
- package/dist/linux/index.d.ts +2 -0
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +1134 -2
- package/dist/linux/index.js.map +22 -5
- package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
- package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
- package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist.d.ts +40 -0
- package/dist/linux/sudo-allowlist.d.ts.map +1 -0
- package/dist/linux/sudo-exec.d.ts +31 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -0
- package/dist/linux/sudo-guard.d.ts +36 -0
- package/dist/linux/sudo-guard.d.ts.map +1 -0
- package/dist/linux/sudo-runner.d.ts +37 -0
- package/dist/linux/sudo-runner.d.ts.map +1 -0
- package/dist/linux/sudo-stage.d.ts +22 -0
- package/dist/linux/sudo-stage.d.ts.map +1 -0
- package/dist/linux/sudo-write.d.ts +25 -0
- package/dist/linux/sudo-write.d.ts.map +1 -0
- package/dist/linux/unit-form.d.ts +2 -0
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/docs/launchd-sudo.md +12 -3
- package/docs/linux-host.md +3 -3
- package/docs/linux-sudo.md +187 -0
- package/package.json +1 -1
- package/src/launchd/sudo-guard-mode.ts +111 -0
- package/src/launchd/sudo-guard.ts +3 -79
- package/src/launchd/sudo-modes.test.ts +38 -1
- package/src/linux/fake-sudo-root.ts +157 -0
- package/src/linux/fake-sudo.ts +142 -0
- package/src/linux/index.ts +2 -0
- package/src/linux/sudo-allowlist-chown.ts +71 -0
- package/src/linux/sudo-allowlist-dir.ts +103 -0
- package/src/linux/sudo-allowlist.test.ts +252 -0
- package/src/linux/sudo-allowlist.ts +223 -0
- package/src/linux/sudo-exec.ts +118 -0
- package/src/linux/sudo-guard.test.ts +136 -0
- package/src/linux/sudo-guard.ts +142 -0
- package/src/linux/sudo-lifecycle.test.ts +151 -0
- package/src/linux/sudo-runner.test.ts +206 -0
- package/src/linux/sudo-runner.ts +164 -0
- package/src/linux/sudo-stage.ts +50 -0
- package/src/linux/sudo-write.ts +167 -0
- package/src/linux/unit-form.ts +2 -1
- package/src/litellm/fake-litellm.ts +2 -0
- package/src/proxmox/client.test.ts +17 -3
- package/src/proxmox/fake-pve-lxc.ts +10 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner over the fake Linux host end to end: writeFileAtomic's exact argv sequence and
|
|
3
|
+
* final bytes/mode/owner, a failed `mv` cleaning up the temp, `removeFile` of an absent path never
|
|
4
|
+
* calling sudo, `checkWrite` never calling sudo, and every logged line carrying no file content.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { CHOWN, INSTALL, MKDIR, MV, RM, RMDIR } from './sudo-allowlist.ts';
|
|
8
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
9
|
+
import { SudoRefusedError } from './sudo-runner.ts';
|
|
10
|
+
|
|
11
|
+
const SECRET = 'super-secret-config-value=hunter2\n';
|
|
12
|
+
const DEST = '/etc/systemd/system/thing.service';
|
|
13
|
+
|
|
14
|
+
describe('what it claims', () => {
|
|
15
|
+
test('privileged, as the operator: it elevates calls, it is not root', () => {
|
|
16
|
+
const { runner } = fakeSudoHost();
|
|
17
|
+
expect(runner.privileged).toBe(true);
|
|
18
|
+
expect(runner.effectiveUid()).toBe(1000);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('writeFileAtomic under a prefix', () => {
|
|
23
|
+
test('install into a derived temp, then mv onto the path — exact argv, and the bytes land', async () => {
|
|
24
|
+
const { fake, logs, privileged, runner } = fakeSudoHost();
|
|
25
|
+
await runner.writeFileAtomic(DEST, new TextEncoder().encode(SECRET), {
|
|
26
|
+
gid: 0,
|
|
27
|
+
mode: 0o644,
|
|
28
|
+
uid: 0,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const calls = privileged();
|
|
32
|
+
expect(calls).toHaveLength(2);
|
|
33
|
+
const [install, mv] = calls;
|
|
34
|
+
expect(install?.[0]).toBe(INSTALL);
|
|
35
|
+
expect(install?.slice(1, 3)).toEqual(['-m', '0644']);
|
|
36
|
+
// install: [-m, mode, -T, --, staged, temp] — the temp is derived FROM dest, in its directory.
|
|
37
|
+
expect(install?.at(-1)).toMatch(
|
|
38
|
+
/^\/etc\/systemd\/system\/\.thing\.service\.hf-[0-9a-f]{12}\.tmp$/,
|
|
39
|
+
);
|
|
40
|
+
expect(mv?.[0]).toBe(MV);
|
|
41
|
+
expect(mv?.slice(1, 3)).toEqual(['-f', '-T']);
|
|
42
|
+
// mv: [-f, -T, --, temp, dest] — its source is exactly install's target.
|
|
43
|
+
expect(mv?.at(-2)).toBe(install?.at(-1));
|
|
44
|
+
expect(mv?.at(-1)).toBe(DEST);
|
|
45
|
+
|
|
46
|
+
const file = fake.files.get(DEST);
|
|
47
|
+
expect(file?.mode).toBe(0o644);
|
|
48
|
+
expect(file?.uid).toBe(0);
|
|
49
|
+
expect(new TextDecoder().decode(file?.bytes)).toBe(SECRET);
|
|
50
|
+
// ⛔ Bytes never travel through argv or the log.
|
|
51
|
+
expect(logs.some((line) => line.includes('hunter2'))).toBe(false);
|
|
52
|
+
for (const line of logs) expect(line.startsWith('homeflare/linux sudo -n ')).toBe(true);
|
|
53
|
+
// No temp file left behind, and the staging directory is gone too.
|
|
54
|
+
expect([...fake.files.keys()].some((path) => path.includes('.hf-'))).toBe(false);
|
|
55
|
+
expect([...fake.dirs.keys()].some((path) => path.startsWith('/tmp/hf-sudo-'))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a failed mv removes the temp it just installed, then still throws', async () => {
|
|
59
|
+
const { fake, privileged, runner, state } = fakeSudoHost();
|
|
60
|
+
state.mvFailure = { exitCode: 1, stderr: 'mv: simulated failure', stdout: '' };
|
|
61
|
+
await expect(
|
|
62
|
+
runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 }),
|
|
63
|
+
).rejects.toThrow('simulated failure');
|
|
64
|
+
|
|
65
|
+
const calls = privileged();
|
|
66
|
+
expect(calls.map((c) => c[0])).toEqual([INSTALL, MV, RM]);
|
|
67
|
+
// ★ rm's argument is install's own temp target — the exact path mv failed to move.
|
|
68
|
+
expect(calls[2]?.at(-1)).toBe(calls[0]?.at(-1));
|
|
69
|
+
expect(fake.files.has(DEST)).toBe(false);
|
|
70
|
+
expect([...fake.files.keys()].some((path) => path.includes('.hf-'))).toBe(false);
|
|
71
|
+
expect([...fake.dirs.keys()].some((path) => path.startsWith('/tmp/hf-sudo-'))).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 🔴 Adversarial review, round 2: a failed CLEANUP rm after a failed mv used to be swallowed
|
|
75
|
+
// silently — the operator learned only about the mv failure, never that a root-owned temp
|
|
76
|
+
// was now littered on the host. The thrown error must say so and name the path.
|
|
77
|
+
test('a cleanup rm that ALSO fails is reported, not swallowed — names the leftover temp', async () => {
|
|
78
|
+
const { privileged, runner, state } = fakeSudoHost();
|
|
79
|
+
state.mvFailure = { exitCode: 1, stderr: 'mv: simulated failure', stdout: '' };
|
|
80
|
+
state.rmFailure = { exitCode: 1, stderr: 'rm: simulated cleanup failure', stdout: '' };
|
|
81
|
+
const write = runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 });
|
|
82
|
+
await expect(write).rejects.toThrow('simulated failure');
|
|
83
|
+
await expect(write).rejects.toThrow('also failed');
|
|
84
|
+
// 🔴 Adversarial review, round 3: `String(cleaned)` on the raw ExecResult printed
|
|
85
|
+
// `[object Object]`, silently dropping rm's own stderr — assert the actual text survives.
|
|
86
|
+
await expect(write).rejects.toThrow('rm: simulated cleanup failure');
|
|
87
|
+
const calls = privileged();
|
|
88
|
+
expect(calls.map((c) => c[0])).toEqual([INSTALL, MV, RM]);
|
|
89
|
+
await expect(write).rejects.toThrow(calls[0]?.at(-1) ?? 'unreachable');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 🔴 Adversarial review, round 2: install never carries -o/-g (coreutils has no way to force
|
|
93
|
+
// numeric there); a non-root owner or group is set by a separate, +-forced chown on the same
|
|
94
|
+
// temp, before mv — and only when one is actually declared, never for a plain root write.
|
|
95
|
+
test('a non-root owner is set by a +-forced chown on the temp, between install and mv', async () => {
|
|
96
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
97
|
+
const path = '/usr/local/bin/hf-thing';
|
|
98
|
+
await runner.writeFileAtomic(path, new TextEncoder().encode('x'), {
|
|
99
|
+
gid: 60,
|
|
100
|
+
mode: 0o644,
|
|
101
|
+
uid: 900,
|
|
102
|
+
});
|
|
103
|
+
const calls = privileged();
|
|
104
|
+
expect(calls.map((c) => c[0])).toEqual([INSTALL, CHOWN, MV]);
|
|
105
|
+
expect(calls[0]?.includes('-o')).toBe(false);
|
|
106
|
+
expect(calls[0]?.includes('-g')).toBe(false);
|
|
107
|
+
expect(calls[1]).toEqual([CHOWN, '+900:+60', '--', calls[0]?.at(-1) ?? '']);
|
|
108
|
+
expect(fake.files.get(path)).toMatchObject({ gid: 60, uid: 900 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a plain root write never chowns — install’s own default already matches', async () => {
|
|
112
|
+
const { privileged, runner } = fakeSudoHost();
|
|
113
|
+
await runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 });
|
|
114
|
+
expect(privileged().map((c) => c[0])).toEqual([INSTALL, MV]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the derived temp is verified absent before install runs', async () => {
|
|
118
|
+
const { base, privileged, runner } = fakeSudoHost();
|
|
119
|
+
// The temp's 12-hex suffix is random, so simulate a collision by making `stat` claim
|
|
120
|
+
// something is already at any `.hf-….tmp` path — `dest` itself is unaffected, so vetWrite's
|
|
121
|
+
// own read of it still sees the real (absent) state.
|
|
122
|
+
const originalStat = base.stat;
|
|
123
|
+
base.stat = async (path) =>
|
|
124
|
+
/\.hf-[0-9a-f]{12}\.tmp$/.test(path)
|
|
125
|
+
? { gid: 0, kind: 'file', mode: 0o600, size: 0, uid: 0 }
|
|
126
|
+
: originalStat(path);
|
|
127
|
+
await expect(
|
|
128
|
+
runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 }),
|
|
129
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
130
|
+
await expect(
|
|
131
|
+
runner.writeFileAtomic(DEST, new TextEncoder().encode('x'), { mode: 0o644 }),
|
|
132
|
+
).rejects.toThrow('already exists');
|
|
133
|
+
// ★ Refused before `install` ever ran — nothing was staged as root over a collision.
|
|
134
|
+
expect(privileged()).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('checkWrite', () => {
|
|
139
|
+
test('never calls sudo, under a prefix or outside one', async () => {
|
|
140
|
+
const { runner, logs, privileged } = fakeSudoHost();
|
|
141
|
+
await runner.checkWrite?.(DEST, { mode: 0o644, uid: 0, gid: 0 });
|
|
142
|
+
await expect(runner.checkWrite?.('/opt/elsewhere/x', { mode: 0o644 })).resolves.toBeUndefined();
|
|
143
|
+
expect(privileged()).toEqual([]);
|
|
144
|
+
expect(logs).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('removeFile', () => {
|
|
149
|
+
test('an absent path under a prefix is a no-op, no sudo', async () => {
|
|
150
|
+
const { privileged, runner } = fakeSudoHost();
|
|
151
|
+
await runner.removeFile('/etc/systemd/system/absent.service');
|
|
152
|
+
expect(privileged()).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a present file under a prefix is removed with rm -f --', async () => {
|
|
156
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
157
|
+
fake.files.set(DEST, { bytes: new Uint8Array(), gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
158
|
+
await runner.removeFile(DEST);
|
|
159
|
+
expect(privileged()).toEqual([[RM, '-f', '--', DEST]]);
|
|
160
|
+
expect(fake.files.has(DEST)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('exec routing', () => {
|
|
165
|
+
test('mkdir/chown under a prefix run as root with absolute argv, chown +-forced', async () => {
|
|
166
|
+
const { fake, privileged, runner } = fakeSudoHost();
|
|
167
|
+
await runner.exec(['mkdir', '-m', '750', '--', '/usr/local/bin/sub']);
|
|
168
|
+
await runner.exec(['chown', '0:0', '--', '/usr/local/bin/sub']);
|
|
169
|
+
expect(privileged()).toEqual([
|
|
170
|
+
[MKDIR, '-m', '750', '--', '/usr/local/bin/sub'],
|
|
171
|
+
[CHOWN, '+0:+0', '--', '/usr/local/bin/sub'],
|
|
172
|
+
]);
|
|
173
|
+
expect(fake.modes.get('/usr/local/bin/sub')).toEqual({ gid: 0, mode: 0o750, uid: 0 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('mkdir/chmod/chown with a dangerous mode or a non-root owner are refused before sudo', async () => {
|
|
177
|
+
const { privileged, runner } = fakeSudoHost();
|
|
178
|
+
await expect(
|
|
179
|
+
runner.exec(['mkdir', '-m', '777', '--', '/usr/local/bin/world']),
|
|
180
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
181
|
+
await expect(
|
|
182
|
+
runner.exec(['chown', '900:60', '--', '/usr/local/bin/sub']),
|
|
183
|
+
).rejects.toBeInstanceOf(SudoRefusedError);
|
|
184
|
+
expect(privileged()).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('rmdir under a prefix runs as root', async () => {
|
|
188
|
+
const { fake, privileged, runner } = fakeSudoHost({ '/usr/local/bin/sub': 0 });
|
|
189
|
+
await runner.exec(['rmdir', '--', '/usr/local/bin/sub']);
|
|
190
|
+
expect(privileged()).toEqual([[RMDIR, '--', '/usr/local/bin/sub']]);
|
|
191
|
+
expect(fake.dirs.has('/usr/local/bin/sub')).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('mkdir outside every prefix runs as the operator, no sudo', async () => {
|
|
195
|
+
const { privileged, runner } = fakeSudoHost({ '/opt': 0 });
|
|
196
|
+
const result = await runner.exec(['mkdir', '-m', '750', '--', '/opt/elsewhere']);
|
|
197
|
+
expect(result.exitCode).toBe(0);
|
|
198
|
+
expect(privileged()).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a sudo argv is refused before anything runs', async () => {
|
|
202
|
+
await expect(fakeSudoHost().runner.exec(['sudo', 'true'])).rejects.toBeInstanceOf(
|
|
203
|
+
SudoRefusedError,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner(): the Linux twin of `../launchd/sudo-runner.ts` — the same idea (the deploy runs
|
|
3
|
+
* as the operator, and only a fixed allowlist of calls goes through `sudo -n`), rebuilt for a
|
|
4
|
+
* non-root ssh user with passwordless sudo instead of a local macOS session. See
|
|
5
|
+
* docs/linux-sudo.md for the full argv table and what is measured versus reasoned.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `sudo -n`, NEVER A PROMPT — same rule, same reason: a deploy that stops to ask for a password
|
|
8
|
+
* is the "no silent sudo" failure this exists to prevent.
|
|
9
|
+
* ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and a file's bytes never reach argv or the
|
|
10
|
+
* log: they travel through the staged file (sudo-stage.ts) alone.
|
|
11
|
+
* ⛔ A PLAN NEVER ELEVATES. `checkWrite` and every read run through `base` — the plain
|
|
12
|
+
* `sshRunner()` — never through sudo. Nothing here probes sudo at construction.
|
|
13
|
+
* ★ PROMISES, NOT EFFECTS, ON PURPOSE — the same divergence `../launchd/runner.ts` documents
|
|
14
|
+
* (H3 in the provider-standard skill): a consumer's own runner is a plain async implementation.
|
|
15
|
+
*/
|
|
16
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
17
|
+
import { sudoRefusal } from '../launchd/sudo-said.ts';
|
|
18
|
+
import { type SshRunnerOptions, sshRunner } from './ssh-runner.ts';
|
|
19
|
+
import { elevatedExec } from './sudo-exec.ts';
|
|
20
|
+
import {
|
|
21
|
+
CHMOD,
|
|
22
|
+
CHOWN,
|
|
23
|
+
INSTALL,
|
|
24
|
+
MKDIR,
|
|
25
|
+
MV,
|
|
26
|
+
RM,
|
|
27
|
+
RMDIR,
|
|
28
|
+
SUDO,
|
|
29
|
+
SYSTEMCTL_ABS,
|
|
30
|
+
SudoRefusedError,
|
|
31
|
+
checkPrefixes,
|
|
32
|
+
prefixOf,
|
|
33
|
+
privilegedProblem,
|
|
34
|
+
routeExec,
|
|
35
|
+
} from './sudo-allowlist.ts';
|
|
36
|
+
import { assertGuardedChain } from './sudo-guard.ts';
|
|
37
|
+
import { type Staged, stageRemote } from './sudo-stage.ts';
|
|
38
|
+
import { type Elevate, vetWrite, writeUnderPrefix } from './sudo-write.ts';
|
|
39
|
+
|
|
40
|
+
export { SudoRefusedError };
|
|
41
|
+
|
|
42
|
+
export type SshSudoRunnerOptions = SshRunnerOptions & {
|
|
43
|
+
/** Directories root may write under. Required; absolute, normalised, never `/`. */
|
|
44
|
+
readonly prefixes: readonly string[];
|
|
45
|
+
/** One line per privileged argv, before it runs. @default a line on stderr */
|
|
46
|
+
readonly log?: (line: string) => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
|
|
50
|
+
export type SshSudoDeps = {
|
|
51
|
+
/** Runs everything, including `sudo` itself. `await sshRunner(options)` for the real one. */
|
|
52
|
+
readonly base: HostRunner;
|
|
53
|
+
readonly stage: (bytes: Uint8Array) => Promise<Staged>;
|
|
54
|
+
readonly log: (line: string) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const outsidePrefixHint =
|
|
58
|
+
(path: string, prefixes: readonly string[]) =>
|
|
59
|
+
(cause: unknown): never => {
|
|
60
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
61
|
+
if (!/permission denied|operation not permitted/i.test(message)) throw cause;
|
|
62
|
+
throw new Error(
|
|
63
|
+
`sshSudoRunner ${path}: the operator could not do this, and the path is under no declared ` +
|
|
64
|
+
`prefix (${prefixes.join(', ')}). If root owns its directory, declare that directory as ` +
|
|
65
|
+
'a prefix.',
|
|
66
|
+
{ cause },
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDeps): HostRunner => {
|
|
71
|
+
const prefixes = checkPrefixes(prefixList);
|
|
72
|
+
const { base } = deps;
|
|
73
|
+
|
|
74
|
+
/** ⛔ THE ONE PLACE `sudo` IS SPAWNED: allowlist, then any host check (`before`), then the log
|
|
75
|
+
* line, then `sudo -n -- argv`. Shared by exec()'s root branch and by writeFileAtomic. */
|
|
76
|
+
const elevate: Elevate = async (argv, context, before) => {
|
|
77
|
+
const shown = JSON.stringify(argv);
|
|
78
|
+
const problem = privilegedProblem(argv, { prefixes, ...context });
|
|
79
|
+
if (problem !== undefined)
|
|
80
|
+
throw new SudoRefusedError(`sshSudoRunner refused ${shown}: ${problem}`);
|
|
81
|
+
await before?.();
|
|
82
|
+
deps.log(`homeflare/linux sudo -n ${shown}`);
|
|
83
|
+
const result = await base.exec([SUDO, '-n', '--', ...argv]);
|
|
84
|
+
const refused = sudoRefusal(shown, result);
|
|
85
|
+
if (refused !== undefined) throw refused;
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
checkWrite: async (path, options) => {
|
|
91
|
+
await vetWrite(base, prefixes, path, options);
|
|
92
|
+
},
|
|
93
|
+
effectiveUid: () => base.effectiveUid(),
|
|
94
|
+
exec: async (argv) => {
|
|
95
|
+
const route = routeExec(argv, prefixes);
|
|
96
|
+
if ('refuse' in route) throw new SudoRefusedError(`sshSudoRunner: ${route.refuse}`);
|
|
97
|
+
if (route.as === 'operator') return base.exec(argv);
|
|
98
|
+
return elevatedExec(base, elevate, prefixes, argv);
|
|
99
|
+
},
|
|
100
|
+
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
101
|
+
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
102
|
+
privileged: true,
|
|
103
|
+
readFile: (path) => base.readFile(path),
|
|
104
|
+
removeFile: async (path) => {
|
|
105
|
+
const prefix = prefixOf(path, prefixes);
|
|
106
|
+
if (prefix === undefined)
|
|
107
|
+
return base.removeFile(path).catch(outsidePrefixHint(path, prefixes));
|
|
108
|
+
// ★ Nothing there is success without a privileged call, so an idempotent delete logs nothing.
|
|
109
|
+
const target = await assertGuardedChain(base, prefix, path, 'file-or-absent');
|
|
110
|
+
if (target === undefined) return;
|
|
111
|
+
const argv = [RM, '-f', '--', path];
|
|
112
|
+
const result = await elevate(argv);
|
|
113
|
+
if (result.exitCode !== 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`sudo -n ${JSON.stringify(argv)} -> ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 300)}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
sleep: (ms) => base.sleep(ms),
|
|
120
|
+
stat: (path) => base.stat(path),
|
|
121
|
+
writeFileAtomic: async (path, bytes, options) => {
|
|
122
|
+
const vetted = await vetWrite(base, prefixes, path, options);
|
|
123
|
+
if (vetted === undefined) {
|
|
124
|
+
return base.writeFileAtomic(path, bytes, options).catch(outsidePrefixHint(path, prefixes));
|
|
125
|
+
}
|
|
126
|
+
await writeUnderPrefix(base, elevate, deps.stage, path, bytes, options, vetted.mode);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const REQUIRED_PROGRAMS = [SUDO, INSTALL, MV, RM, MKDIR, CHMOD, CHOWN, RMDIR, SYSTEMCTL_ABS];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* ★ READ-ONLY, OPTIONAL. One combined `test -x` so a missing coreutils package or a systemd-free
|
|
135
|
+
* container fails at construction with a clear message, instead of at the first deploy step
|
|
136
|
+
* that happens to need it. Never probes sudo itself.
|
|
137
|
+
*/
|
|
138
|
+
const checkProgramsExist = async (base: HostRunner, host: string): Promise<void> => {
|
|
139
|
+
const script = REQUIRED_PROGRAMS.map(
|
|
140
|
+
(p) => `test -x '${p}' || { echo "$0: ${p}"; exit 1; }`,
|
|
141
|
+
).join(' && ');
|
|
142
|
+
const result = await base.exec(['/bin/sh', '-c', script, 'sshSudoRunner']);
|
|
143
|
+
if (result.exitCode !== 0) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`sshSudoRunner ${host}: ${result.stdout.trim() || result.stderr.trim()} is missing or not ` +
|
|
146
|
+
'executable on this host. sshSudoRunner sends absolute GNU coreutils and systemd paths.',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** The real sudo runner: `sshRunner()` for everything, `/usr/bin/sudo -n` for the allowlist. */
|
|
152
|
+
export const sshSudoRunner = async (options: SshSudoRunnerOptions): Promise<HostRunner> => {
|
|
153
|
+
const base = await sshRunner(options);
|
|
154
|
+
await checkProgramsExist(base, options.host);
|
|
155
|
+
return makeSshSudoRunner(options.prefixes, {
|
|
156
|
+
base,
|
|
157
|
+
log:
|
|
158
|
+
options.log ??
|
|
159
|
+
((line) => {
|
|
160
|
+
process.stderr.write(`${line}\n`);
|
|
161
|
+
}),
|
|
162
|
+
stage: stageRemote(base),
|
|
163
|
+
});
|
|
164
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where sudo-runner.ts puts a file's bytes before `install` copies them into a fresh temp file
|
|
3
|
+
* that `mv` then renames into place. The Linux twin of `../launchd/sudo-stage.ts` — rewritten,
|
|
4
|
+
* not reused, because staging happens ON THE REMOTE HOST through the base runner's own calls,
|
|
5
|
+
* never through `node:fs` against this process's local disk.
|
|
6
|
+
*
|
|
7
|
+
* ★ WHY STAGE AT ALL. The operator cannot write inside a root-owned directory, and the bytes must
|
|
8
|
+
* not travel through argv (they would reach the log and `ps`). So they are written, as the
|
|
9
|
+
* operator, to a private file on the SAME host, and `install` copies that one file.
|
|
10
|
+
* ⛔ A 0700 DIRECTORY, A 0600 FILE: between the write and the install, nobody but the operator and
|
|
11
|
+
* root can read the bytes or swap the file. `mktemp -d` creates its directory 0700 by
|
|
12
|
+
* construction (GNU coreutils); the payload's own mode is asked for explicitly.
|
|
13
|
+
*/
|
|
14
|
+
import type { HostRunner } from '../launchd/runner.ts';
|
|
15
|
+
|
|
16
|
+
/** A file staged for `install`, and how to remove it again. */
|
|
17
|
+
export type Staged = { readonly path: string; readonly dispose: () => Promise<void> };
|
|
18
|
+
|
|
19
|
+
const refuse = (message: string): Error => new Error(`sshSudoRunner stage: ${message}`);
|
|
20
|
+
|
|
21
|
+
/** `stage` in `SshSudoDeps` — closes over the base runner so every call writes to the same host. */
|
|
22
|
+
export const stageRemote =
|
|
23
|
+
(base: HostRunner) =>
|
|
24
|
+
async (bytes: Uint8Array): Promise<Staged> => {
|
|
25
|
+
const made = await base.exec(['/usr/bin/mktemp', '-d']);
|
|
26
|
+
if (made.exitCode !== 0)
|
|
27
|
+
throw refuse(`mktemp -d -> ${String(made.exitCode)}: ${made.stderr.trim()}`);
|
|
28
|
+
const dir = made.stdout.trim();
|
|
29
|
+
if (dir === '' || !dir.startsWith('/')) {
|
|
30
|
+
throw refuse(`mktemp -d printed ${JSON.stringify(made.stdout)}, not an absolute path`);
|
|
31
|
+
}
|
|
32
|
+
const dispose = async () => {
|
|
33
|
+
const removed = await base.exec(['/usr/bin/rm', '-rf', '--', dir]);
|
|
34
|
+
// ⚠️ Best-effort: a leaked 0700 temp dir the operator owns is a nuisance, never a secret
|
|
35
|
+
// exposure, so disposal failing never masks the write's own success or failure.
|
|
36
|
+
if (removed.exitCode !== 0) {
|
|
37
|
+
process.stderr.write(
|
|
38
|
+
`sshSudoRunner: could not remove staging dir ${dir}: ${removed.stderr.trim()}\n`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
const path = `${dir}/staged`;
|
|
44
|
+
await base.writeFileAtomic(path, bytes, { mode: 0o600 });
|
|
45
|
+
return { dispose, path };
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
await dispose();
|
|
48
|
+
throw cause;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner's `writeFileAtomic`, split out of sudo-runner.ts so that file stays the wiring.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ WHY NOT `install` STRAIGHT TO `path`, THE WAY THE MAC RUNNER DOES. GNU `install` opens the
|
|
5
|
+
* destination and writes THROUGH it (ssh-scripts.ts's header); there is no macOS-style stage-
|
|
6
|
+
* and-rename built in. So this stages the bytes, `install`s them into a FRESH temp file in the
|
|
7
|
+
* destination's own directory (fresh, so "writes through" changes nothing that mattered), then
|
|
8
|
+
* `mv`s that temp file onto `path` — rename(2), same directory, so the swap is atomic and never
|
|
9
|
+
* torn, exactly what `ssh-scripts.ts writeScript` already does for the unprivileged case.
|
|
10
|
+
* ⛔ `install` NEVER TAKES `-o`/`-g` — see sudo-allowlist-chown.ts's header (coreutils' own
|
|
11
|
+
* `get_ids()` has no way to force numeric ids, unlike `chown`). It always installs owned by
|
|
12
|
+
* whoever `sudo` runs it as; a non-root owner or group is set afterward by a separate,
|
|
13
|
+
* `+`-forced `chown` on that same temp file, before `mv`.
|
|
14
|
+
* ⛔ THE DERIVED TEMP IS VERIFIED ABSENT, AS THE OPERATOR, BEFORE `install` RUNS. A 12-hex-char
|
|
15
|
+
* collision is astronomically unlikely, but "unlikely" is not the bar a privileged write is held
|
|
16
|
+
* to elsewhere in this runner, and the check costs one `stat` this runner already knows how to do.
|
|
17
|
+
* ⛔ ON ANY FAILURE AFTER `install` SUCCEEDS, THE TEMP IS REMOVED (also as root, since only root
|
|
18
|
+
* can now touch it). A left-behind `.foo.hf-xxxx.tmp` would read, on the next plan, as an extra
|
|
19
|
+
* file under the prefix that guard would find and no code would explain — and if THAT cleanup
|
|
20
|
+
* itself fails, the thrown error says so and names the path, rather than silently swallowing it.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
23
|
+
import type { ExecResult, HostRunner, WriteOptions } from '../launchd/runner.ts';
|
|
24
|
+
import { CHOWN, INSTALL, MV, RM, SudoRefusedError, octalMode, prefixOf } from './sudo-allowlist.ts';
|
|
25
|
+
import { assertInstallable, operatorGroups } from './sudo-guard.ts';
|
|
26
|
+
import type { Staged } from './sudo-stage.ts';
|
|
27
|
+
|
|
28
|
+
const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
|
|
29
|
+
const dirOf = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/';
|
|
30
|
+
|
|
31
|
+
/** `<dir>/.<basename>.hf-<12 hex>.tmp`, in `dest`'s own directory — never anywhere else. */
|
|
32
|
+
export const deriveTemp = (dest: string): string =>
|
|
33
|
+
`${dirOf(dest)}/.${basename(dest)}.hf-${randomBytes(6).toString('hex')}.tmp`;
|
|
34
|
+
|
|
35
|
+
/** What one privileged call, already checked against the allowlist, comes back as when it fails. */
|
|
36
|
+
const mustSucceed = (result: ExecResult, argv: readonly string[]): void => {
|
|
37
|
+
if (result.exitCode !== 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`sudo -n ${JSON.stringify(argv)} -> ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 300)}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The cleanup `rm`'s own failure, for the merged error below. 🔴 MEASURED (adversarial review,
|
|
46
|
+
* round 3, 2026-09-23): a plain `ExecResult` (the shape `elevate()` resolves with for a command
|
|
47
|
+
* that RAN but exited non-zero) has no useful `toString()` — `String(cleaned)` printed
|
|
48
|
+
* `[object Object]`, silently dropping the one thing an operator doing manual cleanup needs:
|
|
49
|
+
* what `rm` actually said. A thrown `Error` (a refused or transport-failed `rm`) still prints
|
|
50
|
+
* its own message correctly, so only the `ExecResult` shape needed a name.
|
|
51
|
+
*/
|
|
52
|
+
const describeCleanupFailure = (cleaned: unknown): string => {
|
|
53
|
+
if (
|
|
54
|
+
typeof cleaned === 'object' &&
|
|
55
|
+
cleaned !== null &&
|
|
56
|
+
'exitCode' in cleaned &&
|
|
57
|
+
'stderr' in cleaned
|
|
58
|
+
) {
|
|
59
|
+
const result = cleaned as ExecResult;
|
|
60
|
+
return `exit ${String(result.exitCode)}: ${result.stderr.trim().slice(0, 300)}`;
|
|
61
|
+
}
|
|
62
|
+
return cleaned instanceof Error ? cleaned.message : String(cleaned);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A privileged call: allowlisted, logged, run. Shared with sudo-runner.ts's own exec() routing. */
|
|
66
|
+
export type Elevate = (
|
|
67
|
+
argv: readonly string[],
|
|
68
|
+
context?: { readonly staged?: string; readonly temp?: string },
|
|
69
|
+
before?: () => Promise<unknown>,
|
|
70
|
+
) => Promise<ExecResult>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Plan-time and write-time share this: valid mode, the path chain, mode/owner refusals and the
|
|
74
|
+
* read-back — everything a write can be refused for before anything is staged or run as root.
|
|
75
|
+
* `undefined` outside every prefix, where the caller falls back to `base` directly.
|
|
76
|
+
*/
|
|
77
|
+
export const vetWrite = async (
|
|
78
|
+
base: HostRunner,
|
|
79
|
+
prefixes: readonly string[],
|
|
80
|
+
path: string,
|
|
81
|
+
options: WriteOptions,
|
|
82
|
+
): Promise<{ readonly mode: string; readonly prefix: string } | undefined> => {
|
|
83
|
+
const prefix = prefixOf(path, prefixes);
|
|
84
|
+
if (prefix === undefined) return undefined;
|
|
85
|
+
const mode = octalMode(options.mode);
|
|
86
|
+
if (mode === undefined)
|
|
87
|
+
throw new SudoRefusedError(`sshSudoRunner ${path}: mode must be 0–0o7777`);
|
|
88
|
+
const groups = await operatorGroups(base);
|
|
89
|
+
await assertInstallable(base, prefix, path, options, groups);
|
|
90
|
+
return { mode, prefix };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** `+<uid>`, `:+<gid>`, or `+<uid>:+<gid>` — omitted sides leave that half of ownership alone. */
|
|
94
|
+
const safeOwnerSpec = (uid: number | undefined, gid: number | undefined): string =>
|
|
95
|
+
`${uid === undefined ? '' : `+${uid}`}${gid === undefined ? '' : `:+${gid}`}`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A non-root owner or group, when one was declared: `install` leaves the temp root:root (see the
|
|
99
|
+
* file header), so this is the ONLY call that can hand it to someone else. `undefined` from either
|
|
100
|
+
* side when that half already matches `install`'s own default (0/omitted), so a plain root-owned
|
|
101
|
+
* write costs nothing extra here.
|
|
102
|
+
*/
|
|
103
|
+
const chownNeeded = (
|
|
104
|
+
options: WriteOptions,
|
|
105
|
+
): { readonly uid?: number; readonly gid?: number } | undefined => {
|
|
106
|
+
const uid = options.uid !== undefined && options.uid !== 0 ? options.uid : undefined;
|
|
107
|
+
const gid = options.gid !== undefined && options.gid !== 0 ? options.gid : undefined;
|
|
108
|
+
return uid === undefined && gid === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: { ...(uid === undefined ? {} : { uid }), ...(gid === undefined ? {} : { gid }) };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stage, `install` into a fresh temp file beside `path`, `chown` it if a non-root owner was
|
|
115
|
+
* declared, `mv` it into place. Cleans up the temp on a failed `mv`, and always disposes the
|
|
116
|
+
* stage — whether or not the write succeeded.
|
|
117
|
+
*/
|
|
118
|
+
export const writeUnderPrefix = async (
|
|
119
|
+
base: HostRunner,
|
|
120
|
+
elevate: Elevate,
|
|
121
|
+
stage: (bytes: Uint8Array) => Promise<Staged>,
|
|
122
|
+
path: string,
|
|
123
|
+
bytes: Uint8Array,
|
|
124
|
+
options: WriteOptions,
|
|
125
|
+
mode: string,
|
|
126
|
+
): Promise<void> => {
|
|
127
|
+
const temp = deriveTemp(path);
|
|
128
|
+
const staged = await stage(bytes);
|
|
129
|
+
try {
|
|
130
|
+
const installArgv = [INSTALL, '-m', mode, '-T', '--', staged.path, temp];
|
|
131
|
+
const before = async () => {
|
|
132
|
+
const existing = await base.stat(temp);
|
|
133
|
+
if (existing !== undefined) {
|
|
134
|
+
throw new SudoRefusedError(
|
|
135
|
+
`sshSudoRunner ${temp}: already exists (a ${existing.kind}); refusing to install over ` +
|
|
136
|
+
'it. Nothing ran as root.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
mustSucceed(await elevate(installArgv, { staged: staged.path, temp }, before), installArgv);
|
|
141
|
+
try {
|
|
142
|
+
const owner = chownNeeded(options);
|
|
143
|
+
if (owner !== undefined) {
|
|
144
|
+
const chownArgv = [CHOWN, safeOwnerSpec(owner.uid, owner.gid), '--', temp];
|
|
145
|
+
mustSucceed(await elevate(chownArgv, { temp }), chownArgv);
|
|
146
|
+
}
|
|
147
|
+
const mvArgv = [MV, '-f', '-T', '--', temp, path];
|
|
148
|
+
mustSucceed(await elevate(mvArgv, { temp }), mvArgv);
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
const rmArgv = [RM, '-f', '--', temp];
|
|
151
|
+
const cleaned = await elevate(rmArgv, { temp })
|
|
152
|
+
.then((result) => (result.exitCode === 0 ? undefined : result))
|
|
153
|
+
.catch((rmCause: unknown) => rmCause);
|
|
154
|
+
if (cleaned !== undefined) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`${cause instanceof Error ? cause.message : String(cause)} — AND removing the leftover ` +
|
|
157
|
+
`temp ${temp} also failed (${describeCleanupFailure(cleaned)}); it needs manual ` +
|
|
158
|
+
'cleanup as root.',
|
|
159
|
+
{ cause },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
throw cause;
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
await staged.dispose();
|
|
166
|
+
}
|
|
167
|
+
};
|
package/src/linux/unit-form.ts
CHANGED
|
@@ -155,7 +155,8 @@ export const unitText = (props: SystemdUnitProps): string =>
|
|
|
155
155
|
export const unitPathFor = (props: SystemdUnitProps): string =>
|
|
156
156
|
`${props.directory ?? DEFAULT_UNIT_DIRECTORY}/${props.name}`;
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
/** ★ Exported so sudo-allowlist.ts validates a systemctl unit operand against the same pattern. */
|
|
159
|
+
export const NAME = /^[A-Za-z0-9][A-Za-z0-9_.@:-]*\.([a-z]+)$/;
|
|
159
160
|
/** systemd's own unit types. ★ Fixed vocabulary from `systemd.unit(5)`, not a guess at directives. */
|
|
160
161
|
const TYPES = new Set([
|
|
161
162
|
'automount',
|
|
@@ -48,6 +48,8 @@ export const startFakeLitellm = (options?: {
|
|
|
48
48
|
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status });
|
|
49
49
|
|
|
50
50
|
const server = Bun.serve({
|
|
51
|
+
// ⚠️ Loopback, not the wildcard default — see proxmox/fake-pve-lxc.ts for the race.
|
|
52
|
+
hostname: '127.0.0.1',
|
|
51
53
|
port: 0,
|
|
52
54
|
fetch: async (req) => {
|
|
53
55
|
const url = new URL(req.url);
|
|
@@ -12,7 +12,11 @@ import { PveError, pveWith } from './client.ts';
|
|
|
12
12
|
import type { PveCredential, PveTarget } from './credentials.ts';
|
|
13
13
|
import { orderedMembers, resetLastGoodForTest } from './members.ts';
|
|
14
14
|
|
|
15
|
-
type ServeOptions = {
|
|
15
|
+
type ServeOptions = {
|
|
16
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
17
|
+
hostname: string;
|
|
18
|
+
port: number;
|
|
19
|
+
};
|
|
16
20
|
const { Bun } = globalThis as unknown as {
|
|
17
21
|
Bun: { serve(options: ServeOptions): { port: number; stop(closeActive: boolean): void } };
|
|
18
22
|
};
|
|
@@ -84,6 +88,8 @@ const counting = (data: unknown) => {
|
|
|
84
88
|
hits += 1;
|
|
85
89
|
return Response.json({ data });
|
|
86
90
|
},
|
|
91
|
+
// ⚠️ Loopback, not the wildcard default — see fake-pve-lxc.ts for the measured race.
|
|
92
|
+
hostname: '127.0.0.1',
|
|
87
93
|
port: 0,
|
|
88
94
|
});
|
|
89
95
|
return { hits: () => hits, port: server.port, stop: () => server.stop(true) };
|
|
@@ -106,7 +112,11 @@ describe('cluster member failover', () => {
|
|
|
106
112
|
|
|
107
113
|
it('does not fail over on HTTP 500 from the first member', async () => {
|
|
108
114
|
resetLastGoodForTest();
|
|
109
|
-
const bad = Bun.serve({
|
|
115
|
+
const bad = Bun.serve({
|
|
116
|
+
fetch: () => new Response('fail', { status: 500 }),
|
|
117
|
+
hostname: '127.0.0.1',
|
|
118
|
+
port: 0,
|
|
119
|
+
});
|
|
110
120
|
const good = counting([]);
|
|
111
121
|
try {
|
|
112
122
|
const error = await run(pveWith(target([A, B]), CRED, 'GET', 'pools').pipe(Effect.flip), {
|
|
@@ -144,7 +154,11 @@ describe('cluster member failover', () => {
|
|
|
144
154
|
|
|
145
155
|
it('does not re-send a write after a transport timeout', async () => {
|
|
146
156
|
resetLastGoodForTest();
|
|
147
|
-
const hang = Bun.serve({
|
|
157
|
+
const hang = Bun.serve({
|
|
158
|
+
fetch: () => new Promise<Response>(() => {}),
|
|
159
|
+
hostname: '127.0.0.1',
|
|
160
|
+
port: 0,
|
|
161
|
+
});
|
|
148
162
|
const backup = counting('UPID:1');
|
|
149
163
|
try {
|
|
150
164
|
await run(
|