@celilo/cli 2.2.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/package.json +2 -2
- package/schemas/system_config.json +5 -0
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- 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 +2 -1
- package/src/cli/commands/module-config.test.ts +2 -1
- 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-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +6 -5
- 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/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/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/index.ts +2 -1
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +2 -1
- package/src/config/paths.test.ts +3 -3
- package/src/db/migrate.test.ts +2 -1
- package/src/db/migrate.ts +16 -16
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +37 -2
- package/src/hooks/executor.test.ts +87 -1
- package/src/hooks/executor.ts +57 -21
- package/src/hooks/hook-store.test.ts +6 -0
- package/src/hooks/hook-trespass.test.ts +17 -7
- package/src/hooks/jail.test.ts +61 -2
- package/src/hooks/jail.ts +53 -1
- 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 +60 -0
- package/src/module/packaging/extract.ts +1 -5
- package/src/policy/module-script-scan.ts +0 -40
- package/src/policy/no-swallowed-refusal.test.ts +9 -10
- 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/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/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-envelope-roundtrip.test.ts +2 -1
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- 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 +2 -1
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-validation.test.ts +3 -2
- 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 +3 -2
- 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-deploy.dns-repoint.test.ts +2 -1
- package/src/services/module-deploy.ts +36 -20
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- 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 +3 -2
- 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 +5 -4
- package/src/services/static-content-converge.ts +33 -10
- package/src/services/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +3 -2
- package/src/services/zone-detector.test.ts +2 -1
- 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/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
package/src/config/paths.test.ts
CHANGED
|
@@ -141,7 +141,7 @@ describe('paths configuration', () => {
|
|
|
141
141
|
it.skipIf(skipIntegration({ platform: 'darwin' }))(
|
|
142
142
|
'returns data dir + celilo.db on macOS in production',
|
|
143
143
|
() => {
|
|
144
|
-
process.env.CELILO_DB_PATH
|
|
144
|
+
delete process.env.CELILO_DB_PATH;
|
|
145
145
|
process.env.CELILO_DATA_DIR = undefined;
|
|
146
146
|
process.env.ENVIRONMENT = undefined;
|
|
147
147
|
|
|
@@ -156,7 +156,7 @@ describe('paths configuration', () => {
|
|
|
156
156
|
);
|
|
157
157
|
|
|
158
158
|
it('returns celilo-data/celilo.db in development mode', () => {
|
|
159
|
-
process.env.CELILO_DB_PATH
|
|
159
|
+
delete process.env.CELILO_DB_PATH;
|
|
160
160
|
process.env.CELILO_DATA_DIR = undefined;
|
|
161
161
|
process.env.ENVIRONMENT = 'dev';
|
|
162
162
|
|
|
@@ -165,7 +165,7 @@ describe('paths configuration', () => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it('respects CELILO_DATA_DIR override', () => {
|
|
168
|
-
process.env.CELILO_DB_PATH
|
|
168
|
+
delete process.env.CELILO_DB_PATH;
|
|
169
169
|
process.env.CELILO_DATA_DIR = '/custom/data';
|
|
170
170
|
process.env.ENVIRONMENT = undefined;
|
|
171
171
|
|
package/src/db/migrate.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { describe, expect, it } from 'bun:test';
|
|
|
20
20
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
21
21
|
import { tmpdir } from 'node:os';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
23
24
|
import { closeDb, getDb } from './client';
|
|
24
25
|
import { runMigrations } from './migrate';
|
|
25
26
|
import { systemConfig } from './schema';
|
|
@@ -48,7 +49,7 @@ describe('runMigrations connection lifecycle', () => {
|
|
|
48
49
|
closeDb();
|
|
49
50
|
} finally {
|
|
50
51
|
closeDb();
|
|
51
|
-
|
|
52
|
+
resetTestDbPath();
|
|
52
53
|
rmSync(dir, { recursive: true, force: true });
|
|
53
54
|
}
|
|
54
55
|
});
|
package/src/db/migrate.ts
CHANGED
|
@@ -80,26 +80,26 @@ export function runMigrationsOn(db: DbClient): void {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
83
|
+
* Open a database at `dbPath` with migrations applied, then close it.
|
|
84
|
+
* Standalone entrypoint (`bun run src/db/migrate.ts`); tests also use it to
|
|
85
|
+
* prepare a fresh scratch database.
|
|
86
|
+
*
|
|
87
|
+
* createDbClient migrates synchronously on open (its own runMigrationsOn
|
|
88
|
+
* call), so this function is open + close. It deliberately does NOT call
|
|
89
|
+
* runMigrationsOn again: a second pass is always a ledger no-op when the
|
|
90
|
+
* first succeeded, and keeping it invited a double-migrate everywhere this
|
|
91
|
+
* entrypoint is used (celilo#1315).
|
|
85
92
|
*/
|
|
86
93
|
export async function runMigrations(dbPath?: string) {
|
|
87
94
|
console.log('Running database migrations...');
|
|
88
95
|
const db = createDbClient(dbPath ? { path: dbPath } : undefined);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// Close the connection THIS call created. It is not the singleton —
|
|
97
|
-
// createDbClient does not register one — so the old closeDb() here closed
|
|
98
|
-
// whatever else was open (usually nothing) and leaked this connection,
|
|
99
|
-
// which then held the file in WAL and blocked every later journal-mode
|
|
100
|
-
// switch on it (celilo#1269).
|
|
101
|
-
db.$client.close();
|
|
102
|
-
}
|
|
96
|
+
console.log('Migrations completed successfully');
|
|
97
|
+
// Close the connection THIS call created. It is not the singleton —
|
|
98
|
+
// createDbClient does not register one — so an old closeDb() here closed
|
|
99
|
+
// whatever else was open (usually nothing) and leaked this connection,
|
|
100
|
+
// which then held the file in WAL and blocked every later journal-mode
|
|
101
|
+
// switch on it (celilo#1269).
|
|
102
|
+
db.$client.close();
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
// Run migrations if executed directly
|
|
@@ -7,7 +7,9 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import type { HookLogger, RouteReadView } from '@celilo/capabilities';
|
|
10
|
+
import { and, eq } from 'drizzle-orm';
|
|
10
11
|
import type { DbClient } from '../db/client';
|
|
12
|
+
import { moduleConfigs } from '../db/schema';
|
|
11
13
|
import { upsertModuleConfig } from '../services/module-config';
|
|
12
14
|
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
13
15
|
import { loadCapabilityFunctions } from './capability-loader';
|
|
@@ -40,6 +42,36 @@ export default function createDhcpServer(context) {
|
|
|
40
42
|
}
|
|
41
43
|
`;
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A factory that WRITES through the hook-owned-state accessor, so the loader's
|
|
47
|
+
* store view is proven end to end: the method's write lands as a hook-owned
|
|
48
|
+
* row, and an undeclared name throws instead of landing. Branded
|
|
49
|
+
* (defineCapabilityFunction, the Phase 8 pattern) — the branded path is the
|
|
50
|
+
* one the loader hands the store views to. The temp dir the fixture is
|
|
51
|
+
* dropped into has no node_modules link, so the import uses the workspace
|
|
52
|
+
* package by absolute path, the same trick capability-loader-firewall uses.
|
|
53
|
+
*/
|
|
54
|
+
const TEST_STORE_WRITING_DHCP_MODULE = `
|
|
55
|
+
import { defineCapabilityFunction } from '${join(__dirname, '../../../..', 'packages/capabilities/src/index.ts')}';
|
|
56
|
+
|
|
57
|
+
export default defineCapabilityFunction({
|
|
58
|
+
capability: 'dhcp_server',
|
|
59
|
+
handler: (context) => ({
|
|
60
|
+
async setDnsServers() {},
|
|
61
|
+
async getDnsServers() { return [context.config.marker]; },
|
|
62
|
+
async setDomainName() {},
|
|
63
|
+
async getDomainName() { return context.config.marker; },
|
|
64
|
+
async claimIp() {
|
|
65
|
+
await context.config.set('public_ip', '203.0.113.7');
|
|
66
|
+
return 'claimed';
|
|
67
|
+
},
|
|
68
|
+
async claimUndeclared() {
|
|
69
|
+
await context.config.set('no_such_key', 'x');
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
`;
|
|
74
|
+
|
|
43
75
|
describe('Capability Loader', () => {
|
|
44
76
|
let db: DbClient;
|
|
45
77
|
let tempDir: string;
|
|
@@ -134,6 +166,52 @@ describe('Capability Loader', () => {
|
|
|
134
166
|
expect(await dhcp.getDomainName()).toBe('internal-dhcp');
|
|
135
167
|
});
|
|
136
168
|
|
|
169
|
+
test('a provider factory writes hook-owned config through the injected store view', async () => {
|
|
170
|
+
// The store view must do more than carry the plain record a factory reads.
|
|
171
|
+
// A method writing `context.config.set` lands a hook-owned row (validated
|
|
172
|
+
// against the PROVIDER's manifest, source column 'hook'), and an
|
|
173
|
+
// undeclared name throws instead of writing a phantom row.
|
|
174
|
+
const modulePath = join(tempDir, 'storewriting-vpn');
|
|
175
|
+
const scriptsDir = join(modulePath, 'scripts');
|
|
176
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
177
|
+
writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_STORE_WRITING_DHCP_MODULE);
|
|
178
|
+
const manifest = JSON.stringify({
|
|
179
|
+
variables: { owns: [{ name: 'public_ip', type: 'string', source: 'hook' }] },
|
|
180
|
+
});
|
|
181
|
+
db.$client.run(
|
|
182
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('storewriting-vpn', 'StoreWritingVpn', '1.0.0', '${modulePath}', '${manifest.replace(/'/g, "''")}')`,
|
|
183
|
+
);
|
|
184
|
+
db.$client.run(
|
|
185
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('storewriting-vpn', 'dhcp_server', '1.0.0', '{}', unixepoch())`,
|
|
186
|
+
);
|
|
187
|
+
upsertModuleConfig(db, 'storewriting-vpn', 'marker', 'storewriting-vpn');
|
|
188
|
+
|
|
189
|
+
const result = await loadCapabilityFunctions('store-consumer', db, noopLogger);
|
|
190
|
+
const dhcp = result.dhcp_server as {
|
|
191
|
+
claimIp(): Promise<string>;
|
|
192
|
+
claimUndeclared(): Promise<void>;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
expect(await dhcp.claimIp()).toBe('claimed');
|
|
196
|
+
const row = db
|
|
197
|
+
.select()
|
|
198
|
+
.from(moduleConfigs)
|
|
199
|
+
.where(
|
|
200
|
+
and(eq(moduleConfigs.moduleId, 'storewriting-vpn'), eq(moduleConfigs.key, 'public_ip')),
|
|
201
|
+
)
|
|
202
|
+
.get();
|
|
203
|
+
expect(row?.valueJson).toBe('"203.0.113.7"');
|
|
204
|
+
expect(row?.source).toBe('hook');
|
|
205
|
+
|
|
206
|
+
// The store round-trips text, so the map read the NEXT load takes sees the
|
|
207
|
+
// same string the write sent.
|
|
208
|
+
const reloaded = await loadCapabilityFunctions('store-consumer', db, noopLogger);
|
|
209
|
+
const dhcpAgain = reloaded.dhcp_server as { getDnsServers(): Promise<string[]> };
|
|
210
|
+
expect(await dhcpAgain.getDnsServers()).toEqual(['storewriting-vpn']);
|
|
211
|
+
|
|
212
|
+
await expect(dhcp.claimUndeclared()).rejects.toThrow(/no declared hook-owned config/);
|
|
213
|
+
});
|
|
214
|
+
|
|
137
215
|
test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
|
|
138
216
|
const modulePath = join(tempDir, 'upstream-dhcp');
|
|
139
217
|
const scriptsDir = join(modulePath, 'scripts');
|
|
@@ -60,6 +60,9 @@ import {
|
|
|
60
60
|
import { resolveComputedFields } from '../variables/computed/evaluate';
|
|
61
61
|
import { containsComputedMarker } from '../variables/computed/marker';
|
|
62
62
|
import { buildProviderLookup } from '../variables/computed/provider-lookup';
|
|
63
|
+
import type { BufferedStoreOp } from './hook-protocol';
|
|
64
|
+
import { type HookStores, createHookStores } from './hook-store';
|
|
65
|
+
import { type StoreTransport, buildStoreView } from './hook-store-proxy';
|
|
63
66
|
import { loadHookConfigMap } from './load-hook-config';
|
|
64
67
|
|
|
65
68
|
/**
|
|
@@ -260,6 +263,13 @@ export async function loadCapabilityFunctions(
|
|
|
260
263
|
// This allows modules to optionally consume capabilities at runtime
|
|
261
264
|
// (e.g., caddy uses dns_registrar if available, falls back if not).
|
|
262
265
|
const verbose = process.env.CELILO_DEBUG === '1';
|
|
266
|
+
|
|
267
|
+
// The broker-side stores, resolved from the PROVIDER on the first store
|
|
268
|
+
// call this consumer load makes, and shared across every capability it
|
|
269
|
+
// builds. Nothing pays for the manifest read until a factory actually
|
|
270
|
+
// writes or reads through the accessor. See the storeTransport construction
|
|
271
|
+
// at the factory-invocation site below.
|
|
272
|
+
let storesPromise: Promise<HookStores> | undefined;
|
|
263
273
|
const debugLog = verbose
|
|
264
274
|
? (msg: string) => process.stderr.write(`[capability-loader] ${msg}\n`)
|
|
265
275
|
: (_msg: string) => {};
|
|
@@ -392,10 +402,35 @@ export async function loadCapabilityFunctions(
|
|
|
392
402
|
// Phase 8 path: branded defineCapabilityFunction factory. Call with
|
|
393
403
|
// the canonical { config, secrets, logger } context. The factory
|
|
394
404
|
// applies wrapWithLogging internally so we don't double-wrap.
|
|
405
|
+
//
|
|
406
|
+
// Both maps arrive as the hook-owned-state view (buildStoreView): the
|
|
407
|
+
// plain record the factory reads, PLUS get/set/delete/transaction that
|
|
408
|
+
// reach the broker-side stores (createHookStores), validated against
|
|
409
|
+
// the PROVIDER module's own manifest. The transport is in-process — a
|
|
410
|
+
// capability factory runs here in celilo's process, not in the jail —
|
|
411
|
+
// but the view is the same object a jailed hook sees, so a provider's
|
|
412
|
+
// self-config write goes through the same validated door regardless of
|
|
413
|
+
// which process it runs in (hook-owned-state tasks 0.7 and 5.5 shape).
|
|
395
414
|
if (isCompiledCapabilityFactory(exported)) {
|
|
415
|
+
const storeTransport: StoreTransport = (store, method, args) => {
|
|
416
|
+
storesPromise ??= createHookStores(db, capability.moduleId);
|
|
417
|
+
return storesPromise.then((backend: HookStores): Promise<unknown> => {
|
|
418
|
+
const one = backend[store];
|
|
419
|
+
switch (method) {
|
|
420
|
+
case 'transaction':
|
|
421
|
+
return one.applyTransaction(args[0] as readonly BufferedStoreOp[]);
|
|
422
|
+
case 'set':
|
|
423
|
+
return one.set(args[0] as string, args[1] as string);
|
|
424
|
+
case 'delete':
|
|
425
|
+
return one.delete(args[0] as string);
|
|
426
|
+
default:
|
|
427
|
+
return one.get(args[0] as string);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
};
|
|
396
431
|
const capabilityInterface = exported({
|
|
397
|
-
config: providerConfig,
|
|
398
|
-
secrets: providerSecrets,
|
|
432
|
+
config: buildStoreView('config', providerConfig, storeTransport),
|
|
433
|
+
secrets: buildStoreView('secrets', providerSecrets, storeTransport),
|
|
399
434
|
systems: getModuleSystems(capability.moduleId, db),
|
|
400
435
|
logger,
|
|
401
436
|
// WHO IS CALLING, so a provider can scope per-consumer state to them.
|
|
@@ -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,
|
|
@@ -329,6 +333,88 @@ describe('Hook Executor', () => {
|
|
|
329
333
|
});
|
|
330
334
|
});
|
|
331
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
|
+
|
|
332
418
|
describe('resolveHookTimeouts', () => {
|
|
333
419
|
test('no declaration: 60s total, 30s idle heuristic', () => {
|
|
334
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,
|
|
@@ -73,10 +75,10 @@ import {
|
|
|
73
75
|
import {
|
|
74
76
|
type JailPlan,
|
|
75
77
|
detectJailBackend,
|
|
76
|
-
jailPolicy,
|
|
77
78
|
planJailedSpawn,
|
|
78
79
|
realpathRequest,
|
|
79
80
|
recordJailMode,
|
|
81
|
+
resolveJailPolicy,
|
|
80
82
|
runtimeModulePathsFor,
|
|
81
83
|
} from './jail';
|
|
82
84
|
import {
|
|
@@ -171,32 +173,47 @@ const FORWARDED_ENV = [
|
|
|
171
173
|
] as const;
|
|
172
174
|
|
|
173
175
|
/**
|
|
174
|
-
* The
|
|
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.
|
|
176
189
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* `
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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).
|
|
187
201
|
*
|
|
188
|
-
* Already-present wins: an operator who has
|
|
189
|
-
* unchanged value. An absent inherited PATH still yields
|
|
190
|
-
*
|
|
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.
|
|
191
206
|
*
|
|
192
207
|
* Exported for the allow-list tests (hook-trespass.test.ts), which pin this
|
|
193
208
|
* contract the same way they pin the rest of the child environment.
|
|
194
209
|
*/
|
|
195
210
|
export function childPath(inherited: string | undefined): string {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(':');
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
@@ -330,6 +347,25 @@ export interface ExecuteHookOptions {
|
|
|
330
347
|
hookStores?: HookStoresProvider;
|
|
331
348
|
}
|
|
332
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;
|
|
367
|
+
}
|
|
368
|
+
|
|
333
369
|
/**
|
|
334
370
|
* Execute a hook script
|
|
335
371
|
*
|
|
@@ -394,7 +430,7 @@ export async function executeHookScript(
|
|
|
394
430
|
[process.execPath, HOOK_RUNNER_SPAWN_PATH],
|
|
395
431
|
mountSet,
|
|
396
432
|
detectJailBackend(),
|
|
397
|
-
|
|
433
|
+
resolveJailPolicy(process.env.CELILO_HOOK_JAIL, readStoredJailPolicy()).policy,
|
|
398
434
|
);
|
|
399
435
|
reportJail(jail, logger);
|
|
400
436
|
// Only a real module hook says anything about whether this HOST jails. An
|
|
@@ -14,6 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { type DbClient, closeDb, createDbClient } from '../db/client';
|
|
15
15
|
import { runMigrations } from '../db/migrate';
|
|
16
16
|
import { moduleConfigs, modules, secrets } from '../db/schema';
|
|
17
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
17
18
|
import { createHookStores } from './hook-store';
|
|
18
19
|
|
|
19
20
|
describe('hook stores', () => {
|
|
@@ -54,6 +55,11 @@ describe('hook stores', () => {
|
|
|
54
55
|
|
|
55
56
|
afterEach(() => {
|
|
56
57
|
closeDb();
|
|
58
|
+
// The variable pointed at this suite's (now removed) temp database;
|
|
59
|
+
// leaving it set sends the next var-less reader at a dangling path.
|
|
60
|
+
// Reset to the scratch path so no later reader reaches the operator's
|
|
61
|
+
// real celilo.db (celilo#1315).
|
|
62
|
+
resetTestDbPath();
|
|
57
63
|
rmSync(testDir, { recursive: true, force: true });
|
|
58
64
|
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
59
65
|
});
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
listUnpublishedWorkspacePackages,
|
|
32
32
|
planFallbacks,
|
|
33
33
|
} from '../../../../scripts/workspace-fallback';
|
|
34
|
-
import { childPath, executeHookScript, hookChildEnv } from './executor';
|
|
34
|
+
import { CLI_BIN_DIR, childPath, executeHookScript, hookChildEnv } from './executor';
|
|
35
35
|
import { HOOK_PROTOCOL_VERSION } from './hook-protocol';
|
|
36
36
|
import { detectJailBackend, jailPolicy } from './jail';
|
|
37
37
|
import { createCapturingLogger } from './logger';
|
|
@@ -246,19 +246,29 @@ describe('the child environment is an allow-list', () => {
|
|
|
246
246
|
// celilo#1300: a hook that spawns `celilo` by name must find the same CLI
|
|
247
247
|
// the parent runs, whatever the inherited PATH looks like.
|
|
248
248
|
describe('childPath', () => {
|
|
249
|
-
test('prepends the runtime
|
|
249
|
+
test('prepends the CLI bin and runtime directories so the child reaches the parent CLI', () => {
|
|
250
250
|
const child = childPath('/usr/local/bin:/usr/bin');
|
|
251
|
-
expect(child.startsWith(`${dirname(process.execPath)}:`)).toBe(true);
|
|
251
|
+
expect(child.startsWith(`${CLI_BIN_DIR}:${dirname(process.execPath)}:`)).toBe(true);
|
|
252
252
|
expect(child.endsWith('/usr/local/bin:/usr/bin')).toBe(true);
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
-
test('leaves an inherited PATH that already carries the
|
|
256
|
-
const already = `${dirname(process.execPath)}:/usr/bin`;
|
|
255
|
+
test('leaves an inherited PATH that already carries the directories unchanged', () => {
|
|
256
|
+
const already = `${CLI_BIN_DIR}:${dirname(process.execPath)}:/usr/bin`;
|
|
257
257
|
expect(childPath(already)).toBe(already);
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
-
test('substitutes
|
|
261
|
-
expect(childPath(undefined)).toBe(dirname(process.execPath));
|
|
260
|
+
test('substitutes both directories when the parent has no PATH at all', () => {
|
|
261
|
+
expect(childPath(undefined)).toBe(`${CLI_BIN_DIR}:${dirname(process.execPath)}`);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// The fleet gate (ce-y0fd): the .deb layout runs the CLI through
|
|
265
|
+
// $CELILO_HOME/node_modules/@celilo/cli/bin/celilo, so the directory beside
|
|
266
|
+
// process.execPath holds only bun and holds no celilo. A PATH that resolves
|
|
267
|
+
// `celilo` must therefore come from the CLI package's own bin directory —
|
|
268
|
+
// and that directory must really contain the bin, or every prepended PATH
|
|
269
|
+
// entry is decoration over the same accident celilo#1300 recorded.
|
|
270
|
+
test('prepends a CLI bin directory that actually contains the celilo bin', () => {
|
|
271
|
+
expect(existsSync(join(CLI_BIN_DIR, 'celilo'))).toBe(true);
|
|
262
272
|
});
|
|
263
273
|
});
|
|
264
274
|
});
|
package/src/hooks/jail.test.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
readJailMode,
|
|
28
28
|
realpathRequest,
|
|
29
29
|
recordJailMode,
|
|
30
|
+
resolveJailPolicy,
|
|
30
31
|
runtimeModulePathsFor,
|
|
31
32
|
} from './jail';
|
|
32
33
|
import { deriveMountSet } from './mount-set';
|
|
@@ -272,8 +273,12 @@ describe('jailPolicy reads the operator’s switch', () => {
|
|
|
272
273
|
}
|
|
273
274
|
};
|
|
274
275
|
|
|
275
|
-
test('unset is
|
|
276
|
-
expect(withEnv(undefined, jailPolicy)).toBe('
|
|
276
|
+
test('unset is off (peba, ce-rez7)', () => {
|
|
277
|
+
expect(withEnv(undefined, jailPolicy)).toBe('off');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('an explicit auto still means auto', () => {
|
|
281
|
+
expect(withEnv('auto', jailPolicy)).toBe('auto');
|
|
277
282
|
});
|
|
278
283
|
|
|
279
284
|
test('a typo is refused rather than silently meaning auto', () => {
|
|
@@ -284,6 +289,60 @@ describe('jailPolicy reads the operator’s switch', () => {
|
|
|
284
289
|
});
|
|
285
290
|
});
|
|
286
291
|
|
|
292
|
+
describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', () => {
|
|
293
|
+
test('an env value set and non-empty wins over stored config', () => {
|
|
294
|
+
expect(resolveJailPolicy('required', 'off')).toEqual({ policy: 'required', source: 'env' });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('an EMPTY STRING env value counts as unset and falls through to config', () => {
|
|
298
|
+
// jailPolicy() already treats '' as unset, so the resolver agrees rather
|
|
299
|
+
// than inventing a second rule for what counts as set.
|
|
300
|
+
expect(resolveJailPolicy('', 'off')).toEqual({ policy: 'off', source: 'config' });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('stored config wins when the env var is absent', () => {
|
|
304
|
+
expect(resolveJailPolicy(undefined, 'off')).toEqual({ policy: 'off', source: 'config' });
|
|
305
|
+
expect(resolveJailPolicy(undefined, 'required')).toEqual({
|
|
306
|
+
policy: 'required',
|
|
307
|
+
source: 'config',
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('both absent resolves to off from the default (peba, ce-rez7)', () => {
|
|
312
|
+
expect(resolveJailPolicy(undefined, undefined)).toEqual({ policy: 'off', source: 'default' });
|
|
313
|
+
expect(resolveJailPolicy('', undefined)).toEqual({ policy: 'off', source: 'default' });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('an explicit env auto still wins (peba, ce-rez7)', () => {
|
|
317
|
+
expect(resolveJailPolicy('auto', 'off')).toEqual({ policy: 'auto', source: 'env' });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('a stored empty jail_policy THROWS, it does not resolve to auto (peba, ce-8832)', () => {
|
|
321
|
+
// The set-time pattern ^(auto|off|required)$ rejects '', so a stored one
|
|
322
|
+
// can only arrive via a restore from file or a hand edit to the DB — the
|
|
323
|
+
// untrusted paths D3 exists to catch. It is outside the enum and fails
|
|
324
|
+
// closed like any other bad stored value.
|
|
325
|
+
expect(() => resolveJailPolicy(undefined, '')).toThrow(/is not a hook jail policy/);
|
|
326
|
+
expect(() => resolveJailPolicy(undefined, '')).toThrow(/hooks\.jail_policy=''/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('a typo in the STORED row is refused, not silently treated as auto (D3)', () => {
|
|
330
|
+
// The DB row is a trust boundary like any other (Rule 3.7): restores carry
|
|
331
|
+
// foreign state, and a typo that silently meant `auto` reads as an armed
|
|
332
|
+
// jail when nothing is armed. The config path is no laxer than the env
|
|
333
|
+
// path.
|
|
334
|
+
expect(() => resolveJailPolicy(undefined, 'alwayssafe')).toThrow(/is not a hook jail policy/);
|
|
335
|
+
expect(() => resolveJailPolicy(undefined, 'alwayssafe')).toThrow(
|
|
336
|
+
/hooks\.jail_policy='alwayssafe'/,
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('a typo in the env value is still refused (unchanged jailPolicy behaviour)', () => {
|
|
341
|
+
expect(() => resolveJailPolicy('requried', undefined)).toThrow(/is not a hook jail policy/);
|
|
342
|
+
expect(() => resolveJailPolicy('requried', 'off')).toThrow(/CELILO_HOOK_JAIL='requried'/);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
287
346
|
describe('realpath at the caller (task 4.2k)', () => {
|
|
288
347
|
test('a symlinked module root is resolved before it becomes a bind', () => {
|
|
289
348
|
// `mod.sourcePath` comes out of the database, and a database restored from
|