@homeflare/alchemy 0.6.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 +74 -41
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +91 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +34 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +54 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +22 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +970 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +51 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.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 +16 -12
- 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/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.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 +36 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +393 -234
- package/dist/openbao/index.js.map +23 -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 +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- 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 +107 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- 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 +199 -0
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/mesh-node.md +188 -0
- package/docs/peers.md +39 -0
- package/package.json +7 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +196 -0
- package/src/caddy/config-lifecycle.ts +239 -0
- package/src/caddy/config.ts +153 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +179 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +26 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +229 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +66 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +1 -0
- 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/secret-tripwire.ts +1 -1
- 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 +126 -22
- 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-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +22 -8
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +126 -0
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/fake-stack.ts +112 -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-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +52 -36
- 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-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +166 -0
- package/src/openbao/role-rename.test.ts +205 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Cloudflare.MeshNode` — a Cloudflare Mesh node (a `warp_connector` object), declared without
|
|
3
|
+
* its token ever touching Alchemy state. Usage and the enrolment step: docs/mesh-node.md.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Tunnel.WarpConnector`. A Mesh node that fronts a
|
|
6
|
+
* private service needs a node record in the graph, so the Gateway rules and DNS records that
|
|
7
|
+
* name it deploy together. Alchemy beta.79's WarpConnector would put the node token — the
|
|
8
|
+
* credential that lets any Linux host join the account's Mesh as that node — into plaintext
|
|
9
|
+
* state on every read, and it cannot create an HA node. See mesh-node-form.ts.
|
|
10
|
+
*
|
|
11
|
+
* ★ REMOVAL POLICY: `retain` BY DEFAULT — see the ★ on `MeshNode` below, and what it does to an
|
|
12
|
+
* `ha` replace in mesh-node-lifecycle.ts.
|
|
13
|
+
*/
|
|
14
|
+
import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
|
|
15
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
16
|
+
import { isResolved } from 'alchemy/Diff';
|
|
17
|
+
import * as Provider from 'alchemy/Provider';
|
|
18
|
+
import { Resource, type ResourceClass } from 'alchemy/Resource';
|
|
19
|
+
import * as Context from 'effect/Context';
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
22
|
+
import {
|
|
23
|
+
type MeshNodeAttributes,
|
|
24
|
+
type MeshNodeProps,
|
|
25
|
+
diffMeshNode,
|
|
26
|
+
validateMeshNode,
|
|
27
|
+
} from './mesh-node-form.ts';
|
|
28
|
+
import { deleteMeshNode, readMeshNode, reconcileMeshNode } from './mesh-node-lifecycle.ts';
|
|
29
|
+
import type { Providers } from './providers.ts';
|
|
30
|
+
|
|
31
|
+
export interface MeshNode extends Resource<
|
|
32
|
+
'Cloudflare.MeshNode',
|
|
33
|
+
MeshNodeProps,
|
|
34
|
+
MeshNodeAttributes,
|
|
35
|
+
never,
|
|
36
|
+
Providers
|
|
37
|
+
> {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ★ `defaultRemovalPolicy: 'retain'`, LIKE EVERY KIT RESOURCE WHOSE DELETION BREAKS ITS CONSUMERS
|
|
41
|
+
* (the `Bao.*` families, `R2BucketLock`, the Proxmox storage families; the reasoning is written
|
|
42
|
+
* once in proxmox/resource.ts). Deleting a node cuts every enrolled replica off the Mesh, and
|
|
43
|
+
* nothing brings it back: a new node has a new id, a new token and a new Mesh IP per replica, so
|
|
44
|
+
* each host must be re-enrolled by hand and every Gateway rule or allow-list that pins the old
|
|
45
|
+
* Mesh IP breaks. So dropping the declaration, or `alchemy destroy`, leaves the node live.
|
|
46
|
+
* ⚠️ IT ALSO SKIPS THE OLD NODE'S DELETE IN A REPLACE (Apply.ts, `deleteOldGenerations` and the
|
|
47
|
+
* GC's `retainOldGeneration`, beta.79). A same-name `ha` change therefore REFUSES, deleting
|
|
48
|
+
* nothing, until that deploy opts in with `.pipe(RemovalPolicy.destroy())`; a create-first
|
|
49
|
+
* replace leaves the old node live and unmanaged. Both measured in mesh-node-policy.test.ts.
|
|
50
|
+
* ⛔ `delete` STAYS FULLY IMPLEMENTED. Retain is Terraform's `prevent_destroy`, not a stub: it runs
|
|
51
|
+
* the moment a declaration opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
52
|
+
*/
|
|
53
|
+
export const MeshNode: ResourceClass<MeshNode> = Resource<MeshNode>('Cloudflare.MeshNode', {
|
|
54
|
+
defaultRemovalPolicy: 'retain',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** What every call below needs: the account, credentials and HTTP — Alchemy's own trio. */
|
|
58
|
+
export type MeshNodeServices =
|
|
59
|
+
| Cloudflare.CloudflareEnvironment
|
|
60
|
+
| Credentials
|
|
61
|
+
| HttpClient.HttpClient;
|
|
62
|
+
|
|
63
|
+
/** The account id, resolved the way every `alchemy/Cloudflare` provider resolves it. */
|
|
64
|
+
const accountId = Effect.gen(function* () {
|
|
65
|
+
const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
|
|
66
|
+
return resolved.accountId;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD. `Provider.succeed` (what Alchemy's
|
|
71
|
+
* own providers use) leaves each handler to find CloudflareEnvironment and Credentials in the
|
|
72
|
+
* engine's context at call time — which works for `Cloudflare.providers()` only because it
|
|
73
|
+
* `provideMerge`s them into the stack. Capturing them here keeps this provider correct whether
|
|
74
|
+
* or not the stack also merges `Cloudflare.providers()`.
|
|
75
|
+
*/
|
|
76
|
+
export const MeshNodeProvider = () =>
|
|
77
|
+
Provider.effect(
|
|
78
|
+
MeshNode,
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const services = Context.make(
|
|
81
|
+
Cloudflare.CloudflareEnvironment,
|
|
82
|
+
yield* Cloudflare.CloudflareEnvironment,
|
|
83
|
+
).pipe(
|
|
84
|
+
Context.add(Credentials, yield* Credentials),
|
|
85
|
+
Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
|
86
|
+
);
|
|
87
|
+
const run = <A, E>(effect: Effect.Effect<A, E, MeshNodeServices>) =>
|
|
88
|
+
Effect.provideContext(effect, services);
|
|
89
|
+
|
|
90
|
+
return MeshNode.Provider.of({
|
|
91
|
+
stables: ['id', 'accountId', 'ha'],
|
|
92
|
+
/**
|
|
93
|
+
* ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Tunnel.WarpConnector` already lists
|
|
94
|
+
* every `warp_connector` in the account for `alchemy unsafe nuke`; a second listing here
|
|
95
|
+
* would make nuke delete each node twice. `read` does not depend on this.
|
|
96
|
+
*/
|
|
97
|
+
list: () => Effect.succeed([]),
|
|
98
|
+
nuke: { skip: true },
|
|
99
|
+
/**
|
|
100
|
+
* ⛔ VALIDATE AT PLAN TIME, NOT ONLY IN `reconcile`. An `ha` replace is delete-first: the
|
|
101
|
+
* engine deletes the old node BEFORE the new generation's reconcile runs, so a
|
|
102
|
+
* declaration refused there would leave no node at all. launchd's job-preflight.ts
|
|
103
|
+
* refuses at plan time for the same reason.
|
|
104
|
+
*/
|
|
105
|
+
diff: ({ news, output }) =>
|
|
106
|
+
run(
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const invalid = isResolved(news) ? validateMeshNode(news) : undefined;
|
|
109
|
+
if (invalid !== undefined) return yield* Effect.fail(invalid);
|
|
110
|
+
return diffMeshNode(news, output, yield* accountId);
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
read: ({ olds, output }) =>
|
|
114
|
+
run(Effect.flatMap(accountId, (account) => readMeshNode(account, olds, output))),
|
|
115
|
+
reconcile: ({ news, output }) =>
|
|
116
|
+
run(Effect.flatMap(accountId, (account) => reconcileMeshNode(account, news, output))),
|
|
117
|
+
delete: ({ output }) => run(deleteMeshNode(output)),
|
|
118
|
+
});
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
@@ -8,16 +8,34 @@
|
|
|
8
8
|
* both a `Cloudflare.R2.Bucket` and this lock merges the two:
|
|
9
9
|
* `Layer.mergeAll(Cloudflare.providers(), HomeflareCloudflare.providers())` — the shape
|
|
10
10
|
* a consuming stack names for one talking to more than one account.
|
|
11
|
+
*
|
|
12
|
+
* ★ TWO CREDENTIAL PATHS, ONE PER SDK. `R2BucketLock` uses the `cloudflare` SDK through
|
|
13
|
+
* `CloudflareApiLive` (`CLOUDFLARE_API_TOKEN`, fail-closed). `MeshNode` uses Alchemy's own SDK
|
|
14
|
+
* and resolves credentials and the account through `Cloudflare.CloudflareApiLive()` — the same
|
|
15
|
+
* source `Cloudflare.providers()` uses (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
|
|
16
|
+
* `CLOUDFLARE_ACCOUNT_ID` in CI), so a node lands in the account its Gateway rules do.
|
|
17
|
+
* ⚠️ That second layer is built again here even when the stack also merges
|
|
18
|
+
* `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: it feeds MeshNode only and adds
|
|
19
|
+
* nothing to the stack's context.
|
|
11
20
|
*/
|
|
21
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
12
22
|
import * as Provider from 'alchemy/Provider';
|
|
13
23
|
import * as Layer from 'effect/Layer';
|
|
14
24
|
import { CloudflareApiLive } from './client.ts';
|
|
25
|
+
import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
|
|
15
26
|
import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
|
|
16
27
|
|
|
17
28
|
export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
|
|
18
29
|
|
|
19
30
|
export const providers = () =>
|
|
20
|
-
Layer.effect(Providers, Provider.collection([R2BucketLock])).pipe(
|
|
21
|
-
Layer.provide(
|
|
31
|
+
Layer.effect(Providers, Provider.collection([R2BucketLock, MeshNode])).pipe(
|
|
32
|
+
Layer.provide(
|
|
33
|
+
Layer.mergeAll(
|
|
34
|
+
R2BucketLockProvider(),
|
|
35
|
+
// ⛔ `orDie`, as `Cloudflare.providers()` ends with: a stack's providers layer must not
|
|
36
|
+
// fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
|
|
37
|
+
MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
22
40
|
Layer.provideMerge(CloudflareApiLive),
|
|
23
41
|
);
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
11
11
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
12
12
|
* import { LaunchdJob } from '@homeflare/alchemy/launchd';
|
|
13
|
+
* import { CaddyConfig } from '@homeflare/alchemy/caddy';
|
|
13
14
|
*
|
|
14
15
|
* ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative
|
|
15
16
|
* is a runbook step a human runs once — and a plan can never show a missing runbook
|
|
@@ -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) => {
|
|
@@ -212,11 +212,14 @@ describe('the barrel', () => {
|
|
|
212
212
|
'LaunchdJob',
|
|
213
213
|
'LaunchdJobProvider',
|
|
214
214
|
'PlistError',
|
|
215
|
+
'SudoRefusedError',
|
|
215
216
|
'canActAsRoot',
|
|
216
217
|
'hostRunnerLayer',
|
|
217
218
|
'launchdProviders',
|
|
218
219
|
'localRunner',
|
|
219
220
|
'renderPlist',
|
|
221
|
+
// ★ The opt-in elevating runner; makeSudoRunner and the allowlist internals stay off.
|
|
222
|
+
'sudoRunner',
|
|
220
223
|
]);
|
|
221
224
|
});
|
|
222
225
|
});
|
package/src/launchd/providers.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* written by the same identity on the same host — a stack cannot write the file as one user and
|
|
8
8
|
* bootstrap the job as another by accident.
|
|
9
9
|
* ⚠️ The default is localRunner(): this process, never elevated. A system-domain job therefore
|
|
10
|
-
* needs the deploy itself started as root, or a runner passed here with `privileged: true
|
|
10
|
+
* needs the deploy itself started as root, or a runner passed here with `privileged: true` —
|
|
11
|
+
* `launchdProviders(sudoRunner({ prefixes }))` is the kit's own (sudo-runner.ts).
|
|
11
12
|
*/
|
|
12
13
|
import * as Layer from 'effect/Layer';
|
|
13
14
|
import { HostFileProvider } from './host-file.ts';
|
package/src/launchd/runner.ts
CHANGED
|
@@ -48,13 +48,15 @@ export type HostUser = { readonly uid: number; readonly gid: number; readonly ho
|
|
|
48
48
|
|
|
49
49
|
export interface HostRunner {
|
|
50
50
|
/**
|
|
51
|
-
* ⛔ DECLARED, NEVER INFERRED. `true` only when
|
|
52
|
-
*
|
|
51
|
+
* ⛔ DECLARED, NEVER INFERRED. `true` only when this runner can do every root-needing call the
|
|
52
|
+
* providers make of it: every call already runs as root (a runner that talks to a root helper,
|
|
53
|
+
* say), or the runner elevates exactly those calls (sudoRunner). The providers refuse
|
|
53
54
|
* system-domain writes unless `effectiveUid()` is 0 or this is `true`, so claiming it falsely
|
|
54
55
|
* turns that refusal into an EACCES halfway through a deploy.
|
|
55
|
-
* ⛔ THE KIT NEVER ELEVATES.
|
|
56
|
-
* mid-plan, or
|
|
57
|
-
*
|
|
56
|
+
* ⛔ THE KIT NEVER ELEVATES SILENTLY. localRunner() never calls `sudo`: a deploy that prompts for
|
|
57
|
+
* a password mid-plan, or quietly borrows a cached sudo ticket, is exactly the "no silent sudo"
|
|
58
|
+
* failure. sudoRunner() does call `sudo -n`, but only when a stack passes it explicitly, only
|
|
59
|
+
* for the argv shapes in sudo-allowlist.ts, and logging each one (decided 2026-09-21).
|
|
58
60
|
*/
|
|
59
61
|
readonly privileged: boolean;
|
|
60
62
|
/** The uid file writes and launchctl calls run as. */
|
|
@@ -28,7 +28,7 @@ const INLINE_FLAG = /^--?[a-z0-9-]*(?:token|password|passwd|passphrase|secret|ap
|
|
|
28
28
|
/** A flag whose NEXT argv element is the secret: `--token abc`. */
|
|
29
29
|
const BARE_FLAG = /^--?(?:[a-z0-9]+-)*(?:token|password|passwd|passphrase|secret|api-?key)$/i;
|
|
30
30
|
|
|
31
|
-
const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
|
31
|
+
export const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
|
32
32
|
|
|
33
33
|
/** Paths of every string leaf in a plist value holding a private key. */
|
|
34
34
|
const keyLeaves = (value: PlistValue, path: string): string[] => {
|