@homeflare/alchemy 0.25.1 → 0.27.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 (118) hide show
  1. package/dist/github/declare-repo-baseline.d.ts +11 -0
  2. package/dist/github/declare-repo-baseline.d.ts.map +1 -0
  3. package/dist/github/index.d.ts +20 -11
  4. package/dist/github/index.d.ts.map +1 -1
  5. package/dist/github/index.js +715 -4
  6. package/dist/github/index.js.map +14 -4
  7. package/dist/github/repo-baseline-data.d.ts +75 -0
  8. package/dist/github/repo-baseline-data.d.ts.map +1 -0
  9. package/dist/github/repo-policy.d.ts +4 -4
  10. package/dist/github/repo-policy.d.ts.map +1 -1
  11. package/dist/github/repository-ruleset-constraints.d.ts +105 -0
  12. package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
  13. package/dist/github/repository-ruleset-errors.d.ts +97 -0
  14. package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
  15. package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
  16. package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
  17. package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
  18. package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
  19. package/dist/github/repository-ruleset-form.d.ts +30 -0
  20. package/dist/github/repository-ruleset-form.d.ts.map +1 -0
  21. package/dist/github/repository-ruleset-guards.d.ts +70 -0
  22. package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
  23. package/dist/github/repository-ruleset-octokit.d.ts +5 -0
  24. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
  25. package/dist/github/repository-ruleset-probe.d.ts +87 -0
  26. package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
  27. package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
  28. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
  29. package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
  30. package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
  31. package/dist/github/repository-ruleset.d.ts +89 -0
  32. package/dist/github/repository-ruleset.d.ts.map +1 -0
  33. package/dist/launchd/index.js +51 -47
  34. package/dist/launchd/index.js.map +5 -4
  35. package/dist/launchd/sudo-guard-mode.d.ts +62 -0
  36. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
  37. package/dist/launchd/sudo-guard.d.ts +1 -38
  38. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  39. package/dist/linux/fake-sudo-root.d.ts +26 -0
  40. package/dist/linux/fake-sudo-root.d.ts.map +1 -0
  41. package/dist/linux/fake-sudo.d.ts +56 -0
  42. package/dist/linux/fake-sudo.d.ts.map +1 -0
  43. package/dist/linux/index.d.ts +2 -0
  44. package/dist/linux/index.d.ts.map +1 -1
  45. package/dist/linux/index.js +1134 -2
  46. package/dist/linux/index.js.map +22 -5
  47. package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
  48. package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
  49. package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
  50. package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
  51. package/dist/linux/sudo-allowlist.d.ts +40 -0
  52. package/dist/linux/sudo-allowlist.d.ts.map +1 -0
  53. package/dist/linux/sudo-exec.d.ts +31 -0
  54. package/dist/linux/sudo-exec.d.ts.map +1 -0
  55. package/dist/linux/sudo-guard.d.ts +36 -0
  56. package/dist/linux/sudo-guard.d.ts.map +1 -0
  57. package/dist/linux/sudo-runner.d.ts +37 -0
  58. package/dist/linux/sudo-runner.d.ts.map +1 -0
  59. package/dist/linux/sudo-stage.d.ts +22 -0
  60. package/dist/linux/sudo-stage.d.ts.map +1 -0
  61. package/dist/linux/sudo-write.d.ts +25 -0
  62. package/dist/linux/sudo-write.d.ts.map +1 -0
  63. package/dist/linux/unit-form.d.ts +2 -0
  64. package/dist/linux/unit-form.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  66. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  67. package/docs/launchd-sudo.md +12 -3
  68. package/docs/linux-host.md +3 -3
  69. package/docs/linux-sudo.md +187 -0
  70. package/docs/repo-policy-ruleset-hazards.md +8 -0
  71. package/docs/repo-policy.md +27 -12
  72. package/docs/repository-ruleset.md +114 -0
  73. package/docs/upstream-conformance.md +12 -1
  74. package/docs/upstream-pr-ruleset-fixes.md +77 -0
  75. package/package.json +1 -1
  76. package/src/github/declare-repo-baseline.ts +42 -0
  77. package/src/github/index.ts +43 -11
  78. package/src/github/repo-baseline-data.test.ts +76 -0
  79. package/src/github/repo-baseline-data.ts +86 -0
  80. package/src/github/repo-policy.ts +17 -17
  81. package/src/github/repository-ruleset-constraints.ts +136 -0
  82. package/src/github/repository-ruleset-coverage.test.ts +82 -0
  83. package/src/github/repository-ruleset-errors.ts +136 -0
  84. package/src/github/repository-ruleset-fake-octokit.ts +131 -0
  85. package/src/github/repository-ruleset-fixtures.ts +183 -0
  86. package/src/github/repository-ruleset-form.ts +212 -0
  87. package/src/github/repository-ruleset-guards.ts +194 -0
  88. package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
  89. package/src/github/repository-ruleset-octokit.ts +140 -0
  90. package/src/github/repository-ruleset-probe.ts +116 -0
  91. package/src/github/repository-ruleset-reconcile.ts +176 -0
  92. package/src/github/repository-ruleset-test-helpers.ts +102 -0
  93. package/src/github/repository-ruleset-wire.test.ts +66 -0
  94. package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
  95. package/src/github/repository-ruleset-write.test.ts +83 -0
  96. package/src/github/repository-ruleset.ts +219 -0
  97. package/src/launchd/sudo-guard-mode.ts +111 -0
  98. package/src/launchd/sudo-guard.ts +3 -79
  99. package/src/launchd/sudo-modes.test.ts +38 -1
  100. package/src/linux/fake-sudo-root.ts +157 -0
  101. package/src/linux/fake-sudo.ts +142 -0
  102. package/src/linux/index.ts +2 -0
  103. package/src/linux/sudo-allowlist-chown.ts +71 -0
  104. package/src/linux/sudo-allowlist-dir.ts +103 -0
  105. package/src/linux/sudo-allowlist.test.ts +252 -0
  106. package/src/linux/sudo-allowlist.ts +223 -0
  107. package/src/linux/sudo-exec.ts +118 -0
  108. package/src/linux/sudo-guard.test.ts +136 -0
  109. package/src/linux/sudo-guard.ts +142 -0
  110. package/src/linux/sudo-lifecycle.test.ts +151 -0
  111. package/src/linux/sudo-runner.test.ts +206 -0
  112. package/src/linux/sudo-runner.ts +164 -0
  113. package/src/linux/sudo-stage.ts +50 -0
  114. package/src/linux/sudo-write.ts +167 -0
  115. package/src/linux/unit-form.ts +2 -1
  116. package/src/litellm/fake-litellm.ts +2 -0
  117. package/src/proxmox/client.test.ts +17 -3
  118. package/src/proxmox/fake-pve-lxc.ts +10 -1
@@ -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(
@@ -185,7 +185,16 @@ export const fakePve = (): FakePve => {
185
185
  return json({ data: null, message: 'unhandled\n' }, 501);
186
186
  };
187
187
 
188
- const server = Bun.serve({ fetch: handle, port: 0 });
188
+ // 🔴 LOOPBACK, NOT THE DEFAULT WILDCARD — AND THAT WAS THE FLAKE. Measured 2026-09-23 on
189
+ // macOS: `Bun.serve({ port: 0 })` binds 0.0.0.0, and the kernel will hand a wildcard
190
+ // port-0 bind a port some OTHER socket already holds on 127.0.0.1 (16,373 binds in, it
191
+ // did). Loopback traffic then goes to the more specific listener, so this fake's own
192
+ // requests reached another process's server — another test suite's fake, when several
193
+ // agents run `bun test` at once — and a plan failed with "Transport error" on a GET to a
194
+ // server that was up. A 127.0.0.1 port-0 bind is never handed a port in use on
195
+ // 127.0.0.1, and nothing more specific can shadow it. tests/loopback-servers.test.ts
196
+ // keeps every test server this way.
197
+ const server = Bun.serve({ fetch: handle, hostname: '127.0.0.1', port: 0 });
189
198
  const port = String(server.port);
190
199
  const remapped = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
191
200
  const url = new URL(input instanceof Request ? input.url : String(input));