@celilo/cli 2.1.0 → 2.2.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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +168 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { closeDb, getDb } from '../db/client';
|
|
6
|
+
import { runMigrations } from '../db/migrate';
|
|
7
|
+
import { systemConfig } from '../db/schema';
|
|
5
8
|
import type { ContractHookSignature } from '../manifest/contracts';
|
|
9
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
6
10
|
import {
|
|
7
11
|
checkRequiredCapabilities,
|
|
8
12
|
declaredPathInputs,
|
|
@@ -15,7 +19,8 @@ import {
|
|
|
15
19
|
} from './executor';
|
|
16
20
|
import { readJailMode } from './jail';
|
|
17
21
|
import { createCapturingLogger } from './logger';
|
|
18
|
-
import
|
|
22
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
23
|
+
import type { HookContext, HookDefinition } from './types';
|
|
19
24
|
|
|
20
25
|
const FIXTURES_DIR = join(__dirname, 'test-fixtures');
|
|
21
26
|
|
|
@@ -115,8 +120,8 @@ describe('Hook Executor', () => {
|
|
|
115
120
|
const scriptPath = join(FIXTURES_DIR, 'success-hook.ts');
|
|
116
121
|
|
|
117
122
|
const result = await executeHookScript(scriptPath, {
|
|
118
|
-
config: { username: 'testuser' },
|
|
119
|
-
secrets: { password: 'secret' },
|
|
123
|
+
config: configStore({ username: 'testuser' }),
|
|
124
|
+
secrets: secretStore({ password: 'secret' }),
|
|
120
125
|
systems: [],
|
|
121
126
|
logger,
|
|
122
127
|
debug: false,
|
|
@@ -136,8 +141,8 @@ describe('Hook Executor', () => {
|
|
|
136
141
|
|
|
137
142
|
await expect(
|
|
138
143
|
executeHookScript('/nonexistent/hook.ts', {
|
|
139
|
-
config:
|
|
140
|
-
secrets:
|
|
144
|
+
config: configStore(),
|
|
145
|
+
secrets: secretStore(),
|
|
141
146
|
systems: [],
|
|
142
147
|
logger,
|
|
143
148
|
debug: false,
|
|
@@ -154,8 +159,8 @@ describe('Hook Executor', () => {
|
|
|
154
159
|
|
|
155
160
|
await expect(
|
|
156
161
|
executeHookScript(scriptPath, {
|
|
157
|
-
config:
|
|
158
|
-
secrets:
|
|
162
|
+
config: configStore(),
|
|
163
|
+
secrets: secretStore(),
|
|
159
164
|
systems: [],
|
|
160
165
|
logger,
|
|
161
166
|
debug: false,
|
|
@@ -176,8 +181,8 @@ describe('Hook Executor', () => {
|
|
|
176
181
|
|
|
177
182
|
await expect(
|
|
178
183
|
executeHookScript(scriptPath, {
|
|
179
|
-
config:
|
|
180
|
-
secrets:
|
|
184
|
+
config: configStore(),
|
|
185
|
+
secrets: secretStore(),
|
|
181
186
|
systems: [],
|
|
182
187
|
logger,
|
|
183
188
|
debug: false,
|
|
@@ -193,8 +198,8 @@ describe('Hook Executor', () => {
|
|
|
193
198
|
const scriptPath = join(FIXTURES_DIR, 'void-hook.ts');
|
|
194
199
|
|
|
195
200
|
const result = await executeHookScript(scriptPath, {
|
|
196
|
-
config:
|
|
197
|
-
secrets:
|
|
201
|
+
config: configStore(),
|
|
202
|
+
secrets: secretStore(),
|
|
198
203
|
systems: [],
|
|
199
204
|
logger,
|
|
200
205
|
debug: false,
|
|
@@ -212,8 +217,8 @@ describe('Hook Executor', () => {
|
|
|
212
217
|
|
|
213
218
|
await expect(
|
|
214
219
|
executeHookScript(scriptPath, {
|
|
215
|
-
config:
|
|
216
|
-
secrets:
|
|
220
|
+
config: configStore(),
|
|
221
|
+
secrets: secretStore(),
|
|
217
222
|
systems: [],
|
|
218
223
|
logger,
|
|
219
224
|
debug: false,
|
|
@@ -231,9 +236,9 @@ describe('Hook Executor', () => {
|
|
|
231
236
|
test('honors a caller-supplied idle timeout instead of the 30s default', async () => {
|
|
232
237
|
const { logger } = createCapturingLogger();
|
|
233
238
|
const scriptPath = join(FIXTURES_DIR, 'silent-hook.ts');
|
|
234
|
-
const context = {
|
|
235
|
-
config: { silent_ms: 8000 },
|
|
236
|
-
secrets:
|
|
239
|
+
const context: HookContext = {
|
|
240
|
+
config: configStore({ silent_ms: 8000 }),
|
|
241
|
+
secrets: secretStore(),
|
|
237
242
|
systems: [],
|
|
238
243
|
logger,
|
|
239
244
|
debug: false,
|
|
@@ -310,8 +315,8 @@ describe('Hook Executor', () => {
|
|
|
310
315
|
try {
|
|
311
316
|
const { logger } = createCapturingLogger();
|
|
312
317
|
await executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
|
|
313
|
-
config:
|
|
314
|
-
secrets:
|
|
318
|
+
config: configStore(),
|
|
319
|
+
secrets: secretStore(),
|
|
315
320
|
systems: [],
|
|
316
321
|
logger,
|
|
317
322
|
debug: false,
|
|
@@ -328,6 +333,88 @@ describe('Hook Executor', () => {
|
|
|
328
333
|
});
|
|
329
334
|
});
|
|
330
335
|
|
|
336
|
+
describe('the executor resolves the jail policy through stored config (hook-jail-config-surface slice 4)', () => {
|
|
337
|
+
// Each test gets its own migrated scratch database and its own jail-mode
|
|
338
|
+
// store, so a planted row here never leaks into another test (or another
|
|
339
|
+
// host). With no module tree in `options.jail`, `planJailedSpawn` treats a
|
|
340
|
+
// `required` policy as a hard failure on EVERY host — which makes the
|
|
341
|
+
// resolution outcome observable without depending on what jail backend a
|
|
342
|
+
// given machine happens to have.
|
|
343
|
+
let dir: string;
|
|
344
|
+
const saved: Record<string, string | undefined> = {};
|
|
345
|
+
|
|
346
|
+
const plantPolicyRow = (value: string): void => {
|
|
347
|
+
getDb().insert(systemConfig).values({ key: 'hooks.jail_policy', value }).run();
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const runVoidHook = (): Promise<Record<string, unknown>> => {
|
|
351
|
+
const { logger } = createCapturingLogger();
|
|
352
|
+
return executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
|
|
353
|
+
config: configStore(),
|
|
354
|
+
secrets: secretStore(),
|
|
355
|
+
systems: [],
|
|
356
|
+
logger,
|
|
357
|
+
debug: false,
|
|
358
|
+
screenshotDir: '/tmp',
|
|
359
|
+
stateDir: '/tmp',
|
|
360
|
+
capabilities: {},
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
beforeEach(async () => {
|
|
365
|
+
for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) {
|
|
366
|
+
saved[key] = process.env[key];
|
|
367
|
+
}
|
|
368
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-executor-policy-'));
|
|
369
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
370
|
+
process.env.CELILO_HOOK_JAIL_MODE_PATH = join(dir, 'mode.json');
|
|
371
|
+
delete process.env.CELILO_HOOK_JAIL;
|
|
372
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
afterEach(() => {
|
|
376
|
+
closeDb();
|
|
377
|
+
// The db path resets to the scratch path rather than restoring the
|
|
378
|
+
// saved value: that value may be another suite's temp database, or
|
|
379
|
+
// unset — which sends the next var-less reader to the operator's real
|
|
380
|
+
// celilo.db (celilo#1315).
|
|
381
|
+
resetTestDbPath();
|
|
382
|
+
for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) {
|
|
383
|
+
if (saved[key] === undefined) delete process.env[key];
|
|
384
|
+
else process.env[key] = saved[key];
|
|
385
|
+
}
|
|
386
|
+
rmSync(dir, { recursive: true, force: true });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('a stored row decides when the env is silent', async () => {
|
|
390
|
+
plantPolicyRow('required');
|
|
391
|
+
await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('env wins over a conflicting stored row', async () => {
|
|
395
|
+
process.env.CELILO_HOOK_JAIL = 'off';
|
|
396
|
+
plantPolicyRow('required');
|
|
397
|
+
await expect(runVoidHook()).resolves.toBeDefined();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('env=required still reaches the planner when the row says off', async () => {
|
|
401
|
+
process.env.CELILO_HOOK_JAIL = 'required';
|
|
402
|
+
plantPolicyRow('off');
|
|
403
|
+
await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('a bad stored row fails the hook instead of running it unjailed', async () => {
|
|
407
|
+
plantPolicyRow('alwayssafe');
|
|
408
|
+
await expect(runVoidHook()).rejects.toThrow(/hooks\.jail_policy='alwayssafe'/);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('nothing set resolves to the default, off (peba’s ruling on ce-rez7)', async () => {
|
|
412
|
+
// Migrated DB with no row: the stored-row read returns undefined and the
|
|
413
|
+
// resolver falls through to the default, which jails nobody.
|
|
414
|
+
await expect(runVoidHook()).resolves.toBeDefined();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
331
418
|
describe('resolveHookTimeouts', () => {
|
|
332
419
|
test('no declaration: 60s total, 30s idle heuristic', () => {
|
|
333
420
|
expect(resolveHookTimeouts(undefined, false)).toEqual({
|
package/src/hooks/executor.ts
CHANGED
|
@@ -51,6 +51,8 @@ import {
|
|
|
51
51
|
moduleArtifactDir,
|
|
52
52
|
moduleStateDir,
|
|
53
53
|
} from '@celilo/capabilities';
|
|
54
|
+
import { getDbPath } from '../config/paths';
|
|
55
|
+
import { getDb } from '../db/client';
|
|
54
56
|
import {
|
|
55
57
|
type ContractHookSignature,
|
|
56
58
|
contractHookSignature,
|
|
@@ -60,6 +62,7 @@ import {
|
|
|
60
62
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
61
63
|
import { pruneModuleArtifacts } from './artifact-retention';
|
|
62
64
|
import { startBroker } from './broker';
|
|
65
|
+
import type { HookStoresProvider } from './broker';
|
|
63
66
|
import {
|
|
64
67
|
HOOK_MOUNT_SET_ENV,
|
|
65
68
|
HOOK_PROTOCOL_VERSION,
|
|
@@ -72,10 +75,10 @@ import {
|
|
|
72
75
|
import {
|
|
73
76
|
type JailPlan,
|
|
74
77
|
detectJailBackend,
|
|
75
|
-
jailPolicy,
|
|
76
78
|
planJailedSpawn,
|
|
77
79
|
realpathRequest,
|
|
78
80
|
recordJailMode,
|
|
81
|
+
resolveJailPolicy,
|
|
79
82
|
runtimeModulePathsFor,
|
|
80
83
|
} from './jail';
|
|
81
84
|
import {
|
|
@@ -169,6 +172,50 @@ const FORWARDED_ENV = [
|
|
|
169
172
|
'no_proxy',
|
|
170
173
|
] as const;
|
|
171
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The directory holding the `celilo` bin of the running CLI package, resolved
|
|
177
|
+
* from this file's own location: src/hooks -> the package's bin/. Every
|
|
178
|
+
* distribution layout lands there. The .deb wrapper execs
|
|
179
|
+
* `$CELILO_HOME/node_modules/@celilo/cli/bin/celilo` directly (packaging/celilo/
|
|
180
|
+
* scripts/wrapper.sh), and a bun global install symlinks ~/.bun/bin/celilo into
|
|
181
|
+
* that same bin directory, whose script self-locates its package dir.
|
|
182
|
+
*/
|
|
183
|
+
export const CLI_BIN_DIR = join(import.meta.dir, '..', '..', 'bin');
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The PATH a hook child gets: the CLI package's own bin directory, then the
|
|
187
|
+
* runtime's, ahead of the inherited one — so the child can exec the SAME
|
|
188
|
+
* `celilo` the parent runs, and that celilo can still find its interpreter.
|
|
189
|
+
*
|
|
190
|
+
* `dirname(process.execPath)` alone is NOT the CLI's directory under the fleet
|
|
191
|
+
* layout: the .deb runs the CLI through a wrapper at /usr/local/bin/celilo that
|
|
192
|
+
* execs bun on `node_modules/@celilo/cli/src/cli/index.ts`, so the directory
|
|
193
|
+
* beside the bun binary holds only bun (measured on the fleet, 2026-09-07,
|
|
194
|
+
* ce-y0fd). Hooks then found `celilo` only because the systemd unit happened to
|
|
195
|
+
* inherit /usr/local/bin on its PATH — the same accident that broke every hook
|
|
196
|
+
* spawning `celilo` by name in the e2e management image (celilo#1300).
|
|
197
|
+
* CLI_BIN_DIR resolves where the bin actually lives, whatever layout launched
|
|
198
|
+
* the parent, and comes FIRST so `celilo` is the package's own bin rather than
|
|
199
|
+
* an operator's wrapper. The runtime directory stays second because bin/celilo
|
|
200
|
+
* needs `command -v bun` (it is bash, not a shebanged TS file).
|
|
201
|
+
*
|
|
202
|
+
* Already-present wins: an operator who has a directory on PATH sees an
|
|
203
|
+
* unchanged value. An absent inherited PATH still yields both directories, so
|
|
204
|
+
* a hook can always reach `celilo` even from a stripped parent; bash resolves
|
|
205
|
+
* through the loader's default search.
|
|
206
|
+
*
|
|
207
|
+
* Exported for the allow-list tests (hook-trespass.test.ts), which pin this
|
|
208
|
+
* contract the same way they pin the rest of the child environment.
|
|
209
|
+
*/
|
|
210
|
+
export function childPath(inherited: string | undefined): string {
|
|
211
|
+
const base = inherited === undefined ? [] : inherited.split(':');
|
|
212
|
+
const prepended: string[] = [];
|
|
213
|
+
for (const dir of [CLI_BIN_DIR, dirname(process.execPath)]) {
|
|
214
|
+
if (!base.includes(dir) && !prepended.includes(dir)) prepended.push(dir);
|
|
215
|
+
}
|
|
216
|
+
return [...prepended, ...base].join(':');
|
|
217
|
+
}
|
|
218
|
+
|
|
172
219
|
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
173
220
|
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
174
221
|
/**
|
|
@@ -291,6 +338,32 @@ export interface ExecuteHookOptions {
|
|
|
291
338
|
* fleet-credential operation is REFUSED, naming the gap (Rule 6.4).
|
|
292
339
|
*/
|
|
293
340
|
remoteAccess?: RemoteAccessPolicy;
|
|
341
|
+
/**
|
|
342
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5), built
|
|
343
|
+
* lazily so a hook that never touches `context.secrets` / `context.config`
|
|
344
|
+
* pays nothing. Absent means the broker refuses store writes with an error
|
|
345
|
+
* naming the gap — never a silent drop.
|
|
346
|
+
*/
|
|
347
|
+
hookStores?: HookStoresProvider;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The stored `hooks.jail_policy` value, or undefined when there is no row or
|
|
352
|
+
* no celilo DB to ask (a dev box). Same row-read pattern as
|
|
353
|
+
* `ipam/auto-allocator.ts` and the executor's fellow consumer
|
|
354
|
+
* `cli/commands/system-doctor.ts`; the drizzle table is `systemConfig` in
|
|
355
|
+
* `db/schema.ts`.
|
|
356
|
+
*
|
|
357
|
+
* Unlike `remoteAccess`, this read happens here rather than through a
|
|
358
|
+
* caller-built input because D2 names the executor itself as one of the two
|
|
359
|
+
* consumers that fetch the stored row and hand it to the pure resolver.
|
|
360
|
+
*/
|
|
361
|
+
function readStoredJailPolicy(): string | undefined {
|
|
362
|
+
if (!existsSync(getDbPath())) return undefined;
|
|
363
|
+
const row = getDb()
|
|
364
|
+
.$client.prepare('SELECT value FROM system_config WHERE key = ?')
|
|
365
|
+
.get('hooks.jail_policy') as { value: string } | undefined;
|
|
366
|
+
return row?.value;
|
|
294
367
|
}
|
|
295
368
|
|
|
296
369
|
/**
|
|
@@ -333,6 +406,7 @@ export async function executeHookScript(
|
|
|
333
406
|
scriptPath,
|
|
334
407
|
logger,
|
|
335
408
|
onActivity: markActive,
|
|
409
|
+
stores: options.hookStores,
|
|
336
410
|
});
|
|
337
411
|
let remoteBroker: Awaited<ReturnType<typeof startRemoteBroker>> | undefined;
|
|
338
412
|
|
|
@@ -356,7 +430,7 @@ export async function executeHookScript(
|
|
|
356
430
|
[process.execPath, HOOK_RUNNER_SPAWN_PATH],
|
|
357
431
|
mountSet,
|
|
358
432
|
detectJailBackend(),
|
|
359
|
-
|
|
433
|
+
resolveJailPolicy(process.env.CELILO_HOOK_JAIL, readStoredJailPolicy()).policy,
|
|
360
434
|
);
|
|
361
435
|
reportJail(jail, logger);
|
|
362
436
|
// Only a real module hook says anything about whether this HOST jails. An
|
|
@@ -516,6 +590,10 @@ export function hookChildEnv(
|
|
|
516
590
|
const env: Record<string, string> = {};
|
|
517
591
|
|
|
518
592
|
for (const name of FORWARDED_ENV) {
|
|
593
|
+
if (name === 'PATH') {
|
|
594
|
+
env.PATH = childPath(process.env.PATH);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
519
597
|
const value = process.env[name];
|
|
520
598
|
if (value !== undefined) env[name] = value;
|
|
521
599
|
}
|
|
@@ -564,19 +642,36 @@ function jailRequest(
|
|
|
564
642
|
|
|
565
643
|
/**
|
|
566
644
|
* Say what the jail did, once per run, at a level that matches how surprising
|
|
567
|
-
* it is.
|
|
645
|
+
* it is. A dropped row is now either FATAL or SILENT, never a warning.
|
|
568
646
|
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
647
|
+
* This used to warn about every absent row, because it could not tell a routine
|
|
648
|
+
* absence from a damaging one: "both look identical here, so the line names the
|
|
649
|
+
* paths and lets a reader tell them apart." That reasoning was sound and the
|
|
650
|
+
* result was not. The line fired 96 times with a byte-identical payload in a
|
|
651
|
+
* single `cele2e run --all`, so the case it existed to catch was buried in
|
|
652
|
+
* ninety-six copies of the case that does not matter. A signal that repeats
|
|
653
|
+
* unchanged is one every reader learns to skip.
|
|
654
|
+
*
|
|
655
|
+
* Each row now states what its own absence means (`MountAbsence`), so there is
|
|
656
|
+
* no classification left for a human to do from a path string:
|
|
657
|
+
*
|
|
658
|
+
* required throw. The hook cannot do what it was asked.
|
|
659
|
+
* declared-only silent until a module can declare the facility (ce-qani).
|
|
660
|
+
* runtime silent. A genuinely needed one fails the runtime, louder.
|
|
661
|
+
* conditional silent. Expected at this point in the lifecycle.
|
|
574
662
|
*/
|
|
575
663
|
function reportJail(plan: JailPlan, logger: HookLogger): void {
|
|
576
664
|
if (plan.mode === 'jailed') {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
665
|
+
const fatal = plan.skipped.filter((m) => m.absence === 'required');
|
|
666
|
+
if (fatal.length > 0) {
|
|
667
|
+
// Throw rather than warn. A dropped contract input means the hook writes
|
|
668
|
+
// into the run's PRIVATE TMPFS and reports success over a directory that
|
|
669
|
+
// is discarded when it exits (task 4.2j), and a dropped interpreter or
|
|
670
|
+
// module tree means it could never have run at all. Both were previously
|
|
671
|
+
// survivable-looking warnings.
|
|
672
|
+
const detail = fatal.map((m) => `${m.path} (${m.reason})`).join(', ');
|
|
673
|
+
throw new Error(
|
|
674
|
+
`Hook jail cannot be built: ${fatal.length} required mount(s) absent on this host: ${detail}`,
|
|
580
675
|
);
|
|
581
676
|
}
|
|
582
677
|
return;
|
|
@@ -642,6 +737,13 @@ export interface InvokeHookOptions {
|
|
|
642
737
|
* refused (Rule 6.4).
|
|
643
738
|
*/
|
|
644
739
|
remoteAccess?: RemoteAccessPolicy;
|
|
740
|
+
/**
|
|
741
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5), as a
|
|
742
|
+
* lazy provider: `() => createHookStores(db, moduleId)`. Built on first use
|
|
743
|
+
* inside the run, so a hook that never persists state never reads the
|
|
744
|
+
* manifest or touches the master key.
|
|
745
|
+
*/
|
|
746
|
+
hookStores?: HookStoresProvider;
|
|
645
747
|
}
|
|
646
748
|
|
|
647
749
|
/**
|
|
@@ -885,7 +987,11 @@ export async function invokeHook(
|
|
|
885
987
|
|
|
886
988
|
// Build context
|
|
887
989
|
const loadedCapabilities = options.capabilities ?? {};
|
|
888
|
-
|
|
990
|
+
// The store accessor methods attach on the CHILD side (hook-runner.ts,
|
|
991
|
+
// buildStoreView) where the socket is, so the object serialized here
|
|
992
|
+
// carries the map half only. Same cast rationale as the runner's own
|
|
993
|
+
// context build. (hook-owned-state task 3.5)
|
|
994
|
+
const context = {
|
|
889
995
|
...inputs,
|
|
890
996
|
config,
|
|
891
997
|
secrets,
|
|
@@ -895,7 +1001,7 @@ export async function invokeHook(
|
|
|
895
1001
|
screenshotDir,
|
|
896
1002
|
stateDir,
|
|
897
1003
|
capabilities: loadedCapabilities,
|
|
898
|
-
};
|
|
1004
|
+
} as unknown as HookContext;
|
|
899
1005
|
|
|
900
1006
|
// Pre-flight: every required capability must be loaded (HOOK_API_V2 D3).
|
|
901
1007
|
// Fail before invoking the handler so the script never sees a missing
|
|
@@ -945,6 +1051,7 @@ export async function invokeHook(
|
|
|
945
1051
|
idleTimeoutMs,
|
|
946
1052
|
jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
|
|
947
1053
|
remoteAccess: options.remoteAccess,
|
|
1054
|
+
hookStores: options.hookStores,
|
|
948
1055
|
});
|
|
949
1056
|
|
|
950
1057
|
// Validate outputs against the contract signature
|
|
@@ -37,6 +37,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
37
37
|
import { executeHookScript } from './executor';
|
|
38
38
|
import { detectJailBackend } from './jail';
|
|
39
39
|
import { createCapturingLogger } from './logger';
|
|
40
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
40
41
|
import type { HookContext } from './types';
|
|
41
42
|
|
|
42
43
|
const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-toolchain-hook.ts');
|
|
@@ -115,8 +116,8 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
115
116
|
|
|
116
117
|
const { logger } = createCapturingLogger();
|
|
117
118
|
const context: HookContext = {
|
|
118
|
-
config: PROBE_BROWSER ? { browser_executable: PROBE_BROWSER } :
|
|
119
|
-
secrets:
|
|
119
|
+
config: PROBE_BROWSER ? configStore({ browser_executable: PROBE_BROWSER }) : configStore(),
|
|
120
|
+
secrets: secretStore(),
|
|
120
121
|
systems: [],
|
|
121
122
|
logger,
|
|
122
123
|
debug: false,
|
|
@@ -35,6 +35,7 @@ const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
|
|
|
35
35
|
import { executeHookScript } from './executor';
|
|
36
36
|
import { detectJailBackend, jailPolicy } from './jail';
|
|
37
37
|
import { createCapturingLogger } from './logger';
|
|
38
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
38
39
|
import type { HookContext } from './types';
|
|
39
40
|
|
|
40
41
|
const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-probe-hook.ts');
|
|
@@ -110,12 +111,12 @@ async function runProbe(): Promise<Rig> {
|
|
|
110
111
|
|
|
111
112
|
const { logger } = createCapturingLogger();
|
|
112
113
|
const context: HookContext = {
|
|
113
|
-
config: {
|
|
114
|
+
config: configStore({
|
|
114
115
|
planted_secret: plantedSecret,
|
|
115
116
|
sibling_file: siblingFile,
|
|
116
117
|
staged_input: stagedInput,
|
|
117
|
-
},
|
|
118
|
-
secrets:
|
|
118
|
+
}),
|
|
119
|
+
secrets: secretStore(),
|
|
119
120
|
systems: [],
|
|
120
121
|
logger,
|
|
121
122
|
debug: false,
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
|
|
28
|
-
export const HOOK_PROTOCOL_VERSION =
|
|
28
|
+
export const HOOK_PROTOCOL_VERSION = 2;
|
|
29
29
|
|
|
30
30
|
/** Environment variable carrying the broker's socket path to the child. */
|
|
31
31
|
export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
|
|
@@ -91,6 +91,39 @@ export const CallFrameSchema = z.object({
|
|
|
91
91
|
args: z.array(z.unknown()),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* One hook-owned-state store operation, correlated like a `call` (and answered
|
|
96
|
+
* by the same `return`/`throw` frames — the runner's pending map keys on `id`
|
|
97
|
+
* alone).
|
|
98
|
+
*
|
|
99
|
+
* `store` names which of the module's two stores the call targets; `method` is
|
|
100
|
+
* one of the four `HookStore` operations. A `transaction` call carries the
|
|
101
|
+
* buffered operation list the CHILD accumulated — the hook's `fn` ran entirely
|
|
102
|
+
* child-side, so a throwing `fn` never sends anything, which is what makes the
|
|
103
|
+
* discard real rather than a promise the parent has to honor.
|
|
104
|
+
*
|
|
105
|
+
* Hook-owned-state design: the accessor is implemented once broker-side and
|
|
106
|
+
* reaches the hook as its own RPC family over this envelope, NOT as a fake
|
|
107
|
+
* capability — a fake one would surface in the `capabilities` shape frame and
|
|
108
|
+
* appear to the hook as a provider module.
|
|
109
|
+
*/
|
|
110
|
+
export const StoreCallFrameSchema = z.object({
|
|
111
|
+
type: z.literal('store'),
|
|
112
|
+
id: z.string(),
|
|
113
|
+
store: z.enum(['secrets', 'config']),
|
|
114
|
+
method: z.enum(['get', 'set', 'delete', 'transaction']),
|
|
115
|
+
args: z.array(z.unknown()),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** One buffered operation inside a `transaction` store call. */
|
|
119
|
+
export const BufferedStoreOpSchema = z.object({
|
|
120
|
+
op: z.enum(['set', 'delete']),
|
|
121
|
+
name: z.string(),
|
|
122
|
+
value: z.string().optional(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export type BufferedStoreOp = z.infer<typeof BufferedStoreOpSchema>;
|
|
126
|
+
|
|
94
127
|
/** One `ctx.logger` call. Fire and forget — the hook does not wait on it. */
|
|
95
128
|
export const LogFrameSchema = z.object({
|
|
96
129
|
type: z.literal('log'),
|
|
@@ -113,6 +146,7 @@ export const HookThrewFrameSchema = z.object({
|
|
|
113
146
|
export const ChildFrameSchema = z.discriminatedUnion('type', [
|
|
114
147
|
ReadyFrameSchema,
|
|
115
148
|
CallFrameSchema,
|
|
149
|
+
StoreCallFrameSchema,
|
|
116
150
|
LogFrameSchema,
|
|
117
151
|
ResultFrameSchema,
|
|
118
152
|
HookThrewFrameSchema,
|
|
@@ -145,6 +179,11 @@ export const MountEntrySchema = z.object({
|
|
|
145
179
|
path: z.string(),
|
|
146
180
|
mode: z.enum(['ro', 'rw', 'tmpfs']),
|
|
147
181
|
reason: z.string(),
|
|
182
|
+
// Kept on the wire so this stays the structural twin its docblock below
|
|
183
|
+
// claims. The child ignores it — absence is decided when the set is derived,
|
|
184
|
+
// not by the hook — but a twin that has quietly stopped being one is how the
|
|
185
|
+
// round-trip test starts asserting about a shape nothing sends.
|
|
186
|
+
absence: z.enum(['required', 'declared-only', 'runtime', 'conditional']),
|
|
148
187
|
});
|
|
149
188
|
|
|
150
189
|
/** The derived mount set, on the wire. Structural twin of `MountSet`. */
|
|
@@ -272,6 +311,12 @@ export function createLineReader(onLine: (line: string) => void): (chunk: string
|
|
|
272
311
|
/**
|
|
273
312
|
* The handshake check, in one place so both ends produce the same sentence.
|
|
274
313
|
*
|
|
314
|
+
* Version 2 added the `store` frame family (hook-owned-state task 3.5). Both
|
|
315
|
+
* ends of this protocol ship in the same celilo binary, so the check exists
|
|
316
|
+
* for one situation: a runner answering a broker built from different source
|
|
317
|
+
* than itself. It aborts the hook loudly instead of letting a store write
|
|
318
|
+
* disappear into a frame the other side cannot parse.
|
|
319
|
+
*
|
|
275
320
|
* Names both numbers: a mismatch is an install skew (a `.deb` upgraded while a
|
|
276
321
|
* module's bundled copy was not), and the operator needs to know which side is
|
|
277
322
|
* which to fix it.
|
package/src/hooks/hook-runner.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
serializeError,
|
|
27
27
|
versionMismatch,
|
|
28
28
|
} from './hook-protocol';
|
|
29
|
+
import { buildStoreView } from './hook-store-proxy';
|
|
29
30
|
import type { HookContext, HookLogger } from './types';
|
|
30
31
|
import { forwardLintWarnings } from './unjailed-lint';
|
|
31
32
|
|
|
@@ -110,6 +111,22 @@ function callBroker(capability: string, method: string, args: unknown[]): Promis
|
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Store calls share the capability call's correlation machinery: the broker
|
|
116
|
+
* answers both families with the same `return`/`throw` frames, keyed by `id`.
|
|
117
|
+
*/
|
|
118
|
+
function callStore(
|
|
119
|
+
store: 'secrets' | 'config',
|
|
120
|
+
method: 'get' | 'set' | 'delete' | 'transaction',
|
|
121
|
+
args: unknown[],
|
|
122
|
+
): Promise<unknown> {
|
|
123
|
+
const id = `c${nextCallId++}`;
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
pending.set(id, { resolve, reject });
|
|
126
|
+
send({ type: 'store', id, store, method, args });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
113
130
|
/**
|
|
114
131
|
* The `defineHook` brand check, moved here from the executor unchanged
|
|
115
132
|
* (HOOK_API_V2 Phase 8 / D8). The brand is a `Symbol.for` key, so it survives
|
|
@@ -133,8 +150,27 @@ async function runHook(): Promise<void> {
|
|
|
133
150
|
// hook's logger from here on (anything earlier was buffered).
|
|
134
151
|
forwardLintWarnings(logger.warn);
|
|
135
152
|
|
|
153
|
+
// Attach the hook-owned-state accessor onto both maps. The maps crossed
|
|
154
|
+
// the context frame as plain data; the accessor methods exist only on
|
|
155
|
+
// this side, where the socket is. Old map reads keep working; new code
|
|
156
|
+
// calls `secrets.set(...)` / `config.get(...)` (hook-owned-state task 3.5).
|
|
157
|
+
// The casts say something true: celilo built both fields and controls
|
|
158
|
+
// their shape, the same rationale as the field check above.
|
|
159
|
+
const stores = buildStoreView(
|
|
160
|
+
'secrets',
|
|
161
|
+
(contextData.secrets ?? {}) as Record<string, string>,
|
|
162
|
+
callStore,
|
|
163
|
+
);
|
|
164
|
+
const configStores = buildStoreView(
|
|
165
|
+
'config',
|
|
166
|
+
(contextData.config ?? {}) as Record<string, unknown>,
|
|
167
|
+
callStore,
|
|
168
|
+
);
|
|
169
|
+
|
|
136
170
|
const context = {
|
|
137
171
|
...contextData,
|
|
172
|
+
config: configStores,
|
|
173
|
+
secrets: stores,
|
|
138
174
|
logger,
|
|
139
175
|
capabilities: buildCapabilities(shape),
|
|
140
176
|
} as unknown as HookContext;
|