@homeflare/alchemy 0.7.0 → 0.8.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/README.md +26 -37
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +71 -13
- package/dist/caddy/index.js.map +9 -9
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +342 -8
- package/dist/launchd/index.js.map +15 -11
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +56 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +359 -280
- package/dist/openbao/index.js.map +21 -19
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/peers.md +39 -0
- package/package.json +1 -1
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -11
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +7 -5
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +65 -37
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +35 -14
- package/src/openbao/cloudflare-role.ts +6 -14
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy.ts +7 -7
- package/src/openbao/proxmox-role.ts +7 -16
- package/src/openbao/rename-adoption.test.ts +82 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* · live ≠ stored — DRIFT: a hand `curl` to the API, or a restart that loaded a different file
|
|
10
10
|
* (or an `autosave.json` holding someone else's change, under `--resume`).
|
|
11
11
|
* · declared = live — nothing to load, whatever the state says.
|
|
12
|
+
* With NO state there is no stored digest, and the question is whose config is running: see
|
|
13
|
+
* `claimable` below, which both the adoption probe and the apply ask.
|
|
12
14
|
*/
|
|
13
15
|
import type { Diff } from 'alchemy/Diff';
|
|
14
|
-
import type
|
|
16
|
+
import { type CaddyAdmin, CaddyUnreachableError } from './admin.ts';
|
|
15
17
|
import {
|
|
16
18
|
CaddyAdminError,
|
|
17
19
|
adaptCaddyfile,
|
|
@@ -66,16 +68,75 @@ export const desiredConfig = async (
|
|
|
66
68
|
return { digest: configDigest(adapted.config), warnings: adapted.warnings };
|
|
67
69
|
};
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
export const readLive = async (
|
|
71
|
+
const attributesOf = (
|
|
71
72
|
admin: CaddyAdmin,
|
|
73
|
+
running: unknown,
|
|
72
74
|
sourceFile?: string,
|
|
73
|
-
):
|
|
74
|
-
configSha256: configDigest(
|
|
75
|
+
): CaddyConfigAttributes => ({
|
|
76
|
+
configSha256: configDigest(running),
|
|
75
77
|
endpoint: admin.endpoint,
|
|
76
78
|
...(sourceFile === undefined ? {} : { sourceFile }),
|
|
77
79
|
});
|
|
78
80
|
|
|
81
|
+
/** What is running now, as attributes. `sourceFile` is carried over, never discovered. */
|
|
82
|
+
export const readLive = async (
|
|
83
|
+
admin: CaddyAdmin,
|
|
84
|
+
sourceFile?: string,
|
|
85
|
+
): Promise<CaddyConfigAttributes> =>
|
|
86
|
+
attributesOf(admin, await readRunningConfig(admin), sourceFile);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ⛔ WITH NO STATE, A RUNNING CONFIG IS THIS STACK'S ONLY IF IT IS THE DECLARED ONE (decision,
|
|
90
|
+
* 2026-09-21: CaddyConfig must not adopt silently — the house rule HostFile and LaunchdJob keep).
|
|
91
|
+
* Anything else was put there by a person, another tool or another stack, and loading over it
|
|
92
|
+
* replaces every site it serves. ★ A config that serves nothing (`null`, or no apps) is claimable
|
|
93
|
+
* too: there is nothing to take over, as an empty R2 lock rule set reads as no lock.
|
|
94
|
+
*/
|
|
95
|
+
export const claimable = (running: unknown, declaredDigest: string): boolean =>
|
|
96
|
+
servesNothing(running) || configDigest(running) === declaredDigest;
|
|
97
|
+
|
|
98
|
+
export type Probe = {
|
|
99
|
+
readonly attributes: CaddyConfigAttributes;
|
|
100
|
+
/** The live config IS the declared one: adopting it changes nothing. */
|
|
101
|
+
readonly ours: boolean;
|
|
102
|
+
/** Why the declaration could not be compared, when it could not — the Caddy is then not ours. */
|
|
103
|
+
readonly unchecked?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The adoption probe — `read` with no state: `undefined` when Caddy serves nothing (plan a create),
|
|
108
|
+
* else the live attributes and whether they are provably ours (the provider brands the rest
|
|
109
|
+
* `Unowned`, and the plan refuses them without `--adopt`).
|
|
110
|
+
* ⛔ IT NEVER THROWS OVER THE DECLARATION. The engine also runs this read to recover an interrupted
|
|
111
|
+
* create, with THAT deploy's props (Plan.ts, `status: "creating"`) — so a Caddyfile that failed to
|
|
112
|
+
* adapt then would fail every later plan, the one carrying the fix included. What cannot be
|
|
113
|
+
* compared is "not proven ours", with the reason; the next diff or apply validates the Caddyfile.
|
|
114
|
+
*/
|
|
115
|
+
export const probeLive = async (
|
|
116
|
+
admin: CaddyAdmin,
|
|
117
|
+
caddyfile: string | undefined,
|
|
118
|
+
sourceFile?: string,
|
|
119
|
+
): Promise<Probe | undefined> => {
|
|
120
|
+
const running = await readRunningConfig(admin);
|
|
121
|
+
if (servesNothing(running)) return undefined;
|
|
122
|
+
const attributes = attributesOf(admin, running, sourceFile);
|
|
123
|
+
if (caddyfile === undefined) {
|
|
124
|
+
return { attributes, ours: false, unchecked: 'the stored Caddyfile is not plain text' };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const want = await desiredConfig(admin, { caddyfile });
|
|
128
|
+
return { attributes, ours: claimable(running, want.digest) };
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
// ⚠️ Unreachable mid-probe is still "no Caddy" (config.ts plans without it), not "not ours".
|
|
131
|
+
if (cause instanceof CaddyUnreachableError) throw cause;
|
|
132
|
+
return {
|
|
133
|
+
attributes,
|
|
134
|
+
ours: false,
|
|
135
|
+
unchecked: cause instanceof Error ? cause.message : String(cause),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
79
140
|
/**
|
|
80
141
|
* ★ NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of
|
|
81
142
|
* the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business — see
|
|
@@ -94,6 +155,7 @@ export const diffConfig = async (
|
|
|
94
155
|
news.sourceFile === output.sourceFile &&
|
|
95
156
|
// ⚠️ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the
|
|
96
157
|
// old one keeps what it has — the same as a delete, which never unloads (see config.ts).
|
|
158
|
+
// Planned as an update, but the state does not vouch for that Caddy: see Authority.
|
|
97
159
|
admin.endpoint === output.endpoint;
|
|
98
160
|
return converged ? { action: 'noop' } : { action: 'update' };
|
|
99
161
|
};
|
|
@@ -106,13 +168,42 @@ export type Applied = {
|
|
|
106
168
|
readonly loaded: boolean;
|
|
107
169
|
};
|
|
108
170
|
|
|
109
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* What this apply may load over — config.ts decides from the state and the adopt setting:
|
|
173
|
+
* · `takeOver` — any running config: the state was applied to THIS Caddy (an update, drift
|
|
174
|
+
* correction, or a create the engine adopted), or adoption is on (`--adopt`, or the
|
|
175
|
+
* resource's own `adopt(true)` — host-effect.ts adoptEnabled).
|
|
176
|
+
* · otherwise only a claimable one — plus, as `stored`, the digest the state last recorded: a
|
|
177
|
+
* Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.
|
|
178
|
+
*/
|
|
179
|
+
export type Authority = { readonly takeOver: boolean; readonly stored?: string };
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.
|
|
183
|
+
* ⛔ WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption
|
|
184
|
+
* probe cannot guard every create: it is skipped while `news` holds an Output — ALWAYS on
|
|
185
|
+
* caddyWithFile's first deploy (`sourceFile` is its HostFile's path) — and a Caddy that was down at
|
|
186
|
+
* plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another
|
|
187
|
+
* endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.
|
|
188
|
+
*/
|
|
110
189
|
export const reconcileConfig = async (
|
|
111
190
|
admin: CaddyAdmin,
|
|
112
191
|
props: CaddyConfigProps,
|
|
192
|
+
authority: Authority,
|
|
113
193
|
): Promise<Applied> => {
|
|
114
194
|
const want = await desiredConfig(admin, props);
|
|
115
|
-
const
|
|
195
|
+
const running = await readRunningConfig(admin);
|
|
196
|
+
const before = configDigest(running);
|
|
197
|
+
const known = before === authority.stored || claimable(running, want.digest);
|
|
198
|
+
if (!authority.takeOver && !known) {
|
|
199
|
+
throw refuse(
|
|
200
|
+
admin,
|
|
201
|
+
`Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile adapts ` +
|
|
202
|
+
`to ${short(want.digest)} — loading it would replace every site that config serves. ` +
|
|
203
|
+
'Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy over, or ' +
|
|
204
|
+
'point caddyProviders() at the Caddy you meant.',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
116
207
|
let warnings = want.warnings;
|
|
117
208
|
const loaded = before !== want.digest;
|
|
118
209
|
if (loaded) {
|
package/src/caddy/config.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* - create / update — `POST /adapt` (validate), `POST /load` with `text/caddyfile` (a graceful
|
|
6
6
|
* reload; Caddy keeps the old config if it refuses the new one, and the error names why), then
|
|
7
7
|
* `GET /config/` and insist it matches. Skipped when Caddy already runs this config.
|
|
8
|
-
* - read — the digest of `GET /config/`.
|
|
9
|
-
* config becomes the starting point, and the deploy's forced update loads the declared one.
|
|
8
|
+
* - read — the digest of `GET /config/`. With no state it is the adoption probe (below).
|
|
10
9
|
* - diff — declared, live and stored digests (config-lifecycle.ts). Never `replace`.
|
|
11
10
|
* - delete — ⛔ NOTHING. See below.
|
|
12
11
|
*
|
|
@@ -15,17 +14,23 @@
|
|
|
15
14
|
* servers. `retain` is the default (the engine never calls delete); with `destroy` the handler
|
|
16
15
|
* still only forgets. It never calls `/stop` (which exits the process) or `DELETE /config/`.
|
|
17
16
|
* To empty a Caddy, do it by hand at its admin API.
|
|
17
|
+
* ⛔ NOTHING IS ADOPTED SILENTLY (decision, 2026-09-21 — the house rule HostFile and LaunchdJob
|
|
18
|
+
* keep). With no state, a running Caddy whose live config IS the declared one reads as ours (the
|
|
19
|
+
* adoption changes nothing); any other config reads as `Unowned`, and the plan refuses it unless
|
|
20
|
+
* the deploy runs with `--adopt`. A Caddy serving nothing reads as absent: a create. Where the
|
|
21
|
+
* engine skips that probe, reconcile refuses the same takeover (config-lifecycle.ts).
|
|
18
22
|
* ⛔ `caddyfile` IS NEVER A SECRET — see config-form.ts; refused before anything is sent.
|
|
19
23
|
* ⚠️ WHICH CADDY is the transport's (providers.ts), not a prop: one CaddyAdmin per stack.
|
|
20
24
|
*/
|
|
21
25
|
import { Resource } from 'alchemy';
|
|
26
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
22
27
|
import { isResolved } from 'alchemy/Diff';
|
|
23
28
|
import * as Provider from 'alchemy/Provider';
|
|
24
29
|
import * as Effect from 'effect/Effect';
|
|
25
|
-
import { lift, resolvedString } from '../launchd/host-effect.ts';
|
|
30
|
+
import { adoptEnabled, lift, resolvedString } from '../launchd/host-effect.ts';
|
|
26
31
|
import { CaddyAdminService, CaddyUnreachableError } from './admin.ts';
|
|
27
32
|
import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
28
|
-
import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
33
|
+
import { diffConfig, probeLive, readLive, reconcileConfig } from './config-lifecycle.ts';
|
|
29
34
|
|
|
30
35
|
export type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
|
|
31
36
|
|
|
@@ -64,17 +69,40 @@ export const CaddyConfigProvider = () =>
|
|
|
64
69
|
CaddyConfig,
|
|
65
70
|
Effect.gen(function* () {
|
|
66
71
|
const admin = yield* CaddyAdminService;
|
|
72
|
+
/** The probe, branded for the engine: plain when ours, `Unowned` otherwise. */
|
|
73
|
+
const probe = (caddyfile: string | undefined, sourceFile: string | undefined) =>
|
|
74
|
+
Effect.flatMap(
|
|
75
|
+
lift(() => probeLive(admin, caddyfile, sourceFile)),
|
|
76
|
+
(found) => {
|
|
77
|
+
if (found === undefined) return Effect.succeed(undefined);
|
|
78
|
+
if (found.ours) return Effect.succeed(found.attributes);
|
|
79
|
+
const unowned = Unowned(found.attributes);
|
|
80
|
+
return found.unchecked === undefined
|
|
81
|
+
? Effect.succeed(unowned)
|
|
82
|
+
: Effect.as(
|
|
83
|
+
Effect.logWarning(
|
|
84
|
+
`Caddy.Config at ${admin.endpoint}: the running config could not be compared ` +
|
|
85
|
+
`with the declared Caddyfile (${found.unchecked}), so it is not treated as ours`,
|
|
86
|
+
),
|
|
87
|
+
unowned,
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
67
91
|
return CaddyConfig.Provider.of({
|
|
68
92
|
/** ⛔ A Caddy has one config; there is nothing to enumerate, and nothing for nuke to delete. */
|
|
69
93
|
list: () => Effect.succeed([]),
|
|
70
94
|
|
|
71
95
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
96
|
+
* With state: the live attributes, which diff compares. With none, the ADOPTION PROBE —
|
|
97
|
+
* `olds` is the declaration (Plan.ts passes `news`), and only a live config equal to it is
|
|
98
|
+
* ours. ⚠️ A running Caddy is still the one the stack means (the transport reaches only
|
|
99
|
+
* loopback), but WHOSE CONFIG it runs is not: a person, another tool or another stack.
|
|
75
100
|
*/
|
|
76
|
-
read: ({ olds }) =>
|
|
77
|
-
|
|
101
|
+
read: ({ olds, output }) =>
|
|
102
|
+
(output === undefined
|
|
103
|
+
? probe(resolvedString(olds, 'caddyfile'), resolvedString(olds, 'sourceFile'))
|
|
104
|
+
: lift(() => readLive(admin, resolvedString(olds, 'sourceFile')))
|
|
105
|
+
).pipe(
|
|
78
106
|
// ★ Nothing to adopt from a Caddy that is down: plan a create, which is the same load.
|
|
79
107
|
Effect.catchIf(unreachable, planWithoutCaddy(admin, undefined)),
|
|
80
108
|
),
|
|
@@ -101,9 +129,18 @@ export const CaddyConfigProvider = () =>
|
|
|
101
129
|
}).pipe(Effect.catchIf(unreachable, update));
|
|
102
130
|
},
|
|
103
131
|
|
|
104
|
-
reconcile: ({ news }) =>
|
|
132
|
+
reconcile: ({ fqn, news, output }) =>
|
|
105
133
|
Effect.gen(function* () {
|
|
106
|
-
|
|
134
|
+
// ⛔ State is authority over the Caddy it was applied to, and no other. With none (the
|
|
135
|
+
// engine may not have probed: see reconcileConfig), or with the transport now at
|
|
136
|
+
// another endpoint, only adoption — `--adopt`, or this resource's own `adopt(…)`,
|
|
137
|
+
// as the planner resolves it — loads over a config the stack cannot claim.
|
|
138
|
+
const sameCaddy = output !== undefined && output.endpoint === admin.endpoint;
|
|
139
|
+
const takeOver = sameCaddy || (yield* adoptEnabled(fqn));
|
|
140
|
+
const stored = output === undefined ? {} : { stored: output.configSha256 };
|
|
141
|
+
const applied = yield* lift(() =>
|
|
142
|
+
reconcileConfig(admin, news, { takeOver, ...stored }),
|
|
143
|
+
);
|
|
107
144
|
for (const warning of applied.warnings) {
|
|
108
145
|
yield* Effect.logWarning(`Caddy.Config at ${admin.endpoint}: ${warning}`);
|
|
109
146
|
}
|
package/src/caddy/fake-caddy.ts
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* ★ `listenPort` is the port Caddy BELIEVES it listens on — its Host check uses it — while the
|
|
18
18
|
* fake really listens on an ephemeral one: a Caddy on its default :2019 behind a forward.
|
|
19
19
|
*/
|
|
20
|
+
import type { CaddyAdmin } from './admin.ts';
|
|
21
|
+
import { localCaddyAdmin } from './local-admin.ts';
|
|
22
|
+
|
|
20
23
|
export type Seen = {
|
|
21
24
|
readonly method: string;
|
|
22
25
|
readonly path: string;
|
|
@@ -158,3 +161,19 @@ export const fakeCaddy = (
|
|
|
158
161
|
stop: () => void server.stop(true),
|
|
159
162
|
};
|
|
160
163
|
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A fake Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), and the transport to it.
|
|
167
|
+
* ★ Addressed that way so a Caddyfile with no `admin` line — the common case — passes the guard.
|
|
168
|
+
* A Caddy reached on any other port needs `admin <address>` declared, or the first load would
|
|
169
|
+
* move it (admin-guard.ts).
|
|
170
|
+
*/
|
|
171
|
+
export const fakeDefaultCaddy = (running?: unknown): { admin: CaddyAdmin; caddy: FakeCaddy } => {
|
|
172
|
+
const caddy = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
|
|
173
|
+
const admin = localCaddyAdmin({
|
|
174
|
+
address: caddy.address,
|
|
175
|
+
hostHeader: '127.0.0.1:2019',
|
|
176
|
+
retries: 0,
|
|
177
|
+
});
|
|
178
|
+
return { admin, caddy };
|
|
179
|
+
};
|
package/src/caddy/index.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* the digest, the guards and the admin calls are internals; an `export *` would publish them.
|
|
7
7
|
* ⛔ THE ADMIN API STAYS ON LOOPBACK OR A UNIX SOCKET. It has no authentication; localCaddyAdmin()
|
|
8
8
|
* refuses any other address, and a Caddyfile that would move or expose it is refused before load.
|
|
9
|
-
*
|
|
9
|
+
* ⛔ NOTHING IS ADOPTED SILENTLY: a running config that is not the declared one needs `--adopt`.
|
|
10
|
+
* Guide, order of file and load, adoption, `--resume` and secrets: docs/caddy.md.
|
|
10
11
|
*/
|
|
11
12
|
export type {
|
|
12
13
|
CaddyAdmin,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* way the engine calls them; caddyWithFile's registration; and the barrel, the public API.
|
|
4
4
|
*/
|
|
5
5
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
6
|
-
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
7
6
|
import * as Output from 'alchemy/Output';
|
|
8
7
|
import { Stack } from 'alchemy/Stack';
|
|
9
8
|
import * as Effect from 'effect/Effect';
|
|
@@ -52,37 +51,23 @@ const withProvider = <A>(
|
|
|
52
51
|
};
|
|
53
52
|
|
|
54
53
|
describe('CaddyConfigProvider', () => {
|
|
55
|
-
test('
|
|
56
|
-
const probe = await withProvider((provider) =>
|
|
57
|
-
handler('read', provider.read)({ ...ids, olds: { caddyfile: SITE }, output: undefined }),
|
|
58
|
-
);
|
|
59
|
-
expect(Unowned.is(probe)).toBe(false);
|
|
60
|
-
expect(probe).toMatchObject({ endpoint: fake?.address });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('reconcile applies; delete then sends nothing at all', async () => {
|
|
54
|
+
test('reconcile applies over its own state; delete then sends nothing at all', async () => {
|
|
64
55
|
await withProvider((provider) =>
|
|
65
56
|
Effect.gen(function* () {
|
|
66
|
-
yield* provider.reconcile({
|
|
57
|
+
const output = yield* provider.reconcile({
|
|
67
58
|
...ids,
|
|
68
59
|
bindings: [] as never,
|
|
69
60
|
news: { caddyfile: SITE },
|
|
70
|
-
olds:
|
|
71
|
-
|
|
61
|
+
olds: { caddyfile: SITE },
|
|
62
|
+
// ★ State from an earlier deploy: the running `legacy` config is this stack's to replace.
|
|
63
|
+
output: { configSha256: 'earlier', endpoint: fake?.address ?? '' },
|
|
72
64
|
session: undefined as never,
|
|
73
65
|
});
|
|
66
|
+
expect(fake?.seen.some((call) => call.path === '/load')).toBe(true);
|
|
74
67
|
const before = fake?.seen.length;
|
|
75
|
-
const output = yield* handler(
|
|
76
|
-
'read',
|
|
77
|
-
provider.read,
|
|
78
|
-
)({
|
|
79
|
-
...ids,
|
|
80
|
-
olds: { caddyfile: SITE },
|
|
81
|
-
output: undefined,
|
|
82
|
-
});
|
|
83
68
|
yield* provider.delete({ ...ids, olds: { caddyfile: SITE }, output } as never);
|
|
84
|
-
// ★
|
|
85
|
-
expect(fake?.seen.length).toBe(
|
|
69
|
+
// ★ Delete never unloads, never stops: not one more call reached Caddy.
|
|
70
|
+
expect(fake?.seen.length).toBe(before);
|
|
86
71
|
}),
|
|
87
72
|
);
|
|
88
73
|
});
|
package/src/caddy/with-file.ts
CHANGED
|
@@ -14,9 +14,13 @@
|
|
|
14
14
|
* still an Output, Alchemy skips the adoption probe, and the check runs at apply instead.)
|
|
15
15
|
* · A Caddyfile that adapts but that Caddy then REFUSES (a port in use, a missing cert file) is
|
|
16
16
|
* on disk when `/load` fails. Caddy keeps serving the old config, the deploy fails with Caddy's
|
|
17
|
-
* reason — and a
|
|
18
|
-
*
|
|
19
|
-
* ACCEPTED, and the file is only the
|
|
17
|
+
* reason — and a Caddy started without `--resume` would load the refused file on its next
|
|
18
|
+
* restart. Managed Caddies run with `--resume` (decision, 2026-09-21; docs/caddy.md): they
|
|
19
|
+
* restart from `autosave.json`, the last config Caddy ACCEPTED, and the file is only the
|
|
20
|
+
* first-boot fallback.
|
|
21
|
+
* · The skipped probe is also why the ADOPTION check of a first deploy happens at apply: a Caddy
|
|
22
|
+
* running a config this stack did not load is refused before `/load` unless the deploy runs
|
|
23
|
+
* with `--adopt` (config-lifecycle.ts reconcileConfig) — after this file is written.
|
|
20
24
|
* ⛔ SECRETS ARE REFUSED HERE, AT DECLARATION, before the HostFile exists: its `content` is a prop
|
|
21
25
|
* too, and would carry a literal into state even if the CaddyConfig then refused it.
|
|
22
26
|
* ★ BOTH RETAIN. Removing this from a stack must not delete the file a restart needs.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sudo runner over fake-runner.ts, for the tests beside it: the deploying user is uid 501 and
|
|
3
|
+
* unprivileged, and `/usr/bin/sudo` is a tiny interpreter that runs the three allowlisted programs
|
|
4
|
+
* against the same in-memory host, as root.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY, like fake-runner.ts: no provider imports it, it is not on the barrel, and it never
|
|
7
|
+
* runs a real program. No test anywhere runs a real sudo.
|
|
8
|
+
* ★ THE INTERPRETER READS ONLY WHAT THE ARGV SAYS — mode, owner, group, source, destination — so a
|
|
9
|
+
* runner that dropped `-o` or wrote the wrong path shows up as the wrong file, not a passing test.
|
|
10
|
+
* ★ THE BASE STAYS UNPRIVILEGED, so a write under /Library/LaunchDaemons that skipped sudo fails
|
|
11
|
+
* with the fake's EACCES instead of quietly succeeding.
|
|
12
|
+
* ★ SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
|
|
13
|
+
* before it runs", not merely "logged at some point".
|
|
14
|
+
*/
|
|
15
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
16
|
+
import { LAUNCHCTL } from './launchctl.ts';
|
|
17
|
+
import type { ExecResult, HostRunner } from './runner.ts';
|
|
18
|
+
import { INSTALL, RM, SUDO } from './sudo-allowlist.ts';
|
|
19
|
+
import { makeSudoRunner } from './sudo-runner.ts';
|
|
20
|
+
|
|
21
|
+
export const OPERATOR = 501;
|
|
22
|
+
export const PREFIXES = ['/Library/LaunchDaemons', '/opt/example'];
|
|
23
|
+
|
|
24
|
+
const ok: ExecResult = { exitCode: 0, stderr: '', stdout: '' };
|
|
25
|
+
|
|
26
|
+
/** What `sudo -n` prints (exit 1) when it will not run the command. Reasoned; see sudo-runner.ts. */
|
|
27
|
+
export const SUDO_SAYS = {
|
|
28
|
+
denied:
|
|
29
|
+
"Sorry, user someone is not allowed to execute '/bin/launchctl bootout system/x' as root on example.\n",
|
|
30
|
+
'not-in-sudoers': 'someone is not in the sudoers file.\n',
|
|
31
|
+
'not-on-host': 'someone is not allowed to run sudo on example.\n',
|
|
32
|
+
password: 'sudo: a password is required\n',
|
|
33
|
+
} as const;
|
|
34
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
35
|
+
|
|
36
|
+
/** Parse `install -S -m MODE [-o UID] [-g GID] SRC DEST` the way install(1) would. */
|
|
37
|
+
const parseInstall = (args: readonly string[]) => {
|
|
38
|
+
const mode = Number.parseInt(args[2] ?? '', 8);
|
|
39
|
+
const rest = args.slice(3);
|
|
40
|
+
const flag = (name: string) => {
|
|
41
|
+
const at = rest.indexOf(name);
|
|
42
|
+
return at === -1 ? undefined : Number(rest[at + 1]);
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
dest: rest[rest.length - 1] ?? '',
|
|
46
|
+
gid: flag('-g'),
|
|
47
|
+
mode,
|
|
48
|
+
source: rest[rest.length - 2] ?? '',
|
|
49
|
+
uid: flag('-o'),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** `groups` is the operator's `id -G`; `'unknown'` models that lookup failing. */
|
|
54
|
+
export const fakeSudoHost = (
|
|
55
|
+
groups: readonly number[] | 'unknown' = [20, 80],
|
|
56
|
+
prefixes: readonly string[] = PREFIXES,
|
|
57
|
+
) => {
|
|
58
|
+
const fake = fakeRunner({
|
|
59
|
+
dirs: {
|
|
60
|
+
'/Library/LaunchDaemons': 0,
|
|
61
|
+
'/opt/example': 0,
|
|
62
|
+
'/opt/example/app': 0,
|
|
63
|
+
'/Users/someone/Library/LaunchAgents': OPERATOR,
|
|
64
|
+
'/Users/other/Library/LaunchAgents': 502,
|
|
65
|
+
// Root-owned, and deliberately NOT a prefix: the forgotten-prefix case.
|
|
66
|
+
'/etc/example': 0,
|
|
67
|
+
},
|
|
68
|
+
euid: OPERATOR,
|
|
69
|
+
users: {
|
|
70
|
+
other: { gid: 20, home: '/Users/other', uid: 502 },
|
|
71
|
+
someone: { gid: 20, home: '/Users/someone', uid: OPERATOR },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const staged = new Map<string, Uint8Array>();
|
|
75
|
+
const logs: string[] = [];
|
|
76
|
+
const sudoCalls: string[][] = [];
|
|
77
|
+
const state = {
|
|
78
|
+
/** What sudo itself answers: runs the command, or refuses it with one of SUDO_SAYS. */
|
|
79
|
+
sudo: 'ok' as 'ok' | keyof typeof SUDO_SAYS,
|
|
80
|
+
/** Returned by `install` in place of success; nothing is written. */
|
|
81
|
+
installFailure: undefined as ExecResult | undefined,
|
|
82
|
+
stagedCount: 0,
|
|
83
|
+
/** The staged bytes as `install` saw them, by destination. */
|
|
84
|
+
installedFrom: new Map<string, string>(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const asRoot = async (argv: readonly string[]): Promise<ExecResult> => {
|
|
88
|
+
const [program, ...args] = argv;
|
|
89
|
+
if (program === LAUNCHCTL) return fake.runner.exec(argv);
|
|
90
|
+
if (program === INSTALL) {
|
|
91
|
+
if (state.installFailure !== undefined) return state.installFailure;
|
|
92
|
+
const { dest, gid, mode, source, uid } = parseInstall(args);
|
|
93
|
+
const bytes = staged.get(source);
|
|
94
|
+
if (bytes === undefined) return fail(71, `install: ${source}: No such file or directory`);
|
|
95
|
+
state.installedFrom.set(dest, new TextDecoder().decode(bytes));
|
|
96
|
+
// ★ No -g: the new file takes its directory's group, as a BSD file create does.
|
|
97
|
+
const dirGid = (await fake.runner.stat(dest.slice(0, dest.lastIndexOf('/'))))?.gid ?? 0;
|
|
98
|
+
fake.files.set(dest, { bytes, gid: gid ?? dirGid, kind: 'file', mode, uid: uid ?? 0 });
|
|
99
|
+
return ok;
|
|
100
|
+
}
|
|
101
|
+
if (program === RM) {
|
|
102
|
+
fake.files.delete(args[2] ?? '');
|
|
103
|
+
return ok;
|
|
104
|
+
}
|
|
105
|
+
return fail(1, `sudo: ${String(program)}: command not found`);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const base: HostRunner = {
|
|
109
|
+
...fake.runner,
|
|
110
|
+
exec: async (argv) => {
|
|
111
|
+
if (argv[0] !== SUDO) return fake.runner.exec(argv);
|
|
112
|
+
sudoCalls.push([...argv]);
|
|
113
|
+
if (logs[logs.length - 1] !== `homeflare/launchd sudo -n ${JSON.stringify(argv.slice(3))}`) {
|
|
114
|
+
throw new Error(`fake sudo: ${JSON.stringify(argv)} was not logged before it ran`);
|
|
115
|
+
}
|
|
116
|
+
if (state.sudo !== 'ok') return fail(1, SUDO_SAYS[state.sudo]);
|
|
117
|
+
// argv is [sudo, -n, --, ...command]
|
|
118
|
+
return asRoot(argv.slice(3));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const runner = makeSudoRunner(prefixes, {
|
|
123
|
+
base,
|
|
124
|
+
groups: async () => (groups === 'unknown' ? undefined : groups),
|
|
125
|
+
log: (line) => logs.push(line),
|
|
126
|
+
stage: async (bytes) => {
|
|
127
|
+
state.stagedCount += 1;
|
|
128
|
+
const path = `/private/tmp/hf-sudo-${String(state.stagedCount)}/staged`;
|
|
129
|
+
staged.set(path, bytes);
|
|
130
|
+
return { dispose: async () => void staged.delete(path), path };
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
/** The privileged commands run, without the `sudo -n --` in front. */
|
|
134
|
+
const privileged = () => sudoCalls.map((call) => call.slice(3));
|
|
135
|
+
return { fake, logs, privileged, runner, staged, state, sudoCalls };
|
|
136
|
+
};
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place a HostRunner's promises become Effects — plus the one probe of an unresolved
|
|
3
|
-
* `news` both providers' diffs share.
|
|
3
|
+
* `news` both providers' diffs share, and the engine's adopt setting for a check at apply.
|
|
4
4
|
*
|
|
5
5
|
* ★ The lifecycle files are plain async functions over a HostRunner (see runner.ts for why the
|
|
6
6
|
* runner is promise-based), so each provider handler is a single `lift(() => step(runner, …))`.
|
|
7
7
|
* ⚠️ A thrown Error stays that Error — its message is the refusal a deploy prints. Anything else
|
|
8
8
|
* thrown is wrapped, so the failure channel is always an Error with a readable message.
|
|
9
9
|
*/
|
|
10
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
11
|
+
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
12
|
+
import { Stack } from 'alchemy/Stack';
|
|
10
13
|
import * as Effect from 'effect/Effect';
|
|
14
|
+
import * as Option from 'effect/Option';
|
|
11
15
|
|
|
12
16
|
export const lift = <A>(step: () => Promise<A>): Effect.Effect<A, Error> =>
|
|
13
17
|
Effect.tryPromise({
|
|
@@ -26,3 +30,31 @@ export const resolvedString = (news: unknown, key: string): string | undefined =
|
|
|
26
30
|
const value = (news as Record<string, unknown>)[key];
|
|
27
31
|
return typeof value === 'string' ? value : undefined;
|
|
28
32
|
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the resource at `fqn` may be adopted, resolved exactly as the planner resolves it (alchemy
|
|
36
|
+
* beta.79 Plan.ts: `resource.Adopt ?? shouldAdopt`): a resource-scoped `adopt(…)`, else the
|
|
37
|
+
* `AdoptPolicy` service, else `AlchemyContext.adopt`, else off.
|
|
38
|
+
* ★ FOR A CREATE THE ENGINE NEVER PROBED. Alchemy skips the adoption probe while `news` holds an
|
|
39
|
+
* Output, so an ownership check that must still honour `--adopt` can only run at apply. All three
|
|
40
|
+
* reach the apply: Alchemist runs plan AND apply under the session context, which provides the
|
|
41
|
+
* services from the CLI flag and the Stack the resources registered on (Alchemist/Session.ts,
|
|
42
|
+
* routes/stack.ts; Apply.ts reads the same Stack).
|
|
43
|
+
* ⛔ THE RESOURCE-SCOPED SETTING WINS, BOTH WAYS. The engine captures `adopt(…)` on the resource at
|
|
44
|
+
* registration (Resource.ts `Adopt`, into `Stack.resources[fqn]`) and never hands it to a
|
|
45
|
+
* provider. Reading only the deploy-wide flag let `--adopt` take over a resource declared
|
|
46
|
+
* `.pipe(adopt(false))` — which the planner refuses — and refused one declared `adopt(true)`.
|
|
47
|
+
* ⚠️ `resources` carries an `@internal` comment in Alchemy's source, but it is in the published types
|
|
48
|
+
* and the planner reads `Adopt` from it. caddy/adopt-scope.test.ts registers through the real
|
|
49
|
+
* `Resource`, so an upgrade that stops recording it there fails the suite.
|
|
50
|
+
*/
|
|
51
|
+
export const adoptEnabled = (fqn: string): Effect.Effect<boolean> =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const stack = yield* Effect.serviceOption(Stack);
|
|
54
|
+
const scoped = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : undefined;
|
|
55
|
+
if (scoped !== undefined) return scoped;
|
|
56
|
+
const policy = yield* Effect.serviceOption(AdoptPolicy);
|
|
57
|
+
if (Option.isSome(policy)) return policy.value;
|
|
58
|
+
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
59
|
+
return Option.isSome(context) ? context.value.adopt : false;
|
|
60
|
+
});
|
|
@@ -46,9 +46,12 @@ const identityProblems = (name: string, value: string | number | undefined): str
|
|
|
46
46
|
: [`${name} ${JSON.stringify(value)} is not a valid user or group name`];
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* An absolute, normalised path, or the reasons it is not. ★ Shared with sudo-allowlist.ts, so a
|
|
51
|
+
* path the sudo runner may touch as root obeys exactly the rule a declared file does.
|
|
52
|
+
*/
|
|
53
|
+
export const pathProblems = (path: string): string[] => {
|
|
50
54
|
const found: string[] = [];
|
|
51
|
-
const { path } = props;
|
|
52
55
|
if (!path.startsWith('/')) found.push('path must be absolute');
|
|
53
56
|
// ★ One spelling per file: `a/./b`, `a//b` and `a/../b` would each be a second resource for the
|
|
54
57
|
// same inode, and `..` is how a derived path walks out of the directory it was meant for.
|
|
@@ -62,6 +65,11 @@ export const fileProblems = (props: HostFileProps): string[] => {
|
|
|
62
65
|
found.push('path must be normalised (no trailing slash, no empty, "." or ".." segments)');
|
|
63
66
|
}
|
|
64
67
|
if (path.includes('\x00')) found.push('path contains NUL');
|
|
68
|
+
return found;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const fileProblems = (props: HostFileProps): string[] => {
|
|
72
|
+
const found = pathProblems(props.path);
|
|
65
73
|
const mode = props.mode ?? DEFAULT_MODE;
|
|
66
74
|
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777) found.push('mode must be 0–0o7777');
|
|
67
75
|
found.push(...identityProblems('owner', props.owner));
|
|
@@ -102,7 +102,8 @@ export const reconcileFile = async (
|
|
|
102
102
|
throw refuse(
|
|
103
103
|
props.path,
|
|
104
104
|
`owner ${String(props.owner)} is not the deploying user; only root may chown. Deploy as root ` +
|
|
105
|
-
'or through a privileged HostRunner (
|
|
105
|
+
'or through a privileged HostRunner (sudoRunner() with this path under a prefix). This ' +
|
|
106
|
+
'provider never calls sudo itself.',
|
|
106
107
|
);
|
|
107
108
|
}
|
|
108
109
|
const stat = await runner.stat(props.path);
|
package/src/launchd/index.ts
CHANGED
|
@@ -24,3 +24,5 @@ export { PlistError, renderPlist } from './plist.ts';
|
|
|
24
24
|
export { launchdProviders } from './providers.ts';
|
|
25
25
|
export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
|
|
26
26
|
export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
|
|
27
|
+
export type { SudoRunnerOptions } from './sudo-runner.ts';
|
|
28
|
+
export { SudoRefusedError, sudoRunner } from './sudo-runner.ts';
|
|
@@ -61,8 +61,9 @@ export const assertMayWrite = (runner: HostRunner, label: string, domain: Parsed
|
|
|
61
61
|
if (domain.kind === 'system') {
|
|
62
62
|
throw refuse(
|
|
63
63
|
label,
|
|
64
|
-
'the system domain needs root. Run the deploy as root, or
|
|
65
|
-
'
|
|
64
|
+
'the system domain needs root. Run the deploy as root, or pass a deliberately privileged ' +
|
|
65
|
+
'HostRunner: sudoRunner() elevates exactly the calls this needs. This provider never ' +
|
|
66
|
+
'calls sudo itself.',
|
|
66
67
|
);
|
|
67
68
|
}
|
|
68
69
|
if (domain.uid !== runner.effectiveUid()) {
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { jobSecretProblems } from './secret-tripwire.ts';
|
|
16
16
|
|
|
17
17
|
/** ★ Labels become file names: no `/`, no leading dot, nothing a path could re-interpret. */
|
|
18
|
-
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
18
|
+
export const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* ⛔ LABELS OTHER TOOLS OWN ARE NEVER DECLARED HERE. nix-darwin (`org.nixos.*`) rewrites its plists
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* the published dist also loads under node (the package's smoke test and the kit's AGENTS.md:
|
|
6
6
|
* consumers run dist/, not src/), and both runtimes implement these modules.
|
|
7
7
|
* ⛔ NO ELEVATION, EVER. `privileged` is false; a system-domain write succeeds only when the whole
|
|
8
|
-
* deploy was started as root. See the ⛔ on HostRunner.privileged for why nothing here calls sudo
|
|
8
|
+
* deploy was started as root. See the ⛔ on HostRunner.privileged for why nothing here calls sudo;
|
|
9
|
+
* the opt-in elevating runner is sudo-runner.ts, which wraps this one.
|
|
9
10
|
* ⛔ NO SHELL. `exec` spawns argv directly, so a label, path or argument can never be re-parsed
|
|
10
11
|
* as shell syntax.
|
|
11
12
|
*/
|
|
@@ -21,13 +22,15 @@ export type LocalRunnerOptions = {
|
|
|
21
22
|
readonly execTimeoutMs?: number;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
/** The errno code of a thrown fs error (`ENOENT`, `EACCES`, …), if it has one. */
|
|
26
|
+
export const errnoCode = (cause: unknown): string | undefined =>
|
|
25
27
|
typeof cause === 'object' && cause !== null && 'code' in cause
|
|
26
28
|
? String((cause as { code: unknown }).code)
|
|
27
29
|
: undefined;
|
|
28
30
|
|
|
29
31
|
/** ENOENT and ENOTDIR both mean "nothing is at this path". */
|
|
30
|
-
const absent = (cause: unknown): boolean =>
|
|
32
|
+
const absent = (cause: unknown): boolean =>
|
|
33
|
+
errnoCode(cause) === 'ENOENT' || errnoCode(cause) === 'ENOTDIR';
|
|
31
34
|
|
|
32
35
|
const run = (argv: readonly string[], timeoutMs: number): Promise<ExecResult> =>
|
|
33
36
|
new Promise((resolve, reject) => {
|