@celilo/cli 1.8.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/CELILO_CORE_MODULES.md +2 -0
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +161 -5
- package/src/hooks/broker.test.ts +153 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/define-hook.test.ts +1 -0
- package/src/hooks/executor.test.ts +7 -0
- package/src/hooks/executor.ts +245 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-state-dir.test.ts +109 -0
- package/src/hooks/hook-timeout.test.ts +104 -0
- package/src/hooks/hook-trespass.test.ts +202 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/mount-set.test.ts +148 -0
- package/src/hooks/mount-set.ts +234 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/contracts/v1.ts +21 -6
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/packaging/module-state-directory.test.ts +105 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/capability-shape.ts +13 -1
- package/src/policy/module-business-baseline.ts +36 -0
- package/src/policy/module-dep-reachability.test.ts +167 -0
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +2 -2
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/dns-internal-records.test.ts +3 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/port-forwards.test.ts +2 -2
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/test-utils/module-fixtures.ts +147 -35
- package/src/test-utils/setup-test-db.ts +0 -80
package/src/hooks/executor.ts
CHANGED
|
@@ -7,14 +7,28 @@
|
|
|
7
7
|
* - Output validation
|
|
8
8
|
* - Structured logging
|
|
9
9
|
*
|
|
10
|
-
* **Execution model:** hook
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* **Execution model:** a hook script is a program celilo RUNS, not a library
|
|
11
|
+
* celilo loads. `executeHookScript` spawns `bun hook-runner.ts <script>` and
|
|
12
|
+
* becomes a broker: it keeps the database, the master key and the live
|
|
13
|
+
* capability objects, and answers the child over a versioned NDJSON protocol
|
|
14
|
+
* on a Unix socket (`hook-protocol.ts`, `broker.ts`). See
|
|
15
|
+
* `openspec/changes/hook-process-boundary/design.md`.
|
|
16
|
+
*
|
|
17
|
+
* Three things follow, and they are the point:
|
|
18
|
+
*
|
|
19
|
+
* - The child's environment is an ALLOW-LIST, not `...process.env`. celilo's
|
|
20
|
+
* own paths and the operator's tokens stop being visible (design D5).
|
|
21
|
+
* - A timeout is a real `proc.kill()`. The old implementation raced the
|
|
22
|
+
* hook's promise against a timer and cancelled nothing, so a hook that
|
|
23
|
+
* "timed out" kept its capability objects and went on registering DNS
|
|
24
|
+
* records after celilo reported the deploy failed (celilo#1003).
|
|
25
|
+
* - A hook's memory is its own process's, not celilo's heap.
|
|
26
|
+
*
|
|
27
|
+
* Hooks still do NOT execute on the target machine. One that needs to touch a
|
|
28
|
+
* target initiates SSH outbound itself, so anything it depends on (chromium,
|
|
17
29
|
* system binaries, credentials) must be available on the celilo CLI host.
|
|
30
|
+
* Stage 2 of the change above jails the filesystem and stage 3 scopes that
|
|
31
|
+
* reachability; neither has landed.
|
|
18
32
|
*
|
|
19
33
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
20
34
|
*/
|
|
@@ -23,9 +37,9 @@ import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs
|
|
|
23
37
|
import { dirname, join, resolve } from 'node:path';
|
|
24
38
|
import {
|
|
25
39
|
type DeployedSystem,
|
|
26
|
-
isCompiledHook,
|
|
27
40
|
isMissingProviderInputError,
|
|
28
41
|
moduleArtifactDir,
|
|
42
|
+
moduleStateDir,
|
|
29
43
|
} from '@celilo/capabilities';
|
|
30
44
|
import {
|
|
31
45
|
type ContractHookSignature,
|
|
@@ -35,6 +49,14 @@ import {
|
|
|
35
49
|
} from '../manifest/contracts';
|
|
36
50
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
37
51
|
import { pruneModuleArtifacts } from './artifact-retention';
|
|
52
|
+
import { startBroker } from './broker';
|
|
53
|
+
import {
|
|
54
|
+
HOOK_PROTOCOL_VERSION,
|
|
55
|
+
HOOK_PROTOCOL_VERSION_ENV,
|
|
56
|
+
HOOK_SOCKET_ENV,
|
|
57
|
+
createLineReader,
|
|
58
|
+
deserializeError,
|
|
59
|
+
} from './hook-protocol';
|
|
38
60
|
import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
|
|
39
61
|
|
|
40
62
|
/** Default total timeout: 60 seconds */
|
|
@@ -51,6 +73,76 @@ const DEFAULT_TIMEOUT_MS = 60_000;
|
|
|
51
73
|
*/
|
|
52
74
|
const IDLE_TIMEOUT_MS = 30_000;
|
|
53
75
|
|
|
76
|
+
/** How often the idle tracker looks. Unchanged from the promise-based timer. */
|
|
77
|
+
const IDLE_POLL_MS = 5_000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* SIGTERM is a request. A hook that traps it and keeps working would leave the
|
|
81
|
+
* boundary buying nothing over the promise race it replaced, so SIGKILL
|
|
82
|
+
* follows. Short, because by this point celilo has already told the operator
|
|
83
|
+
* the hook failed.
|
|
84
|
+
*/
|
|
85
|
+
const SIGKILL_GRACE_MS = 2_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Environment variables a hook inherits. Everything else is dropped (D5).
|
|
89
|
+
*
|
|
90
|
+
* Two kinds, and the distinction is the whole rule.
|
|
91
|
+
*
|
|
92
|
+
* **How to run** — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`. A hook that spawns
|
|
93
|
+
* anything needs these, and withholding them hides no path.
|
|
94
|
+
*
|
|
95
|
+
* **How to reach the network, and whom to trust** — the CA and proxy families.
|
|
96
|
+
* These configure the RUNTIME, not the module: they say which certificate
|
|
97
|
+
* authorities this host trusts and how it reaches the internet. They name no
|
|
98
|
+
* secret, they are not module-specific, and withholding them reduces a hook's
|
|
99
|
+
* authority by nothing at all. It only makes its TLS connections wrong.
|
|
100
|
+
*
|
|
101
|
+
* `NODE_EXTRA_CA_CERTS` is why this second group exists rather than being an
|
|
102
|
+
* afterthought. `packages/e2e/docker/Dockerfile.management:107` sets it because
|
|
103
|
+
* `bun fetch()` otherwise ignores `/etc/ssl/certs/ca-certificates.crt`
|
|
104
|
+
* entirely and rejects every certificate the simulated CA issues. Twelve
|
|
105
|
+
* hook-reachable module files make HTTPS requests. Dropping it would have
|
|
106
|
+
* broken all of them, in the rig only, as a TLS error naming no cause.
|
|
107
|
+
*
|
|
108
|
+
* The proxy family is not set anywhere today. It is here because it is the
|
|
109
|
+
* same kind of thing, it costs nothing when absent, and the alternative is
|
|
110
|
+
* discovering it the way `NODE_EXTRA_CA_CERTS` was discovered.
|
|
111
|
+
*
|
|
112
|
+
* **`TF_CLI_CONFIG_FILE` is deliberately NOT here.** It is toolchain
|
|
113
|
+
* configuration too, but no hook invokes the terraform binary — every mention
|
|
114
|
+
* of terraform in a module script is a `generated/terraform/` PATH. celilo runs
|
|
115
|
+
* terraform from its own process, which keeps its own environment. Recorded so
|
|
116
|
+
* the next reader does not have to re-derive it.
|
|
117
|
+
*
|
|
118
|
+
* What is NOT in either group: anything module-specific. A module that needs a
|
|
119
|
+
* value declares it (config) or is handed it (a hook input). See the note on
|
|
120
|
+
* `DDNS_ENDPOINT` in openspec/changes/hook-process-boundary/baseline.md for the
|
|
121
|
+
* one case that got this wrong and what was done about it.
|
|
122
|
+
*/
|
|
123
|
+
const FORWARDED_ENV = [
|
|
124
|
+
// How to run.
|
|
125
|
+
'PATH',
|
|
126
|
+
'HOME',
|
|
127
|
+
'LANG',
|
|
128
|
+
'TZ',
|
|
129
|
+
'TMPDIR',
|
|
130
|
+
// Whom to trust.
|
|
131
|
+
'NODE_EXTRA_CA_CERTS',
|
|
132
|
+
'SSL_CERT_FILE',
|
|
133
|
+
'SSL_CERT_DIR',
|
|
134
|
+
// How to reach the network.
|
|
135
|
+
'HTTP_PROXY',
|
|
136
|
+
'HTTPS_PROXY',
|
|
137
|
+
'NO_PROXY',
|
|
138
|
+
'http_proxy',
|
|
139
|
+
'https_proxy',
|
|
140
|
+
'no_proxy',
|
|
141
|
+
] as const;
|
|
142
|
+
|
|
143
|
+
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
144
|
+
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
145
|
+
|
|
54
146
|
/**
|
|
55
147
|
* Validate hook inputs against a contract signature.
|
|
56
148
|
*
|
|
@@ -148,127 +240,159 @@ export async function executeHookScript(
|
|
|
148
240
|
throw new Error(`Hook script not found: ${scriptPath}`);
|
|
149
241
|
}
|
|
150
242
|
|
|
151
|
-
//
|
|
243
|
+
// Activity is now ANY frame or ANY byte the child writes, rather than only a
|
|
244
|
+
// `ctx.logger` call. That is strictly more generous than the old tracker and
|
|
245
|
+
// it removes a class of false idle-kill: a hook that prints progress to
|
|
246
|
+
// stdout used to look silent (celilo#622 is the same family).
|
|
152
247
|
let lastActivity = Date.now();
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
// Wrap logger to track activity for idle timeout
|
|
156
|
-
const trackedLogger: HookLogger = {
|
|
157
|
-
info(message: string) {
|
|
158
|
-
lastActivity = Date.now();
|
|
159
|
-
originalLogger.info(message);
|
|
160
|
-
},
|
|
161
|
-
warn(message: string) {
|
|
162
|
-
lastActivity = Date.now();
|
|
163
|
-
originalLogger.warn(message);
|
|
164
|
-
},
|
|
165
|
-
error(message: string) {
|
|
166
|
-
lastActivity = Date.now();
|
|
167
|
-
originalLogger.error(message);
|
|
168
|
-
},
|
|
169
|
-
success(message: string) {
|
|
170
|
-
lastActivity = Date.now();
|
|
171
|
-
originalLogger.success(message);
|
|
172
|
-
},
|
|
248
|
+
const markActive = () => {
|
|
249
|
+
lastActivity = Date.now();
|
|
173
250
|
};
|
|
174
251
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
logger: trackedLogger,
|
|
178
|
-
};
|
|
252
|
+
const { logger, ...serializableContext } = context;
|
|
253
|
+
delete (serializableContext as Record<string, unknown>).capabilities;
|
|
179
254
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
const result = await Promise.race(promises);
|
|
255
|
+
const broker = await startBroker({
|
|
256
|
+
capabilities: context.capabilities,
|
|
257
|
+
context: serializableContext as Record<string, unknown>,
|
|
258
|
+
scriptPath,
|
|
259
|
+
logger,
|
|
260
|
+
onActivity: markActive,
|
|
261
|
+
});
|
|
189
262
|
|
|
190
|
-
|
|
191
|
-
|
|
263
|
+
try {
|
|
264
|
+
const child = Bun.spawn({
|
|
265
|
+
cmd: [process.execPath, HOOK_RUNNER_PATH],
|
|
266
|
+
env: hookChildEnv(broker.socketPath),
|
|
267
|
+
stdout: 'pipe',
|
|
268
|
+
stderr: 'pipe',
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// The child's stdout and stderr stay what they are — human output. They
|
|
272
|
+
// now reach `ctx.logger` instead of celilo's own stdout, which is the
|
|
273
|
+
// attribution: a `console.log` from an in-process hook landed unlabelled
|
|
274
|
+
// in the CLI's output with nothing saying which hook wrote it.
|
|
275
|
+
const forwarding = Promise.all([
|
|
276
|
+
forwardStream(child.stdout, logger.info.bind(logger), markActive),
|
|
277
|
+
forwardStream(child.stderr, logger.warn.bind(logger), markActive),
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
let killedFor: string | null = null;
|
|
281
|
+
const kill = (reason: string) => {
|
|
282
|
+
if (killedFor) return;
|
|
283
|
+
killedFor = reason;
|
|
284
|
+
// Refuse capability calls FIRST. A hook between `kill` and its own death
|
|
285
|
+
// can still have a call in flight, and answering it is exactly the
|
|
286
|
+
// failure this replaces.
|
|
287
|
+
broker.stop();
|
|
288
|
+
child.kill('SIGTERM');
|
|
289
|
+
setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
|
|
290
|
+
};
|
|
192
291
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
|
|
292
|
+
const totalTimer = setTimeout(() => kill('total'), timeoutMs);
|
|
293
|
+
// Debug runs get no idle timer, same as before: an operator stepping
|
|
294
|
+
// through a browser hook is silent for minutes on purpose.
|
|
295
|
+
const idleTimer = context.debug
|
|
296
|
+
? undefined
|
|
297
|
+
: setInterval(() => {
|
|
298
|
+
if (Date.now() - lastActivity >= idleTimeoutMs) kill('idle');
|
|
299
|
+
}, IDLE_POLL_MS);
|
|
300
|
+
|
|
301
|
+
let exitCode: number;
|
|
302
|
+
try {
|
|
303
|
+
exitCode = await child.exited;
|
|
304
|
+
// Both channels, not just the process: the terminal frame can still be
|
|
305
|
+
// in the socket buffer when the exit is observed.
|
|
306
|
+
await broker.drained();
|
|
307
|
+
await forwarding;
|
|
308
|
+
} finally {
|
|
309
|
+
clearTimeout(totalTimer);
|
|
310
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
311
|
+
}
|
|
212
312
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
313
|
+
// Process exit is the ONE terminal event, so there is no race to lose and
|
|
314
|
+
// nothing keeps running past this line. That is the difference from the
|
|
315
|
+
// `Promise.race` this replaces (celilo#1003).
|
|
316
|
+
if (killedFor === 'total') {
|
|
317
|
+
throw new Error(`Hook total timeout exceeded (${Math.round(timeoutMs / 1000)}s)`);
|
|
318
|
+
}
|
|
319
|
+
if (killedFor === 'idle') {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
216
324
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
325
|
+
const outcome = broker.outcome();
|
|
326
|
+
if (!outcome) {
|
|
327
|
+
const faults = broker.faults();
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Hook process exited with code ${exitCode} without returning a result.${
|
|
330
|
+
faults.length > 0 ? ` ${faults.join('; ')}` : ''
|
|
331
|
+
}`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!outcome.ok) throw deserializeError(outcome.error);
|
|
336
|
+
return outcome.outputs;
|
|
337
|
+
} finally {
|
|
338
|
+
broker.close();
|
|
221
339
|
}
|
|
340
|
+
}
|
|
222
341
|
|
|
223
|
-
|
|
342
|
+
/**
|
|
343
|
+
* The child's environment, built from an allow-list (design D5).
|
|
344
|
+
*
|
|
345
|
+
* NOT `...process.env`. `packages/event-bus/src/dispatcher.ts` spawns handlers
|
|
346
|
+
* that way and it grants a subprocess MORE than the in-process call did — the
|
|
347
|
+
* whole environment plus an explicit database path. Copying that pattern would
|
|
348
|
+
* buy the crash isolation and none of the authority reduction.
|
|
349
|
+
*
|
|
350
|
+
* Measured on the operator's own shell: what a hook inherited before this was
|
|
351
|
+
* 24 variables naming celilo state or carrying a credential, twenty of them
|
|
352
|
+
* third-party API tokens with nothing to do with celilo
|
|
353
|
+
* (`openspec/changes/hook-process-boundary/baseline.md`).
|
|
354
|
+
*
|
|
355
|
+
* This is not the boundary — a hook can still COMPUTE the default master-key
|
|
356
|
+
* path from `$HOME` and read it. It is the cheap half, and it is what stops the
|
|
357
|
+
* dispatcher's mistake repeating here. Stage 2's mount set is what makes the
|
|
358
|
+
* path unreachable.
|
|
359
|
+
*
|
|
360
|
+
* Planning function (Rule 10.4) — pure.
|
|
361
|
+
*/
|
|
362
|
+
export function hookChildEnv(socketPath: string): Record<string, string> {
|
|
363
|
+
const env: Record<string, string> = {};
|
|
224
364
|
|
|
225
|
-
|
|
226
|
-
|
|
365
|
+
for (const name of FORWARDED_ENV) {
|
|
366
|
+
const value = process.env[name];
|
|
367
|
+
if (value !== undefined) env[name] = value;
|
|
227
368
|
}
|
|
228
369
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
370
|
+
env[HOOK_SOCKET_ENV] = socketPath;
|
|
371
|
+
env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
|
|
372
|
+
// Forwarded when the operator set it, not synthesised from `debug` — a hook
|
|
373
|
+
// reads the flag off `ctx.debug`, which crosses in the context frame.
|
|
374
|
+
if (process.env.CELILO_DEBUG !== undefined) env.CELILO_DEBUG = process.env.CELILO_DEBUG;
|
|
232
375
|
|
|
233
|
-
return
|
|
376
|
+
return env;
|
|
234
377
|
}
|
|
235
378
|
|
|
236
|
-
/**
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
379
|
+
/** Read a piped stream line by line into the logger. */
|
|
380
|
+
async function forwardStream(
|
|
381
|
+
stream: ReadableStream<Uint8Array>,
|
|
382
|
+
emit: (line: string) => void,
|
|
383
|
+
markActive: () => void,
|
|
384
|
+
): Promise<void> {
|
|
385
|
+
const decoder = new TextDecoder();
|
|
386
|
+
const feed = createLineReader((line) => {
|
|
387
|
+
markActive();
|
|
388
|
+
emit(line);
|
|
244
389
|
});
|
|
245
|
-
}
|
|
246
390
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
getIdleDuration: () => number,
|
|
253
|
-
idleTimeoutMs: number,
|
|
254
|
-
): Promise<never> {
|
|
255
|
-
return new Promise((_, reject) => {
|
|
256
|
-
const interval = setInterval(() => {
|
|
257
|
-
if (getIdleDuration() >= idleTimeoutMs) {
|
|
258
|
-
clearInterval(interval);
|
|
259
|
-
reject(
|
|
260
|
-
new Error(
|
|
261
|
-
`Hook idle timeout exceeded (no log output for ${Math.round(idleTimeoutMs / 1000)}s)`,
|
|
262
|
-
),
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
}, 5000);
|
|
266
|
-
|
|
267
|
-
// Don't block process exit
|
|
268
|
-
if (interval.unref) {
|
|
269
|
-
interval.unref();
|
|
270
|
-
}
|
|
271
|
-
});
|
|
391
|
+
for await (const chunk of stream) {
|
|
392
|
+
feed(decoder.decode(chunk, { stream: true }));
|
|
393
|
+
}
|
|
394
|
+
// A final line with no trailing newline would otherwise be dropped.
|
|
395
|
+
feed('\n');
|
|
272
396
|
}
|
|
273
397
|
|
|
274
398
|
export interface InvokeHookOptions {
|
|
@@ -505,6 +629,10 @@ export async function invokeHook(
|
|
|
505
629
|
// below — the capability pre-flight is one — and nothing ever cleans
|
|
506
630
|
// those up, so a module accrues one per affected run forever.
|
|
507
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);
|
|
508
636
|
|
|
509
637
|
// Build context
|
|
510
638
|
const loadedCapabilities = options.capabilities ?? {};
|
|
@@ -516,6 +644,7 @@ export async function invokeHook(
|
|
|
516
644
|
logger,
|
|
517
645
|
debug,
|
|
518
646
|
screenshotDir,
|
|
647
|
+
stateDir,
|
|
519
648
|
capabilities: loadedCapabilities,
|
|
520
649
|
};
|
|
521
650
|
|
|
@@ -550,6 +679,11 @@ export async function invokeHook(
|
|
|
550
679
|
// Create the artifact directory only once every early return is behind
|
|
551
680
|
// us, so the `finally` below is guaranteed to run and reclaim it.
|
|
552
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 });
|
|
553
687
|
// Prune on write, so retention needs no scheduler of its own and cannot
|
|
554
688
|
// fall behind a module that runs often.
|
|
555
689
|
pruneModuleArtifacts(dirname(screenshotDir));
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { MissingProviderInputError, isMissingProviderInputError } from '@celilo/capabilities';
|
|
3
|
+
import {
|
|
4
|
+
type ChildFrame,
|
|
5
|
+
HOOK_PROTOCOL_VERSION,
|
|
6
|
+
type ParentFrame,
|
|
7
|
+
createLineReader,
|
|
8
|
+
deserializeError,
|
|
9
|
+
encodeFrame,
|
|
10
|
+
parseChildFrame,
|
|
11
|
+
parseParentFrame,
|
|
12
|
+
serializeError,
|
|
13
|
+
versionMismatch,
|
|
14
|
+
} from './hook-protocol';
|
|
15
|
+
|
|
16
|
+
const CHILD_FRAMES: ChildFrame[] = [
|
|
17
|
+
{ type: 'ready', protocolVersion: HOOK_PROTOCOL_VERSION },
|
|
18
|
+
{
|
|
19
|
+
type: 'call',
|
|
20
|
+
id: 'c1',
|
|
21
|
+
capability: 'public_web',
|
|
22
|
+
method: 'register_route',
|
|
23
|
+
args: [{ path: '/x' }],
|
|
24
|
+
},
|
|
25
|
+
{ type: 'log', level: 'info', message: 'hello' },
|
|
26
|
+
{ type: 'log', level: 'success', message: 'done' },
|
|
27
|
+
{ type: 'result', outputs: { api_key: 'k' } },
|
|
28
|
+
{ type: 'throw', error: { name: 'Error', message: 'boom', stack: 'at x' } },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const PARENT_FRAMES: ParentFrame[] = [
|
|
32
|
+
{
|
|
33
|
+
type: 'context',
|
|
34
|
+
protocolVersion: HOOK_PROTOCOL_VERSION,
|
|
35
|
+
scriptPath: '/m/scripts/h.ts',
|
|
36
|
+
context: { config: { a: 1 }, secrets: {}, systems: [], debug: false, screenshotDir: '/tmp/a' },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'capabilities',
|
|
40
|
+
shape: { firewall: { methods: ['exposeService'], data: { providerModuleId: 'iptables' } } },
|
|
41
|
+
},
|
|
42
|
+
{ type: 'return', id: 'c1', value: { success: true } },
|
|
43
|
+
{ type: 'throw', id: 'c1', error: { name: 'Error', message: 'nope' } },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
describe('hook protocol', () => {
|
|
47
|
+
describe('round trip', () => {
|
|
48
|
+
for (const frame of CHILD_FRAMES) {
|
|
49
|
+
test(`child ${frame.type}${'level' in frame ? `/${frame.level}` : ''}`, () => {
|
|
50
|
+
const parsed = parseChildFrame(encodeFrame(frame).trimEnd());
|
|
51
|
+
expect(parsed.ok).toBe(true);
|
|
52
|
+
if (parsed.ok) expect(parsed.frame).toEqual(frame);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const frame of PARENT_FRAMES) {
|
|
57
|
+
test(`parent ${frame.type}`, () => {
|
|
58
|
+
const parsed = parseParentFrame(encodeFrame(frame).trimEnd());
|
|
59
|
+
expect(parsed.ok).toBe(true);
|
|
60
|
+
if (parsed.ok) expect(parsed.frame).toEqual(frame);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('every frame ends in exactly one newline', () => {
|
|
65
|
+
for (const frame of [...CHILD_FRAMES, ...PARENT_FRAMES]) {
|
|
66
|
+
const encoded = encodeFrame(frame);
|
|
67
|
+
expect(encoded.endsWith('\n')).toBe(true);
|
|
68
|
+
expect(encoded.slice(0, -1)).not.toContain('\n');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('malformed input is a value, never a throw', () => {
|
|
74
|
+
// The boundary's whole claim is that the child cannot take celilo down. A
|
|
75
|
+
// reader that throws on a bad line hands that back.
|
|
76
|
+
const bad = [
|
|
77
|
+
'',
|
|
78
|
+
'not json at all',
|
|
79
|
+
'{',
|
|
80
|
+
'{"type":"nope"}',
|
|
81
|
+
'{"type":"call"}',
|
|
82
|
+
'null',
|
|
83
|
+
'[]',
|
|
84
|
+
'"a string"',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (const line of bad) {
|
|
88
|
+
test(`child reader survives ${JSON.stringify(line)}`, () => {
|
|
89
|
+
const parsed = parseChildFrame(line);
|
|
90
|
+
expect(parsed.ok).toBe(false);
|
|
91
|
+
if (!parsed.ok) expect(parsed.error.length).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test('a very long malformed line is truncated in the message', () => {
|
|
96
|
+
const parsed = parseChildFrame('x'.repeat(5000));
|
|
97
|
+
expect(parsed.ok).toBe(false);
|
|
98
|
+
if (!parsed.ok) expect(parsed.error.length).toBeLessThan(200);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a parent frame is not a child frame', () => {
|
|
102
|
+
expect(parseChildFrame(encodeFrame(PARENT_FRAMES[1]).trimEnd()).ok).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('line reader', () => {
|
|
107
|
+
test('reassembles a frame split across chunks', () => {
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
const feed = createLineReader((l) => lines.push(l));
|
|
110
|
+
const encoded = encodeFrame(CHILD_FRAMES[1]);
|
|
111
|
+
feed(encoded.slice(0, 7));
|
|
112
|
+
expect(lines).toEqual([]);
|
|
113
|
+
feed(encoded.slice(7));
|
|
114
|
+
expect(lines).toHaveLength(1);
|
|
115
|
+
expect(parseChildFrame(lines[0]).ok).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('splits several frames arriving in one chunk', () => {
|
|
119
|
+
const lines: string[] = [];
|
|
120
|
+
const feed = createLineReader((l) => lines.push(l));
|
|
121
|
+
feed(CHILD_FRAMES.map(encodeFrame).join(''));
|
|
122
|
+
expect(lines).toHaveLength(CHILD_FRAMES.length);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('holds a partial tail rather than emitting it', () => {
|
|
126
|
+
const lines: string[] = [];
|
|
127
|
+
const feed = createLineReader((l) => lines.push(l));
|
|
128
|
+
feed(`${encodeFrame(CHILD_FRAMES[0])}{"type":"log"`);
|
|
129
|
+
expect(lines).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('handshake', () => {
|
|
134
|
+
test('matching versions pass', () => {
|
|
135
|
+
expect(versionMismatch(HOOK_PROTOCOL_VERSION, 'the hook runner')).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a mismatch names both numbers', () => {
|
|
139
|
+
const message = versionMismatch(99, 'the hook runner');
|
|
140
|
+
expect(message).toContain('99');
|
|
141
|
+
expect(message).toContain(String(HOOK_PROTOCOL_VERSION));
|
|
142
|
+
expect(message).toContain('the hook runner');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('errors', () => {
|
|
147
|
+
test('a plain Error keeps name, message and stack', () => {
|
|
148
|
+
const rebuilt = deserializeError(serializeError(new TypeError('bad shape')));
|
|
149
|
+
expect(rebuilt.name).toBe('TypeError');
|
|
150
|
+
expect(rebuilt.message).toBe('bad shape');
|
|
151
|
+
expect(rebuilt.stack).toBeTruthy();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a non-Error throw still crosses', () => {
|
|
155
|
+
expect(deserializeError(serializeError('just a string')).message).toBe('just a string');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('MissingProviderInputError survives, fields intact', () => {
|
|
159
|
+
// The one error the framework READS rather than displays. If the four
|
|
160
|
+
// fields do not survive, the cross-module ensure interview never runs
|
|
161
|
+
// and the deploy fails with a message instead of a question.
|
|
162
|
+
const original = new MissingProviderInputError({
|
|
163
|
+
providerModuleId: 'caddy',
|
|
164
|
+
ensureId: 'hostnames',
|
|
165
|
+
value: 'foo.example.com',
|
|
166
|
+
humanContext: 'so the route resolves',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const rebuilt = deserializeError(serializeError(original));
|
|
170
|
+
|
|
171
|
+
expect(isMissingProviderInputError(rebuilt)).toBe(true);
|
|
172
|
+
if (!isMissingProviderInputError(rebuilt)) throw new Error('unreachable');
|
|
173
|
+
expect(rebuilt.providerModuleId).toBe('caddy');
|
|
174
|
+
expect(rebuilt.ensureId).toBe('hostnames');
|
|
175
|
+
expect(rebuilt.value).toBe('foo.example.com');
|
|
176
|
+
expect(rebuilt.humanContext).toBe('so the route resolves');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('an absent humanContext does not become the string "undefined"', () => {
|
|
180
|
+
const rebuilt = deserializeError(
|
|
181
|
+
serializeError(
|
|
182
|
+
new MissingProviderInputError({ providerModuleId: 'p', ensureId: 'e', value: 'v' }),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
expect((rebuilt as unknown as Record<string, unknown>).humanContext).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('an ordinary Error carries no fields envelope', () => {
|
|
189
|
+
expect(serializeError(new Error('x')).fields).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|