@celilo/cli 2.3.0 → 3.0.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 (90) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/serve.ts +13 -1
  4. package/src/cli/commands/module-health.test.ts +35 -0
  5. package/src/cli/commands/module-health.ts +11 -3
  6. package/src/cli/commands/module-publish.test.ts +22 -0
  7. package/src/cli/commands/module-publish.ts +14 -12
  8. package/src/cli/commands/module-update.ts +69 -17
  9. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  10. package/src/cli/commands/module-upgrade.ts +43 -3
  11. package/src/cli/commands/publish/helpers.ts +4 -3
  12. package/src/cli/commands/publish/index.ts +13 -1
  13. package/src/cli/commands/publish/plan.test.ts +64 -0
  14. package/src/cli/commands/publish/plan.ts +52 -19
  15. package/src/cli/commands/publish/types.ts +16 -3
  16. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  17. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  18. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  19. package/src/cli/commands/subscribers-serve.ts +22 -4
  20. package/src/cli/commands/system-audit.ts +14 -5
  21. package/src/cli/commands/system-update.ts +11 -6
  22. package/src/cli/completion.ts +10 -1
  23. package/src/cli/fuel-gauge.ts +12 -4
  24. package/src/cli/index.ts +12 -0
  25. package/src/cli/json-output.test.ts +81 -0
  26. package/src/cli/types.ts +9 -0
  27. package/src/hooks/broker.test.ts +9 -3
  28. package/src/hooks/executor.test.ts +5 -2
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  30. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  31. package/src/hooks/hook-trespass.test.ts +13 -4
  32. package/src/hooks/run-named-hook.ts +19 -16
  33. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  34. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  35. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  36. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  37. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  38. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  41. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  42. package/src/manifest/contracts/v1.ts +19 -14
  43. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  44. package/src/manifest/schema.ts +66 -16
  45. package/src/manifest/validate.test.ts +47 -0
  46. package/src/policy/capability-shape-baseline.ts +63 -21
  47. package/src/policy/capability-shape-drift.test.ts +53 -1
  48. package/src/policy/capability-shape.test.ts +105 -0
  49. package/src/policy/capability-shape.ts +283 -2
  50. package/src/registry/client.test.ts +65 -0
  51. package/src/registry/client.ts +7 -7
  52. package/src/secrets/storage.test.ts +69 -3
  53. package/src/secrets/storage.ts +71 -1
  54. package/src/services/audit/health.test.ts +58 -0
  55. package/src/services/audit/health.ts +15 -3
  56. package/src/services/audit/index.test.ts +1 -1
  57. package/src/services/audit/interface-classification.test.ts +16 -5
  58. package/src/services/audit/interface-classification.ts +25 -2
  59. package/src/services/audit/module-versions.ts +5 -1
  60. package/src/services/audit/public-dns.test.ts +20 -0
  61. package/src/services/audit/public-dns.ts +7 -2
  62. package/src/services/audit/recurrence-gate.test.ts +225 -0
  63. package/src/services/audit/trusted-sources.test.ts +17 -0
  64. package/src/services/audit/trusted-sources.ts +21 -0
  65. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  66. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  67. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  68. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  69. package/src/services/build-bus/hook-dispatch.ts +99 -121
  70. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  71. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  72. package/src/services/build-bus/receiver-daemon.ts +355 -0
  73. package/src/services/build-bus/self-update.ts +156 -0
  74. package/src/services/capability-compat.test.ts +90 -0
  75. package/src/services/capability-compat.ts +128 -0
  76. package/src/services/deploy-terraform.ts +38 -1
  77. package/src/services/events-daemon.test.ts +57 -0
  78. package/src/services/events-daemon.ts +76 -0
  79. package/src/services/firewall-reach.ts +21 -8
  80. package/src/services/fleet-checks.test.ts +37 -1
  81. package/src/services/fleet-checks.ts +31 -1
  82. package/src/services/health-runner.ts +43 -5
  83. package/src/services/module-deploy-prune.test.ts +89 -0
  84. package/src/services/module-deploy.ts +72 -125
  85. package/src/services/module-types-drift.test.ts +1 -1
  86. package/src/services/module-validator/capability-versions.test.ts +13 -2
  87. package/src/services/terraform-safety.test.ts +83 -0
  88. package/src/services/terraform-safety.ts +53 -0
  89. package/src/services/update/orchestrator.test.ts +1 -1
  90. package/tsconfig.json +2 -13
@@ -27,6 +27,7 @@
27
27
  * have failed anything. See celilo#703.
28
28
  */
29
29
 
30
+ import { Database } from 'bun:sqlite';
30
31
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
31
32
  import { spawnSync } from 'node:child_process';
32
33
  import { readFileSync, readdirSync } from 'node:fs';
@@ -49,6 +50,7 @@ const JSON_COMMANDS = [
49
50
  'module list --json',
50
51
  'module health --json',
51
52
  'system audit --json',
53
+ 'system update --dry-run --json',
52
54
  'alerts list --json',
53
55
  'commands --json',
54
56
  ] as const;
@@ -90,6 +92,85 @@ describe('celilo#698 — JSON commands emit parseable JSON', () => {
90
92
  }
91
93
  });
92
94
 
95
+ /**
96
+ * celilo#1362 — a --json command must stay parseable while its health checks
97
+ * run. The celilo#698 gates above all fire against an EMPTY fleet, where no
98
+ * health check ever executes and the pollution path never wakes up: the
99
+ * gauge silently animated frames onto stdout (plus plain `[module:hook]`
100
+ * logger lines) for every check, and `system audit --json` redirected to a
101
+ * file carried ~1 MB of `Testing app` frames before the first `{`.
102
+ *
103
+ * This gate seeds a module with a logging, sleeping health_check hook
104
+ * (`test-fixtures/modules/gauge-pollution-test`) so the checks genuinely
105
+ * run, then parses each fleet-wide --json command's stdout verbatim.
106
+ */
107
+ describe('celilo#1362 — --json output stays parseable while health checks run', () => {
108
+ let ctx: IntegrationTestContext;
109
+ const FIXTURE_PATH = join(import.meta.dir, '../../test-fixtures/modules/gauge-pollution-test');
110
+ const MODULE_ID = 'gauge-pollution-test';
111
+ const SPAWN_ENV = () => ({
112
+ ...process.env,
113
+ CELILO_DB_PATH: ctx.dbPath,
114
+ CELILO_DATA_DIR: ctx.dataDir,
115
+ CELILO_SUPPRESS_DEPRECATION: '1',
116
+ });
117
+
118
+ beforeAll(async () => {
119
+ ctx = await setupIntegrationTest();
120
+
121
+ const imported = spawnSync(
122
+ 'bun',
123
+ ['run', 'src/cli/index.ts', 'module', 'import', FIXTURE_PATH],
124
+ { encoding: 'utf8', env: SPAWN_ENV(), stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000 },
125
+ );
126
+ expect(
127
+ imported.status,
128
+ `module import exited ${imported.status}\nstdout: ${imported.stdout}\nstderr: ${imported.stderr}`,
129
+ ).toBe(0);
130
+
131
+ // runAllHealthChecks only checks INSTALLED/VERIFIED modules. Seeded
132
+ // the same way capability-abi-mismatch.test.ts seeds providers: the
133
+ // lifecycle under test is output purity, not the deploy flow.
134
+ const db = new Database(ctx.dbPath);
135
+ try {
136
+ db.run(`UPDATE modules SET state = 'VERIFIED' WHERE id = '${MODULE_ID}'`);
137
+ } finally {
138
+ db.close();
139
+ }
140
+ });
141
+
142
+ afterAll(async () => {
143
+ await ctx.cleanup();
144
+ });
145
+
146
+ for (const command of [
147
+ 'system audit --json',
148
+ 'system update --dry-run --json',
149
+ 'module health --json',
150
+ ]) {
151
+ test(`celilo ${command} emits parseable JSON with a health-checked module deployed`, () => {
152
+ const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], {
153
+ encoding: 'utf8',
154
+ env: SPAWN_ENV(),
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ timeout: 120_000,
157
+ });
158
+
159
+ expect(
160
+ result.status,
161
+ `celilo ${command} exited ${result.status}\nstderr: ${result.stderr}`,
162
+ ).toBe(0);
163
+
164
+ // Verbatim JSON.parse — the gauge frames and plain logger lines both
165
+ // land here pre-fix, and either one alone breaks the parse.
166
+ expect(() => JSON.parse(result.stdout)).not.toThrow();
167
+
168
+ // And the fixture's own log lines must not ride along on stdout.
169
+ expect(result.stdout).not.toContain('gauge-pollution step');
170
+ });
171
+ }
172
+ });
173
+
93
174
  /**
94
175
  * Walk from the index of a `{` to the index just past its matching `}`,
95
176
  * skipping over string literals so a brace inside a string doesn't unbalance
package/src/cli/types.ts CHANGED
@@ -35,6 +35,15 @@ export interface CommandError {
35
35
  success: false;
36
36
  error: string;
37
37
  details?: unknown;
38
+ /**
39
+ * Marks a refusal that is not a failure: the action was skipped BEFORE any
40
+ * state changed and will be retried when its precondition holds (the
41
+ * registry poll defers a consumer upgrade whose capability requirements the
42
+ * deployed providers cannot serve — celilo#1361). Callers that read only
43
+ * `success` still see a non-deploy; the poll uses this to report deferrals
44
+ * separately from failures instead of counting them as red.
45
+ */
46
+ deferred?: boolean;
38
47
  }
39
48
 
40
49
  export type CommandResult = CommandSuccess | CommandError;
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { describe, expect, test } from 'bun:test';
14
14
  import { execSync } from 'node:child_process';
15
- import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import { MissingProviderInputError } from '@celilo/capabilities';
@@ -60,10 +60,13 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
60
60
  stateDir: dir,
61
61
  capabilities: demoCapabilities(),
62
62
  };
63
- return await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
63
+ await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
64
64
  timeoutMs: 30_000,
65
65
  idleTimeoutMs: 30_000,
66
66
  });
67
+ // The fixture hands its report through the state directory: hook return
68
+ // values are no longer carried anywhere (hook-owned-state D5).
69
+ return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
67
70
  } finally {
68
71
  rmSync(dir, { recursive: true, force: true });
69
72
  }
@@ -137,11 +140,14 @@ async function runStoreHook(
137
140
  capabilities: {},
138
141
  ...contextData,
139
142
  } as unknown as HookContext;
140
- return await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
143
+ await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
141
144
  timeoutMs: 30_000,
142
145
  idleTimeoutMs: 30_000,
143
146
  hookStores: () => Promise.resolve(memoryStores()),
144
147
  });
148
+ // The fixture hands its report through the state directory: hook return
149
+ // values are no longer carried anywhere (hook-owned-state D5).
150
+ return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
145
151
  } finally {
146
152
  rmSync(dir, { recursive: true, force: true });
147
153
  }
@@ -131,7 +131,8 @@ describe('Hook Executor', () => {
131
131
  vps_ip: '10.0.0.1',
132
132
  });
133
133
 
134
- expect(result).toEqual({ api_key: 'test-key-for-10.0.0.1' });
134
+ // Void hook: no outputs. The envelope carries an empty record.
135
+ expect(result).toEqual({});
135
136
  expect(messages).toContainEqual({ level: 'info', message: 'Starting test hook' });
136
137
  expect(messages).toContainEqual({ level: 'success', message: 'Test hook completed' });
137
138
  });
@@ -622,7 +623,9 @@ describe('Hook Executor', () => {
622
623
  );
623
624
 
624
625
  expect(result.success).toBe(true);
625
- expect(result.outputs).toEqual({ api_key: 'test-key-for-10.0.0.5' });
626
+ // Hook return values are no longer consumed (hook-owned-state D5): a
627
+ // void hook's outputs are empty, and nothing persists them.
628
+ expect(result.outputs).toEqual({});
626
629
  expect(result.duration).toBeGreaterThanOrEqual(0);
627
630
  expect(messages.some((m) => m.message.includes('completed successfully'))).toBe(true);
628
631
  });
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  import { describe, expect, test } from 'bun:test';
34
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
34
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
35
35
  import { tmpdir } from 'node:os';
36
36
  import { dirname, join, resolve } from 'node:path';
37
37
  import { executeHookScript } from './executor';
@@ -132,7 +132,7 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
132
132
  const savedPolicy = process.env.CELILO_HOOK_JAIL;
133
133
  process.env.CELILO_HOOK_JAIL = 'required';
134
134
  try {
135
- const reach = (await executeHookScript(PROBE_HOOK, context, {
135
+ await executeHookScript(PROBE_HOOK, context, {
136
136
  timeoutMs: 60_000,
137
137
  idleTimeoutMs: 60_000,
138
138
  jail: {
@@ -145,7 +145,12 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
145
145
  ? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
146
146
  : [],
147
147
  },
148
- })) as unknown as Reach;
148
+ });
149
+ // The fixture hands its report through the state directory: hook return
150
+ // values are no longer carried anywhere (hook-owned-state D5).
151
+ const reach = JSON.parse(
152
+ readFileSync(join(stateDir, 'report.json'), 'utf-8'),
153
+ ) as unknown as Reach;
149
154
 
150
155
  return { reach, cleanup: restorePolicy };
151
156
  } catch (error) {
@@ -69,6 +69,9 @@ interface Rig {
69
69
  cleanup: () => void;
70
70
  }
71
71
 
72
+ /** Where the probe fixture hands its report to the parent (hook-owned-state D5). */
73
+ const REPORT_PATH = 'report.json';
74
+
72
75
  /**
73
76
  * Lay out a module store the way celilo does, run the probe hook against it,
74
77
  * and hand back both the hook's report and the paths so the parent can check
@@ -134,14 +137,19 @@ async function runProbe(): Promise<Rig> {
134
137
  const savedPolicy = process.env.CELILO_HOOK_JAIL;
135
138
  process.env.CELILO_HOOK_JAIL = 'required';
136
139
  try {
137
- const outputs = (await executeHookScript(PROBE_HOOK, context, {
140
+ await executeHookScript(PROBE_HOOK, context, {
138
141
  timeoutMs: 60_000,
139
142
  idleTimeoutMs: 60_000,
140
143
  jail: {
141
144
  modulePath,
142
145
  pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
143
146
  },
144
- })) as unknown as Rig['outputs'];
147
+ });
148
+ // The fixture hands its report through the state directory: hook return
149
+ // values are no longer carried anywhere (hook-owned-state D5).
150
+ const outputs = JSON.parse(
151
+ readFileSync(join(stateDir, REPORT_PATH), 'utf-8'),
152
+ ) as unknown as Rig['outputs'];
145
153
  return {
146
154
  outputs,
147
155
  stagedInput,
@@ -155,14 +155,16 @@ afterAll(() => {
155
155
 
156
156
  async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
157
157
  const { logger, messages } = createCapturingLogger();
158
+ const screenshotDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
159
+ const stateDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
158
160
  const context: HookContext = {
159
161
  config: configStore({ sibling_module_id: 'hello-foo', other_system_ip: '' }),
160
162
  secrets: secretStore(),
161
163
  systems: [],
162
164
  logger,
163
165
  debug: false,
164
- screenshotDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
165
- stateDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
166
+ screenshotDir,
167
+ stateDir,
166
168
  capabilities: {},
167
169
  };
168
170
 
@@ -173,7 +175,7 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
173
175
  const savedPolicy = process.env.CELILO_HOOK_JAIL;
174
176
  if (jail) process.env.CELILO_HOOK_JAIL = 'required';
175
177
  try {
176
- const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
178
+ await executeHookScript(TRESPASS_SCRIPT, context, {
177
179
  timeoutMs: 60_000,
178
180
  idleTimeoutMs: 60_000,
179
181
  // The module's own tree, two levels above the hook script. Absent means
@@ -181,12 +183,19 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
181
183
  ...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}),
182
184
  });
183
185
  return {
184
- report: outputs as unknown as TrespassReport,
186
+ // The fixture hands its report through the state directory the jail
187
+ // binds read-write: the same channel works jailed and unjailed (D5
188
+ // removed the return channel).
189
+ report: JSON.parse(
190
+ readFileSync(join(stateDir, 'report.json'), 'utf-8'),
191
+ ) as unknown as TrespassReport,
185
192
  lines: messages.map((m) => m.message),
186
193
  };
187
194
  } finally {
188
195
  if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
189
196
  else process.env.CELILO_HOOK_JAIL = savedPolicy;
197
+ rmSync(screenshotDir, { recursive: true, force: true });
198
+ rmSync(stateDir, { recursive: true, force: true });
190
199
  }
191
200
  }
192
201
 
@@ -1,3 +1,4 @@
1
+ import type { HookDefinition } from '@celilo/capabilities';
1
2
  /**
2
3
  * Helper to load + invoke a single named hook on a module.
3
4
  *
@@ -41,6 +42,16 @@ export interface RunNamedHookOptions {
41
42
  * hook's own `timeout` or the executor default applies.
42
43
  */
43
44
  timeoutMs?: number;
45
+ /**
46
+ * Use this hook definition instead of the manifest's own entry for the
47
+ * name. Only `on_upstream_publish` needs it: its manifest value is an
48
+ * ARRAY of match-rule entries and the build-bus dispatcher has already
49
+ * chosen the matching one, so the definition it passes is the matched
50
+ * entry (module-orchestrator-primitives slice 7). Every other hook keeps
51
+ * reading the manifest, and `celilo module run-hook` — which has no
52
+ * matched entry to supply — still gets `notDefined` for the array form.
53
+ */
54
+ hookDefinition?: HookDefinition;
44
55
  }
45
56
 
46
57
  export interface RunNamedHookResult extends HookResult {
@@ -113,22 +124,14 @@ export async function runNamedHook(
113
124
  }
114
125
 
115
126
  const manifest = module.manifestData as ModuleManifest;
116
- const hookDef = manifest.hooks?.[hookName as keyof typeof manifest.hooks];
117
- if (!hookDef) {
118
- return {
119
- success: true,
120
- outputs: {},
121
- duration: Date.now() - startedAt,
122
- notDefined: true,
123
- };
124
- }
125
- // Defensive narrow: `on_upstream_publish` is also under
126
- // `manifest.hooks` (build-bus, Phase 4) but is an ARRAY of
127
- // match-rule hooks dispatched by the receiver daemon — NOT
128
- // runnable via `celilo module run-hook`. The HookName type
129
- // excludes it, but the indexed-access return type can't prove
130
- // that, so narrow at the value level.
131
- if (Array.isArray(hookDef)) {
127
+ // `on_upstream_publish` is under `manifest.hooks` as an ARRAY of
128
+ // match-rule entries; the dispatcher supplies the matched entry via
129
+ // `options.hookDefinition`. Without one (e.g. `celilo module run-hook`)
130
+ // there is no single definition to run, so the hook reports notDefined
131
+ // rather than guessing an entry.
132
+ const hookDef =
133
+ options.hookDefinition ?? manifest.hooks?.[hookName as keyof typeof manifest.hooks];
134
+ if (!hookDef || Array.isArray(hookDef)) {
132
135
  return {
133
136
  success: true,
134
137
  outputs: {},
@@ -20,6 +20,5 @@ export default defineHook({
20
20
  writeFileSync(join(dir, 'spa-failure.png'), 'not-really-a-png');
21
21
  writeFileSync(join(dir, 'spa-failure.html'), '<html></html>');
22
22
  writeFileSync(join(dir, 'spa-failure.requests.txt'), 'GET /api/getActiveMonth 200');
23
- return { api_key: 'wrote-artifacts' };
24
23
  },
25
24
  });
@@ -5,8 +5,13 @@
5
5
  * One test per SHAPE rather than per method: the broker is generic, so a
6
6
  * per-method suite would prove the same thing thirty-seven times and drift the
7
7
  * moment a capability gained a method.
8
+ *
9
+ * Reports through a JSON file in its state directory: hook return values are
10
+ * no longer carried anywhere (hook-owned-state D5).
8
11
  */
9
12
 
13
+ import { writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
10
15
  import { defineHook, isMissingProviderInputError } from '@celilo/capabilities';
11
16
 
12
17
  interface DemoCapability {
@@ -27,25 +32,25 @@ export default defineHook({
27
32
  // `demo` is a fixture capability, not a registry entry, so the typed
28
33
  // capability map does not know it. The broker is generic and does not care.
29
34
  const demo = (ctx.capabilities as unknown as Record<string, DemoCapability>).demo;
30
- const outputs: Record<string, unknown> = {};
35
+ const report: Record<string, unknown> = {};
31
36
 
32
37
  // Non-function properties are copied verbatim, which is what keeps
33
38
  // `providerModuleId` readable — a hook names the provider in its errors.
34
- outputs.providerModuleId = demo.providerModuleId;
35
- outputs.version = demo.version;
39
+ report.providerModuleId = demo.providerModuleId;
40
+ report.version = demo.version;
36
41
 
37
42
  // An optional method the provider did not implement must be ABSENT, not a
38
43
  // proxy that throws, or `if (cap.registerTrustedSource)` answers wrongly.
39
- outputs.optionalMethodAbsent = demo.sometimesAbsent === undefined;
44
+ report.optionalMethodAbsent = demo.sometimesAbsent === undefined;
40
45
 
41
- outputs.returned = await demo.echo({ x: 1, nested: { y: [2, 3] } });
42
- outputs.undefinedBecomesNull = await demo.returnsNothing({});
46
+ report.returned = await demo.echo({ x: 1, nested: { y: [2, 3] } });
47
+ report.undefinedBecomesNull = await demo.returnsNothing({});
43
48
 
44
49
  try {
45
50
  await demo.boom({});
46
- outputs.plainThrow = 'did not throw';
51
+ report.plainThrow = 'did not throw';
47
52
  } catch (error) {
48
- outputs.plainThrow = {
53
+ report.plainThrow = {
49
54
  isError: error instanceof Error,
50
55
  name: (error as Error).name,
51
56
  message: (error as Error).message,
@@ -55,10 +60,10 @@ export default defineHook({
55
60
 
56
61
  try {
57
62
  await demo.missingInput({});
58
- outputs.missingProviderInput = 'did not throw';
63
+ report.missingProviderInput = 'did not throw';
59
64
  } catch (error) {
60
65
  const e = error as Record<string, unknown>;
61
- outputs.missingProviderInput = {
66
+ report.missingProviderInput = {
62
67
  recognised: isMissingProviderInputError(error),
63
68
  providerModuleId: e.providerModuleId,
64
69
  ensureId: e.ensureId,
@@ -69,11 +74,13 @@ export default defineHook({
69
74
 
70
75
  try {
71
76
  await (demo as unknown as { nope(): Promise<unknown> }).nope();
72
- outputs.unknownMethod = 'did not throw';
77
+ report.unknownMethod = 'did not throw';
73
78
  } catch (error) {
74
- outputs.unknownMethod = (error as Error).message;
79
+ report.unknownMethod = (error as Error).message;
75
80
  }
76
81
 
77
- return outputs;
82
+ // Hand the report to the caller through the state directory, the one
83
+ // channel a hook still has.
84
+ writeFileSync(join(ctx.stateDir as string, 'report.json'), JSON.stringify(report));
78
85
  },
79
86
  });
@@ -6,6 +6,10 @@
6
6
  * bubblewrap removes the path and gives `ENOENT`; `sandbox-exec` denies it and
7
7
  * gives `EPERM`. Both satisfy the requirement, so this fixture records only
8
8
  * whether the access worked and hands the message back for the post-mortem.
9
+ *
10
+ * The report is written to a JSON file in the state directory: hook return
11
+ * values are no longer carried anywhere (hook-owned-state D5), and `state/`
12
+ * is the one writable path inside the jail.
9
13
  */
10
14
 
11
15
  import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
@@ -32,7 +36,7 @@ export default defineHook({
32
36
  staged_input: string;
33
37
  };
34
38
 
35
- return {
39
+ const report = {
36
40
  // Outside the module tree entirely, and the whole acceptance criterion:
37
41
  // celilo's data directory is not bound, so the key is not merely denied
38
42
  // but absent.
@@ -68,5 +72,9 @@ export default defineHook({
68
72
  for (const name of names) readFileSync(join(sshDir, name));
69
73
  }),
70
74
  };
75
+
76
+ // Hand the report to the parent through the state directory — the one
77
+ // channel a jailed hook still has (D5 removed the return channel).
78
+ writeFileSync(`${ctx.stateDir}/report.json`, JSON.stringify(report));
71
79
  },
72
80
  });
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { execFileSync, execSync } from 'node:child_process';
24
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
25
25
  import { dirname } from 'node:path';
26
26
  import { BROWSER_ROOT, defineHook } from '@celilo/capabilities';
27
27
 
@@ -81,7 +81,7 @@ export default defineHook({
81
81
  // run that supplied one — a false ABSENT indistinguishable from a real one.
82
82
  const browser = (ctx.config as { browser_executable?: string }).browser_executable ?? '';
83
83
 
84
- return {
84
+ const reach = {
85
85
  // The one every other shell-out depends on. `execSync` spawns
86
86
  // `/bin/sh -c`, so a jail without it cannot run ANY hook that uses
87
87
  // `node:child_process`, whatever binary that hook was reaching for.
@@ -223,5 +223,10 @@ export default defineHook({
223
223
  throw new Error('no bwrap on any known path');
224
224
  }),
225
225
  };
226
+
227
+ // Hand the report to the parent through the state directory — the one
228
+ // channel a jailed hook still has (hook-owned-state D5 removed the
229
+ // return channel).
230
+ writeFileSync(`${ctx.stateDir}/report.json`, JSON.stringify(reach));
226
231
  },
227
232
  });
@@ -21,6 +21,5 @@ export default defineHook({
21
21
  ctx.logger.info('runaway hook starting');
22
22
  await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 2000)));
23
23
  writeFileSync(String(ctx.config.marker_path), 'the hook kept running\n');
24
- return {};
25
24
  },
26
25
  });
@@ -17,6 +17,5 @@ export default defineHook({
17
17
  ctx.logger.info('sigterm-ignoring hook starting');
18
18
  await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 20_000)));
19
19
  writeFileSync(String(ctx.config.marker_path), 'survived SIGTERM\n');
20
- return {};
21
20
  },
22
21
  });
@@ -15,6 +15,5 @@ export default defineHook({
15
15
  handler: async (ctx) => {
16
16
  ctx.logger.info('Starting, then going quiet');
17
17
  await new Promise((r) => setTimeout(r, Number(ctx.config.silent_ms ?? 0)));
18
- return {};
19
18
  },
20
19
  });
@@ -3,25 +3,32 @@
3
3
  * the process boundary — set/get round trip on both stores, delete, a
4
4
  * committed transaction, a discarded transaction, and the undeclared-name
5
5
  * throw the whole design exists to produce.
6
+ *
7
+ * Reports through a JSON file in its state directory: hook return values
8
+ * are no longer carried anywhere (hook-owned-state D5), and the state
9
+ * directory is the sanctioned place a hook hands structured data to its
10
+ * caller.
6
11
  */
7
12
 
13
+ import { writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
8
15
  import { defineHook } from '@celilo/capabilities';
9
16
 
10
17
  export default defineHook({
11
18
  hook: 'on_install',
12
19
  requires: [],
13
20
  handler: async (ctx) => {
14
- const outputs: Record<string, unknown> = {};
21
+ const report: Record<string, unknown> = {};
15
22
 
16
23
  // Round trip on each store.
17
24
  await ctx.secrets.set('bot_token', 'token-value');
18
- outputs.secretRoundTrip = await ctx.secrets.get('bot_token');
25
+ report.secretRoundTrip = await ctx.secrets.get('bot_token');
19
26
 
20
27
  await ctx.config.set('public_ip', '203.0.113.7');
21
- outputs.configRoundTrip = await ctx.config.get('public_ip');
28
+ report.configRoundTrip = await ctx.config.get('public_ip');
22
29
 
23
30
  // The map surface still answers from the values the context carried.
24
- outputs.mapRead = ctx.config.mapOnlyValue;
31
+ report.mapRead = ctx.config.mapOnlyValue;
25
32
 
26
33
  // Delete removes; deleting a declared-but-never-written name is a no-op.
27
34
  // (An UNDECLARED name on delete throws — that is 3.4, tested below.
@@ -29,15 +36,15 @@ export default defineHook({
29
36
  await ctx.secrets.delete('bot_token');
30
37
  await ctx.secrets.set('api_key', 'to-delete');
31
38
  await ctx.secrets.delete('api_key');
32
- outputs.deletedIsGone = await ctx.secrets.get('api_key');
39
+ report.deletedIsGone = await ctx.secrets.get('api_key');
33
40
 
34
41
  // Committed transaction: both writes land.
35
42
  await ctx.secrets.transaction((s) => {
36
43
  s.set('bot_token', 'committed-a');
37
44
  s.set('api_key', 'committed-b');
38
45
  });
39
- outputs.transactionA = await ctx.secrets.get('bot_token');
40
- outputs.transactionB = await ctx.secrets.get('api_key');
46
+ report.transactionA = await ctx.secrets.get('bot_token');
47
+ report.transactionB = await ctx.secrets.get('api_key');
41
48
 
42
49
  // Discarded transaction: nothing lands, including the overwrite of the
43
50
  // committed value above.
@@ -47,17 +54,19 @@ export default defineHook({
47
54
  throw new Error('hook failed midway');
48
55
  });
49
56
  } catch (error) {
50
- outputs.discarded = (error as Error).message;
57
+ report.discarded = (error as Error).message;
51
58
  }
52
- outputs.afterDiscard = await ctx.secrets.get('bot_token');
59
+ report.afterDiscard = await ctx.secrets.get('bot_token');
53
60
 
54
61
  // An undeclared name throws, naming the module and the declared set.
55
62
  try {
56
63
  await ctx.secrets.set('not_declared', 'x');
57
64
  } catch (error) {
58
- outputs.undeclaredSecret = (error as Error).message;
65
+ report.undeclaredSecret = (error as Error).message;
59
66
  }
60
67
 
61
- return outputs;
68
+ // Hand the report to the caller through the state directory, the one
69
+ // channel a hook still has.
70
+ writeFileSync(join(ctx.stateDir as string, 'report.json'), JSON.stringify(report));
62
71
  },
63
72
  });
@@ -2,9 +2,10 @@
2
2
  * Test fixture: successful hook script. Migrated to defineHook in
3
3
  * HOOK_API_V2 Phase 8 — the executor now requires the brand.
4
4
  *
5
- * Uses `hook: 'container_created'` because that hook's contract output
6
- * is `SecretCapturingHookOutput` (Record<string, unknown> | void), so
7
- * returning an `api_key` field is allowed.
5
+ * Declares `hook: 'container_created'` and returns void: hook return
6
+ * values are no longer persisted or consumed (hook-owned-state D5), so a
7
+ * hook reports through the logger and, where a test needs structured data
8
+ * back, through a file written to its state directory.
8
9
  */
9
10
 
10
11
  import { defineHook } from '@celilo/capabilities';
@@ -15,6 +16,5 @@ export default defineHook({
15
16
  handler: async (ctx) => {
16
17
  ctx.logger.info('Starting test hook');
17
18
  ctx.logger.success('Test hook completed');
18
- return { api_key: `test-key-for-${(ctx.vps_ip as string | undefined) ?? 'unknown'}` };
19
19
  },
20
20
  });