@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.
- package/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +2 -2
- package/src/capabilities/validation.test.ts +51 -0
- package/src/capabilities/validation.ts +22 -8
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +182 -9
- package/src/hooks/broker.test.ts +152 -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/executor.ts +234 -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-timeout.test.ts +103 -0
- package/src/hooks/hook-trespass.test.ts +201 -0
- package/src/hooks/injected-capabilities.test.ts +75 -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/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/import.ts +19 -1
- package/src/module/packaging/module-state-directory.test.ts +99 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +96 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +129 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +68 -7
- 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 +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/consumer-cleanup.ts +13 -7
- package/src/services/dns-internal-records.test.ts +74 -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/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +8 -4
- package/src/services/port-forwards.ts +0 -11
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/variables/context.ts +75 -10
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/test-utils/setup-test-db.ts +0 -80
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook process boundary's wire protocol.
|
|
3
|
+
*
|
|
4
|
+
* A hook runs in its own `bun` process and reaches celilo — the database, the
|
|
5
|
+
* master key, the live capability objects — only through these frames. See
|
|
6
|
+
* `openspec/changes/hook-process-boundary/design.md`, D2 and D3.
|
|
7
|
+
*
|
|
8
|
+
* Newline-delimited JSON over a Unix socket, Zod-validated, in two
|
|
9
|
+
* discriminated unions. Modelled directly on `packages/core/src/protocol.ts`,
|
|
10
|
+
* which does the same job for the remote CLI.
|
|
11
|
+
*
|
|
12
|
+
* **Why a socket and not stdout.** Seventeen module script files spawn
|
|
13
|
+
* subprocesses. A grandchild writing raw bytes to fd 1 would corrupt the frame
|
|
14
|
+
* stream and no JS-level capture prevents it. The remote CLI gets away with
|
|
15
|
+
* stdout because it *translates* every unrecognised line into a log message;
|
|
16
|
+
* here the same line could be half a frame. The child's stdout and stderr stay
|
|
17
|
+
* exactly what they are — human output the parent forwards to the logger.
|
|
18
|
+
*
|
|
19
|
+
* **Where this lives.** Both ends are framework code: the broker in
|
|
20
|
+
* `broker.ts` and the runner shim in `hook-runner.ts`, both under
|
|
21
|
+
* `apps/celilo/src/hooks/`. Neither side imports the other, so the protocol
|
|
22
|
+
* needs no package of its own and `@celilo/capabilities` — which every MODULE
|
|
23
|
+
* imports — stays out of it. A hook script never sees these types.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
|
|
28
|
+
export const HOOK_PROTOCOL_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
/** Environment variable carrying the broker's socket path to the child. */
|
|
31
|
+
export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
|
|
32
|
+
/** Environment variable carrying the parent's protocol version to the child. */
|
|
33
|
+
export const HOOK_PROTOCOL_VERSION_ENV = 'CELILO_HOOK_PROTOCOL_VERSION';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An error crossing the boundary.
|
|
37
|
+
*
|
|
38
|
+
* `fields` is what carries `MissingProviderInputError`'s `providerModuleId` /
|
|
39
|
+
* `ensureId` / `value` / `humanContext` (design D6). The framework READS that
|
|
40
|
+
* error rather than displaying it — `invokeHook` inspects it to drive the
|
|
41
|
+
* cross-module ensure interview — so those four fields have to survive the
|
|
42
|
+
* round trip intact. They do, because `isMissingProviderInputError` is
|
|
43
|
+
* duck-typed rather than `instanceof`: it was written that way for a module's
|
|
44
|
+
* bundled copy of `@celilo/capabilities` (celilo#173), and a process is one
|
|
45
|
+
* more of the same boundary.
|
|
46
|
+
*/
|
|
47
|
+
export const HookErrorSchema = z.object({
|
|
48
|
+
name: z.string(),
|
|
49
|
+
message: z.string(),
|
|
50
|
+
stack: z.string().optional(),
|
|
51
|
+
fields: z.record(z.unknown()).optional(),
|
|
52
|
+
});
|
|
53
|
+
export type HookError = z.infer<typeof HookErrorSchema>;
|
|
54
|
+
|
|
55
|
+
// ── child → parent ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** Handshake. The parent checks the version and refuses a mismatch. */
|
|
58
|
+
export const ReadyFrameSchema = z.object({
|
|
59
|
+
type: z.literal('ready'),
|
|
60
|
+
protocolVersion: z.number().int(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** A capability method call, correlated with its `return`/`throw` by `id`. */
|
|
64
|
+
export const CallFrameSchema = z.object({
|
|
65
|
+
type: z.literal('call'),
|
|
66
|
+
id: z.string(),
|
|
67
|
+
capability: z.string(),
|
|
68
|
+
method: z.string(),
|
|
69
|
+
args: z.array(z.unknown()),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** One `ctx.logger` call. Fire and forget — the hook does not wait on it. */
|
|
73
|
+
export const LogFrameSchema = z.object({
|
|
74
|
+
type: z.literal('log'),
|
|
75
|
+
level: z.enum(['info', 'warn', 'error', 'success']),
|
|
76
|
+
message: z.string(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** The hook returned. Terminal. */
|
|
80
|
+
export const ResultFrameSchema = z.object({
|
|
81
|
+
type: z.literal('result'),
|
|
82
|
+
outputs: z.record(z.unknown()),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** The hook threw. Terminal. Distinct from a `throw` answering one `call`. */
|
|
86
|
+
export const HookThrewFrameSchema = z.object({
|
|
87
|
+
type: z.literal('throw'),
|
|
88
|
+
error: HookErrorSchema,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const ChildFrameSchema = z.discriminatedUnion('type', [
|
|
92
|
+
ReadyFrameSchema,
|
|
93
|
+
CallFrameSchema,
|
|
94
|
+
LogFrameSchema,
|
|
95
|
+
ResultFrameSchema,
|
|
96
|
+
HookThrewFrameSchema,
|
|
97
|
+
]);
|
|
98
|
+
export type ChildFrame = z.infer<typeof ChildFrameSchema>;
|
|
99
|
+
|
|
100
|
+
// ── parent → child ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Everything in `HookContext` that is plain data: the hook's inputs, `config`,
|
|
104
|
+
* `secrets`, `systems`, `debug`, `screenshotDir`. Not `logger` and not
|
|
105
|
+
* `capabilities` — the shim rebuilds both from frames.
|
|
106
|
+
*/
|
|
107
|
+
export const ContextFrameSchema = z.object({
|
|
108
|
+
type: z.literal('context'),
|
|
109
|
+
protocolVersion: z.number().int(),
|
|
110
|
+
scriptPath: z.string(),
|
|
111
|
+
context: z.record(z.unknown()),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The capability shape descriptor (design D2).
|
|
116
|
+
*
|
|
117
|
+
* The broker does not know what a capability is. It walks the object
|
|
118
|
+
* `loadCapabilityFunctions` returns the way `wrapWithLogging` does:
|
|
119
|
+
* function-valued keys become `methods`, everything else is copied into
|
|
120
|
+
* `data` — which is where `stampProvider`'s `providerModuleId` lives, and a
|
|
121
|
+
* hook reads it to name the provider in an error.
|
|
122
|
+
*
|
|
123
|
+
* An optional method the provider did not implement is simply absent from
|
|
124
|
+
* `methods`, so it is absent on the proxy, so `if (cap.registerTrustedSource)`
|
|
125
|
+
* keeps working with no special case.
|
|
126
|
+
*/
|
|
127
|
+
export const CapabilitiesFrameSchema = z.object({
|
|
128
|
+
type: z.literal('capabilities'),
|
|
129
|
+
shape: z.record(
|
|
130
|
+
z.object({
|
|
131
|
+
methods: z.array(z.string()),
|
|
132
|
+
data: z.record(z.unknown()),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** A capability call returned. */
|
|
138
|
+
export const ReturnFrameSchema = z.object({
|
|
139
|
+
type: z.literal('return'),
|
|
140
|
+
id: z.string(),
|
|
141
|
+
value: z.unknown(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** A capability call threw. */
|
|
145
|
+
export const CallThrewFrameSchema = z.object({
|
|
146
|
+
type: z.literal('throw'),
|
|
147
|
+
id: z.string(),
|
|
148
|
+
error: HookErrorSchema,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export const ParentFrameSchema = z.discriminatedUnion('type', [
|
|
152
|
+
ContextFrameSchema,
|
|
153
|
+
CapabilitiesFrameSchema,
|
|
154
|
+
ReturnFrameSchema,
|
|
155
|
+
CallThrewFrameSchema,
|
|
156
|
+
]);
|
|
157
|
+
export type ParentFrame = z.infer<typeof ParentFrameSchema>;
|
|
158
|
+
|
|
159
|
+
/** One capability's entry in the shape descriptor. */
|
|
160
|
+
export type CapabilityShape = z.infer<typeof CapabilitiesFrameSchema>['shape'][string];
|
|
161
|
+
|
|
162
|
+
// ── framing ───────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export type ParseResult<T> = { ok: true; frame: T } | { ok: false; error: string };
|
|
165
|
+
|
|
166
|
+
/** `JSON.stringify` plus the delimiter. One frame, one line. */
|
|
167
|
+
export function encodeFrame(frame: ChildFrame | ParentFrame): string {
|
|
168
|
+
return `${JSON.stringify(frame)}\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse one line into a frame.
|
|
173
|
+
*
|
|
174
|
+
* Returns the failure as a value rather than throwing it. A malformed line is
|
|
175
|
+
* a hook failure with a readable message, never a parse crash inside celilo:
|
|
176
|
+
* the whole point of the boundary is that the child cannot take the parent
|
|
177
|
+
* down, and a reader that throws would hand that back.
|
|
178
|
+
*/
|
|
179
|
+
function parseFrame<T>(schema: z.ZodType<T>, line: string): ParseResult<T> {
|
|
180
|
+
let json: unknown;
|
|
181
|
+
try {
|
|
182
|
+
json = JSON.parse(line);
|
|
183
|
+
} catch {
|
|
184
|
+
return { ok: false, error: `not JSON: ${truncate(line)}` };
|
|
185
|
+
}
|
|
186
|
+
const parsed = schema.safeParse(json);
|
|
187
|
+
if (!parsed.success) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: `${parsed.error.issues[0]?.message ?? 'invalid'}: ${truncate(line)}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, frame: parsed.data };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function parseChildFrame(line: string): ParseResult<ChildFrame> {
|
|
197
|
+
return parseFrame(ChildFrameSchema, line);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseParentFrame(line: string): ParseResult<ParentFrame> {
|
|
201
|
+
return parseFrame(ParentFrameSchema, line);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function truncate(line: string): string {
|
|
205
|
+
return line.length > 120 ? `${line.slice(0, 120)}…` : line;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Split a byte stream into complete lines, holding the partial tail.
|
|
210
|
+
*
|
|
211
|
+
* Both ends need this and neither can assume a frame arrives in one chunk —
|
|
212
|
+
* a capability payload is easily larger than a socket read.
|
|
213
|
+
*/
|
|
214
|
+
export function createLineReader(onLine: (line: string) => void): (chunk: string) => void {
|
|
215
|
+
let buffer = '';
|
|
216
|
+
return (chunk: string) => {
|
|
217
|
+
buffer += chunk;
|
|
218
|
+
let newline = buffer.indexOf('\n');
|
|
219
|
+
while (newline !== -1) {
|
|
220
|
+
const line = buffer.slice(0, newline);
|
|
221
|
+
buffer = buffer.slice(newline + 1);
|
|
222
|
+
if (line.trim() !== '') onLine(line);
|
|
223
|
+
newline = buffer.indexOf('\n');
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The handshake check, in one place so both ends produce the same sentence.
|
|
230
|
+
*
|
|
231
|
+
* Names both numbers: a mismatch is an install skew (a `.deb` upgraded while a
|
|
232
|
+
* module's bundled copy was not), and the operator needs to know which side is
|
|
233
|
+
* which to fix it.
|
|
234
|
+
*/
|
|
235
|
+
export function versionMismatch(theirs: number, side: string): string | null {
|
|
236
|
+
if (theirs === HOOK_PROTOCOL_VERSION) return null;
|
|
237
|
+
return `Hook protocol version mismatch: ${side} speaks ${theirs}, this process speaks ${HOOK_PROTOCOL_VERSION}.`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── error envelopes ───────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/** Fields `MissingProviderInputError` carries and the framework reads (D6). */
|
|
243
|
+
const CARRIED_ERROR_FIELDS = ['providerModuleId', 'ensureId', 'value', 'humanContext'] as const;
|
|
244
|
+
|
|
245
|
+
/** Turn a thrown value into something that survives JSON. */
|
|
246
|
+
export function serializeError(error: unknown): HookError {
|
|
247
|
+
if (!(error instanceof Error)) {
|
|
248
|
+
return { name: 'Error', message: String(error) };
|
|
249
|
+
}
|
|
250
|
+
const source = error as unknown as Record<string, unknown>;
|
|
251
|
+
const fields: Record<string, unknown> = {};
|
|
252
|
+
for (const key of CARRIED_ERROR_FIELDS) {
|
|
253
|
+
if (source[key] !== undefined) fields[key] = source[key];
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
name: error.name,
|
|
257
|
+
message: error.message,
|
|
258
|
+
stack: error.stack,
|
|
259
|
+
...(Object.keys(fields).length > 0 ? { fields } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Rebuild a real `Error` so `try`/`catch` inside a hook behaves as it does
|
|
265
|
+
* in-process, and so the framework's duck-typed guards still recognise it.
|
|
266
|
+
*/
|
|
267
|
+
export function deserializeError(error: HookError): Error {
|
|
268
|
+
const rebuilt = new Error(error.message);
|
|
269
|
+
rebuilt.name = error.name;
|
|
270
|
+
if (error.stack) rebuilt.stack = error.stack;
|
|
271
|
+
for (const [key, value] of Object.entries(error.fields ?? {})) {
|
|
272
|
+
(rebuilt as unknown as Record<string, unknown>)[key] = value;
|
|
273
|
+
}
|
|
274
|
+
return rebuilt;
|
|
275
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook runner shim.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY thing in celilo that `import()`s a module's hook script,
|
|
5
|
+
* and it runs in its own `bun` process with an allow-listed environment. It
|
|
6
|
+
* connects to the broker's socket, receives the context and the capability
|
|
7
|
+
* shape, rebuilds `HookContext` on this side, invokes the hook, and reports.
|
|
8
|
+
*
|
|
9
|
+
* It is spawned, never imported — `executeHookScript` runs
|
|
10
|
+
* `bun <this file> ` and talks to it over the socket named in
|
|
11
|
+
* `CELILO_HOOK_SOCKET`. Nothing here is exported for that reason.
|
|
12
|
+
*
|
|
13
|
+
* Execution function (Rule 10.1).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { connect } from 'node:net';
|
|
17
|
+
import { isCompiledHook } from '@celilo/capabilities';
|
|
18
|
+
import {
|
|
19
|
+
type CapabilityShape,
|
|
20
|
+
type ChildFrame,
|
|
21
|
+
HOOK_PROTOCOL_VERSION,
|
|
22
|
+
HOOK_SOCKET_ENV,
|
|
23
|
+
createLineReader,
|
|
24
|
+
encodeFrame,
|
|
25
|
+
parseParentFrame,
|
|
26
|
+
serializeError,
|
|
27
|
+
versionMismatch,
|
|
28
|
+
} from './hook-protocol';
|
|
29
|
+
import type { HookContext, HookLogger } from './types';
|
|
30
|
+
|
|
31
|
+
const socketPath = process.env[HOOK_SOCKET_ENV];
|
|
32
|
+
if (!socketPath) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`${HOOK_SOCKET_ENV} is not set; the hook runner is not spawnable directly.\n`,
|
|
35
|
+
);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const socket = connect(socketPath);
|
|
40
|
+
socket.setEncoding('utf-8');
|
|
41
|
+
|
|
42
|
+
/** Capability calls in flight, correlated with their answer by id. */
|
|
43
|
+
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
44
|
+
let nextCallId = 0;
|
|
45
|
+
|
|
46
|
+
let contextData: Record<string, unknown> | undefined;
|
|
47
|
+
let scriptPath: string | undefined;
|
|
48
|
+
let shape: Record<string, CapabilityShape> | undefined;
|
|
49
|
+
let started = false;
|
|
50
|
+
|
|
51
|
+
function send(frame: ChildFrame): void {
|
|
52
|
+
socket.write(encodeFrame(frame));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write the terminal frame and leave.
|
|
57
|
+
*
|
|
58
|
+
* The explicit exit is deliberate. A hook that leaves a timer or an open
|
|
59
|
+
* handle behind would otherwise hold this process open long past its answer,
|
|
60
|
+
* and the parent — which treats process exit as the one terminal event —
|
|
61
|
+
* would sit there until the timeout killed a hook that had already finished.
|
|
62
|
+
*/
|
|
63
|
+
function finish(frame: ChildFrame): void {
|
|
64
|
+
socket.end(encodeFrame(frame), () => process.exit(0));
|
|
65
|
+
// The flush callback does not fire if the peer is already gone.
|
|
66
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const logger: HookLogger = {
|
|
70
|
+
info: (message) => send({ type: 'log', level: 'info', message }),
|
|
71
|
+
warn: (message) => send({ type: 'log', level: 'warn', message }),
|
|
72
|
+
error: (message) => send({ type: 'log', level: 'error', message }),
|
|
73
|
+
success: (message) => send({ type: 'log', level: 'success', message }),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rebuild `context.capabilities` from the shape descriptor.
|
|
78
|
+
*
|
|
79
|
+
* Each method forwards; each non-function property is copied verbatim, which
|
|
80
|
+
* is what keeps `providerModuleId` readable. A method the provider does not
|
|
81
|
+
* implement is simply absent from the descriptor and therefore absent here, so
|
|
82
|
+
* `if (capabilities.firewall.registerTrustedSource)` still answers correctly
|
|
83
|
+
* with no special case for optional methods.
|
|
84
|
+
*/
|
|
85
|
+
function buildCapabilities(descriptor: Record<string, CapabilityShape>): Record<string, unknown> {
|
|
86
|
+
const capabilities: Record<string, unknown> = {};
|
|
87
|
+
|
|
88
|
+
for (const [name, entry] of Object.entries(descriptor)) {
|
|
89
|
+
const proxy: Record<string, unknown> = { ...entry.data };
|
|
90
|
+
for (const method of entry.methods) {
|
|
91
|
+
proxy[method] = (...args: unknown[]) => callBroker(name, method, args);
|
|
92
|
+
}
|
|
93
|
+
capabilities[name] = proxy;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return capabilities;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function callBroker(capability: string, method: string, args: unknown[]): Promise<unknown> {
|
|
100
|
+
const id = `c${nextCallId++}`;
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
pending.set(id, { resolve, reject });
|
|
103
|
+
send({ type: 'call', id, capability, method, args });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The `defineHook` brand check, moved here from the executor unchanged
|
|
109
|
+
* (HOOK_API_V2 Phase 8 / D8). The brand is a `Symbol.for` key, so it survives
|
|
110
|
+
* the identity boundary between this copy of `@celilo/capabilities` and the
|
|
111
|
+
* one the module bundles (celilo#173) — which is the same reason the check
|
|
112
|
+
* could move at all.
|
|
113
|
+
*/
|
|
114
|
+
async function runHook(): Promise<void> {
|
|
115
|
+
if (started || !contextData || !shape || !scriptPath) return;
|
|
116
|
+
started = true;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// celilo built this object and removed exactly two fields, so the cast
|
|
120
|
+
// says something true. The check is here because a shape error would
|
|
121
|
+
// otherwise surface deep inside somebody's hook as a missing property.
|
|
122
|
+
for (const field of ['config', 'secrets', 'systems', 'debug', 'screenshotDir'] as const) {
|
|
123
|
+
if (!(field in contextData)) throw new Error(`Hook context is missing '${field}'.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const context = {
|
|
127
|
+
...contextData,
|
|
128
|
+
logger,
|
|
129
|
+
capabilities: buildCapabilities(shape),
|
|
130
|
+
} as unknown as HookContext;
|
|
131
|
+
|
|
132
|
+
const module = await import(scriptPath);
|
|
133
|
+
|
|
134
|
+
if (typeof module.default !== 'function') {
|
|
135
|
+
throw new Error(`Hook script must export a default function: ${scriptPath}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isCompiledHook(module.default)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See reference/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = await module.default(context);
|
|
145
|
+
|
|
146
|
+
if (result === null || result === undefined) {
|
|
147
|
+
finish({ type: 'result', outputs: {} });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (typeof result !== 'object' || Array.isArray(result)) {
|
|
152
|
+
throw new Error('Hook script must return an object (or nothing)');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
finish({ type: 'result', outputs: result as Record<string, unknown> });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
finish({ type: 'throw', error: serializeError(error) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const feed = createLineReader((line) => {
|
|
162
|
+
const parsed = parseParentFrame(line);
|
|
163
|
+
if (!parsed.ok) {
|
|
164
|
+
// Deliberately WITHOUT the offending line, unlike the broker's mirror of
|
|
165
|
+
// this check. celilo builds these frames with `JSON.stringify`, so their
|
|
166
|
+
// content is no help in diagnosing a parse failure — and the `context`
|
|
167
|
+
// frame carries the module's secrets, which would then ride an error
|
|
168
|
+
// message out to the operator's terminal and any alert it raises. The
|
|
169
|
+
// other direction echoes the line because there the bytes ARE the
|
|
170
|
+
// diagnostic: a grandchild writing to the socket is the failure design D3
|
|
171
|
+
// exists to catch.
|
|
172
|
+
finish({
|
|
173
|
+
type: 'throw',
|
|
174
|
+
error: {
|
|
175
|
+
name: 'Error',
|
|
176
|
+
message: 'Malformed frame from celilo; the hook context could not be read.',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const frame = parsed.frame;
|
|
183
|
+
switch (frame.type) {
|
|
184
|
+
case 'context': {
|
|
185
|
+
const mismatch = versionMismatch(frame.protocolVersion, 'celilo');
|
|
186
|
+
if (mismatch) {
|
|
187
|
+
finish({ type: 'throw', error: { name: 'Error', message: mismatch } });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
contextData = frame.context;
|
|
191
|
+
scriptPath = frame.scriptPath;
|
|
192
|
+
void runHook();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case 'capabilities':
|
|
197
|
+
shape = frame.shape;
|
|
198
|
+
void runHook();
|
|
199
|
+
return;
|
|
200
|
+
|
|
201
|
+
case 'return': {
|
|
202
|
+
pending.get(frame.id)?.resolve(frame.value);
|
|
203
|
+
pending.delete(frame.id);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case 'throw': {
|
|
208
|
+
// Rebuilt inline rather than via deserializeError so the hook's own
|
|
209
|
+
// `catch` sees a real Error carrying MissingProviderInputError's fields
|
|
210
|
+
// — `isMissingProviderInputError` is duck-typed and reads them.
|
|
211
|
+
const rebuilt = new Error(frame.error.message);
|
|
212
|
+
rebuilt.name = frame.error.name;
|
|
213
|
+
if (frame.error.stack) rebuilt.stack = frame.error.stack;
|
|
214
|
+
for (const [key, value] of Object.entries(frame.error.fields ?? {})) {
|
|
215
|
+
(rebuilt as unknown as Record<string, unknown>)[key] = value;
|
|
216
|
+
}
|
|
217
|
+
pending.get(frame.id)?.reject(rebuilt);
|
|
218
|
+
pending.delete(frame.id);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
socket.on('data', feed);
|
|
225
|
+
socket.on('connect', () => send({ type: 'ready', protocolVersion: HOOK_PROTOCOL_VERSION }));
|
|
226
|
+
socket.on('error', (error) => {
|
|
227
|
+
// The channel is the only way to report anything, so there is nowhere to
|
|
228
|
+
// send this. Exit non-zero and let the parent say what it saw.
|
|
229
|
+
process.stderr.write(`hook runner: socket error: ${error.message}\n`);
|
|
230
|
+
process.exit(3);
|
|
231
|
+
});
|