@celilo/cli 1.9.0 → 1.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^3.0.0",
61
+ "@celilo/capabilities": "^3.1.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.9.1",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -55,6 +55,7 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
55
55
  logger: createCapturingLogger().logger,
56
56
  debug: false,
57
57
  screenshotDir: dir,
58
+ stateDir: dir,
58
59
  capabilities: demoCapabilities(),
59
60
  };
60
61
  return await executeHookScript(
@@ -49,6 +49,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
49
49
  consumerModuleId: 'test-consumer',
50
50
  debug: false,
51
51
  screenshotDir: '',
52
+ stateDir: '',
52
53
  capabilities: {},
53
54
  ...overrides,
54
55
  };
@@ -118,6 +118,7 @@ describe('Hook Executor', () => {
118
118
  logger,
119
119
  debug: false,
120
120
  screenshotDir: '/tmp',
121
+ stateDir: '/tmp',
121
122
  capabilities: {},
122
123
  vps_ip: '10.0.0.1',
123
124
  });
@@ -138,6 +139,7 @@ describe('Hook Executor', () => {
138
139
  logger,
139
140
  debug: false,
140
141
  screenshotDir: '/tmp',
142
+ stateDir: '/tmp',
141
143
  capabilities: {},
142
144
  }),
143
145
  ).rejects.toThrow('Hook script not found');
@@ -155,6 +157,7 @@ describe('Hook Executor', () => {
155
157
  logger,
156
158
  debug: false,
157
159
  screenshotDir: '/tmp',
160
+ stateDir: '/tmp',
158
161
  capabilities: {},
159
162
  }),
160
163
  ).rejects.toThrow('must export a default function');
@@ -176,6 +179,7 @@ describe('Hook Executor', () => {
176
179
  logger,
177
180
  debug: false,
178
181
  screenshotDir: '/tmp',
182
+ stateDir: '/tmp',
179
183
  capabilities: {},
180
184
  }),
181
185
  ).rejects.toThrow('does not use defineHook()');
@@ -192,6 +196,7 @@ describe('Hook Executor', () => {
192
196
  logger,
193
197
  debug: false,
194
198
  screenshotDir: '/tmp',
199
+ stateDir: '/tmp',
195
200
  capabilities: {},
196
201
  });
197
202
 
@@ -210,6 +215,7 @@ describe('Hook Executor', () => {
210
215
  logger,
211
216
  debug: false,
212
217
  screenshotDir: '/tmp',
218
+ stateDir: '/tmp',
213
219
  capabilities: {},
214
220
  }),
215
221
  ).rejects.toThrow('Hook execution failed: simulated error');
@@ -229,6 +235,7 @@ describe('Hook Executor', () => {
229
235
  logger,
230
236
  debug: false,
231
237
  screenshotDir: '/tmp',
238
+ stateDir: '/tmp',
232
239
  capabilities: {},
233
240
  };
234
241
 
@@ -39,6 +39,7 @@ import {
39
39
  type DeployedSystem,
40
40
  isMissingProviderInputError,
41
41
  moduleArtifactDir,
42
+ moduleStateDir,
42
43
  } from '@celilo/capabilities';
43
44
  import {
44
45
  type ContractHookSignature,
@@ -628,6 +629,10 @@ export async function invokeHook(
628
629
  // below — the capability pre-flight is one — and nothing ever cleans
629
630
  // those up, so a module accrues one per affected run forever.
630
631
  const screenshotDir = moduleArtifactDir(modulePath, `${hookName}-${startTime}`);
632
+ // Unlike the artifact directory, this one is NOT per-run and is created
633
+ // unconditionally: a hook must be able to write state on its first ever run,
634
+ // and should never have to mkdir its own sanctioned location (celilo#1000).
635
+ const stateDir = moduleStateDir(modulePath);
631
636
 
632
637
  // Build context
633
638
  const loadedCapabilities = options.capabilities ?? {};
@@ -639,6 +644,7 @@ export async function invokeHook(
639
644
  logger,
640
645
  debug,
641
646
  screenshotDir,
647
+ stateDir,
642
648
  capabilities: loadedCapabilities,
643
649
  };
644
650
 
@@ -673,6 +679,11 @@ export async function invokeHook(
673
679
  // Create the artifact directory only once every early return is behind
674
680
  // us, so the `finally` below is guaranteed to run and reclaim it.
675
681
  mkdirSync(screenshotDir, { recursive: true });
682
+ // Created every run and reclaimed on none. An empty `state/` is not litter
683
+ // the way an empty per-run artifact directory is: there is one of them, it is
684
+ // where the module is told to write, and deleting it between runs would make
685
+ // its existence depend on whether the last hook happened to use it.
686
+ mkdirSync(stateDir, { recursive: true });
676
687
  // Prune on write, so retention needs no scheduler of its own and cannot
677
688
  // fall behind a module that runs often.
678
689
  pruneModuleArtifacts(dirname(screenshotDir));
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { moduleStateDir } from '@celilo/capabilities';
6
+ import { classifyModulePath } from '../module/packaging/package-rules';
7
+ import { invokeHook } from './executor';
8
+ import type { HookLogger } from './types';
9
+
10
+ /**
11
+ * celilo#1000's FIRST acceptance criterion, which #1091 did not meet:
12
+ * "a hook has a documented, framework-provided place to write."
13
+ *
14
+ * The classification landed without the surface, so `module audit` tolerated
15
+ * `state/` while a hook had no way to ask where it was. Every assertion below
16
+ * is about the hook LEARNING the path, never about the path's spelling. A test
17
+ * that asserts `ctx.stateDir === join(root, 'state')` would pass just as well
18
+ * with no surface at all, because it computes the answer it is checking.
19
+ */
20
+
21
+ const silent: HookLogger = {
22
+ info() {},
23
+ warn() {},
24
+ error() {},
25
+ success() {},
26
+ };
27
+
28
+ function scratchModule(script: string): string {
29
+ const root = mkdtempSync(join(tmpdir(), 'celilo-statedir-'));
30
+ Bun.write(join(root, 'hook.ts'), script);
31
+ return root;
32
+ }
33
+
34
+ describe('celilo#1000: a hook is TOLD where its state directory is', () => {
35
+ test('the hook receives a usable stateDir it did not construct', async () => {
36
+ const root = scratchModule(`
37
+ import { defineHook } from '@celilo/capabilities';
38
+ import { writeFileSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ export default defineHook({
41
+ requires: [] as const,
42
+ handler: async (ctx) => {
43
+ // The hook builds NOTHING. It writes where it was told.
44
+ writeFileSync(join(ctx.stateDir as string, 'cursor'), 'run-1');
45
+ return { received: ctx.stateDir };
46
+ },
47
+ });
48
+ `);
49
+ try {
50
+ const result = await invokeHook(
51
+ root,
52
+ 'on_install',
53
+ '1.0',
54
+ { script: 'hook.ts' },
55
+ {},
56
+ {},
57
+ {},
58
+ silent,
59
+ );
60
+ expect(result.error).toBeUndefined();
61
+ expect(result.success).toBe(true);
62
+
63
+ // The framework's own answer, not this test's guess.
64
+ const expected = moduleStateDir(root);
65
+ expect(result.outputs.received).toBe(expected);
66
+ expect(readFileSync(join(expected, 'cursor'), 'utf-8')).toBe('run-1');
67
+ } finally {
68
+ rmSync(root, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ test('it exists before the hook runs, on a module that never wrote state', async () => {
73
+ const root = scratchModule(`
74
+ import { defineHook } from '@celilo/capabilities';
75
+ import { existsSync } from 'node:fs';
76
+ export default defineHook({
77
+ requires: [] as const,
78
+ handler: async (ctx) => ({ existed: existsSync(ctx.stateDir as string) }),
79
+ });
80
+ `);
81
+ try {
82
+ // Nothing has ever written here. A hook must not have to mkdir -p its
83
+ // own sanctioned location.
84
+ expect(existsSync(moduleStateDir(root))).toBe(false);
85
+ const result = await invokeHook(
86
+ root,
87
+ 'on_install',
88
+ '1.0',
89
+ { script: 'hook.ts' },
90
+ {},
91
+ {},
92
+ {},
93
+ silent,
94
+ );
95
+ expect(result.outputs.existed).toBe(true);
96
+ } finally {
97
+ rmSync(root, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ test('what the hook was told to use is what the audit tolerates', () => {
102
+ // The two halves of celilo#1000 agree by construction rather than by
103
+ // someone remembering to keep them in step.
104
+ const root = '/tmp/some-module';
105
+ const told = moduleStateDir(root);
106
+ const relative = told.slice(root.length + 1);
107
+ expect(classifyModulePath(`${relative}/anything.db`)).toBe('derived');
108
+ });
109
+ });
@@ -46,6 +46,7 @@ function contextFor(config: Record<string, unknown>): HookContext {
46
46
  logger: createCapturingLogger().logger,
47
47
  debug: false,
48
48
  screenshotDir: scratch(),
49
+ stateDir: scratch(),
49
50
  capabilities: {},
50
51
  };
51
52
  }
@@ -117,6 +117,7 @@ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[]
117
117
  logger,
118
118
  debug: false,
119
119
  screenshotDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
120
+ stateDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
120
121
  capabilities: {},
121
122
  };
122
123
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Hermetic guards on the hook jail's mount set (design D9).
3
+ *
4
+ * `deriveMountSet` is pure, so every property D9 asserts is checkable here
5
+ * without spawning anything. The two that matter most are absence properties:
6
+ * what the jail does NOT contain is the acceptance criterion.
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs } from './mount-set';
11
+
12
+ const BASE = {
13
+ modulePath: '/var/celilo/modules/caddy',
14
+ stateDir: '/var/celilo/modules/caddy/state',
15
+ socketDir: '/tmp/celilo-hook-a1b2c3',
16
+ runtimePath: '/usr/local/bin/bun',
17
+ runnerPath: '/opt/celilo/src/hooks/hook-runner.ts',
18
+ pathInputs: [],
19
+ };
20
+
21
+ const pathsOf = (r: Parameters<typeof deriveMountSet>[0]) =>
22
+ deriveMountSet(r).entries.map((e) => e.path);
23
+
24
+ describe('bwrap is never in the mount set', () => {
25
+ // The AppArmor profile grants userns to /usr/bin/bwrap for anyone on the
26
+ // box. A jailed hook that could exec it would be uid 0 with CAP_SYS_ADMIN in
27
+ // its own namespace. bwrap runs OUTSIDE the jail because it creates the
28
+ // jail, so withholding it costs nothing.
29
+ test('is absent from an ordinary derivation', () => {
30
+ expect(pathsOf(BASE).filter(isForbidden)).toEqual([]);
31
+ });
32
+
33
+ test('is dropped even when a contract input names it', () => {
34
+ // The realistic route back in is a developer debugging `bwrap: command not
35
+ // found` and adding it. This asserts the derivation refuses, rather than
36
+ // trusting nobody will ask.
37
+ const set = deriveMountSet({
38
+ ...BASE,
39
+ pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'read' }],
40
+ });
41
+ expect(set.entries.map((e) => e.path)).not.toContain('/usr/bin/bwrap');
42
+ });
43
+
44
+ test('every forbidden path is actually recognised', () => {
45
+ for (const p of forbiddenPaths()) expect(isForbidden(p)).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe('what the jail must not contain', () => {
50
+ test("celilo's data directory and its secrets are absent", () => {
51
+ const paths = pathsOf(BASE);
52
+ // Absence, not a check. These simply do not exist inside the jail.
53
+ expect(paths).not.toContain('/var/celilo');
54
+ expect(paths).not.toContain('/var/celilo/master.key');
55
+ expect(paths).not.toContain('/var/celilo/celilo.db');
56
+ });
57
+
58
+ test("a sibling module's tree is absent", () => {
59
+ const paths = pathsOf(BASE);
60
+ expect(paths).not.toContain('/var/celilo/modules');
61
+ expect(paths.some((p) => p.includes('/modules/technitium'))).toBe(false);
62
+ });
63
+
64
+ test('the celilo CLI is absent, so a hook cannot re-enter it (celilo#1121)', () => {
65
+ expect(pathsOf(BASE)).not.toContain('/usr/local/bin/celilo');
66
+ });
67
+ });
68
+
69
+ describe('ordering is semantic', () => {
70
+ test('the tmpfs leads, so it cannot erase the socket or a staged input', () => {
71
+ // Every staged contract input and the broker socket live under os.tmpdir().
72
+ // Bind them before the tmpfs and they vanish — and a hook whose backup_dir
73
+ // is silently empty SUCCEEDS and produces a backup containing nothing.
74
+ const set = deriveMountSet({
75
+ ...BASE,
76
+ pathInputs: [
77
+ { name: 'backup_dir', value: '/tmp/celilo-backup-9f/envelope/data', access: 'write' },
78
+ ],
79
+ });
80
+ const tmpfsAt = set.entries.findIndex((e) => e.mode === 'tmpfs' && e.path === '/tmp');
81
+ const socketAt = set.entries.findIndex((e) => e.path === BASE.socketDir);
82
+ const inputAt = set.entries.findIndex((e) => e.path.startsWith('/tmp/celilo-backup-'));
83
+
84
+ expect(tmpfsAt).toBeGreaterThanOrEqual(0);
85
+ expect(socketAt).toBeGreaterThan(tmpfsAt);
86
+ expect(inputAt).toBeGreaterThan(tmpfsAt);
87
+ });
88
+
89
+ test('writable directories come after the read-only module tree', () => {
90
+ const set = deriveMountSet({ ...BASE, screenshotDir: `${BASE.modulePath}/screenshots/run1` });
91
+ const treeAt = set.entries.findIndex((e) => e.path === BASE.modulePath && e.mode === 'ro');
92
+ for (const carved of ['state', 'generated', 'screenshots/run1']) {
93
+ const at = set.entries.findIndex((e) => e.path === `${BASE.modulePath}/${carved}`);
94
+ expect(at).toBeGreaterThan(treeAt);
95
+ expect(set.entries[at]?.mode).toBe('rw');
96
+ }
97
+ });
98
+ });
99
+
100
+ describe('contract inputs are bound at their declared access', () => {
101
+ test("'write' is read-write and 'read' is read-only", () => {
102
+ const set = deriveMountSet({
103
+ ...BASE,
104
+ pathInputs: [
105
+ { name: 'backup_dir', value: '/tmp/stage/data', access: 'write' },
106
+ { name: 'artifact_path', value: '/tmp/stage/db.sqlite', access: 'read' },
107
+ ],
108
+ });
109
+ expect(set.entries.find((e) => e.path === '/tmp/stage/data')?.mode).toBe('rw');
110
+ expect(set.entries.find((e) => e.path === '/tmp/stage/db.sqlite')?.mode).toBe('ro');
111
+ });
112
+
113
+ test('an undeclared input contributes nothing', () => {
114
+ // db_path was passed for months without being declared. A derivation that
115
+ // walks declarations cannot see it, which is the correct outcome here and
116
+ // the reason the contract has to declare what it passes (celilo#1118).
117
+ expect(pathsOf(BASE)).not.toContain('/var/celilo/celilo.db');
118
+ });
119
+ });
120
+
121
+ describe('paths are identical inside and outside', () => {
122
+ test('no entry is remapped', () => {
123
+ const args = toBwrapArgs(deriveMountSet(BASE));
124
+ for (let i = 0; i < args.length; i++) {
125
+ if (args[i] === '--bind' || args[i] === '--ro-bind') {
126
+ expect(args[i + 1]).toBe(args[i + 2] as string);
127
+ }
128
+ }
129
+ });
130
+
131
+ test('the jail names its own working directory', () => {
132
+ // The spawn sets no cwd, so the child inherits celilo's — a directory that
133
+ // usually does not exist inside the jail.
134
+ expect(deriveMountSet(BASE).chdir).toBe(BASE.modulePath);
135
+ expect(toBwrapArgs(deriveMountSet(BASE))).toContain('--chdir');
136
+ });
137
+ });
138
+
139
+ describe('~/.ssh is stage 2 only', () => {
140
+ test('bound read-only when supplied', () => {
141
+ const set = deriveMountSet({ ...BASE, sshDir: '/var/celilo/.ssh' });
142
+ expect(set.entries.find((e) => e.path === '/var/celilo/.ssh')?.mode).toBe('ro');
143
+ });
144
+
145
+ test('absent when not supplied, which is what stage 3 does', () => {
146
+ expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
147
+ });
148
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The hook jail's mount set (openspec/changes/hook-process-boundary, D9).
3
+ *
4
+ * A jailed hook sees exactly the paths listed here and nothing else. Not
5
+ * "denied" — ABSENT. A hook reaching for celilo's master key gets `ENOENT`,
6
+ * because inside the jail there is no such file. That is the acceptance
7
+ * criterion, and it is satisfied by absence rather than by a check.
8
+ *
9
+ * The set is DERIVED, never declared by the module. A module cannot ask for
10
+ * more, which is the whole point.
11
+ *
12
+ * This file is pure. It computes a description of a filesystem view and
13
+ * touches nothing. That is what lets `bwrap` is never in the mount set be a
14
+ * hermetic test rather than an integration one, and what lets the unjailed
15
+ * advisory lint (task 4.7) consume the same computation instead of a second
16
+ * one that can drift from it.
17
+ */
18
+
19
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
20
+ import type { PathAccess } from '@celilo/capabilities';
21
+
22
+ /**
23
+ * `tmpfs` is not an access level, it is "put a fresh empty filesystem here".
24
+ * It has to be its own mode because ORDER matters and a tmpfs erases whatever
25
+ * the jail would otherwise see at that path.
26
+ */
27
+ export type MountMode = 'ro' | 'rw' | 'tmpfs';
28
+
29
+ export interface MountEntry {
30
+ /**
31
+ * The path, IDENTICAL inside and outside the jail. Never remapped.
32
+ *
33
+ * Paths cross the capability boundary as strings: a hook hands the broker a
34
+ * path it wrote to, and the broker has to be able to read it. A remapped
35
+ * mount would make those two names disagree and the failure would look like
36
+ * a missing file rather than a translation bug.
37
+ */
38
+ readonly path: string;
39
+ readonly mode: MountMode;
40
+ /** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */
41
+ readonly reason: string;
42
+ }
43
+
44
+ export interface MountSet {
45
+ /**
46
+ * ORDER IS SEMANTIC. bubblewrap applies these in sequence and a later entry
47
+ * wins over an earlier one, which is what lets a read-write directory sit
48
+ * inside a read-only tree. Do not sort this list.
49
+ */
50
+ readonly entries: readonly MountEntry[];
51
+ /**
52
+ * The jail's working directory.
53
+ *
54
+ * The spawn does not set `cwd`, so a hook child inherits celilo's — whatever
55
+ * directory the operator's shell happened to be in. Inside the jail that
56
+ * directory usually does not exist, and bubblewrap fails on a path nobody
57
+ * chose. So the jail names one explicitly.
58
+ */
59
+ readonly chdir: string;
60
+ }
61
+
62
+ /** A path input the contract declared, paired with the value the framework resolved. */
63
+ export interface DeclaredPathInput {
64
+ readonly name: string;
65
+ readonly value: string;
66
+ readonly access: PathAccess;
67
+ }
68
+
69
+ export interface MountSetRequest {
70
+ /**
71
+ * The module's own tree. Comes from the DB as `module.sourcePath`, which is
72
+ * NOT guaranteed to sit under the module store — a restored database carries
73
+ * the absolute paths of the box it was taken from (ISS-0052). Resolved below
74
+ * before it becomes a bind-mount argument.
75
+ */
76
+ readonly modulePath: string;
77
+ /** `<module>/state`, celilo#1000's sanctioned writable directory. */
78
+ readonly stateDir: string;
79
+ /** `<module>/screenshots/<run>`, this run only. Absent when the hook takes none. */
80
+ readonly screenshotDir?: string;
81
+ /** The directory holding the broker's unix socket. */
82
+ readonly socketDir: string;
83
+ /** The interpreter celilo spawns (`process.execPath`). */
84
+ readonly runtimePath: string;
85
+ /** The runner shim, which lives in celilo's tree rather than the module's. */
86
+ readonly runnerPath: string;
87
+ /** Contract-declared path inputs, already resolved to values. */
88
+ readonly pathInputs: readonly DeclaredPathInput[];
89
+ /**
90
+ * The operator's `~/.ssh`, read-only, STAGE 2 ONLY.
91
+ *
92
+ * `remote.ts` still runs inside the hook and needs the key. Stage 3 brokers
93
+ * those calls and drops this row, which is what turns D12's target check
94
+ * from a convention into a boundary. Dropping it before stage 3 lands
95
+ * hardens nothing — it just stops every hook reaching its own systems.
96
+ */
97
+ readonly sshDir?: string;
98
+ }
99
+
100
+ /** Directories whose contents the runtime needs in order to start at all. */
101
+ const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
102
+
103
+ /**
104
+ * Paths that must NEVER appear in a mount set, whatever asks for them.
105
+ *
106
+ * `bwrap` is the load-bearing entry and the reason this list exists rather
107
+ * than being a comment. celilo ships an AppArmor profile granting `userns` to
108
+ * `/usr/bin/bwrap` so the jail can be built at all, and that grant applies to
109
+ * anyone on the box who runs it. A jailed hook that could exec `bwrap` would
110
+ * get a namespace of its own, be uid 0 inside it with `CAP_SYS_ADMIN`, and
111
+ * reach kernel surface an unprivileged user cannot otherwise touch — which is
112
+ * the entire reason Ubuntu restricts unprivileged user namespaces.
113
+ *
114
+ * `bwrap` runs OUTSIDE the jail because it is what creates the jail. It does
115
+ * not need to exist inside one, so leaving it out costs nothing.
116
+ *
117
+ * Task 4.10 already predicts how this gets undone: a browser hook fails with
118
+ * `bwrap: command not found`, a developer reads a missing binary and adds it,
119
+ * the suite goes green, and the escape path is open with nothing to read. The
120
+ * test on this constant is that missing thing to read.
121
+ */
122
+ const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const;
123
+
124
+ function entry(path: string, mode: MountMode, reason: string): MountEntry {
125
+ return { path, mode, reason };
126
+ }
127
+
128
+ /**
129
+ * Compute the filesystem view a hook gets.
130
+ *
131
+ * Pure: resolves paths lexically and reads nothing from disk. `resolve` is not
132
+ * `realpath` — it cannot follow a symlink, because following one is I/O. The
133
+ * caller supplies already-real paths; on macOS that distinction is the
134
+ * difference between a rule that applies and one that silently does not
135
+ * (task 4.8), so the caller's `realpath` is not optional.
136
+ */
137
+ export function deriveMountSet(request: MountSetRequest): MountSet {
138
+ const modulePath = resolve(request.modulePath);
139
+ const entries: MountEntry[] = [];
140
+
141
+ // 1. A private /tmp FIRST, because it erases everything beneath it.
142
+ //
143
+ // This has to lead. The broker's socket directory is an `mkdtemp` under
144
+ // `os.tmpdir()`, and every staged contract input (backup_dir, restore_dir,
145
+ // the cross-module roots) comes from `stagingDirFor`, also under
146
+ // `os.tmpdir()`. Bind those first and the tmpfs wipes them.
147
+ //
148
+ // The failure that causes is not a crash. A hook whose `backup_dir` is
149
+ // silently an empty tmpfs directory writes into it, returns success, and
150
+ // produces a backup containing NOTHING. It is found at restore. So the gate
151
+ // on this asserts the artifact is non-empty, never that the hook exited zero.
152
+ entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run'));
153
+
154
+ // 2. The runtime. Without it nothing runs, so it is not really a policy row.
155
+ entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
156
+ entries.push(entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns'));
157
+ for (const dir of RUNTIME_SUPPORT_DIRS) {
158
+ entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
159
+ }
160
+
161
+ // 3. The module's own tree, read-only, then its writable directories carved
162
+ // on top. bubblewrap resolves that in the right order, which is why the
163
+ // order here is not cosmetic.
164
+ //
165
+ // D9 says "the module's own tree is bound read-only". These are the
166
+ // carved exceptions to that sentence, and there are three of them rather
167
+ // than the one D9's prose implies.
168
+ entries.push(entry(modulePath, 'ro', "the module's own tree"));
169
+ entries.push(
170
+ entry(resolve(request.stateDir), 'rw', 'ctx.stateDir, the sanctioned writable directory'),
171
+ );
172
+ entries.push(
173
+ entry(join(modulePath, 'generated'), 'rw', "celilo's generated output the hook may amend"),
174
+ );
175
+ if (request.screenshotDir) {
176
+ entries.push(entry(resolve(request.screenshotDir), 'rw', 'ctx.screenshotDir, this run only'));
177
+ }
178
+
179
+ // 4. The broker channel. Bound AFTER the tmpfs, per the note above.
180
+ entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket'));
181
+
182
+ // 5. Contract-declared path inputs, at the access the contract declares.
183
+ // Never inferred from the name — see ContractField.path.
184
+ for (const input of request.pathInputs) {
185
+ entries.push(
186
+ entry(
187
+ resolve(input.value),
188
+ input.access === 'write' ? 'rw' : 'ro',
189
+ `contract input '${input.name}' (${input.access})`,
190
+ ),
191
+ );
192
+ }
193
+
194
+ // 6. Stage 2 only. See MountSetRequest.sshDir.
195
+ if (request.sshDir) {
196
+ entries.push(
197
+ entry(
198
+ resolve(request.sshDir),
199
+ 'ro',
200
+ 'remote.ts needs the key until the broker holds it (D12)',
201
+ ),
202
+ );
203
+ }
204
+
205
+ return {
206
+ entries: entries.filter((e) => !isForbidden(e.path)),
207
+ // The module's own tree is the only directory guaranteed to exist inside
208
+ // the jail and to mean something to the hook.
209
+ chdir: modulePath,
210
+ };
211
+ }
212
+
213
+ /** Is this path one nothing may ever mount? See NEVER_MOUNT. */
214
+ export function isForbidden(path: string): boolean {
215
+ const resolved = isAbsolute(path) ? resolve(path) : path;
216
+ return NEVER_MOUNT.some((forbidden) => resolved === forbidden);
217
+ }
218
+
219
+ /** The forbidden list, for the test that asserts it is honoured. */
220
+ export function forbiddenPaths(): readonly string[] {
221
+ return NEVER_MOUNT;
222
+ }
223
+
224
+ /** Render a mount set as bubblewrap arguments, in order. */
225
+ export function toBwrapArgs(set: MountSet): string[] {
226
+ const args: string[] = [];
227
+ for (const e of set.entries) {
228
+ if (e.mode === 'tmpfs') args.push('--tmpfs', e.path);
229
+ else if (e.mode === 'rw') args.push('--bind', e.path, e.path);
230
+ else args.push('--ro-bind', e.path, e.path);
231
+ }
232
+ args.push('--chdir', set.chdir);
233
+ return args;
234
+ }
@@ -22,7 +22,7 @@
22
22
  * change and requires a v2.0 contract.
23
23
  */
24
24
 
25
- import type { HookName } from '@celilo/capabilities';
25
+ import type { HookName, PathAccess } from '@celilo/capabilities';
26
26
 
27
27
  /**
28
28
  * Per-input/output metadata.
@@ -33,6 +33,21 @@ import type { HookName } from '@celilo/capabilities';
33
33
  */
34
34
  export interface ContractField {
35
35
  required: boolean;
36
+ /**
37
+ * Set when the framework supplies a filesystem path in this field, and the
38
+ * access the hook is given to it. Absent means "not a path".
39
+ *
40
+ * The hook jail derives its bind mounts from this (design D9). Path-ness is
41
+ * NEVER inferred from the field name at runtime: a name heuristic silently
42
+ * changes behaviour the day somebody adds an input called `workspace`, and
43
+ * the symptom is an ENOENT on a path that visibly exists on the box.
44
+ *
45
+ * A field carrying a path-shaped value with no annotation here is a defect.
46
+ * `db_path` was one for months — passed by `backup-create.ts` and declared
47
+ * nowhere, so anything reasoning from this table was wrong about what a
48
+ * backup hook receives.
49
+ */
50
+ path?: { access: PathAccess };
36
51
  }
37
52
 
38
53
  /**
@@ -112,7 +127,7 @@ export const V1_HOOKS: ContractHooks = {
112
127
  },
113
128
  on_backup: {
114
129
  inputs: {
115
- backup_dir: { required: true },
130
+ backup_dir: { required: true, path: { access: 'write' } },
116
131
  /**
117
132
  * Path to a directory containing read-only mirrors of OTHER modules'
118
133
  * `generated/terraform/` trees, plus an `index.json` enumerating
@@ -136,7 +151,7 @@ export const V1_HOOKS: ContractHooks = {
136
151
  * terraform.tfstate
137
152
  * terraform.tfstate.backup
138
153
  */
139
- cross_module_root: { required: false },
154
+ cross_module_root: { required: false, path: { access: 'read' } },
140
155
  },
141
156
  outputs: {
142
157
  artifact_count: { required: true },
@@ -146,7 +161,7 @@ export const V1_HOOKS: ContractHooks = {
146
161
  },
147
162
  on_backup_analyze: {
148
163
  inputs: {
149
- artifact_path: { required: true },
164
+ artifact_path: { required: true, path: { access: 'read' } },
150
165
  },
151
166
  outputs: {
152
167
  artifact_count: { required: true },
@@ -156,7 +171,7 @@ export const V1_HOOKS: ContractHooks = {
156
171
  },
157
172
  on_restore: {
158
173
  inputs: {
159
- restore_dir: { required: true },
174
+ restore_dir: { required: true, path: { access: 'read' } },
160
175
  schema_version: { required: true },
161
176
  /**
162
177
  * Path to a writable staging directory the framework atomically
@@ -170,7 +185,7 @@ export const V1_HOOKS: ContractHooks = {
170
185
  * a single rename + cleanup of any stale files, so a partial
171
186
  * write (hook crashed mid-restore) leaves the live state intact.
172
187
  */
173
- cross_module_write_root: { required: false },
188
+ cross_module_write_root: { required: false, path: { access: 'write' } },
174
189
  },
175
190
  outputs: {
176
191
  restored_items: { required: true },
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { moduleStateDir } from '@celilo/capabilities';
5
6
  import { moduleIntegrity, modules } from '../../db/schema';
6
7
  import { cleanupTestDatabase, setupTestDatabase } from '../../test-utils/database';
7
8
  import { auditModule } from './audit';
@@ -78,9 +79,14 @@ describe('celilo#1000: state/ is the hook-writable directory', () => {
78
79
  const before = await auditModule('state-gate', db);
79
80
  expect(before.violations).toEqual([]);
80
81
 
81
- // Now a hook runs and writes something nobody declared.
82
- mkdirSync(join(root, 'state'), { recursive: true });
83
- writeFileSync(join(root, 'state', unanticipatedName(42)), 'whatever the hook needed');
82
+ // Now a hook runs and writes something nobody declared. The path comes
83
+ // from the framework, NOT from this test: a gate that hand-rolls
84
+ // `join(root, 'state')` proves the audit tolerates a directory while
85
+ // saying nothing about whether a module can find it, which is exactly
86
+ // how the surface half of celilo#1000 shipped missing.
87
+ const stateDir = moduleStateDir(root);
88
+ mkdirSync(stateDir, { recursive: true });
89
+ writeFileSync(join(stateDir, unanticipatedName(42)), 'whatever the hook needed');
84
90
 
85
91
  const after = await auditModule('state-gate', db);
86
92
  expect(after.violations).toEqual([]);
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Every module's bundled `@celilo/*` range must be able to resolve the version
3
+ * the workspace is about to publish.
4
+ *
5
+ * The failure this exists to catch has no symptom. A module bundles its own
6
+ * copy of `@celilo/capabilities` and runs THAT copy (celilo#173), so when the
7
+ * workspace publishes a new major, a module still pinned to the old caret
8
+ * range keeps installing the old copy. Everything typechecks. Every test
9
+ * passes. The new export is simply unreachable to every module in the fleet,
10
+ * and nothing anywhere says so.
11
+ *
12
+ * That is not hypothetical. `firewall_registry` was registered in
13
+ * `CapabilityRegistry` and npm's published `@celilo/capabilities@2.6.0`
14
+ * contains zero occurrences of it. Every module bundled `^2.6.0`, and a caret
15
+ * range never crosses a major, so the registration reached nothing. It was
16
+ * found by a person reading the diff (celilo#1089), not by a gate.
17
+ *
18
+ * `npm-consumer-smoke` cannot see it: it installs from locally-packed tarballs
19
+ * and never consults a registry, which is exactly why this class drifts unseen.
20
+ * `check:modules` cannot see it either — it installs each module's DECLARED
21
+ * range and typechecks, so a module that does not yet USE the new export is
22
+ * perfectly happy on the old copy. Both gates are green while the thing is
23
+ * broken.
24
+ *
25
+ * ## THIS GATE IS DELIBERATELY NARROW. READ THIS BEFORE TRUSTING IT.
26
+ *
27
+ * Its name invites a stronger reading than it earns. It compares the WORKSPACE
28
+ * version against each module's range. It does NOT consult a registry, so it
29
+ * cannot see a gap between what is PUBLISHED and what a module bundles.
30
+ *
31
+ * Measured, not assumed: run against `00c12ec2^1` — the commit immediately
32
+ * before the Version Packages PR that shipped capabilities 3.0.0 — this gate
33
+ * passes 3/0. At that commit the workspace was 2.6.0 and every module was
34
+ * `^2.6.0`, which satisfies, while npm's published 2.6.0 contained zero
35
+ * occurrences of `firewall_registry`. **So it would not have caught
36
+ * celilo#1089, the bug it was written for.** Nor `hello-trespass` sitting at
37
+ * `^2.3.0`, because `satisfies('2.6.0', '^2.3.0')` is true: a caret crosses
38
+ * minors freely and stops only at a major.
39
+ *
40
+ * If you are here because a published export turned out to be unreachable and
41
+ * you are wondering why this was green: it never looked. That check needs a
42
+ * registry query, which is network-bound and therefore does not belong beside
43
+ * hermetic checks in `ci/validate` — a flaky gate gets disabled. Its home is
44
+ * the release pipeline or a scheduled job.
45
+ *
46
+ * And one limitation no in-repo gate can ever close, however it is written:
47
+ * out-of-repo modules. `lunacycle` lives outside this checkout and no sweep
48
+ * here can see its pin at all (lunacycle#64).
49
+ *
50
+ * What this DOES catch is a module whose pin is out of lockstep with the
51
+ * workspace, which happens when a module is added or edited outside the
52
+ * release sweep's window: the Version Packages PR is open with everything at
53
+ * `^3.0.0`, a new module merges to main at `^2.6.0`, the version PR merges,
54
+ * and nothing re-runs the sweep until the next release.
55
+ *
56
+ * `scripts/sync-consumer-pins.ts` is what normally keeps these aligned, and it
57
+ * runs inside the version-PR phase of `release.yml`. This gate is the check
58
+ * that it RAN and covered everything — a module added between releases carries
59
+ * whatever range its author typed until the next release sweeps it.
60
+ */
61
+
62
+ import { describe, expect, test } from 'bun:test';
63
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
64
+ import { join } from 'node:path';
65
+ import { repoRoot } from './capability-shape';
66
+
67
+ interface PackageJson {
68
+ name?: string;
69
+ version?: string;
70
+ dependencies?: Record<string, string>;
71
+ devDependencies?: Record<string, string>;
72
+ }
73
+
74
+ function readJson(path: string): PackageJson {
75
+ return JSON.parse(readFileSync(path, 'utf-8')) as PackageJson;
76
+ }
77
+
78
+ /** `@celilo/*` package name → the version this checkout would publish. */
79
+ function workspaceVersions(root: string): Map<string, string> {
80
+ const versions = new Map<string, string>();
81
+ for (const dir of ['packages', 'apps']) {
82
+ const base = join(root, dir);
83
+ if (!existsSync(base)) continue;
84
+ for (const entry of readdirSync(base)) {
85
+ const manifest = join(base, entry, 'package.json');
86
+ if (!existsSync(manifest)) continue;
87
+ const pkg = readJson(manifest);
88
+ if (pkg.name?.startsWith('@celilo/') && pkg.version) versions.set(pkg.name, pkg.version);
89
+ }
90
+ }
91
+ return versions;
92
+ }
93
+
94
+ interface ModuleDep {
95
+ module: string;
96
+ dep: string;
97
+ range: string;
98
+ }
99
+
100
+ /** Every `@celilo/*` dependency declared by any module's `scripts/` package. */
101
+ function moduleDeps(root: string): ModuleDep[] {
102
+ const out: ModuleDep[] = [];
103
+ const modulesDir = join(root, 'modules');
104
+ for (const module of readdirSync(modulesDir)) {
105
+ const manifest = join(modulesDir, module, 'scripts', 'package.json');
106
+ if (!existsSync(manifest)) continue;
107
+ const pkg = readJson(manifest);
108
+ for (const [dep, range] of Object.entries({
109
+ ...(pkg.dependencies ?? {}),
110
+ ...(pkg.devDependencies ?? {}),
111
+ })) {
112
+ if (dep.startsWith('@celilo/')) out.push({ module, dep, range });
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ describe('a module can reach what the workspace publishes', () => {
119
+ const root = repoRoot();
120
+ const versions = workspaceVersions(root);
121
+ const deps = moduleDeps(root);
122
+
123
+ test('the scan found the workspace packages and the module deps', () => {
124
+ // An empty scan passes every assertion below while checking nothing, which
125
+ // is the failure mode that makes a green gate worse than no gate.
126
+ expect(versions.size).toBeGreaterThan(0);
127
+ expect(deps.length).toBeGreaterThan(0);
128
+ expect(versions.has('@celilo/capabilities')).toBe(true);
129
+ });
130
+
131
+ test('every module range resolves the version this checkout would publish', () => {
132
+ const unreachable = deps
133
+ .filter(({ dep, range }) => {
134
+ const version = versions.get(dep);
135
+ // A dep on a package this checkout does not build is out of scope: it
136
+ // resolves from the registry like any third-party dependency.
137
+ if (!version) return false;
138
+ return !Bun.semver.satisfies(version, range);
139
+ })
140
+ .map(
141
+ ({ module, dep, range }) =>
142
+ `${module} pins ${dep}@${range}, which cannot resolve the workspace's ${versions.get(dep)}`,
143
+ )
144
+ .sort();
145
+
146
+ const guidance = [
147
+ 'A module bundles its own copy and runs THAT copy (celilo#173). A range that',
148
+ 'cannot reach the version about to be published means every export added',
149
+ 'since is unreachable to that module, silently.',
150
+ '',
151
+ 'Fix: bun scripts/sync-consumer-pins.ts (then commit the rewritten pins)',
152
+ '',
153
+ ...unreachable,
154
+ ].join('\n');
155
+
156
+ expect(unreachable, guidance).toEqual([]);
157
+ });
158
+
159
+ test('PROVE IT FAILS: a caret range does not cross a major', () => {
160
+ // The exact shape of celilo#1089: modules on ^2.6.0, workspace at 3.0.0.
161
+ expect(Bun.semver.satisfies('3.0.0', '^2.6.0')).toBe(false);
162
+ // And the near-miss that makes it easy to believe you are covered: a caret
163
+ // DOES cross a minor, so a lagging pin looks harmless right up until the
164
+ // major.
165
+ expect(Bun.semver.satisfies('2.6.0', '^2.3.0')).toBe(true);
166
+ });
167
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CLIContext } from './cli-context';
11
+ import { getModuleTestConfig } from './fixtures';
11
12
  import { type IntegrationTestContext, setupIntegrationTest } from './integration';
12
13
 
13
14
  /**
@@ -38,6 +39,52 @@ interface HomebridgeModuleOptions extends BaseFixtureOptions {
38
39
  withMockInfrastructure?: boolean;
39
40
  }
40
41
 
42
+ /**
43
+ * A verified container service covering every allocatable zone, so a fixture
44
+ * can reach `module generate`.
45
+ *
46
+ * Extracted rather than copied: generation refuses without infrastructure, so
47
+ * every fixture that generates needs this, and a second inline copy would drift
48
+ * from the first the moment `containerServices` gains a column.
49
+ */
50
+ async function insertMockContainerService(dbPath: string): Promise<void> {
51
+ const { getDb } = await import('../db/client');
52
+ const { containerServices } = await import('../db/schema');
53
+ const originalDbPath = process.env.CELILO_DB_PATH;
54
+ process.env.CELILO_DB_PATH = dbPath;
55
+ try {
56
+ const db = getDb();
57
+ await db.insert(containerServices).values({
58
+ id: 'test-proxmox',
59
+ serviceId: 'test-proxmox',
60
+ name: 'Test Proxmox',
61
+ providerName: 'proxmox',
62
+ zones: ['internal', 'dmz', 'app', 'secure'],
63
+ apiCredentialsEncrypted: JSON.stringify({
64
+ encryptedValue: 'dummy',
65
+ iv: 'dummy',
66
+ authTag: 'dummy',
67
+ }),
68
+ providerConfig: {
69
+ default_target_node: 'pve',
70
+ lxc_template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
71
+ storage: 'local-lvm',
72
+ },
73
+ verified: true,
74
+ verifiedAt: new Date(),
75
+ verificationError: null,
76
+ createdAt: new Date(),
77
+ updatedAt: new Date(),
78
+ });
79
+ } finally {
80
+ if (originalDbPath) {
81
+ process.env.CELILO_DB_PATH = originalDbPath;
82
+ } else {
83
+ process.env.CELILO_DB_PATH = undefined;
84
+ }
85
+ }
86
+ }
87
+
41
88
  /**
42
89
  * Homebridge module fixture result
43
90
  */
@@ -83,41 +130,7 @@ export async function homebridgeModule(
83
130
 
84
131
  // Setup mock infrastructure if requested (needed for generation)
85
132
  if (options.withMockInfrastructure) {
86
- const { getDb } = await import('../db/client');
87
- const { containerServices } = await import('../db/schema');
88
- const originalDbPath = process.env.CELILO_DB_PATH;
89
- process.env.CELILO_DB_PATH = ctx.dbPath;
90
- try {
91
- const db = getDb();
92
- await db.insert(containerServices).values({
93
- id: 'test-proxmox',
94
- serviceId: 'test-proxmox',
95
- name: 'Test Proxmox',
96
- providerName: 'proxmox',
97
- zones: ['internal', 'dmz', 'app', 'secure'],
98
- apiCredentialsEncrypted: JSON.stringify({
99
- encryptedValue: 'dummy',
100
- iv: 'dummy',
101
- authTag: 'dummy',
102
- }),
103
- providerConfig: {
104
- default_target_node: 'pve',
105
- lxc_template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst',
106
- storage: 'local-lvm',
107
- },
108
- verified: true,
109
- verifiedAt: new Date(),
110
- verificationError: null,
111
- createdAt: new Date(),
112
- updatedAt: new Date(),
113
- });
114
- } finally {
115
- if (originalDbPath) {
116
- process.env.CELILO_DB_PATH = originalDbPath;
117
- } else {
118
- process.env.CELILO_DB_PATH = undefined;
119
- }
120
- }
133
+ await insertMockContainerService(ctx.dbPath);
121
134
  }
122
135
 
123
136
  // Create CLI context with integration test env
@@ -195,6 +208,105 @@ export async function homebridgeModule(
195
208
  };
196
209
  }
197
210
 
211
+ /**
212
+ * `dnsmasq-dhcp` fixture, for the golden comparison.
213
+ *
214
+ * ⚠️ IMPORTS THE REAL MODULE, `../../modules/dnsmasq-dhcp`, and not a copy under
215
+ * `test-fixtures/modules/`. That is the whole point of it.
216
+ *
217
+ * `homebridgeModule` imports a copy, and the copy has drifted 29 lines in the
218
+ * manifest and 6 in the Terraform template. So the golden gate compares
219
+ * generated-from-a-copy against golden-from-the-same-copy: both sides move
220
+ * together, and the shipped module is on neither. Worse, the copy is frozen at
221
+ * `version: 1.0.0` with the container-infrastructure variables `required: true`
222
+ * — the pre-fix state of a real bug (marking them required made homebridge
223
+ * container-only). The gate still passes against that shape and cannot notice
224
+ * it returning. Tracked as celilo#1084.
225
+ *
226
+ * Pointing at the shipped module means this golden fails when the generator's
227
+ * output for the shipped module changes, which is the only version worth having.
228
+ */
229
+ export interface DnsmasqDhcpModuleFixture {
230
+ cli: CLIContext;
231
+ moduleId: string;
232
+ config: Record<string, unknown>;
233
+ context: IntegrationTestContext;
234
+ cleanup: () => Promise<void>;
235
+ }
236
+
237
+ export interface DnsmasqDhcpModuleOptions extends BaseFixtureOptions {
238
+ /** Seed a container service, without which generation has no infrastructure. */
239
+ withMockInfrastructure?: boolean;
240
+ /** Set the `network.internal.*` system config the templates resolve against. */
241
+ withSystemConfig?: boolean;
242
+ }
243
+
244
+ export async function dnsmasqDhcpModule(
245
+ options: DnsmasqDhcpModuleOptions = {},
246
+ ): Promise<DnsmasqDhcpModuleFixture> {
247
+ const ctx = await setupIntegrationTest();
248
+
249
+ if (options.withMockInfrastructure) {
250
+ await insertMockContainerService(ctx.dbPath);
251
+ }
252
+
253
+ const cli = await CLIContext.create('src/cli/index.ts', {
254
+ CELILO_DB_PATH: ctx.dbPath,
255
+ CELILO_DATA_DIR: ctx.dataDir,
256
+ });
257
+
258
+ const moduleId = 'dnsmasq-dhcp';
259
+
260
+ if (options.withSystemConfig) {
261
+ // `internal` only — it is the single zone this module declares, and adding
262
+ // others would put values in the golden that nothing generated from.
263
+ (await cli.run('system config set network.bridge vmbr0')).expectSuccess();
264
+ (await cli.run('system config set network.internal.vlan 1')).expectSuccess();
265
+ (await cli.run('system config set network.internal.gateway 10.99.1.1')).expectSuccess();
266
+ (await cli.run('system config set network.internal.subnet 10.99.1.0/24')).expectSuccess();
267
+ (
268
+ await cli.run(
269
+ 'system config set ssh.public_key ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... test@celilo',
270
+ )
271
+ ).expectSuccess();
272
+ }
273
+
274
+ // Read from `test-fixtures/test-values.yml` rather than written out here, so
275
+ // that file's entry for this module is load-bearing rather than decorative. A
276
+ // golden generated from values nothing else references would drift from the
277
+ // canonical set with nothing noticing.
278
+ //
279
+ // That entry carries EVERY `required: true` variable, including the ones with
280
+ // defaults: celilo asks for a required variable whether or not it has one,
281
+ // and an unanswered ask has no headless answer, so the command blocks until
282
+ // its timeout naming no variable.
283
+ const config = await getModuleTestConfig(moduleId);
284
+ if (Object.keys(config).length === 0) {
285
+ throw new Error(
286
+ `test-values.yml carries no entry for '${moduleId}', so this fixture would generate from an empty config and the golden would record that. Add one.`,
287
+ );
288
+ }
289
+
290
+ (await cli.run(`module import ../../modules/${moduleId}`)).expectSuccess();
291
+
292
+ for (const [key, value] of Object.entries(config)) {
293
+ // An array has to reach the CLI as JSON; a scalar is quoted as-is.
294
+ const encoded = Array.isArray(value) ? `'${JSON.stringify(value)}'` : `"${value}"`;
295
+ (await cli.run(`module config set ${moduleId} ${key} ${encoded}`)).expectSuccess();
296
+ }
297
+
298
+ if (options.generated) {
299
+ (await cli.run(`module generate ${moduleId}`)).expectSuccess();
300
+ }
301
+
302
+ const cleanup = async () => {
303
+ await cli.dispose();
304
+ await ctx.cleanup();
305
+ };
306
+
307
+ return { cli, moduleId, config, context: ctx, cleanup };
308
+ }
309
+
198
310
  /**
199
311
  * Caddy module fixture options
200
312
  */