@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.
Files changed (60) hide show
  1. package/dist/launchd/index.js +51 -47
  2. package/dist/launchd/index.js.map +5 -4
  3. package/dist/launchd/sudo-guard-mode.d.ts +62 -0
  4. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
  5. package/dist/launchd/sudo-guard.d.ts +1 -38
  6. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  7. package/dist/linux/fake-sudo-root.d.ts +26 -0
  8. package/dist/linux/fake-sudo-root.d.ts.map +1 -0
  9. package/dist/linux/fake-sudo.d.ts +56 -0
  10. package/dist/linux/fake-sudo.d.ts.map +1 -0
  11. package/dist/linux/index.d.ts +2 -0
  12. package/dist/linux/index.d.ts.map +1 -1
  13. package/dist/linux/index.js +1134 -2
  14. package/dist/linux/index.js.map +22 -5
  15. package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
  16. package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
  17. package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
  18. package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
  19. package/dist/linux/sudo-allowlist.d.ts +40 -0
  20. package/dist/linux/sudo-allowlist.d.ts.map +1 -0
  21. package/dist/linux/sudo-exec.d.ts +31 -0
  22. package/dist/linux/sudo-exec.d.ts.map +1 -0
  23. package/dist/linux/sudo-guard.d.ts +36 -0
  24. package/dist/linux/sudo-guard.d.ts.map +1 -0
  25. package/dist/linux/sudo-runner.d.ts +37 -0
  26. package/dist/linux/sudo-runner.d.ts.map +1 -0
  27. package/dist/linux/sudo-stage.d.ts +22 -0
  28. package/dist/linux/sudo-stage.d.ts.map +1 -0
  29. package/dist/linux/sudo-write.d.ts +25 -0
  30. package/dist/linux/sudo-write.d.ts.map +1 -0
  31. package/dist/linux/unit-form.d.ts +2 -0
  32. package/dist/linux/unit-form.d.ts.map +1 -1
  33. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  34. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  35. package/docs/launchd-sudo.md +12 -3
  36. package/docs/linux-host.md +3 -3
  37. package/docs/linux-sudo.md +187 -0
  38. package/package.json +1 -1
  39. package/src/launchd/sudo-guard-mode.ts +111 -0
  40. package/src/launchd/sudo-guard.ts +3 -79
  41. package/src/launchd/sudo-modes.test.ts +38 -1
  42. package/src/linux/fake-sudo-root.ts +157 -0
  43. package/src/linux/fake-sudo.ts +142 -0
  44. package/src/linux/index.ts +2 -0
  45. package/src/linux/sudo-allowlist-chown.ts +71 -0
  46. package/src/linux/sudo-allowlist-dir.ts +103 -0
  47. package/src/linux/sudo-allowlist.test.ts +252 -0
  48. package/src/linux/sudo-allowlist.ts +223 -0
  49. package/src/linux/sudo-exec.ts +118 -0
  50. package/src/linux/sudo-guard.test.ts +136 -0
  51. package/src/linux/sudo-guard.ts +142 -0
  52. package/src/linux/sudo-lifecycle.test.ts +151 -0
  53. package/src/linux/sudo-runner.test.ts +206 -0
  54. package/src/linux/sudo-runner.ts +164 -0
  55. package/src/linux/sudo-stage.ts +50 -0
  56. package/src/linux/sudo-write.ts +167 -0
  57. package/src/linux/unit-form.ts +2 -1
  58. package/src/litellm/fake-litellm.ts +2 -0
  59. package/src/proxmox/client.test.ts +17 -3
  60. 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
+ };
@@ -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
- const NAME = /^[A-Za-z0-9][A-Za-z0-9_.@:-]*\.([a-z]+)$/;
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 = { fetch(request: Request): Response | Promise<Response>; port: number };
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({ fetch: () => new Response('fail', { status: 500 }), port: 0 });
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({ fetch: () => new Promise<Response>(() => {}), port: 0 });
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(