@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.
- package/dist/github/declare-repo-baseline.d.ts +11 -0
- package/dist/github/declare-repo-baseline.d.ts.map +1 -0
- package/dist/github/index.d.ts +20 -11
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +715 -4
- package/dist/github/index.js.map +14 -4
- package/dist/github/repo-baseline-data.d.ts +75 -0
- package/dist/github/repo-baseline-data.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +4 -4
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-constraints.d.ts +105 -0
- package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
- package/dist/github/repository-ruleset-errors.d.ts +97 -0
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
- package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
- package/dist/github/repository-ruleset-form.d.ts +30 -0
- package/dist/github/repository-ruleset-form.d.ts.map +1 -0
- package/dist/github/repository-ruleset-guards.d.ts +70 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +5 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-probe.d.ts +87 -0
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +89 -0
- package/dist/github/repository-ruleset.d.ts.map +1 -0
- 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/docs/repo-policy-ruleset-hazards.md +8 -0
- package/docs/repo-policy.md +27 -12
- package/docs/repository-ruleset.md +114 -0
- package/docs/upstream-conformance.md +12 -1
- package/docs/upstream-pr-ruleset-fixes.md +77 -0
- package/package.json +1 -1
- package/src/github/declare-repo-baseline.ts +42 -0
- package/src/github/index.ts +43 -11
- package/src/github/repo-baseline-data.test.ts +76 -0
- package/src/github/repo-baseline-data.ts +86 -0
- package/src/github/repo-policy.ts +17 -17
- package/src/github/repository-ruleset-constraints.ts +136 -0
- package/src/github/repository-ruleset-coverage.test.ts +82 -0
- package/src/github/repository-ruleset-errors.ts +136 -0
- package/src/github/repository-ruleset-fake-octokit.ts +131 -0
- package/src/github/repository-ruleset-fixtures.ts +183 -0
- package/src/github/repository-ruleset-form.ts +212 -0
- package/src/github/repository-ruleset-guards.ts +194 -0
- package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
- package/src/github/repository-ruleset-octokit.ts +140 -0
- package/src/github/repository-ruleset-probe.ts +116 -0
- package/src/github/repository-ruleset-reconcile.ts +176 -0
- package/src/github/repository-ruleset-test-helpers.ts +102 -0
- package/src/github/repository-ruleset-wire.test.ts +66 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
- package/src/github/repository-ruleset-write.test.ts +83 -0
- package/src/github/repository-ruleset.ts +219 -0
- 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,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(
|
|
@@ -185,7 +185,16 @@ export const fakePve = (): FakePve => {
|
|
|
185
185
|
return json({ data: null, message: 'unhandled\n' }, 501);
|
|
186
186
|
};
|
|
187
187
|
|
|
188
|
-
|
|
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));
|