@celilo/cli 2.1.0 → 2.2.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 (93) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +2 -2
  4. package/schemas/system_config.json +2 -1
  5. package/src/capabilities/public-web-publish.test.ts +61 -0
  6. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  7. package/src/cli/commands/firewall-interface-list.ts +73 -7
  8. package/src/cli/commands/machine-add.ts +12 -55
  9. package/src/cli/commands/module-config.test.ts +20 -1
  10. package/src/cli/commands/module-import.ts +1 -1
  11. package/src/cli/commands/module-update.test.ts +82 -0
  12. package/src/cli/commands/module-update.ts +14 -4
  13. package/src/cli/commands/monitor.ts +2 -10
  14. package/src/cli/commands/restore.ts +16 -6
  15. package/src/cli/generate-zsh-completion.ts +1 -1
  16. package/src/cli/index.ts +4 -3
  17. package/src/cli/restore-migration-failure.test.ts +159 -0
  18. package/src/db/client.ts +5 -0
  19. package/src/db/migrate.test.ts +61 -135
  20. package/src/db/migrate.ts +7 -2
  21. package/src/db/schema.ts +10 -0
  22. package/src/hooks/broker.test.ts +106 -2
  23. package/src/hooks/broker.ts +91 -1
  24. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  25. package/src/hooks/capability-loader.ts +15 -1
  26. package/src/hooks/define-hook.test.ts +4 -3
  27. package/src/hooks/executor.test.ts +19 -18
  28. package/src/hooks/executor.ts +82 -11
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  30. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  31. package/src/hooks/hook-protocol.ts +46 -1
  32. package/src/hooks/hook-runner.ts +36 -0
  33. package/src/hooks/hook-store-proxy.test.ts +109 -0
  34. package/src/hooks/hook-store-proxy.ts +85 -0
  35. package/src/hooks/hook-store.test.ts +162 -0
  36. package/src/hooks/hook-store.ts +290 -0
  37. package/src/hooks/hook-timeout.test.ts +3 -2
  38. package/src/hooks/hook-trespass.test.ts +29 -5
  39. package/src/hooks/jail.test.ts +1 -1
  40. package/src/hooks/jail.ts +14 -7
  41. package/src/hooks/mount-set.test.ts +208 -0
  42. package/src/hooks/mount-set.ts +62 -14
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  45. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  46. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  47. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  48. package/src/hooks/unjailed-lint.test.ts +22 -6
  49. package/src/manifest/schema.ts +1 -0
  50. package/src/module/packaging/build.ts +70 -2
  51. package/src/module/web-root.ts +17 -1
  52. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  53. package/src/policy/module-script-scan.test.ts +42 -1
  54. package/src/policy/module-script-scan.ts +275 -5
  55. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  56. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  57. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  58. package/src/registry/client.test.ts +149 -0
  59. package/src/registry/client.ts +203 -11
  60. package/src/services/alerting/format.test.ts +57 -0
  61. package/src/services/alerting/format.ts +24 -0
  62. package/src/services/alerting/run-monitor.ts +2 -2
  63. package/src/services/backup-create.ts +7 -7
  64. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  65. package/src/services/backup-restore.ts +8 -4
  66. package/src/services/bus-interview.ts +37 -14
  67. package/src/services/config-provenance.ts +4 -0
  68. package/src/services/control-plane-bootstrap.test.ts +121 -1
  69. package/src/services/control-plane-bootstrap.ts +51 -4
  70. package/src/services/deploy-preflight.ts +8 -2
  71. package/src/services/deploy-validation.test.ts +22 -0
  72. package/src/services/deploy-validation.ts +8 -0
  73. package/src/services/dns-discovery.test.ts +54 -0
  74. package/src/services/dns-discovery.ts +47 -5
  75. package/src/services/fleet-key.test.ts +66 -2
  76. package/src/services/fleet-key.ts +54 -0
  77. package/src/services/health-runner.ts +2 -0
  78. package/src/services/module-config.ts +20 -2
  79. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  80. package/src/services/module-deploy.ts +163 -1
  81. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  82. package/src/services/module-validator/git-hygiene.ts +83 -14
  83. package/src/services/restore-from-file.test.ts +20 -0
  84. package/src/services/restore-from-file.ts +21 -6
  85. package/src/services/static-content-converge.test.ts +140 -2
  86. package/src/services/static-content-converge.ts +55 -8
  87. package/src/services/system-config-schema-types.ts +1 -1
  88. package/src/services/system-config-validator.test.ts +36 -0
  89. package/src/services/system-config-validator.ts +11 -0
  90. package/src/services/trusted-sources.test.ts +30 -0
  91. package/src/services/trusted-sources.ts +47 -10
  92. package/src/templates/generator.ts +9 -2
  93. package/src/variables/context.ts +16 -5
@@ -39,6 +39,8 @@ export interface MountEntry {
39
39
  readonly mode: MountMode;
40
40
  /** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */
41
41
  readonly reason: string;
42
+ /** What this row's absence from the host means. See `MountAbsence`. */
43
+ readonly absence: MountAbsence;
42
44
  }
43
45
 
44
46
  export interface MountSet {
@@ -154,8 +156,27 @@ const RESOLVER_FILES = ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts']
154
156
  */
155
157
  const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const;
156
158
 
157
- function entry(path: string, mode: MountMode, reason: string): MountEntry {
158
- return { path, mode, reason };
159
+ /**
160
+ * What a row's ABSENCE from the host means. Required, never defaulted: a default
161
+ * lets the next mount be added without deciding which kind it is, and that is
162
+ * exactly how the undifferentiated `skipped` list came to exist.
163
+ *
164
+ * bubblewrap fails the whole jail on a bind whose source is missing, so an
165
+ * absent row must be dropped. The question this answers is whether dropping it
166
+ * is fine, fatal, or nobody's business.
167
+ */
168
+ export type MountAbsence =
169
+ /** Fatal. The hook cannot do what it was asked without this. */
170
+ | 'required'
171
+ /** Fatal only for a module that declared it needs the facility. */
172
+ | 'declared-only'
173
+ /** Not reported. A genuinely needed one fails the runtime, which is louder. */
174
+ | 'runtime'
175
+ /** Expected at some lifecycle points. Debug at most. */
176
+ | 'conditional';
177
+
178
+ function entry(path: string, mode: MountMode, reason: string, absence: MountAbsence): MountEntry {
179
+ return { path, mode, reason, absence };
159
180
  }
160
181
 
161
182
  /**
@@ -182,21 +203,25 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
182
203
  // silently an empty tmpfs directory writes into it, returns success, and
183
204
  // produces a backup containing NOTHING. It is found at restore. So the gate
184
205
  // on this asserts the artifact is non-empty, never that the hook exited zero.
185
- entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run'));
206
+ entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run', 'runtime'));
186
207
 
187
208
  // 2. The runtime. Without it nothing runs, so it is not really a policy row.
188
- entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
189
- entries.push(entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns'));
209
+ entries.push(entry(request.runtimePath, 'ro', 'the interpreter', 'required'));
210
+ entries.push(
211
+ entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns', 'required'),
212
+ );
190
213
  // See MountSetRequest.runtimeModulePaths. Without these the shim starts and
191
214
  // immediately dies on `Cannot find module`.
192
215
  for (const dir of request.runtimeModulePaths ?? []) {
193
- entries.push(entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports"));
216
+ entries.push(
217
+ entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports", 'required'),
218
+ );
194
219
  }
195
220
  for (const dir of RUNTIME_SUPPORT_DIRS) {
196
- entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
221
+ entries.push(entry(dir, 'ro', 'shared libraries and trust store', 'runtime'));
197
222
  }
198
223
  for (const file of RESOLVER_FILES) {
199
- entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES'));
224
+ entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES', 'runtime'));
200
225
  }
201
226
  // The fleet browser, read-only (task 4.10).
202
227
  //
@@ -225,7 +250,9 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
225
250
  // the browser rather than here. That interim landed 2026-08-31 (celilo#1215):
226
251
  // the launch path in `test-fixtures/jail-toolchain-hook.ts` carries the flag,
227
252
  // and `jail-browser-launch-flags.test.ts` pins it there.
228
- entries.push(entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned'));
253
+ entries.push(
254
+ entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned', 'declared-only'),
255
+ );
229
256
 
230
257
  // 3. The module's own tree, read-only, then its writable directories carved
231
258
  // on top. bubblewrap resolves that in the right order, which is why the
@@ -234,19 +261,36 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
234
261
  // D9 says "the module's own tree is bound read-only". These are the
235
262
  // carved exceptions to that sentence, and there are three of them rather
236
263
  // than the one D9's prose implies.
237
- entries.push(entry(modulePath, 'ro', "the module's own tree"));
264
+ entries.push(entry(modulePath, 'ro', "the module's own tree", 'required'));
238
265
  entries.push(
239
- entry(resolve(request.stateDir), 'rw', 'ctx.stateDir, the sanctioned writable directory'),
266
+ entry(
267
+ resolve(request.stateDir),
268
+ 'rw',
269
+ 'ctx.stateDir, the sanctioned writable directory',
270
+ 'required',
271
+ ),
240
272
  );
241
273
  entries.push(
242
- entry(join(modulePath, 'generated'), 'rw', "celilo's generated output the hook may amend"),
274
+ entry(
275
+ join(modulePath, 'generated'),
276
+ 'rw',
277
+ "celilo's generated output the hook may amend",
278
+ 'conditional',
279
+ ),
243
280
  );
244
281
  if (request.screenshotDir) {
245
- entries.push(entry(resolve(request.screenshotDir), 'rw', 'ctx.screenshotDir, this run only'));
282
+ entries.push(
283
+ entry(
284
+ resolve(request.screenshotDir),
285
+ 'rw',
286
+ 'ctx.screenshotDir, this run only',
287
+ 'conditional',
288
+ ),
289
+ );
246
290
  }
247
291
 
248
292
  // 4. The broker channel. Bound AFTER the tmpfs, per the note above.
249
- entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket'));
293
+ entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket', 'required'));
250
294
 
251
295
  // 5. Contract-declared path inputs, at the access the contract declares.
252
296
  // Never inferred from the name — see ContractField.path.
@@ -256,6 +300,10 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
256
300
  resolve(input.value),
257
301
  input.access === 'write' ? 'rw' : 'ro',
258
302
  `contract input '${input.name}' (${input.access})`,
303
+ // Fatal, always. A hook whose declared WRITE path is dropped writes into
304
+ // the run's private tmpfs and reports success over a directory that is
305
+ // discarded when it exits. celilo#1248-adjacent; see jail.ts's note.
306
+ 'required',
259
307
  ),
260
308
  );
261
309
  }
@@ -26,6 +26,7 @@ import {
26
26
  import { remoteAccessPolicy } from '../services/remote-access';
27
27
  import { loadCapabilityFunctions } from './capability-loader';
28
28
  import { invokeHook } from './executor';
29
+ import { createHookStores } from './hook-store';
29
30
  import { loadHookConfigMap } from './load-hook-config';
30
31
  import type { HookLogger, HookName, HookResult } from './types';
31
32
 
@@ -192,6 +193,7 @@ export async function runNamedHook(
192
193
  requiredCapabilities,
193
194
  systems: getModuleSystems(moduleId, db),
194
195
  remoteAccess: remoteAccessPolicy(moduleId, db),
196
+ hookStores: () => createHookStores(db, moduleId),
195
197
  timeoutMs: options.timeoutMs,
196
198
  },
197
199
  );
@@ -26,7 +26,7 @@ export default defineHook({
26
26
  hook: 'container_created',
27
27
  requires: [],
28
28
  handler: async (ctx) => {
29
- const config = ctx.config as {
29
+ const config = ctx.config as unknown as {
30
30
  planted_secret: string;
31
31
  sibling_file: string;
32
32
  staged_input: string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Test fixture: an on_restore hook in the celilo-mgmt staging shape, minus
3
+ * everything the real one owns but the staging itself. Copies the artifact's
4
+ * celilo.db into restore_dir/system/ so the Phase 4 wrapper's
5
+ * applyStagedSystemFiles has something to swap.
6
+ */
7
+
8
+ import { cpSync, existsSync, mkdirSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { defineHook } from '@celilo/capabilities';
11
+
12
+ export default defineHook({
13
+ hook: 'on_restore',
14
+ requires: [],
15
+ optional: [],
16
+ handler: async (ctx) => {
17
+ const restoreDir = (ctx as unknown as { restore_dir: string }).restore_dir;
18
+ const systemDir = join(restoreDir, 'system');
19
+ mkdirSync(systemDir, { recursive: true });
20
+ const artifactDb = join(restoreDir, 'celilo.db');
21
+ if (existsSync(artifactDb)) {
22
+ cpSync(artifactDb, join(systemDir, 'celilo.db'));
23
+ }
24
+ return { restored_items: 1 };
25
+ },
26
+ });
@@ -0,0 +1,47 @@
1
+ import type { HookStore, HookStoreBackedMap } from '@celilo/capabilities';
2
+
3
+ /**
4
+ * In-memory hook-owned-state stores for tests that build a HookContext
5
+ * without a broker. The four methods mutate the record directly; the
6
+ * manifest validation a real store applies lives broker-side
7
+ * (hook-store.ts) and has its own tests.
8
+ *
9
+ * Methods are defined non-enumerably, so the result spreads, serializes and
10
+ * `toEqual`s as the plain record the test wrote — the same shape a real
11
+ * broker-side context carries when executeHookScript frames it.
12
+ */
13
+ export function configStore(
14
+ values: Record<string, unknown> = {},
15
+ ): Record<string, unknown> & HookStore {
16
+ return attach(values);
17
+ }
18
+
19
+ export function secretStore(values: Record<string, string> = {}): HookStoreBackedMap {
20
+ // Downcast: the caller passed Record<string, string>; attach only adds
21
+ // methods and never writes, so the value type is unchanged.
22
+ return attach(values) as HookStoreBackedMap;
23
+ }
24
+
25
+ function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
26
+ const store: HookStore = {
27
+ get: async (name) => values[name] as string | undefined,
28
+ set: async (name, value) => {
29
+ values[name] = value;
30
+ },
31
+ delete: async (name) => {
32
+ delete values[name];
33
+ },
34
+ transaction: async (fn) => {
35
+ await fn(store);
36
+ },
37
+ };
38
+ for (const [name, method] of Object.entries(store)) {
39
+ Object.defineProperty(values, name, {
40
+ value: method,
41
+ enumerable: false,
42
+ writable: true,
43
+ configurable: true,
44
+ });
45
+ }
46
+ return Object.assign(values, store);
47
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Test fixture: a hook that exercises the hook-owned-state accessor across
3
+ * the process boundary — set/get round trip on both stores, delete, a
4
+ * committed transaction, a discarded transaction, and the undeclared-name
5
+ * throw the whole design exists to produce.
6
+ */
7
+
8
+ import { defineHook } from '@celilo/capabilities';
9
+
10
+ export default defineHook({
11
+ hook: 'on_install',
12
+ requires: [],
13
+ handler: async (ctx) => {
14
+ const outputs: Record<string, unknown> = {};
15
+
16
+ // Round trip on each store.
17
+ await ctx.secrets.set('bot_token', 'token-value');
18
+ outputs.secretRoundTrip = await ctx.secrets.get('bot_token');
19
+
20
+ await ctx.config.set('public_ip', '203.0.113.7');
21
+ outputs.configRoundTrip = await ctx.config.get('public_ip');
22
+
23
+ // The map surface still answers from the values the context carried.
24
+ outputs.mapRead = ctx.config.mapOnlyValue;
25
+
26
+ // Delete removes; deleting a declared-but-never-written name is a no-op.
27
+ // (An UNDECLARED name on delete throws — that is 3.4, tested below.
28
+ // Declared-and-absent is the no-op case 3.7 pins.)
29
+ await ctx.secrets.delete('bot_token');
30
+ await ctx.secrets.set('api_key', 'to-delete');
31
+ await ctx.secrets.delete('api_key');
32
+ outputs.deletedIsGone = await ctx.secrets.get('api_key');
33
+
34
+ // Committed transaction: both writes land.
35
+ await ctx.secrets.transaction((s) => {
36
+ s.set('bot_token', 'committed-a');
37
+ s.set('api_key', 'committed-b');
38
+ });
39
+ outputs.transactionA = await ctx.secrets.get('bot_token');
40
+ outputs.transactionB = await ctx.secrets.get('api_key');
41
+
42
+ // Discarded transaction: nothing lands, including the overwrite of the
43
+ // committed value above.
44
+ try {
45
+ await ctx.secrets.transaction(async (s) => {
46
+ s.set('bot_token', 'discarded');
47
+ throw new Error('hook failed midway');
48
+ });
49
+ } catch (error) {
50
+ outputs.discarded = (error as Error).message;
51
+ }
52
+ outputs.afterDiscard = await ctx.secrets.get('bot_token');
53
+
54
+ // An undeclared name throws, naming the module and the declared set.
55
+ try {
56
+ await ctx.secrets.set('not_declared', 'x');
57
+ } catch (error) {
58
+ outputs.undeclaredSecret = (error as Error).message;
59
+ }
60
+
61
+ return outputs;
62
+ },
63
+ });
@@ -26,6 +26,7 @@ import { executeHookScript, hookChildEnv } from './executor';
26
26
  import type { MountSetWire } from './hook-protocol';
27
27
  import { createCapturingLogger } from './logger';
28
28
  import { deriveMountSet } from './mount-set';
29
+ import { configStore, secretStore } from './test-fixtures/store-backed';
29
30
  import type { HookContext } from './types';
30
31
  import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint';
31
32
 
@@ -54,10 +55,25 @@ function scratchModule(script: string): string {
54
55
 
55
56
  const describeSet = (overrides: Partial<MountSetWire>): MountSetWire => ({
56
57
  entries: [
57
- { path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run' },
58
- { path: '/srv/celilo/mod', mode: 'ro', reason: "the module's own tree" },
59
- { path: '/srv/celilo/mod/state', mode: 'rw', reason: 'the sanctioned writable directory' },
60
- { path: '/srv/staged', mode: 'rw', reason: "contract input 'backup_dir' (write)" },
58
+ { path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run', absence: 'runtime' },
59
+ {
60
+ path: '/srv/celilo/mod',
61
+ mode: 'ro',
62
+ reason: "the module's own tree",
63
+ absence: 'required',
64
+ },
65
+ {
66
+ path: '/srv/celilo/mod/state',
67
+ mode: 'rw',
68
+ reason: 'the sanctioned writable directory',
69
+ absence: 'required',
70
+ },
71
+ {
72
+ path: '/srv/staged',
73
+ mode: 'rw',
74
+ reason: "contract input 'backup_dir' (write)",
75
+ absence: 'required',
76
+ },
61
77
  ],
62
78
  chdir: '/srv/celilo/mod',
63
79
  ...overrides,
@@ -159,8 +175,8 @@ describe('the lint inside a real hook run', () => {
159
175
  mkdirSync(join(root, 'state'), { recursive: true });
160
176
  const { logger, messages } = createCapturingLogger();
161
177
  const context: HookContext = {
162
- config: {},
163
- secrets: {},
178
+ config: configStore(),
179
+ secrets: secretStore(),
164
180
  systems: [],
165
181
  logger,
166
182
  debug: false,
@@ -17,6 +17,7 @@ export const VariableSourceSchema = z.enum([
17
17
  'system',
18
18
  'terraform',
19
19
  'infrastructure',
20
+ 'hook',
20
21
  ]);
21
22
 
22
23
  /**
@@ -1,8 +1,9 @@
1
1
  import { execFileSync, execSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, join, relative } from 'node:path';
6
+ import { gunzipSync } from 'node:zlib';
6
7
  import { create as tarCreate } from 'tar';
7
8
  import { parse as parseYaml } from 'yaml';
8
9
  import { log } from '../../cli/prompts';
@@ -120,6 +121,33 @@ export async function computeChecksums(sourceDir: string): Promise<ChecksumsData
120
121
  };
121
122
  }
122
123
 
124
+ /**
125
+ * Ceiling on the final tar+gzip write of the .netapp. Every step before it
126
+ * (staging, the module's own build command) already carries a timeout, but the
127
+ * tar stream did not — and 2026-09-04 it stalled mid-write (all samples in
128
+ * kevent64, artifact truncated at 59 percent) and sat there for two hours with
129
+ * no error and no exit. A bounded wait turns that into a nameable failure.
130
+ */
131
+ const PACKAGE_STREAM_TIMEOUT_MS = 300_000;
132
+
133
+ /**
134
+ * Validate that a produced .netapp is a complete gzip stream.
135
+ *
136
+ * A .netapp is a gzipped tar (tarCreate above runs with gzip: true), so a
137
+ * full gunzip pass is the whole check: a stream truncated mid-write fails
138
+ * with 'unexpected end of file' instead of surviving to the consumer, where
139
+ * the same zlib error reads as a truncated DOWNLOAD from an unrelated cause.
140
+ * Returns null when valid, otherwise the reason.
141
+ */
142
+ export function verifyNetappIntegrity(packagePath: string): string | null {
143
+ try {
144
+ gunzipSync(readFileSync(packagePath));
145
+ return null;
146
+ } catch (err) {
147
+ return err instanceof Error ? err.message : String(err);
148
+ }
149
+ }
150
+
123
151
  /**
124
152
  * Build a module package (.netapp file)
125
153
  *
@@ -321,7 +349,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
321
349
 
322
350
  // Create tarball
323
351
  const finalOutputPath = outputPath || join(process.cwd(), `${moduleId}.netapp`);
324
- await tarCreate(
352
+ const tarPromise = tarCreate(
325
353
  {
326
354
  file: finalOutputPath,
327
355
  cwd: buildDir,
@@ -329,6 +357,46 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
329
357
  },
330
358
  ['checksums.json', 'signature.sig', ...Object.keys(checksumsData.files)],
331
359
  );
360
+ let timer: ReturnType<typeof setTimeout> | undefined;
361
+ const timeout = new Promise<never>((_, reject) => {
362
+ timer = setTimeout(
363
+ () =>
364
+ reject(
365
+ new Error(
366
+ `packaging tar stream exceeded ${PACKAGE_STREAM_TIMEOUT_MS / 1000}s and was abandoned mid-write`,
367
+ ),
368
+ ),
369
+ PACKAGE_STREAM_TIMEOUT_MS,
370
+ );
371
+ });
372
+ timer?.unref?.();
373
+ try {
374
+ await Promise.race([tarPromise, timeout]);
375
+ } catch (tarError) {
376
+ // The losing side may still hold the output file open: swallow its
377
+ // eventual rejection and remove the partial artifact so nothing
378
+ // downstream mistakes a truncated write for a package.
379
+ tarPromise.catch(() => {});
380
+ rmSync(finalOutputPath, { force: true });
381
+ return {
382
+ success: false,
383
+ error: `Failed to build module: ${
384
+ tarError instanceof Error ? tarError.message : 'Unknown error'
385
+ }`,
386
+ };
387
+ }
388
+
389
+ // Refuse to declare success on an artifact we cannot read back. The tar
390
+ // step resolved, so this only trips if the stream left the file truncated
391
+ // anyway — the exact corruption build-infra shipped on 2026-09-04.
392
+ const integrityError = verifyNetappIntegrity(finalOutputPath);
393
+ if (integrityError) {
394
+ rmSync(finalOutputPath, { force: true });
395
+ return {
396
+ success: false,
397
+ error: `Produced package failed gzip integrity check (${finalOutputPath}): ${integrityError}`,
398
+ };
399
+ }
332
400
 
333
401
  return {
334
402
  success: true,
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { join } from 'node:path';
17
- import { MODULE_WEB_ROOT } from '@celilo/capabilities';
17
+ import { MODULE_STATE_WEB_ROOT, MODULE_WEB_ROOT } from '@celilo/capabilities';
18
18
  import { eq } from 'drizzle-orm';
19
19
  import type { DbClient } from '../db/client';
20
20
  import { modules } from '../db/schema';
@@ -33,3 +33,19 @@ export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | u
33
33
  if (!module) return undefined;
34
34
  return join(module.sourcePath, MODULE_WEB_ROOT);
35
35
  }
36
+
37
+ /**
38
+ * Absolute path to `<module root>/state/site` — the state web overlay
39
+ * (celilo#1265) — or undefined when no such module is installed.
40
+ *
41
+ * Beside `resolveModuleWebRoot` for the same reason that function lives here:
42
+ * ONE application of the convention, by the one party that can resolve it for
43
+ * a module that is not running. Does NOT check that the directory exists — a
44
+ * module with no generated site content has no overlay, and deciding what an
45
+ * absent directory means belongs to the caller (see `requireWebRoot`).
46
+ */
47
+ export function resolveModuleStateWebRoot(moduleId: string, db: DbClient): string | undefined {
48
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
49
+ if (!module) return undefined;
50
+ return join(module.sourcePath, MODULE_STATE_WEB_ROOT);
51
+ }