@celilo/cli 1.7.0 → 1.9.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 (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -0,0 +1,103 @@
1
+ /**
2
+ * celilo#1003: a hook that times out must be KILLED, not abandoned.
3
+ *
4
+ * `module-lifecycle`'s spec has always required this:
5
+ *
6
+ * > WHEN a hook produces no output for longer than the idle timeout
7
+ * > THEN celilo SHALL terminate it rather than hang indefinitely
8
+ *
9
+ * The old executor raced the hook's promise against a timer and cancelled
10
+ * nothing, because a promise cannot be cancelled. The existing suite asserted
11
+ * the rejection, which held, and the requirement did not.
12
+ *
13
+ * So these tests assert the harm rather than the rejection: a marker file the
14
+ * hook writes only after its bound has passed. Both of them fail against the
15
+ * in-process executor and neither says anything about how the kill is
16
+ * implemented.
17
+ */
18
+
19
+ import { afterEach, describe, expect, test } from 'bun:test';
20
+ import { execSync } from 'node:child_process';
21
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { executeHookScript } from './executor';
25
+ import { createCapturingLogger } from './logger';
26
+ import type { HookContext } from './types';
27
+
28
+ const FIXTURES = join(__dirname, 'test-fixtures');
29
+ const dirs: string[] = [];
30
+
31
+ afterEach(() => {
32
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ function scratch(): string {
36
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-timeout-'));
37
+ dirs.push(dir);
38
+ return dir;
39
+ }
40
+
41
+ function contextFor(config: Record<string, unknown>): HookContext {
42
+ return {
43
+ config,
44
+ secrets: {},
45
+ systems: [],
46
+ logger: createCapturingLogger().logger,
47
+ debug: false,
48
+ screenshotDir: scratch(),
49
+ capabilities: {},
50
+ };
51
+ }
52
+
53
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
54
+
55
+ /**
56
+ * Hook processes still parented to this one. By ppid rather than by script
57
+ * name: matching the name picks up whatever shell has the filename in its own
58
+ * command line, which is a false positive that looks exactly like a real leak.
59
+ */
60
+ function survivingHookProcesses(): string[] {
61
+ return execSync('ps -Ao ppid=,args=', { encoding: 'utf-8' })
62
+ .split('\n')
63
+ .filter(
64
+ (line) => Number.parseInt(line.trim(), 10) === process.pid && line.includes('hook-runner'),
65
+ );
66
+ }
67
+
68
+ describe('hook timeout is a kill, not a race', () => {
69
+ test('a hook that outruns its total timeout stops doing work', async () => {
70
+ const marker = join(scratch(), 'kept-running');
71
+
72
+ await expect(
73
+ executeHookScript(
74
+ join(FIXTURES, 'runaway-hook.ts'),
75
+ contextFor({ sleep_ms: 1500, marker_path: marker }),
76
+ 400,
77
+ 400,
78
+ ),
79
+ ).rejects.toThrow(/timeout/i);
80
+
81
+ // Past when the abandoned hook would have written it.
82
+ await sleep(2000);
83
+ expect(existsSync(marker)).toBe(false);
84
+ expect(survivingHookProcesses()).toEqual([]);
85
+ }, 15_000);
86
+
87
+ test('a hook that declines SIGTERM is killed anyway', async () => {
88
+ const marker = join(scratch(), 'survived');
89
+
90
+ await expect(
91
+ executeHookScript(
92
+ join(FIXTURES, 'sigterm-ignoring-hook.ts'),
93
+ contextFor({ sleep_ms: 6000, marker_path: marker }),
94
+ 400,
95
+ 400,
96
+ ),
97
+ ).rejects.toThrow(/timeout/i);
98
+
99
+ await sleep(6500);
100
+ expect(existsSync(marker)).toBe(false);
101
+ expect(survivingHookProcesses()).toEqual([]);
102
+ }, 20_000);
103
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The recurrence gate for openspec/changes/hook-process-boundary (celilo#1001).
3
+ *
4
+ * Runs `modules/hello-trespass`'s hook through `executeHookScript` and asserts
5
+ * exactly what the stage that has landed claims, and nothing more.
6
+ *
7
+ * **Stage 1 claims the environment. It does not claim the filesystem and it
8
+ * does not claim the network.** So this file asserts the sensitive environment
9
+ * is empty, and asserts the other three trespasses STILL SUCCEED. A gate that
10
+ * claimed more than its stage delivers would go green for the wrong reason and
11
+ * would have to be rewritten — quietly weakening it — the first time somebody
12
+ * noticed. Stage 2 flips the two filesystem rows and stage 3 flips the SSH row;
13
+ * each stage edits the assertion it earns.
14
+ *
15
+ * `HOME` is redirected to a scratch directory holding a planted master key and
16
+ * a planted SSH key, so the trespasses are deterministic and the operator's
17
+ * real key is never read (CLAUDE.md: never test against live data). `HOME` is
18
+ * on design D5's allow-list, so the child resolves the same planted paths the
19
+ * parent planted — which is the point of D5's own caveat: removing
20
+ * `CELILO_DATA_DIR` does not hide a path a hook can compute.
21
+ */
22
+
23
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { dirname, join, resolve } from 'node:path';
28
+ import { executeHookScript, hookChildEnv } from './executor';
29
+ import { createCapturingLogger } from './logger';
30
+ import type { HookContext } from './types';
31
+
32
+ const TRESPASS_SCRIPT = resolve(
33
+ __dirname,
34
+ '../../../../modules/hello-trespass/scripts/trespass.ts',
35
+ );
36
+
37
+ interface TrespassOutcome {
38
+ attempted: boolean;
39
+ succeeded: boolean;
40
+ target: string;
41
+ detail: string;
42
+ }
43
+
44
+ interface TrespassReport {
45
+ master_key: TrespassOutcome;
46
+ sibling_write: TrespassOutcome;
47
+ ssh_key: TrespassOutcome;
48
+ remote_exec: TrespassOutcome;
49
+ sensitive_env: string[];
50
+ }
51
+
52
+ let scratchHome: string;
53
+ let realHome: string | undefined;
54
+
55
+ /**
56
+ * Install the fixture module's own dependencies if they are not there.
57
+ *
58
+ * A `modules/<id>/scripts` directory is a standalone package, deliberately
59
+ * outside the root workspace globs so that a module resolves the PUBLISHED
60
+ * `@celilo/capabilities` it will actually run on the fleet rather than the
61
+ * workspace copy. bun links workspace dependencies into each package's own
62
+ * `node_modules` and never into the repo root, so there is nothing here for
63
+ * the fixture to walk up to: with no install of its own the hook dies with
64
+ * `Cannot find module '@celilo/capabilities'`.
65
+ *
66
+ * `bun run setup` does this for every module, and CI does it in its
67
+ * check-modules step — which runs LAST on purpose, because twenty-two installs
68
+ * is the slowest thing in that job. This gate is the first test to reach into
69
+ * a module's script tree, so it prepares the one fixture it needs instead of
70
+ * making every PR pay for all of them up front.
71
+ *
72
+ * It installs rather than skipping. A gate that quietly does not run is the
73
+ * exact failure this whole change exists to make impossible.
74
+ */
75
+ function ensureFixtureInstalled(): void {
76
+ const scriptsDir = dirname(TRESPASS_SCRIPT);
77
+ if (existsSync(join(scriptsDir, 'node_modules', '@celilo', 'capabilities'))) return;
78
+ // `process.execPath`, not `bun` on PATH: this is the bun already running the
79
+ // suite, so it works wherever the suite does.
80
+ execFileSync(process.execPath, ['install'], { cwd: scriptsDir, stdio: 'pipe' });
81
+ }
82
+
83
+ beforeAll(() => {
84
+ ensureFixtureInstalled();
85
+ scratchHome = mkdtempSync(join(tmpdir(), 'celilo-trespass-home-'));
86
+
87
+ // The master key at getMasterKeyPath()'s DEFAULT location for this platform.
88
+ // Both branches are planted rather than the current one only, so the fixture
89
+ // reads a planted key whichever host runs the suite.
90
+ for (const dir of [
91
+ join(scratchHome, 'Library', 'Application Support', 'celilo'),
92
+ join(scratchHome, '.local', 'share', 'celilo'),
93
+ ]) {
94
+ mkdirSync(dir, { recursive: true });
95
+ writeFileSync(join(dir, 'master.key'), 'not-the-real-key\n');
96
+ }
97
+
98
+ mkdirSync(join(scratchHome, '.ssh'), { recursive: true });
99
+ writeFileSync(join(scratchHome, '.ssh', 'id_ed25519'), 'not-a-real-key\n');
100
+
101
+ realHome = process.env.HOME;
102
+ process.env.HOME = scratchHome;
103
+ });
104
+
105
+ afterAll(() => {
106
+ if (realHome === undefined) delete process.env.HOME;
107
+ else process.env.HOME = realHome;
108
+ rmSync(scratchHome, { recursive: true, force: true });
109
+ });
110
+
111
+ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[] }> {
112
+ const { logger, messages } = createCapturingLogger();
113
+ const context: HookContext = {
114
+ config: { sibling_module_id: 'hello-foo', other_system_ip: '' },
115
+ secrets: {},
116
+ systems: [],
117
+ logger,
118
+ debug: false,
119
+ screenshotDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
120
+ capabilities: {},
121
+ };
122
+
123
+ const outputs = await executeHookScript(TRESPASS_SCRIPT, context, 60_000, 60_000);
124
+ return {
125
+ report: outputs as unknown as TrespassReport,
126
+ lines: messages.map((m) => m.message),
127
+ };
128
+ }
129
+
130
+ describe('the child environment is an allow-list', () => {
131
+ // The trespass gate below proves nothing sensitive gets through. This is the
132
+ // complement: the six things that MUST get through, because a hook with no
133
+ // `PATH` cannot spawn anything and one with no `HOME` breaks more than it
134
+ // protects.
135
+ test('forwards the allow-list and the channel, and nothing else', () => {
136
+ process.env.CELILO_TEST_SECRET = 'must-not-cross';
137
+ try {
138
+ const env = hookChildEnv('/tmp/celilo-hook-x/s');
139
+
140
+ expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
141
+ expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe('1');
142
+ expect(env.PATH).toBe(process.env.PATH as string);
143
+ expect(env.HOME).toBe(process.env.HOME as string);
144
+ expect(env.CELILO_TEST_SECRET).toBeUndefined();
145
+
146
+ const allowed = new Set([
147
+ 'PATH',
148
+ 'HOME',
149
+ 'LANG',
150
+ 'TZ',
151
+ 'TMPDIR',
152
+ 'CELILO_DEBUG',
153
+ 'CELILO_HOOK_SOCKET',
154
+ 'CELILO_HOOK_PROTOCOL_VERSION',
155
+ ]);
156
+ expect(Object.keys(env).filter((name) => !allowed.has(name))).toEqual([]);
157
+ } finally {
158
+ delete process.env.CELILO_TEST_SECRET;
159
+ }
160
+ });
161
+
162
+ test('omits an allow-listed variable the parent does not have', () => {
163
+ const saved = process.env.TZ;
164
+ delete process.env.TZ;
165
+ try {
166
+ // Absent, not the string "undefined" — which is what a naive copy
167
+ // produces and what a shell then happily uses as a timezone.
168
+ expect('TZ' in hookChildEnv('/tmp/s')).toBe(false);
169
+ } finally {
170
+ if (saved !== undefined) process.env.TZ = saved;
171
+ }
172
+ });
173
+ });
174
+
175
+ describe('hook process boundary — hello-trespass gate', () => {
176
+ test('stage 1: the environment is clean, and nothing else has changed', async () => {
177
+ const { report, lines } = await runTrespass();
178
+
179
+ // The evidence, printed whether the assertions pass or fail. Without it a
180
+ // red run says which assertion broke and not what the hook actually saw.
181
+ console.log(['', 'hello-trespass:', ...lines.slice(1)].join('\n'));
182
+
183
+ // What stage 1 claims. Empty because the child's environment is an
184
+ // allow-list, not `...process.env` (design D5).
185
+ expect(report.sensitive_env).toEqual([]);
186
+
187
+ // What stage 1 deliberately does NOT claim. The master key is still
188
+ // readable, because the hook computes the default path rather than
189
+ // reading it out of the environment. Stage 2's mount set is what removes
190
+ // it, by not binding the data directory at all.
191
+ expect(report.master_key.succeeded).toBe(true);
192
+
193
+ // Nor the sibling's tree: the module store is still one `..` away.
194
+ expect(report.sibling_write.succeeded).toBe(true);
195
+
196
+ // Nor the SSH key, which is what scopes reachability in stage 3 (D12).
197
+ // `attempted: false` means the host has no private key at all — a bare CI
198
+ // runner — which is an absent precondition, not a refusal.
199
+ expect(report.ssh_key.attempted && !report.ssh_key.succeeded).toBe(false);
200
+ });
201
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Every name the loader can put in `ctx.capabilities` must be a registered
3
+ * capability.
4
+ *
5
+ * This is the gate for the class of bug, not for its instances. Twice now a
6
+ * capability has been injected under a literal name that appeared in no
7
+ * registry and, in one case, had no declared type anywhere:
8
+ *
9
+ * - `web_routes`, whose two methods were synchronous. The hook process
10
+ * boundary's D2 measured the hook-facing surface as uniformly async by
11
+ * reading `CapabilityRegistry`, so it never saw them. caddy's `on_install`
12
+ * died on `{} is not iterable` when the async proxy handed it a Promise.
13
+ * - `firewall_registry`, which had no `FirewallRegistryCapability` type, no
14
+ * registry entry, and no contract version. It happens to be async, so it
15
+ * broke nothing, which is exactly why nobody found it.
16
+ *
17
+ * `type-invariants.test.ts` proves every REGISTERED capability's methods are
18
+ * async. That proof is only worth as much as the registry's completeness, and
19
+ * completeness is what this file checks. Together they close the loop: the
20
+ * registry names everything injected, and everything named is async.
21
+ *
22
+ * It reads the loader's source because the property is about what the code can
23
+ * assign, not about what one run happens to produce. A runtime test would need
24
+ * every provider deployed in every combination to see all the branches.
25
+ */
26
+
27
+ import { describe, expect, test } from 'bun:test';
28
+ import { readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { KNOWN_CAPABILITY_NAMES } from '@celilo/capabilities';
31
+
32
+ const LOADER = join(__dirname, 'capability-loader.ts');
33
+
34
+ /**
35
+ * Literal names assigned into the capability map, e.g. `result.web_routes =`.
36
+ *
37
+ * Computed assignments (`result[capName] =`) are deliberately not matched:
38
+ * `capName` is already a registry name, since it comes from the capability
39
+ * table keyed by the same list.
40
+ */
41
+ function injectedCapabilityNames(source: string): string[] {
42
+ const names = new Set<string>();
43
+ for (const match of source.matchAll(/^\s*result\.([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)) {
44
+ names.add(match[1]);
45
+ }
46
+ return [...names].sort();
47
+ }
48
+
49
+ describe('capabilities injected into the hook context', () => {
50
+ test('every literally-named injection is a registered capability', () => {
51
+ const injected = injectedCapabilityNames(readFileSync(LOADER, 'utf-8'));
52
+
53
+ // If this is empty the regex has drifted and the gate is checking nothing,
54
+ // which is the failure mode that makes a green test worse than no test.
55
+ expect(injected.length).toBeGreaterThan(0);
56
+
57
+ const known: readonly string[] = KNOWN_CAPABILITY_NAMES;
58
+ const unregistered = injected.filter((name) => !known.includes(name));
59
+
60
+ expect(unregistered).toEqual([]);
61
+ });
62
+
63
+ test('PROVE IT FAILS: an unregistered name is caught', () => {
64
+ const withNewInjection = `
65
+ result.public_web = createPublicWeb({});
66
+ result.brand_new_view = somethingUndeclared;
67
+ `;
68
+ const known: readonly string[] = KNOWN_CAPABILITY_NAMES;
69
+ const unregistered = injectedCapabilityNames(withNewInjection).filter(
70
+ (name) => !known.includes(name),
71
+ );
72
+
73
+ expect(unregistered).toEqual(['brand_new_view']);
74
+ });
75
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Test fixture: a hook that exercises every shape a capability call can take
3
+ * across the process boundary.
4
+ *
5
+ * One test per SHAPE rather than per method: the broker is generic, so a
6
+ * per-method suite would prove the same thing thirty-seven times and drift the
7
+ * moment a capability gained a method.
8
+ */
9
+
10
+ import { defineHook, isMissingProviderInputError } from '@celilo/capabilities';
11
+
12
+ interface DemoCapability {
13
+ providerModuleId?: string;
14
+ version?: string;
15
+ echo(request: Record<string, unknown>): Promise<unknown>;
16
+ boom(request: Record<string, unknown>): Promise<unknown>;
17
+ missingInput(request: Record<string, unknown>): Promise<unknown>;
18
+ returnsNothing(request: Record<string, unknown>): Promise<unknown>;
19
+ /** Never implemented by this provider — the absent-optional-method case. */
20
+ sometimesAbsent?(request: Record<string, unknown>): Promise<unknown>;
21
+ }
22
+
23
+ export default defineHook({
24
+ hook: 'container_created',
25
+ requires: [],
26
+ handler: async (ctx) => {
27
+ // `demo` is a fixture capability, not a registry entry, so the typed
28
+ // capability map does not know it. The broker is generic and does not care.
29
+ const demo = (ctx.capabilities as unknown as Record<string, DemoCapability>).demo;
30
+ const outputs: Record<string, unknown> = {};
31
+
32
+ // Non-function properties are copied verbatim, which is what keeps
33
+ // `providerModuleId` readable — a hook names the provider in its errors.
34
+ outputs.providerModuleId = demo.providerModuleId;
35
+ outputs.version = demo.version;
36
+
37
+ // An optional method the provider did not implement must be ABSENT, not a
38
+ // proxy that throws, or `if (cap.registerTrustedSource)` answers wrongly.
39
+ outputs.optionalMethodAbsent = demo.sometimesAbsent === undefined;
40
+
41
+ outputs.returned = await demo.echo({ x: 1, nested: { y: [2, 3] } });
42
+ outputs.undefinedBecomesNull = await demo.returnsNothing({});
43
+
44
+ try {
45
+ await demo.boom({});
46
+ outputs.plainThrow = 'did not throw';
47
+ } catch (error) {
48
+ outputs.plainThrow = {
49
+ isError: error instanceof Error,
50
+ name: (error as Error).name,
51
+ message: (error as Error).message,
52
+ hasStack: typeof (error as Error).stack === 'string',
53
+ };
54
+ }
55
+
56
+ try {
57
+ await demo.missingInput({});
58
+ outputs.missingProviderInput = 'did not throw';
59
+ } catch (error) {
60
+ const e = error as Record<string, unknown>;
61
+ outputs.missingProviderInput = {
62
+ recognised: isMissingProviderInputError(error),
63
+ providerModuleId: e.providerModuleId,
64
+ ensureId: e.ensureId,
65
+ value: e.value,
66
+ humanContext: e.humanContext,
67
+ };
68
+ }
69
+
70
+ try {
71
+ await (demo as unknown as { nope(): Promise<unknown> }).nope();
72
+ outputs.unknownMethod = 'did not throw';
73
+ } catch (error) {
74
+ outputs.unknownMethod = (error as Error).message;
75
+ }
76
+
77
+ return outputs;
78
+ },
79
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Test fixture: a hook that outruns its total timeout and then writes a file.
3
+ *
4
+ * The marker is the harm, not the mechanism. `Promise.race` in the old
5
+ * executor rejected at the bound and cancelled nothing, so the hook kept its
6
+ * capability objects and went on doing work — registering DNS, opening ports —
7
+ * minutes after celilo reported the deploy failed. A test that only asserts
8
+ * the rejection cannot see that; it passed throughout (celilo#1003).
9
+ *
10
+ * The marker is written AFTER the sleep, so its existence after the bound is
11
+ * proof the hook was abandoned rather than killed.
12
+ */
13
+
14
+ import { writeFileSync } from 'node:fs';
15
+ import { defineHook } from '@celilo/capabilities';
16
+
17
+ export default defineHook({
18
+ hook: 'container_created',
19
+ requires: [],
20
+ handler: async (ctx) => {
21
+ ctx.logger.info('runaway hook starting');
22
+ await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 2000)));
23
+ writeFileSync(String(ctx.config.marker_path), 'the hook kept running\n');
24
+ return {};
25
+ },
26
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Test fixture: a hook that traps SIGTERM and keeps going.
3
+ *
4
+ * SIGTERM is a request. This is the case that makes the grace period load
5
+ * bearing: without the SIGKILL that follows it, a hook can decline to die and
6
+ * the boundary buys nothing over the promise race it replaced.
7
+ */
8
+
9
+ import { writeFileSync } from 'node:fs';
10
+ import { defineHook } from '@celilo/capabilities';
11
+
12
+ export default defineHook({
13
+ hook: 'container_created',
14
+ requires: [],
15
+ handler: async (ctx) => {
16
+ process.on('SIGTERM', () => ctx.logger.info('declining to die'));
17
+ ctx.logger.info('sigterm-ignoring hook starting');
18
+ await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 20_000)));
19
+ writeFileSync(String(ctx.config.marker_path), 'survived SIGTERM\n');
20
+ return {};
21
+ },
22
+ });
@@ -258,4 +258,51 @@ resource "local_file" "test" {
258
258
  expect(errors[0]?.variable).toBe('$self:invalid_var');
259
259
  });
260
260
  });
261
+
262
+ /**
263
+ * The capability-secret gate at import consumes these, so what happens when
264
+ * templates cannot be read is a security property rather than a detail. It
265
+ * has to FAIL CLOSED: an unreadable tree must abort the import, never hand
266
+ * the gate an empty reference set that reads as "this module references no
267
+ * secrets". It was already true here — but true by inspection, which is the
268
+ * weakest way for a security property to be true.
269
+ */
270
+ describe('capability references, and failing closed', () => {
271
+ test('collects $capability: references from templates', async () => {
272
+ const dir = await mkdtemp(join(tmpdir(), 'celilo-tplrefs-'));
273
+ try {
274
+ await mkdir(join(dir, 'terraform'), { recursive: true });
275
+ await writeFile(
276
+ join(dir, 'terraform', 'main.tf.tpl'),
277
+ 'acme_dns = "$capability:dns_internal.tsig_key"\nzone = "$capability:dns_internal.dns.domain"\n',
278
+ );
279
+ const manifest = createTestManifest({
280
+ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
281
+ });
282
+
283
+ const result = await validateModuleTemplates(dir, manifest);
284
+
285
+ expect(result.capabilityReferences.sort()).toEqual([
286
+ 'dns_internal.dns.domain',
287
+ 'dns_internal.tsig_key',
288
+ ]);
289
+ } finally {
290
+ await rm(dir, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test('an unreadable module tree fails, and yields no references', async () => {
295
+ // A path that does not exist stands in for any read failure. The PAIRING
296
+ // is the property: success:false travels WITH the empty array, and
297
+ // `import.ts` returns on !success before the access gate runs, so the
298
+ // empty set can never be mistaken for "nothing referenced".
299
+ const result = await validateModuleTemplates(
300
+ join(tmpdir(), 'celilo-no-such-module-tree-9f3a2b'),
301
+ createTestManifest(),
302
+ );
303
+
304
+ expect(result.success).toBe(false);
305
+ expect(result.capabilityReferences).toEqual([]);
306
+ });
307
+ });
261
308
  });
@@ -19,6 +19,12 @@ export interface TemplateValidationError {
19
19
  export interface TemplateValidationResult {
20
20
  success: boolean;
21
21
  errors: TemplateValidationError[];
22
+ /**
23
+ * `<capability>.<path>` paths the templates reference. The capability-access
24
+ * check at import consumes these, so a secret referenced only from a template
25
+ * is refused at import rather than later at generation.
26
+ */
27
+ capabilityReferences: string[];
22
28
  }
23
29
 
24
30
  /**
@@ -277,10 +283,16 @@ export async function validateModuleTemplates(
277
283
  // Find all .tpl files
278
284
  const templateFiles = await findTemplateFiles(modulePath, modulePath);
279
285
 
280
- // Validate each template file
286
+ // Validate each template file. Every one is parsed here anyway, so the
287
+ // capability references fall out of work already being done — no second
288
+ // walk of the module tree.
289
+ const capabilityReferences = new Set<string>();
281
290
  for (const relativePath of templateFiles) {
282
291
  const fullPath = join(modulePath, relativePath);
283
292
  const content = await readFile(fullPath, 'utf-8');
293
+ for (const variable of parseVariables(content)) {
294
+ if (variable.type === 'capability') capabilityReferences.add(variable.path);
295
+ }
284
296
  const errors = validateTemplateContent(content, manifest, relativePath);
285
297
  allErrors.push(...errors);
286
298
  }
@@ -288,6 +300,7 @@ export async function validateModuleTemplates(
288
300
  return {
289
301
  success: allErrors.length === 0,
290
302
  errors: allErrors,
303
+ capabilityReferences: [...capabilityReferences],
291
304
  };
292
305
  } catch (error) {
293
306
  return {
@@ -299,6 +312,10 @@ export async function validateModuleTemplates(
299
312
  error: `Failed to validate templates: ${error instanceof Error ? error.message : 'Unknown error'}`,
300
313
  },
301
314
  ],
315
+ // Unreadable templates cannot yield references, and import returns on
316
+ // !success before the access check runs, so this can never reach it as a
317
+ // silent pass.
318
+ capabilityReferences: [],
302
319
  };
303
320
  }
304
321
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A module may not declare a provider view.
3
+ *
4
+ * `web_routes` and `firewall_registry` are in `KNOWN_CAPABILITY_NAMES`, so the
5
+ * name check that asks only "is this known?" accepts them. celilo injects both
6
+ * into the hooks of the module that PROVIDES the paired capability and never
7
+ * to a consumer, so a module requiring one would validate, publish, deploy, and
8
+ * then find the capability simply absent at hook time — with a pre-flight error
9
+ * naming a missing provider that was never going to exist.
10
+ *
11
+ * Registering the two names (celilo#1007) is what created this opening. They
12
+ * were invisible to every check before that, which was its own, worse problem.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { PROVIDER_VIEW_CAPABILITIES } from '@celilo/capabilities';
17
+ import type { ModuleManifest } from './schema';
18
+ import { validateCapabilityNames } from './validate';
19
+
20
+ function manifestRequiring(name: string): ModuleManifest {
21
+ return {
22
+ requires: { capabilities: [{ name, version: '1.0.0' }] },
23
+ } as unknown as ModuleManifest;
24
+ }
25
+
26
+ function manifestOptionally(name: string): ModuleManifest {
27
+ return {
28
+ requires: { capabilities: [] },
29
+ optional: { capabilities: [{ name, version: '1.0.0' }] },
30
+ } as unknown as ModuleManifest;
31
+ }
32
+
33
+ describe('provider views are not declarable', () => {
34
+ for (const view of PROVIDER_VIEW_CAPABILITIES) {
35
+ test(`requires.capabilities rejects '${view}'`, () => {
36
+ const result = validateCapabilityNames(manifestRequiring(view));
37
+ expect(result).not.toBeNull();
38
+ expect(result?.errors[0].message).toContain('provider view');
39
+ });
40
+
41
+ test(`optional.capabilities rejects '${view}' too`, () => {
42
+ // The optional path is checked for the same reason the privileged-
43
+ // capability check covers it: otherwise the declaration is smuggled in
44
+ // through the soft-require door.
45
+ const result = validateCapabilityNames(manifestOptionally(view));
46
+ expect(result).not.toBeNull();
47
+ expect(result?.errors[0].message).toContain('provider view');
48
+ });
49
+
50
+ test(`'${view}' is not offered in the suggestion list`, () => {
51
+ // The message for a genuine typo lists what an author CAN require, so a
52
+ // view must not appear there either.
53
+ const result = validateCapabilityNames(manifestRequiring('definitely_not_a_capability'));
54
+ expect(result?.errors[0].message).not.toContain(view);
55
+ });
56
+ }
57
+
58
+ test('a real capability still validates', () => {
59
+ expect(validateCapabilityNames(manifestRequiring('public_web'))).toBeNull();
60
+ });
61
+ });