@homeflare/alchemy 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/caddy/index.js.map +2 -2
  2. package/dist/ceph/index.js +2 -1
  3. package/dist/ceph/index.js.map +3 -3
  4. package/dist/launchd/fake-runner.d.ts.map +1 -1
  5. package/dist/launchd/index.d.ts +1 -1
  6. package/dist/launchd/index.d.ts.map +1 -1
  7. package/dist/launchd/index.js +7 -1
  8. package/dist/launchd/index.js.map +5 -5
  9. package/dist/launchd/local-runner.d.ts.map +1 -1
  10. package/dist/launchd/runner.d.ts +21 -0
  11. package/dist/launchd/runner.d.ts.map +1 -1
  12. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  13. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  14. package/dist/linux/fake-linux-host.d.ts.map +1 -1
  15. package/dist/linux/index.d.ts +1 -1
  16. package/dist/linux/index.d.ts.map +1 -1
  17. package/dist/linux/index.js +16 -4
  18. package/dist/linux/index.js.map +7 -7
  19. package/dist/linux/ssh-runner.d.ts.map +1 -1
  20. package/dist/linux/sudo-runner.d.ts.map +1 -1
  21. package/dist/opnsense/alias-form.d.ts +14 -1
  22. package/dist/opnsense/alias-form.d.ts.map +1 -1
  23. package/dist/opnsense/alias.d.ts +1 -1
  24. package/dist/opnsense/alias.d.ts.map +1 -1
  25. package/dist/opnsense/group-form.d.ts +8 -2
  26. package/dist/opnsense/group-form.d.ts.map +1 -1
  27. package/dist/opnsense/group.d.ts +1 -1
  28. package/dist/opnsense/group.d.ts.map +1 -1
  29. package/dist/opnsense/index.js +15 -7
  30. package/dist/opnsense/index.js.map +7 -7
  31. package/dist/opnsense/wire.d.ts +42 -12
  32. package/dist/opnsense/wire.d.ts.map +1 -1
  33. package/dist/proxmox/index.js +48 -34
  34. package/dist/proxmox/index.js.map +7 -6
  35. package/dist/proxmox/storage-form.d.ts +28 -3
  36. package/dist/proxmox/storage-form.d.ts.map +1 -1
  37. package/dist/proxmox/storage-plugin-options.d.ts +2 -0
  38. package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
  39. package/dist/proxmox/storage-wire.d.ts +28 -1
  40. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  41. package/dist/proxmox/storage.d.ts +6 -1
  42. package/dist/proxmox/storage.d.ts.map +1 -1
  43. package/dist/release/index.d.ts +1 -1
  44. package/dist/release/index.d.ts.map +1 -1
  45. package/dist/release/index.js.map +2 -2
  46. package/docs/distilled-interim.md +7 -0
  47. package/docs/launchd.md +5 -3
  48. package/docs/provider-standard.md +5 -1
  49. package/docs/talos-argocd-dogfood.md +160 -0
  50. package/package.json +2 -2
  51. package/src/launchd/fake-runner.ts +2 -0
  52. package/src/launchd/index.ts +8 -1
  53. package/src/launchd/local-runner.ts +16 -1
  54. package/src/launchd/runner.ts +22 -0
  55. package/src/launchd/sudo-runner.ts +3 -0
  56. package/src/linux/directory-lifecycle.ts +18 -3
  57. package/src/linux/directory-sudo.test.ts +59 -0
  58. package/src/linux/directory.test.ts +34 -8
  59. package/src/linux/fake-linux-host.ts +2 -0
  60. package/src/linux/index.ts +1 -0
  61. package/src/linux/ssh-runner.ts +4 -0
  62. package/src/linux/sudo-runner.ts +3 -0
  63. package/src/opnsense/alias-form.test.ts +24 -12
  64. package/src/opnsense/alias-form.ts +20 -7
  65. package/src/opnsense/alias.test.ts +13 -6
  66. package/src/opnsense/alias.ts +16 -10
  67. package/src/opnsense/group-form.test.ts +12 -8
  68. package/src/opnsense/group-form.ts +13 -4
  69. package/src/opnsense/group.test.ts +18 -7
  70. package/src/opnsense/group.ts +10 -3
  71. package/src/opnsense/wire.ts +57 -12
  72. package/src/proxmox/storage-form.ts +48 -8
  73. package/src/proxmox/storage-plugin-options.ts +45 -0
  74. package/src/proxmox/storage-update.test.ts +183 -0
  75. package/src/proxmox/storage-wire.ts +54 -7
  76. package/src/proxmox/storage.ts +18 -6
  77. package/src/release/index.ts +1 -0
@@ -0,0 +1,160 @@
1
+ # Talos + Argo CD dogfood — Distilled and Alchemy path
2
+
3
+ Status: decision record — fixes the per-vendor Distilled/Alchemy path for the
4
+ Talos + Argo CD dogfood stack; retire when the stack ships and each row is
5
+ proven live.
6
+ Verified: 2026-09-27 (LAND pass: CRD ownership, Talos K-A3, Cilium CNI
7
+ ordering, topology and the interim-package rule checked against merged PRs
8
+ 262/295/307/311; see each section below).
9
+
10
+ Opened 2026-09-24 after
11
+ [PR 242](https://github.com/taslabs-net/homeflare-kit/pull/242) closed without
12
+ merge.
13
+
14
+ Placeholder stack: Talos Kubernetes on Proxmox VE, **Argo CD owns GitOps**.
15
+ This page is the Distilled / Alchemy path for each vendor — not a stack file,
16
+ and not a licence to invent SDKs.
17
+
18
+ ## Why there are no interim packages
19
+
20
+ [distilled-interim.md](./distilled-interim.md) is generate+copy from a real
21
+ vendor schema (distilled clone Steps 1–8, then copy `src/` into
22
+ `packages/distilled-<vendor>`). ⛔ **`src/` is copied, never hand-written.**
23
+ A stub invented so a `@distilled.cloud/<vendor>` name can publish is the
24
+ failure #242 shipped: Alchemy would `catchTag` errors from a client nobody
25
+ can regenerate. ⛔ Do not add npm aliases for packages that do not exist.
26
+
27
+ ## Per-vendor decision
28
+
29
+ | vendor | decision |
30
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | Headlamp | Skip Distilled / Alchemy vendor family — Argo Application / Helm |
32
+ | Traefik | No Distilled SDK (no official OpenAPI; do not invent) |
33
+ | Cilium | No hand-written interim — install vehicle is D-S2 (open); CRDs via upstream Alchemy `Kubernetes.Manifest` |
34
+ | CNPG | `postgresql.cnpg.io/v1` CRDs — upstream Alchemy `Kubernetes.Manifest` (S1), not `@distilled.cloud/kubernetes` (built-in API groups only) |
35
+ | Valkey | operator CRDs — upstream Alchemy `Kubernetes.Manifest` (S1); Sentinel is RESP, not HTTP |
36
+ | Argo CD | `Application`/`AppProject`/`ApplicationSet` — upstream Alchemy `Kubernetes.Manifest` (decision 49); `Repository`/`RepoCreds`/`Cluster` — `Argocd.Repository`/`RepoCreds`/`Cluster` (kit, built over `@distilled.cloud/argocd`, [PR 262](https://github.com/taslabs-net/homeflare-kit/pull/262), merged) |
37
+ | Talos CLI | No Distilled Talos SDK — stays on `talosctl`, reworked under K-A3 ([PR 307](https://github.com/taslabs-net/homeflare-kit/pull/307)/[311](https://github.com/taslabs-net/homeflare-kit/pull/311): `insecure` prop dropped, `Bootstrap.peers` added) |
38
+
39
+ Headlamp's `/config` and cluster proxy are not a vendor control plane.
40
+
41
+ Traefik declarative config is Kubernetes CRDs (`IngressRoute`, `Middleware`, …
42
+ — `traefik.io`, not a built-in group) / Helm values (experimental flags as
43
+ Helm props). CRDs go through upstream Alchemy `Kubernetes.Manifest` (S1), the
44
+ same as every other vendor's CRDs on this page, never
45
+ `@distilled.cloud/kubernetes`. The dashboard `GET /api/…` is read-only
46
+ runtime — optional later, not the dogfood install path.
47
+
48
+ Cilium's BGP and network-policy objects are genuine CRDs, not a built-in
49
+ Kubernetes API group, so they go through upstream Alchemy's
50
+ `Kubernetes.Manifest` (S1) — never `@distilled.cloud/kubernetes`: its 24
51
+ service files (checked at the kit's pin, `c2a78002`) cover only built-in
52
+ apiserver groups, and its `apiextensions` service defines CRD _schemas_, not
53
+ CRD _instances_. The install vehicle itself — inline manifests in the seeded
54
+ Talos machine config vs. a Helm step between `Kubeconfig` and `ClusterHealth`
55
+ — is **D-S2, still open**
56
+ ([first-boot doc](https://github.com/taslabs-net/homeflare-kit/blob/main/docs/plans/2026-09-26-talos-stack-first-boot.md#tim-must-decide),
57
+ "Tim must decide"; inline manifests is the doc's recommendation, not yet
58
+ accepted). Either vehicle must set `cluster.network.cni.name: none` and
59
+ `cluster.proxy.disabled: true` (kube-proxy replacement) in the seeded config,
60
+ because Argo CD cannot install Cilium — Argo CD itself needs pod networking
61
+ first. Official swagger exists (`cilium/cilium` `api/v1/openapi.yaml`) but
62
+ the default transport is the unix socket `/var/run/cilium/cilium.sock`;
63
+ generate+copy a Distilled SDK later only if node-local agent health over
64
+ that socket is needed.
65
+
66
+ Argo CD's own storage — `Repository`, `RepoCreds` and `Cluster` — is a `Secret`
67
+ labeled with Argo CD's own convention, not a published Kubernetes API type, so
68
+ the kit's `Argocd.Repository`/`RepoCreds`/`Cluster` (built over
69
+ `@distilled.cloud/argocd`, already on npm) are the correct owner
70
+ ([PR 262](https://github.com/taslabs-net/homeflare-kit/pull/262), merged). The
71
+ objects Argo CD reconciles — `Application`, `AppProject`, `ApplicationSet` —
72
+ are genuine `argoproj.io/v1alpha1` CRDs, so upstream Alchemy's
73
+ `Kubernetes.Manifest` owns those (decision 49, "upstream wins"), not a house
74
+ wrapper — see [argocd.md](./argocd.md) and
75
+ [argocd-kubernetes.md](./argocd-kubernetes.md). No Distilled Talos API in this
76
+ path.
77
+
78
+ ## Gates
79
+
80
+ - ⛔ Do not publish an interim Distilled package until generate+copy from a
81
+ real vendor schema ([distilled-interim.md](./distilled-interim.md) steps 1–2).
82
+ - ⛔ Do not invent a Traefik Distilled SDK without an official schema.
83
+ - `Argocd.Repository`/`RepoCreds`/`Cluster` were built ahead of a live
84
+ Talos-on-PVE cluster ([PR 262](https://github.com/taslabs-net/homeflare-kit/pull/262),
85
+ merged 2026-09-24; tested against `fake-argocd.ts`, a fake in-memory Argo CD
86
+ API — the HTTP client and Distilled protocol underneath it are real, only
87
+ the Argo CD server is faked; never exercised against a real instance) —
88
+ Tim's build-ahead-of-need call. Declaring and applying
89
+ `Application`/`AppProject`/`ApplicationSet` objects in a real stack still
90
+ waits on that cluster existing.
91
+ - Prefer upstream Alchemy Kubernetes resources when they exist (S1).
92
+
93
+ ## Placeholder variables
94
+
95
+ Names a consuming stack substitutes. Origins use RFC 2606 `*.example.com`.
96
+ Addresses use RFC 5737 documentation ranges. Secret _values_ never appear —
97
+ only env-var NAMES (S25). Nothing here is applied.
98
+
99
+ ### Topology — 3 control plane, no workers today
100
+
101
+ `homeflare-proxmox`'s `src/talos-shape.ts` declares exactly 3 control-plane
102
+ VMs (`n2`/`n3`/`n4`, vmids 10000–10002) and no worker pool. The
103
+ `TALOS_WORKER_*` rows are placeholders for if/when a worker pool is added;
104
+ nothing consumes them yet.
105
+
106
+ | variable | placeholder |
107
+ | ------------------------------------- | ------------------------------- |
108
+ | `TALOS_CLUSTER_NAME` | `dogfood` |
109
+ | `TALOS_CP_NODES` | `cp-a`, `cp-b`, `cp-c` |
110
+ | `TALOS_WORKER_NODES` (none today) | `wk-a`, `wk-b`, `wk-c` |
111
+ | `TALOS_CP_ADDRESSES` | `192.0.2.10/24` … `.12` |
112
+ | `TALOS_WORKER_ADDRESSES` (none today) | `192.0.2.20/24` … `.22` |
113
+ | `TALOS_CONTROL_PLANE_ENDPOINT` | `https://k8s.example.com:6443` |
114
+ | `TALOS_VERSION` | `v1.13.x` (never measured live) |
115
+ | `STORAGE_CLASS` | `STORAGE_CLASS` |
116
+ | `STORAGE_SIZE` | `STORAGE_SIZE` |
117
+
118
+ ### Cilium BGP
119
+
120
+ | variable | placeholder |
121
+ | ------------------------- | ----------------- |
122
+ | `CILIUM_BGP_CLUSTER_ASN` | `64512` (private) |
123
+ | `CILIUM_BGP_PEER_ASN` | `64513` |
124
+ | `CILIUM_BGP_PEER_ADDRESS` | `198.51.100.1` |
125
+ | `CILIUM_BGP_VIP_CIDR` | `203.0.113.0/24` |
126
+
127
+ ### Traefik
128
+
129
+ | variable | placeholder |
130
+ | ---------------------------- | ----------------------------------------------- |
131
+ | `TRAEFIK_ENTRYPOINTS` | `web`, `websecure` |
132
+ | `TRAEFIK_EXPERIMENTAL_FLAGS` | Helm props, e.g. `kubernetesGateway`, `knative` |
133
+ | `TRAEFIK_NAMESPACE` | `traefik` |
134
+
135
+ ### Installs Argo owns
136
+
137
+ | variable | placeholder |
138
+ | --------------------------- | ---------------------------- |
139
+ | `HEADLAMP_INGRESS_HOST` | `headlamp.example.com` |
140
+ | `CNPG_CLUSTER_NAME` | `dogfood-pg` |
141
+ | `CNPG_INSTANCES` | `3` |
142
+ | `CNPG_SUPERUSER_SECRET_ENV` | `CNPG_SUPERUSER_PASSWORD` |
143
+ | `VALKEY_SENTINEL_NAME` | `dogfood-valkey` |
144
+ | `VALKEY_PASSWORD_ENV` | `VALKEY_PASSWORD` |
145
+ | `ARGOCD_SERVER` | `https://argocd.example.com` |
146
+ | `ARGOCD_TOKEN_ENV` | `ARGOCD_TOKEN` |
147
+
148
+ ## Non-goals
149
+
150
+ - No Flux. Argo CD is the GitOps controller.
151
+ - No new Distilled Talos SDK from this page — `@homeflare/alchemy/talos`
152
+ already reworked under K-A3 ([PR 307](https://github.com/taslabs-net/homeflare-kit/pull/307),
153
+ [PR 311](https://github.com/taslabs-net/homeflare-kit/pull/311)); it stays on
154
+ `talosctl`.
155
+ - No new Alchemy provider code from this page itself — `Argocd.*` (PR 262)
156
+ and upstream `Kubernetes.Manifest` already cover this stack's objects.
157
+ Declaring and applying them against a real cluster still waits on
158
+ Talos-on-PVE existing (see Gates).
159
+ - No `packages/distilled-traefik`, `packages/distilled-cilium`, or other
160
+ interim Distilled packages for this stack.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -131,7 +131,7 @@
131
131
  "@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
132
132
  "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
133
133
  "@distilled.cloud/openbao": "npm:@homeflare/distilled-openbao@0.2.2",
134
- "@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.2.0",
134
+ "@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.3.0",
135
135
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
136
136
  "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.3",
137
137
  "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
@@ -156,6 +156,8 @@ export const fakeRunner = (options: FakeOptions = {}) => {
156
156
  lookupUser: async (nameOrId) =>
157
157
  options.users?.[nameOrId] ??
158
158
  Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
159
+ // ★ This fake models launchd, which only ever exists on a Mac — fixed, not an option.
160
+ platform: 'darwin',
159
161
  privileged,
160
162
  readFile: async (path) => files.get(path)?.bytes,
161
163
  removeFile: async (path) => {
@@ -24,7 +24,14 @@ export { PlistError, renderPlist } from './plist.ts';
24
24
  export type { PortClaim } from './port-claims.ts';
25
25
  export { PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
26
26
  export { launchdProviders } from './providers.ts';
27
- export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
27
+ export type {
28
+ ExecResult,
29
+ FileStat,
30
+ HostPlatform,
31
+ HostRunner,
32
+ HostUser,
33
+ WriteOptions,
34
+ } from './runner.ts';
28
35
  export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
29
36
  export type { SudoRunnerOptions } from './sudo-runner.ts';
30
37
  export { SudoRefusedError, sudoRunner } from './sudo-runner.ts';
@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto';
15
15
  import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
16
16
  import { basename, dirname, join } from 'node:path';
17
17
  import { groupQuery, parseGroup, parseUser, userQuery } from './host-lookup.ts';
18
- import type { ExecResult, FileStat, HostRunner, WriteOptions } from './runner.ts';
18
+ import type { ExecResult, FileStat, HostPlatform, HostRunner, WriteOptions } from './runner.ts';
19
19
 
20
20
  export type LocalRunnerOptions = {
21
21
  /** Kill a spawned program after this long. @default 120_000 */
@@ -98,6 +98,20 @@ const writeAtomic = async (path: string, bytes: Uint8Array, options: WriteOption
98
98
  export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
99
99
  const timeoutMs = options.execTimeoutMs ?? 120_000;
100
100
  const platform = process.platform;
101
+ // ★ THE ONE PLACE `process.platform` MAY DECIDE HOST BEHAVIOUR: here, the target IS this
102
+ // process's own machine, so `process.platform` and `HostRunner.platform` name the same host.
103
+ // Everywhere else (chmodChownEnd, host-lookup's callers over ssh) reads `runner.platform`
104
+ // instead — see the incident on `HostRunner.platform`'s own doc comment.
105
+ // ⛔ DECLARED, NEVER GUESSED, past these two: silently folding freebsd/win32/etc. into 'linux'
106
+ // would hand a BSD host GNU's `--`, which macOS's own (BSD-flavoured) chmod/chown already
107
+ // reject — the same shape of bug this PR fixes. sshRunner's own `uname -s` probe refuses a
108
+ // non-Linux target the same way; localRunner refuses here instead of guessing.
109
+ if (platform !== 'darwin' && platform !== 'linux') {
110
+ throw new Error(
111
+ `localRunner(): unsupported process.platform ${JSON.stringify(platform)} — HostRunner only targets darwin and linux.`,
112
+ );
113
+ }
114
+ const hostPlatform: HostPlatform = platform;
101
115
  return {
102
116
  effectiveUid: () => process.geteuid?.() ?? -1,
103
117
  exec: (argv) => run(argv, timeoutMs),
@@ -109,6 +123,7 @@ export const localRunner = (options: LocalRunnerOptions = {}): HostRunner => {
109
123
  const result = await run(userQuery(platform, nameOrId), timeoutMs);
110
124
  return result.exitCode === 0 ? parseUser(platform, result.stdout) : undefined;
111
125
  },
126
+ platform: hostPlatform,
112
127
  privileged: false,
113
128
  readFile: async (path) => {
114
129
  try {
@@ -59,7 +59,29 @@ export type WriteOptions = {
59
59
 
60
60
  export type HostUser = { readonly uid: number; readonly gid: number; readonly home: string };
61
61
 
62
+ /**
63
+ * The two host families a HostRunner ever actually targets. ★ Not `NodeJS.Platform`: nothing
64
+ * here runs against `win32`/`aix`/etc, and a narrower type is what makes `runner.platform ===
65
+ * 'darwin'` exhaustive rather than a string comparison that silently matches nothing.
66
+ */
67
+ export type HostPlatform = 'darwin' | 'linux';
68
+
62
69
  export interface HostRunner {
70
+ /**
71
+ * ⛔ DECLARED, NEVER INFERRED — same rule as `privileged`, and the same failure mode. The OS of
72
+ * the TARGET this runner writes to, never of the process running Alchemy: `localRunner()` may
73
+ * run ON either, but `sshRunner()`/`sshSudoRunner()` always reach a Linux host from wherever
74
+ * Alchemy itself happens to run.
75
+ * 🔴 INCIDENT, homeflare-ct100 2026-09-27: `Host.Directory`'s chmod/chown end-of-options token
76
+ * (directory-lifecycle.ts `chmodChownEnd`) read `process.platform` — the Mac mini's own OS —
77
+ * instead of the runner's target. Deploying from the Mac to CT100 (a Debian LXC over
78
+ * `sshSudoRunner`) dropped GNU's required `--`, and `sudo-allowlist-dir.ts`'s `dirProgramProblem`
79
+ * refused every chown with `SudoRefusedError` even though the path was under a declared prefix.
80
+ * `localRunner()`'s own local deploys never showed it: there, `process.platform` and the
81
+ * target happen to be the same machine. This field is the fix: every call site that needs to
82
+ * know the target's OS reads `runner.platform`, never `process.platform`.
83
+ */
84
+ readonly platform: HostPlatform;
63
85
  /**
64
86
  * ⛔ DECLARED, NEVER INFERRED. `true` only when this runner can do every root-needing call the
65
87
  * providers make of it: every call already runs as root (a runner that talks to a root helper,
@@ -162,6 +162,9 @@ export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): H
162
162
  },
163
163
  lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
164
164
  lookupUser: (nameOrId) => base.lookupUser(nameOrId),
165
+ // ★ Forwarded, never hardcoded: this runner elevates on the SAME target `base` already
166
+ // declared (localRunner's own machine), so it carries no opinion of its own.
167
+ platform: base.platform,
165
168
  // ★ True, so the providers let a system-domain job through to this runner (canActAsRoot).
166
169
  privileged: true,
167
170
  readFile: (path) => base.readFile(path),
@@ -100,8 +100,18 @@ const octal = (mode: number) => mode.toString(8).padStart(3, '0');
100
100
  * keep it. These paths are absolute, so the token is only an option guard.
101
101
  * ⛔ Linux keeps `--`. The sudo allowlist requires that exact token; dropping it there
102
102
  * refuses the elevation.
103
+ * 🔴 INCIDENT, homeflare-ct100 2026-09-27: this used to decide on `process.platform` — the OS of
104
+ * the machine RUNNING Alchemy — instead of `runner.platform`, the OS of the TARGET the runner
105
+ * actually writes to. Deploying from the Mac mini (`darwin`) to CT100, a Debian LXC reached
106
+ * over `sshSudoRunner`, dropped the required `--` and `sudo-allowlist-dir.ts`'s
107
+ * `dirProgramProblem` refused every chown with `SudoRefusedError`, even though the path was
108
+ * under a declared prefix — `localRunner()`'s own local (Mac-to-Mac) deploys never showed it,
109
+ * because there `process.platform` and the target happen to be the same machine. Reading
110
+ * `runner.platform` instead (declared by every HostRunner — see its own doc comment) makes the
111
+ * decision follow the target, not the caller.
103
112
  */
104
- const chmodChownEnd = (): readonly string[] => (process.platform === 'darwin' ? [] : ['--']);
113
+ const chmodChownEnd = (runner: HostRunner): readonly string[] =>
114
+ runner.platform === 'darwin' ? [] : ['--'];
105
115
 
106
116
  /** One program, checked: anything but exit 0 is an Error naming the argv and the host's words. */
107
117
  const must = async (runner: HostRunner, path: string, argv: readonly string[]): Promise<void> => {
@@ -160,7 +170,12 @@ export const reconcileDirectory = async (
160
170
  }
161
171
  await must(runner, props.path, ['mkdir', '-m', octal(want.mode), '--', props.path]);
162
172
  } else if (stat.mode !== want.mode) {
163
- await must(runner, props.path, ['chmod', octal(want.mode), ...chmodChownEnd(), props.path]);
173
+ await must(runner, props.path, [
174
+ 'chmod',
175
+ octal(want.mode),
176
+ ...chmodChownEnd(runner),
177
+ props.path,
178
+ ]);
164
179
  }
165
180
  /**
166
181
  * ★ Only when it is actually wrong. A `chown` that changes nothing is still a write on the host
@@ -174,7 +189,7 @@ export const reconcileDirectory = async (
174
189
  ? undefined
175
190
  : ownerArg(want);
176
191
  if (owner !== undefined)
177
- await must(runner, props.path, ['chown', owner, ...chmodChownEnd(), props.path]);
192
+ await must(runner, props.path, ['chown', owner, ...chmodChownEnd(runner), props.path]);
178
193
  const after = await readDirectory(runner, props.path);
179
194
  // ⚠️ READ BACK: a runner that ignored the mode shows up here, not as a forever-`update`.
180
195
  if (after === undefined || after.mode !== want.mode || !ownerMatches(after, want)) {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Host.Directory driven through sshSudoRunner — the exact path the homeflare-ct100 incident
3
+ * (2026-09-27) broke: a deploy running on a Mac (darwin) targeting a Linux host over ssh dropped
4
+ * the sudo allowlist's required `--`, because directory-lifecycle.ts's `chmodChownEnd()` read
5
+ * `process.platform` — the Mac's own OS — instead of the runner's own declared target.
6
+ *
7
+ * ★ WHY A SEPARATE FILE FROM directory.test.ts. Those tests exercise the lifecycle directly
8
+ * against a bare fake host; these go through the REAL `sshSudoRunner` + `sudo-allowlist.ts`
9
+ * layer (`canonicalize`, `dirProgramProblem`), which is what actually enforces the `--` token
10
+ * and is what refused the live deploy with `SudoRefusedError`.
11
+ * ⛔ NEITHER TEST BELOW READS `process.platform`. `fakeLinuxHost()` (via `fakeSudoHost()`)
12
+ * declares `platform: 'linux'` itself — sshRunner's own probe requires it — so this suite
13
+ * catches the regression on every machine that runs `bun test`, regardless of that machine's
14
+ * own OS. (This repo's own `check` job runs on `ubuntu-latest`, not the mini, so CI alone never
15
+ * reproduces the "darwin process, Linux target" split the live incident measured; this suite's
16
+ * fixed, declared target is what actually proves the fix, on any OS.)
17
+ */
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { reconcileDirectory } from './directory-lifecycle.ts';
20
+ import { fakeSudoHost } from './fake-sudo.ts';
21
+
22
+ describe('Host.Directory through sshSudoRunner (a Linux target)', () => {
23
+ test('the elevated chown and chmod carry -- and the allowlist accepts them', async () => {
24
+ const { privileged, runner } = fakeSudoHost();
25
+ expect(runner.platform).toBe('linux'); // ★ declared by sshRunner's own uname probe.
26
+
27
+ // A fresh create under a declared prefix: mkdir runs as root, then chown (owner/group
28
+ // declared) also runs as root — both routed through routeExec()/elevatedExec().
29
+ const created = await reconcileDirectory(runner, {
30
+ group: 0,
31
+ mode: 0o755,
32
+ owner: 0,
33
+ path: '/etc/systemd/system/hf-test-dir',
34
+ });
35
+ expect(created).toEqual({
36
+ gid: 0,
37
+ mode: 0o755,
38
+ path: '/etc/systemd/system/hf-test-dir',
39
+ uid: 0,
40
+ });
41
+
42
+ // A mode drift on the next reconcile routes an elevated chmod too.
43
+ await reconcileDirectory(
44
+ runner,
45
+ { group: 0, mode: 0o750, owner: 0, path: '/etc/systemd/system/hf-test-dir' },
46
+ created,
47
+ );
48
+
49
+ const chown = privileged().find((call) => call[0] === '/usr/bin/chown');
50
+ const chmod = privileged().find((call) => call[0] === '/usr/bin/chmod');
51
+ // ⛔ REGRESSION: before the fix, these two calls carried no `--` whenever `bun test` ran on a
52
+ // Mac, and `sudo-allowlist-dir.ts`'s `dirProgramProblem` refused them with
53
+ // `SudoRefusedError` — the exact failure measured against homeflare-ct100 2026-09-27.
54
+ expect(chown).toBeDefined();
55
+ expect(chown).toContain('--');
56
+ expect(chmod).toBeDefined();
57
+ expect(chmod).toContain('--');
58
+ });
59
+ });
@@ -3,6 +3,7 @@
3
3
  * to create a parent.
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test';
6
+ import type { HostRunner } from '../launchd/runner.ts';
6
7
  import {
7
8
  deleteDirectory,
8
9
  diffDirectory,
@@ -105,8 +106,16 @@ describe('drift', () => {
105
106
  });
106
107
 
107
108
  describe('argv', () => {
108
- test('chmod and chown omit -- on Darwin; mkdir keeps it on every platform', async () => {
109
+ // ⛔ REGRESSION, 2026-09-27: this used to read `process.platform !== 'darwin'` to decide what
110
+ // the fake (a LINUX host) should have produced — so it only ever proved the real behaviour
111
+ // when `bun test` itself happened to run on Linux (matching the fake's target by
112
+ // coincidence), and passed just as happily on a Mac while asserting the bug itself (no `--`
113
+ // for a Linux target). `runner.platform` is declared by the fixture instead, so these two
114
+ // tests hold regardless of what OS actually runs the suite — the exact "darwin process,
115
+ // Linux target" split the homeflare-ct100 incident measured.
116
+ test('mkdir keeps --, and chmod/chown carry -- for a Linux target', async () => {
109
117
  const fake = host();
118
+ expect(fake.runner.platform).toBe('linux'); // ★ fakeLinuxHost's own declaration.
110
119
  const created = await reconcileDirectory(fake.runner, {
111
120
  group: 0,
112
121
  mode: 0o755,
@@ -118,13 +127,30 @@ describe('argv', () => {
118
127
  { group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
119
128
  created,
120
129
  );
121
- const gnu = process.platform !== 'darwin';
122
- const chown = fake.calls.find((call) => call[0] === 'chown');
123
- const chmod = fake.calls.find((call) => call[0] === 'chmod');
124
- const mkdir = fake.calls.find((call) => call[0] === 'mkdir');
125
- expect(chown?.includes('--')).toBe(gnu);
126
- expect(chmod?.includes('--')).toBe(gnu);
127
- expect(mkdir).toContain('--');
130
+ expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
131
+ expect(fake.calls.find((call) => call[0] === 'chown')).toContain('--');
132
+ expect(fake.calls.find((call) => call[0] === 'chmod')).toContain('--');
133
+ });
134
+
135
+ test('mkdir still keeps --, but chmod/chown omit it for a runner declared as Darwin', async () => {
136
+ const fake = host();
137
+ // ★ Same fake host, only the declared target changes — proves chmodChownEnd() reads
138
+ // `runner.platform`, never the machine `bun test` itself happens to run on.
139
+ const darwinRunner: HostRunner = { ...fake.runner, platform: 'darwin' };
140
+ const created = await reconcileDirectory(darwinRunner, {
141
+ group: 0,
142
+ mode: 0o755,
143
+ owner: 0,
144
+ path: '/opt/app/bin',
145
+ });
146
+ await reconcileDirectory(
147
+ darwinRunner,
148
+ { group: 0, mode: 0o750, owner: 0, path: '/opt/app/bin' },
149
+ created,
150
+ );
151
+ expect(fake.calls.find((call) => call[0] === 'mkdir')).toContain('--');
152
+ expect(fake.calls.find((call) => call[0] === 'chown')?.includes('--')).toBe(false);
153
+ expect(fake.calls.find((call) => call[0] === 'chmod')?.includes('--')).toBe(false);
128
154
  });
129
155
  });
130
156
 
@@ -127,6 +127,8 @@ export const fakeLinuxHost = (options: FakeLinuxOptions = {}) => {
127
127
  lookupUser: async (nameOrId) =>
128
128
  options.users?.[nameOrId] ??
129
129
  Object.values(options.users ?? {}).find((user) => String(user.uid) === nameOrId),
130
+ // ★ This fake models sshRunner()'s own target, which is always Linux — fixed, not an option.
131
+ platform: 'linux',
130
132
  privileged,
131
133
  readFile: async (path) => files.get(path)?.bytes,
132
134
  removeFile: async (path) => {
@@ -24,6 +24,7 @@ export { sshSudoRunner } from './sudo-runner.ts';
24
24
  export type {
25
25
  ExecResult,
26
26
  FileStat,
27
+ HostPlatform,
27
28
  HostRunner,
28
29
  HostUser,
29
30
  WriteOptions,
@@ -154,6 +154,10 @@ export const sshRunner = async (options: SshRunnerOptions): Promise<HostRunner>
154
154
  const result = await run(execScript(userQuery('linux', nameOrId)));
155
155
  return result.exitCode === 0 ? parseUser('linux', result.stdout) : undefined;
156
156
  },
157
+ // ★ Fixed, not read from the caller's own `process.platform`: the probe above already
158
+ // refused anything but `uname -s` = Linux, so this is the one true answer for this runner —
159
+ // whatever OS is running Alchemy itself is irrelevant (HostRunner.platform's doc comment).
160
+ platform: 'linux',
157
161
  privileged: false,
158
162
  readFile: async (path) => {
159
163
  const result = await run(readScript(path));
@@ -99,6 +99,9 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
99
99
  },
100
100
  lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
101
101
  lookupUser: (nameOrId) => base.lookupUser(nameOrId),
102
+ // ★ Forwarded, never hardcoded — same reasoning as ../launchd/sudo-runner.ts's own forward:
103
+ // this runner elevates on the SAME target `base` (sshRunner) already declared.
104
+ platform: base.platform,
102
105
  privileged: true,
103
106
  readFile: (path) => base.readFile(path),
104
107
  removeFile: async (path) => {
@@ -1,19 +1,21 @@
1
1
  /**
2
- * `alias-form.ts`'s pure functions against literal `AliasItem` fixtures — no network, no fake
3
- * server. Mirrors `../discord/command-form.ts`'s own test file in spirit.
2
+ * `alias-form.ts`'s pure functions against literal `ModelAliasReadItem` fixtures (the WHOLE-MODEL
3
+ * `get()`'s read-shaped item, option maps and all — OPNSENSE-2) — no network, no fake server.
4
+ * Mirrors `../discord/command-form.ts`'s own test file in spirit.
4
5
  */
5
6
  import { describe, expect, test } from 'bun:test';
6
7
  import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
7
8
  import { attributesOf, matches } from './alias-form.ts';
8
9
  import type { AliasProps } from './alias.ts';
10
+ import { optionMap } from './wire.ts';
9
11
 
10
12
  const UUID = '11111111-1111-1111-1111-111111111111';
11
13
 
12
- const liveHost = (overrides: Partial<alias.AliasItem> = {}): alias.AliasItem => ({
13
- content: '10.20.10.1',
14
+ const liveHost = (overrides: Partial<alias.ModelAliasReadItem> = {}): alias.ModelAliasReadItem => ({
15
+ content: optionMap('10.20.10.1'),
14
16
  enabled: '1',
15
17
  name: 'homeflare_edge',
16
- type: 'host',
18
+ type: optionMap('host'),
17
19
  ...overrides,
18
20
  });
19
21
 
@@ -38,10 +40,20 @@ describe('alias-form attributesOf', () => {
38
40
  expect(attributesOf(UUID, liveHost({ enabled: '0' })).enabled).toBe(false);
39
41
  });
40
42
 
41
- test('categories are split, sorted and deduplicated', () => {
42
- const live = liveHost({ categories: 'c-two,c-one,c-one' });
43
+ test('categories are the selected keys, sorted and deduplicated', () => {
44
+ const live = liveHost({ categories: optionMap('c-two', 'c-one') });
43
45
  expect(attributesOf(UUID, live).categories).toEqual(['c-one', 'c-two']);
44
46
  });
47
+
48
+ test('an unselected option-map entry is not returned (single-select `type`)', () => {
49
+ const live = liveHost({
50
+ type: {
51
+ host: { value: 'Host(s)', selected: 1 },
52
+ network: { value: 'Network(s)', selected: 0 },
53
+ },
54
+ });
55
+ expect(attributesOf(UUID, live).type).toBe('host');
56
+ });
45
57
  });
46
58
 
47
59
  describe('alias-form matches', () => {
@@ -57,12 +69,12 @@ describe('alias-form matches', () => {
57
69
  });
58
70
 
59
71
  test('content drift is caught', () => {
60
- const drifted = attributesOf(UUID, liveHost({ content: '10.20.10.2' }));
72
+ const drifted = attributesOf(UUID, liveHost({ content: optionMap('10.20.10.2') }));
61
73
  expect(matches(drifted, props)).toBe(false);
62
74
  });
63
75
 
64
76
  test('category order alone is not drift — both sides normalise through the same set', () => {
65
- const live = attributesOf(UUID, liveHost({ categories: 'b,a' }));
77
+ const live = attributesOf(UUID, liveHost({ categories: optionMap('b', 'a') }));
66
78
  const declared: AliasProps = { ...props, categories: ['a', 'b'] };
67
79
  expect(matches(live, declared)).toBe(true);
68
80
  });
@@ -79,9 +91,9 @@ describe('propsFromLive round-trip — the declaration renderer whole contract',
79
91
  // alias.ts re-exports `attributesOf` itself as `propsFromLive`; tested directly here since
80
92
  // alias.ts also wires up the Resource machinery this file does not need to import.
81
93
  const live = liveHost({
82
- categories: 'c-two,c-one',
83
- content: '203.0.113.0/24',
84
- type: 'network',
94
+ categories: optionMap('c-two', 'c-one'),
95
+ content: optionMap('203.0.113.0/24'),
96
+ type: optionMap('network'),
85
97
  });
86
98
  const rendered = attributesOf(UUID, live);
87
99
  expect(matches(attributesOf(UUID, live), rendered)).toBe(true);
@@ -18,24 +18,37 @@
18
18
  */
19
19
  import type { AliasAttributes, AliasProps } from './alias.ts';
20
20
  import type * as alias from '@distilled.cloud/opnsense/firewall_alias';
21
- import { bool01, csvSet, csvSetOf, numField } from './wire.ts';
21
+ import { bool01, csvSetOf, numField, selectedOf, selectedOneOf } from './wire.ts';
22
22
 
23
23
  /**
24
24
  * ★ ATTRIBUTES AND THE DECLARATION RENDERER ARE ONE FUNCTION. Attributes ARE exactly the props a
25
25
  * noop declaration needs — see the exported `propsFromLive` alias in alias.ts's barrel and
26
26
  * `alias-form.test.ts`'s round-trip test (`matches(attributesOf(uuid, live), attributesOf(uuid,
27
27
  * live))` is always true by construction, and so is the general case).
28
+ *
29
+ * `live` is `ModelAliasReadItem` (the WHOLE-MODEL `get()`'s read-shaped item), not `AliasItem` —
30
+ * `type`/`interface`/`proto`/`categories` decode as option maps, not strings (OPNSENSE-2; see
31
+ * wire.ts's module doc). `selectedOf`/`selectedOneOf` extract the selected key(s) back to the
32
+ * same plain-string/string[] shape `AliasAttributes` already declared.
33
+ *
34
+ * `content` is `.\AliasContentField`, which overrides `getNodeData()` independently of
35
+ * `BaseListField` (`Firewall/FieldTypes/AliasContentField.php:69-76`) — it still decodes as a map,
36
+ * but every entry is unconditionally `selected: 1` (it is a raw newline-separated list, not a
37
+ * real option set), and ITS wire separator is `\n`, not `,`. `Object.keys` preserves the
38
+ * insertion order OPNsense's own `explode()` built the map in, which matters for a host/URL list
39
+ * in a way `selectedOf`'s alphabetical sort does not preserve — so this one field is reconstructed
40
+ * directly rather than through `selectedOf`.
28
41
  */
29
- export const attributesOf = (uuid: string, live: alias.AliasItem): AliasAttributes => ({
30
- categories: csvSet(live.categories),
31
- content: live.content ?? '',
42
+ export const attributesOf = (uuid: string, live: alias.ModelAliasReadItem): AliasAttributes => ({
43
+ categories: selectedOf(live.categories),
44
+ content: Object.keys(live.content ?? {}).join('\n'),
32
45
  counters: bool01(live.counters, false),
33
46
  description: live.description ?? '',
34
47
  enabled: bool01(live.enabled, true),
35
- interface: live.interface ?? '',
48
+ interface: selectedOneOf(live.interface),
36
49
  name: live.name,
37
- proto: live.proto ?? '',
38
- type: live.type,
50
+ proto: selectedOf(live.proto).join(','),
51
+ type: selectedOneOf(live.type),
39
52
  updatefreq: numField(live.updatefreq, 0),
40
53
  uuid,
41
54
  });