@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/hooks/jail.ts
CHANGED
|
@@ -418,16 +418,68 @@ function unavailableReason(error: unknown): string {
|
|
|
418
418
|
* `CELILO_HOOK_JAIL=requried` that silently meant `auto` would read as the
|
|
419
419
|
* jail being enforced when it is not, which is the one mistake this variable
|
|
420
420
|
* exists to prevent.
|
|
421
|
+
*
|
|
422
|
+
* The unset/empty default is `off` (peba's ruling on ce-rez7): jailing must
|
|
423
|
+
* begin because someone set a switch, not because a package upgrade installed
|
|
424
|
+
* a backend. Turning the jail ON is a deliberate act.
|
|
421
425
|
*/
|
|
422
426
|
export function jailPolicy(): JailPolicy {
|
|
423
427
|
const raw = process.env.CELILO_HOOK_JAIL;
|
|
424
|
-
if (raw === undefined || raw === '') return '
|
|
428
|
+
if (raw === undefined || raw === '') return 'off';
|
|
425
429
|
if (raw === 'auto' || raw === 'off' || raw === 'required') return raw;
|
|
426
430
|
throw new Error(
|
|
427
431
|
`CELILO_HOOK_JAIL='${raw}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
428
432
|
);
|
|
429
433
|
}
|
|
430
434
|
|
|
435
|
+
/** Where the effective jail policy came from. Rendered by `system doctor` (D4). */
|
|
436
|
+
export type JailPolicySource = 'env' | 'config' | 'default';
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Apply D2's precedence to the two sources: env wins, then stored config,
|
|
440
|
+
* then the default, which is `off` (peba's ruling on ce-rez7; see
|
|
441
|
+
* openspec/changes/hook-jail-config-surface/design.md for the reversal
|
|
442
|
+
* trigger).
|
|
443
|
+
*
|
|
444
|
+
* Pure, so both consumers (executor.ts and system-doctor.ts) can fetch the
|
|
445
|
+
* stored row themselves and stay testable without a database.
|
|
446
|
+
*
|
|
447
|
+
* The empty-string env value is treated as unset, matching `jailPolicy()`'s
|
|
448
|
+
* own handling — one rule for "what counts as set", not a second one here.
|
|
449
|
+
*
|
|
450
|
+
* The config path is deliberately stricter than the env path: a stored
|
|
451
|
+
* empty string THROWS (peba's ruling on ce-8832). The set-time pattern
|
|
452
|
+
* `^(auto|off|required)$` rejects `''`, so a stored one can only arrive via
|
|
453
|
+
* a restore from file or a hand edit to the DB — exactly the untrusted
|
|
454
|
+
* paths D3 exists to catch. Resolving it to a silent `auto` would let
|
|
455
|
+
* foreign state pick the jailing default with nobody told, and failing
|
|
456
|
+
* closed is the whole point. An empty CELILO_HOOK_JAIL stays "not set",
|
|
457
|
+
* because `jailPolicy()` relies on that and slice 1's safety rests on the
|
|
458
|
+
* two resolvers agreeing.
|
|
459
|
+
*/
|
|
460
|
+
export function resolveJailPolicy(
|
|
461
|
+
envValue: string | undefined,
|
|
462
|
+
configValue: string | undefined,
|
|
463
|
+
): { policy: JailPolicy; source: JailPolicySource } {
|
|
464
|
+
if (envValue !== undefined && envValue !== '') {
|
|
465
|
+
if (envValue === 'auto' || envValue === 'off' || envValue === 'required') {
|
|
466
|
+
return { policy: envValue, source: 'env' };
|
|
467
|
+
}
|
|
468
|
+
throw new Error(
|
|
469
|
+
`CELILO_HOOK_JAIL='${envValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (configValue !== undefined) {
|
|
473
|
+
if (configValue === 'auto' || configValue === 'off' || configValue === 'required') {
|
|
474
|
+
return { policy: configValue, source: 'config' };
|
|
475
|
+
}
|
|
476
|
+
throw new Error(
|
|
477
|
+
`hooks.jail_policy='${configValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { policy: 'off', source: 'default' };
|
|
481
|
+
}
|
|
482
|
+
|
|
431
483
|
/**
|
|
432
484
|
* Resolve every path in a mount-set request through the filesystem (task 4.2k).
|
|
433
485
|
*
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
} from '../../services/module-instances';
|
|
14
14
|
import { computeFileChecksum } from './checksum';
|
|
15
15
|
import type { IntegrityViolation } from './extract';
|
|
16
|
-
import { compareVerbatimRoleAssets, readVerbatimRoleAssets } from './generated-plane';
|
|
17
16
|
import { type HostPlaneResult, verifyModuleOnHosts } from './host-plane';
|
|
18
17
|
import { classifyModulePath } from './package-rules';
|
|
19
18
|
|
|
@@ -204,30 +203,11 @@ export async function auditModule(
|
|
|
204
203
|
}
|
|
205
204
|
}
|
|
206
205
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
const generatedDir = join(moduleDir, 'generated');
|
|
213
|
-
if (existsSync(generatedDir)) {
|
|
214
|
-
const differences = compareVerbatimRoleAssets(
|
|
215
|
-
await readVerbatimRoleAssets(moduleDir),
|
|
216
|
-
await readVerbatimRoleAssets(generatedDir),
|
|
217
|
-
);
|
|
218
|
-
for (const difference of differences) {
|
|
219
|
-
violations.push({
|
|
220
|
-
type: 'stale-generated',
|
|
221
|
-
path: difference.relPath,
|
|
222
|
-
expectedDigest: difference.installedDigest,
|
|
223
|
-
actualDigest: difference.generatedDigest,
|
|
224
|
-
message:
|
|
225
|
-
difference.reason === 'missing'
|
|
226
|
-
? `Generated project is missing ${difference.relPath} — the next deploy would ship nothing for it. Run 'celilo module generate ${moduleId}'.`
|
|
227
|
-
: `Generated project holds different bytes for ${difference.relPath} (generated ${difference.generatedDigest}, installed ${difference.installedDigest}) — the next deploy would ship the wrong ones. Run 'celilo module generate ${moduleId}'.`,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
}
|
|
206
|
+
// The second plane (is what we would deploy built from what we installed?)
|
|
207
|
+
// retired here: the generated tree is ephemeral now (D4 of
|
|
208
|
+
// control-plane-stops-building-modules) — rendered for a deploy and
|
|
209
|
+
// deleted on success — so a persistent generated copy that verbatim role
|
|
210
|
+
// assets could be compared against no longer exists to compare.
|
|
231
211
|
|
|
232
212
|
// Check for extra files (not in checksums)
|
|
233
213
|
const actualFiles = await scanDirectory(moduleDir, moduleDir);
|
|
@@ -244,7 +224,10 @@ export async function auditModule(
|
|
|
244
224
|
}
|
|
245
225
|
|
|
246
226
|
// Plane three: is what is running what we generated? One SSH per system,
|
|
247
|
-
// so it is asked only when the caller says so.
|
|
227
|
+
// so it is asked only when the caller says so. With the generated tree
|
|
228
|
+
// ephemeral (D4), a deep audit after a successful deploy finds no playbook
|
|
229
|
+
// and says so — the remedy is generate, which is cheap and never builds.
|
|
230
|
+
const generatedDir = join(moduleDir, 'generated');
|
|
248
231
|
let hostPlane: HostPlaneResult | undefined;
|
|
249
232
|
if (options.deep) {
|
|
250
233
|
hostPlane = await verifyModuleOnHosts({
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { resolveBuildCommandPaths } from './build-paths';
|
|
7
|
+
|
|
8
|
+
/** Repo root: this file sits at apps/celilo/src/module/packaging/. */
|
|
9
|
+
const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..', '..');
|
|
10
|
+
|
|
11
|
+
/** Every module in the repo that declares a `build:` block (design, Context). */
|
|
12
|
+
const BUILD_MODULES = [
|
|
13
|
+
'celilo-registry',
|
|
14
|
+
'celilo-web-console',
|
|
15
|
+
'celilo-website',
|
|
16
|
+
'forgejo',
|
|
17
|
+
'npm-cache-node',
|
|
18
|
+
'wireguard-manager',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
function buildCommand(moduleId: string): string {
|
|
22
|
+
const manifestPath = join(REPO_ROOT, 'modules', moduleId, 'manifest.yml');
|
|
23
|
+
if (!existsSync(manifestPath)) throw new Error(`fixture missing: ${manifestPath}`);
|
|
24
|
+
const manifest = parseYaml(readFileSync(manifestPath, 'utf8')) as {
|
|
25
|
+
build?: { command?: string };
|
|
26
|
+
};
|
|
27
|
+
const command = manifest.build?.command;
|
|
28
|
+
if (!command) throw new Error(`${moduleId} declares no build command`);
|
|
29
|
+
return command;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('publish-time build path gate', () => {
|
|
33
|
+
test('flags celilo-registry when packaged outside the monorepo (the control-plane shape)', () => {
|
|
34
|
+
// The known-bad form, per celilo#1307: the script reaches a sibling of the
|
|
35
|
+
// module source (`../../packages/registry-server`). Inside the monorepo
|
|
36
|
+
// that resolves; from a tree where the module stands alone — a control
|
|
37
|
+
// plane, or any packaging that is not the source checkout — it does not.
|
|
38
|
+
// The spec scenario pins this: "packaged from a tree where that directory
|
|
39
|
+
// does not exist".
|
|
40
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
41
|
+
const moduleSourceDir = join(scratch, 'celilo-registry');
|
|
42
|
+
cpSync(join(REPO_ROOT, 'modules', 'celilo-registry'), moduleSourceDir, {
|
|
43
|
+
recursive: true,
|
|
44
|
+
});
|
|
45
|
+
const violations = resolveBuildCommandPaths(buildCommand('celilo-registry'), {
|
|
46
|
+
moduleSourceDir,
|
|
47
|
+
buildDir: moduleSourceDir,
|
|
48
|
+
});
|
|
49
|
+
expect(violations).toHaveLength(1);
|
|
50
|
+
expect(violations[0].rawPath).toContain('../../packages/registry-server');
|
|
51
|
+
expect(violations[0].resolvedPath).not.toContain(REPO_ROOT);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('flags a plain relative escape that does not resolve (spec scenario)', () => {
|
|
55
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
56
|
+
const violations = resolveBuildCommandPaths(
|
|
57
|
+
'cd ../../packages/registry-server && bun install',
|
|
58
|
+
{ moduleSourceDir: scratch, buildDir: scratch },
|
|
59
|
+
);
|
|
60
|
+
expect(violations).toHaveLength(1);
|
|
61
|
+
expect(violations[0].rawPath).toBe('../../packages/registry-server');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('accepts $CELILO_MODULE_SOURCE_DIR/server when it exists (npm-cache-node shape)', () => {
|
|
65
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
66
|
+
const moduleSourceDir = join(scratch, 'npm-cache-node');
|
|
67
|
+
cpSync(
|
|
68
|
+
join(REPO_ROOT, 'modules', 'npm-cache-node', 'server'),
|
|
69
|
+
join(moduleSourceDir, 'server'),
|
|
70
|
+
{
|
|
71
|
+
recursive: true,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
const violations = resolveBuildCommandPaths(buildCommand('npm-cache-node'), {
|
|
75
|
+
moduleSourceDir,
|
|
76
|
+
buildDir: moduleSourceDir,
|
|
77
|
+
});
|
|
78
|
+
expect(violations).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('accepts every module build command at publish time in the monorepo', () => {
|
|
82
|
+
// The regression surface for the five healthy modules. celilo-registry
|
|
83
|
+
// passes here too, and that is correct: at publish, inside the monorepo,
|
|
84
|
+
// its `cd` target exists (the binaries shipped on 2026-08-20 prove the
|
|
85
|
+
// build ran). The gate refuses the script only where it genuinely cannot
|
|
86
|
+
// resolve — the first test above.
|
|
87
|
+
for (const moduleId of BUILD_MODULES) {
|
|
88
|
+
const moduleSourceDir = join(REPO_ROOT, 'modules', moduleId);
|
|
89
|
+
const violations = resolveBuildCommandPaths(buildCommand(moduleId), {
|
|
90
|
+
moduleSourceDir,
|
|
91
|
+
buildDir: moduleSourceDir,
|
|
92
|
+
});
|
|
93
|
+
expect(violations, moduleId).toEqual([]);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('skips a cd target it cannot resolve rather than guessing (forgejo shape)', () => {
|
|
98
|
+
// `cd "$D"` where D was assigned earlier in the same command: the gate
|
|
99
|
+
// does not interpret shell, so an unresolvable target is skipped, not
|
|
100
|
+
// failed. A guard that rejects working modules is worse than none.
|
|
101
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
102
|
+
const violations = resolveBuildCommandPaths('D=files && mkdir -p "$D" && cd "$D" && ls', {
|
|
103
|
+
moduleSourceDir: scratch,
|
|
104
|
+
buildDir: scratch,
|
|
105
|
+
});
|
|
106
|
+
expect(violations).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('tracks the working directory across chained cds (celilo-web-console shape)', () => {
|
|
110
|
+
// `cd $SOURCE/../../apps/console` then `cd ../console-server`: the second
|
|
111
|
+
// target resolves against the FIRST cd's destination, not the build dir.
|
|
112
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
|
|
113
|
+
const moduleSourceDir = join(scratch, 'web');
|
|
114
|
+
const monorepo = join(scratch, 'mono');
|
|
115
|
+
cpSync(join(REPO_ROOT, 'modules', 'celilo-web-console'), moduleSourceDir, { recursive: true });
|
|
116
|
+
// A monorepo-shaped sibling tree, minimal but real.
|
|
117
|
+
const { mkdirSync } = require('node:fs') as typeof import('node:fs');
|
|
118
|
+
mkdirSync(join(monorepo, 'apps', 'console'), { recursive: true });
|
|
119
|
+
mkdirSync(join(monorepo, 'apps', 'console-server'), { recursive: true });
|
|
120
|
+
cpSync(moduleSourceDir, join(monorepo, 'modules', 'celilo-web-console'), { recursive: true });
|
|
121
|
+
const violations = resolveBuildCommandPaths(buildCommand('celilo-web-console'), {
|
|
122
|
+
moduleSourceDir: join(monorepo, 'modules', 'celilo-web-console'),
|
|
123
|
+
buildDir: join(monorepo, 'modules', 'celilo-web-console'),
|
|
124
|
+
});
|
|
125
|
+
expect(violations).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Publish-time gate on a module build command's filesystem references.
|
|
6
|
+
*
|
|
7
|
+
* A `build:` block runs when the module is packaged (publish/package/import),
|
|
8
|
+
* inside a staged copy of the module, with `CELILO_MODULE_SOURCE_DIR` pointing
|
|
9
|
+
* back at the authored source tree. A `cd` in that command is the build's
|
|
10
|
+
* claim about where its inputs live; a target that does not resolve there
|
|
11
|
+
* means the module ships inputs it does not carry, which is how celilo-registry
|
|
12
|
+
* was unbuildable on every real install for four months without anyone finding
|
|
13
|
+
* out (celilo#1307). The packager refuses up front and names the path.
|
|
14
|
+
*
|
|
15
|
+
* The gate does not interpret shell. It finds `cd` commands lexically, knows
|
|
16
|
+
* the two variables the packager supplies, and probes what those resolve to.
|
|
17
|
+
* A target it cannot resolve (an untracked shell variable, a command
|
|
18
|
+
* substitution, a glob) is SKIPPED, not failed: guessing would reject working
|
|
19
|
+
* modules, and a guard that rejects working modules is worse than none.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface BuildPathViolation {
|
|
23
|
+
/** The path exactly as the build command wrote it, before substitution. */
|
|
24
|
+
rawPath: string;
|
|
25
|
+
/** The absolute path the packaging runtime would resolve it to. */
|
|
26
|
+
resolvedPath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolveBuildCommandPathsOptions {
|
|
30
|
+
/** What `$CELILO_MODULE_SOURCE_DIR` resolves to when the command runs. */
|
|
31
|
+
moduleSourceDir: string;
|
|
32
|
+
/** The staged tree the command runs in (its initial cwd, and `$STAGE`). */
|
|
33
|
+
buildDir: string;
|
|
34
|
+
/** Injectable existence probe; defaults to existsSync. */
|
|
35
|
+
exists?: (path: string) => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the argument of every unquoted `cd` command in the string, with the
|
|
40
|
+
* quoting preserved so later substitution can strip it.
|
|
41
|
+
*
|
|
42
|
+
* A `cd` counts only at a command position: string start, or after `&&`, `||`,
|
|
43
|
+
* `;`, `|`, `(`, or a newline, ignoring whitespace. Quoted spans (single and
|
|
44
|
+
* double) are skipped whole, so a `cd` inside a string is not mistaken for a
|
|
45
|
+
* command.
|
|
46
|
+
*/
|
|
47
|
+
export function findCdTargets(command: string): string[] {
|
|
48
|
+
const targets: string[] = [];
|
|
49
|
+
let atCommandStart = true;
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < command.length) {
|
|
52
|
+
const ch = command[i];
|
|
53
|
+
if (ch === "'") {
|
|
54
|
+
const end = command.indexOf("'", i + 1);
|
|
55
|
+
i = end === -1 ? command.length : end + 1;
|
|
56
|
+
atCommandStart = false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '"') {
|
|
60
|
+
const end = command.indexOf('"', i + 1);
|
|
61
|
+
i = end === -1 ? command.length : end + 1;
|
|
62
|
+
atCommandStart = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
66
|
+
i += 2;
|
|
67
|
+
atCommandStart = false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '(') {
|
|
71
|
+
atCommandStart = true;
|
|
72
|
+
i += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (/\s/.test(ch)) {
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (atCommandStart && command.startsWith('cd', i) && /\s/.test(command[i + 2] ?? '')) {
|
|
80
|
+
const { target, next } = readCdTarget(command, i + 2);
|
|
81
|
+
if (target.length > 0) targets.push(target);
|
|
82
|
+
i = next;
|
|
83
|
+
atCommandStart = false;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
atCommandStart = false;
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
return targets;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read one `cd` argument starting just after the `cd` keyword. Stops at an
|
|
94
|
+
* unquoted command terminator. Backslash escapes are carried through so the
|
|
95
|
+
* caller sees exactly what the shell would.
|
|
96
|
+
*/
|
|
97
|
+
function readCdTarget(command: string, start: number): { target: string; next: number } {
|
|
98
|
+
let i = start;
|
|
99
|
+
while (i < command.length && /\s/.test(command[i])) i += 1;
|
|
100
|
+
const begin = i;
|
|
101
|
+
let end = i;
|
|
102
|
+
while (i < command.length) {
|
|
103
|
+
const ch = command[i];
|
|
104
|
+
if (ch === "'") {
|
|
105
|
+
const close = command.indexOf("'", i + 1);
|
|
106
|
+
i = close === -1 ? command.length : close + 1;
|
|
107
|
+
end = i;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '"') {
|
|
111
|
+
const close = command.indexOf('"', i + 1);
|
|
112
|
+
i = close === -1 ? command.length : close + 1;
|
|
113
|
+
end = i;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
117
|
+
i += 2;
|
|
118
|
+
end = i;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === '&' || ch === '|' || ch === ';' || ch === '\n') break;
|
|
122
|
+
i += 1;
|
|
123
|
+
if (!/\s/.test(ch) || command.slice(begin, i).trim().length > 0) end = i;
|
|
124
|
+
}
|
|
125
|
+
return { target: command.slice(begin, end).trim(), next: i };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Substitute the packager-supplied variables, strip quoting, and drop the
|
|
130
|
+
* target to `null` when anything unresolvable remains. `null` means "this
|
|
131
|
+
* gate cannot answer whether this path exists", never "it does not exist".
|
|
132
|
+
*/
|
|
133
|
+
function resolveTarget(target: string, options: ResolveBuildCommandPathsOptions): string | null {
|
|
134
|
+
const substituted = target
|
|
135
|
+
.replaceAll('$CELILO_MODULE_SOURCE_DIR', options.moduleSourceDir)
|
|
136
|
+
.replaceAll('$(pwd)', options.buildDir)
|
|
137
|
+
.replaceAll('$STAGE', options.buildDir)
|
|
138
|
+
.replaceAll('"', '')
|
|
139
|
+
.replaceAll("'", '');
|
|
140
|
+
if (/[$`*?~[(]/.test(substituted)) return null;
|
|
141
|
+
if (substituted.startsWith('-')) return null; // `cd -` / flags, not a path
|
|
142
|
+
return substituted;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function resolveBuildCommandPaths(
|
|
146
|
+
command: string,
|
|
147
|
+
options: ResolveBuildCommandPathsOptions,
|
|
148
|
+
): BuildPathViolation[] {
|
|
149
|
+
const exists = options.exists ?? existsSync;
|
|
150
|
+
const violations: BuildPathViolation[] = [];
|
|
151
|
+
// The simulated working directory of the build. `null` means unknown: the
|
|
152
|
+
// previous cd could not be resolved, so a later relative cd cannot be either.
|
|
153
|
+
let cwd: string | null = options.buildDir;
|
|
154
|
+
for (const rawTarget of findCdTargets(command)) {
|
|
155
|
+
const target = resolveTarget(rawTarget, options);
|
|
156
|
+
if (target === null) {
|
|
157
|
+
cwd = null;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const resolved: string | null = target.startsWith('/')
|
|
161
|
+
? target
|
|
162
|
+
: cwd === null
|
|
163
|
+
? null
|
|
164
|
+
: resolve(cwd, target);
|
|
165
|
+
if (resolved === null) {
|
|
166
|
+
cwd = null;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!exists(resolved)) {
|
|
170
|
+
violations.push({ rawPath: rawTarget, resolvedPath: resolved });
|
|
171
|
+
}
|
|
172
|
+
cwd = resolved;
|
|
173
|
+
}
|
|
174
|
+
return violations;
|
|
175
|
+
}
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
10
11
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
12
|
import { tmpdir } from 'node:os';
|
|
12
13
|
import { join } from 'node:path';
|
|
13
|
-
import { computeChecksums } from './build';
|
|
14
|
+
import { buildModule, computeChecksums } from './build';
|
|
14
15
|
|
|
15
16
|
describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () => {
|
|
16
17
|
let dir: string;
|
|
@@ -73,3 +74,72 @@ describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () =>
|
|
|
73
74
|
expect(paths.some((p) => p.startsWith('e2e/'))).toBe(false);
|
|
74
75
|
});
|
|
75
76
|
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The pack-staging branch must not eat the hook runtime (celilo#1310).
|
|
80
|
+
*
|
|
81
|
+
* `buildModule` stages through `bun pm pack` whenever a module has a root
|
|
82
|
+
* package.json, and npm-pack semantics drop every `node_modules` — a `files`
|
|
83
|
+
* entry naming the path does not override it. That silently contradicted
|
|
84
|
+
* `includeNodeModulesPath`, which exists to ship `scripts/node_modules` in full
|
|
85
|
+
* so hooks resolve their deps on a target with no reachable registry.
|
|
86
|
+
*
|
|
87
|
+
* Nothing in `modules/` can reach this branch: every module there has only
|
|
88
|
+
* `scripts/package.json` and takes the `cpSync` path. So the regression is only
|
|
89
|
+
* visible against a fixture shaped like a module that is ALSO a bun project.
|
|
90
|
+
*/
|
|
91
|
+
describe('buildModule — a root package.json must not strip the hook runtime', () => {
|
|
92
|
+
let source: string;
|
|
93
|
+
let out: string;
|
|
94
|
+
|
|
95
|
+
function write(rel: string, content: string): void {
|
|
96
|
+
const full = join(source, rel);
|
|
97
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
98
|
+
writeFileSync(full, content, 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
source = mkdtempSync(join(tmpdir(), 'celilo-pack-root-pkg-'));
|
|
103
|
+
out = join(mkdtempSync(join(tmpdir(), 'celilo-pack-out-')), 'm.netapp');
|
|
104
|
+
|
|
105
|
+
write('manifest.yml', 'celilo_contract: "1.0"\nid: root-pkg-module\nname: M\nversion: 0.1.0\n');
|
|
106
|
+
// The shape that triggers it: a module that is also a bun project. `files`
|
|
107
|
+
// deliberately names scripts/ — proving the loss is npm-pack semantics and
|
|
108
|
+
// not a missing entry.
|
|
109
|
+
write(
|
|
110
|
+
'package.json',
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
name: 'root-pkg-module',
|
|
113
|
+
version: '0.1.0',
|
|
114
|
+
private: true,
|
|
115
|
+
files: ['manifest.yml', 'scripts/'],
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
write('scripts/on_install.ts', "import '@celilo/capabilities';\n");
|
|
119
|
+
write(
|
|
120
|
+
'scripts/package.json',
|
|
121
|
+
JSON.stringify({ name: 'm-hooks', dependencies: { tldts: '^7.0.0' } }),
|
|
122
|
+
);
|
|
123
|
+
write('scripts/node_modules/tldts/index.js', 'module.exports = {};\n');
|
|
124
|
+
write(
|
|
125
|
+
'scripts/node_modules/tldts/package.json',
|
|
126
|
+
JSON.stringify({ name: 'tldts', version: '7.4.12' }),
|
|
127
|
+
);
|
|
128
|
+
write('scripts/node_modules/.bin/tldts', '#!/bin/sh\n');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
rmSync(source, { recursive: true, force: true });
|
|
133
|
+
rmSync(join(out, '..'), { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('ships scripts/node_modules even though bun pm pack dropped it', async () => {
|
|
137
|
+
const result = await buildModule({ sourceDir: source, outputPath: out });
|
|
138
|
+
expect(result.success).toBe(true);
|
|
139
|
+
|
|
140
|
+
const listed = execFileSync('tar', ['tzf', out], { encoding: 'utf-8' }).split('\n');
|
|
141
|
+
expect(listed).toContain('scripts/node_modules/tldts/index.js');
|
|
142
|
+
// The one thing the runtime closure still excludes.
|
|
143
|
+
expect(listed).not.toContain('scripts/node_modules/.bin/tldts');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -9,6 +9,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
9
9
|
import { log } from '../../cli/prompts';
|
|
10
10
|
import { formatViolations, scanModuleDirectory } from '../../policy/module-script-scan';
|
|
11
11
|
import { validateModuleDirectory } from '../import';
|
|
12
|
+
import { resolveBuildCommandPaths } from './build-paths';
|
|
12
13
|
import { computeFileChecksum } from './checksum';
|
|
13
14
|
import { classifyModulePath, includeNodeModulesPath } from './package-rules';
|
|
14
15
|
import { signChecksums } from './signature';
|
|
@@ -23,6 +24,37 @@ export interface ChecksumsData {
|
|
|
23
24
|
files: Record<string, string>; // filepath -> xxhash
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Put `scripts/node_modules` back after a `bun pm pack` staging.
|
|
29
|
+
*
|
|
30
|
+
* `bun pm pack` drops every `node_modules` unconditionally — that is npm-pack
|
|
31
|
+
* semantics, and a `files` entry naming the path does not override it. But the
|
|
32
|
+
* packager is explicit downstream (`includeNodeModulesPath`) that
|
|
33
|
+
* `scripts/node_modules` is the module's HOOK RUNTIME and ships in full, so
|
|
34
|
+
* hooks resolve their third-party deps on a target with no reachable registry.
|
|
35
|
+
*
|
|
36
|
+
* Those two rules disagreed, and which one won was decided by whether a module
|
|
37
|
+
* happened to have a root `package.json`. Every module in `modules/` has only
|
|
38
|
+
* `scripts/package.json`, so all of them take the `cpSync` branch below and
|
|
39
|
+
* keep their runtime. A module that is ALSO a bun project — a build script, a
|
|
40
|
+
* data pipeline, its own tests — takes this branch instead and shipped without
|
|
41
|
+
* one. Nothing caught it: package, publish, import and deploy all succeed, and
|
|
42
|
+
* the first symptom is the hook dying on the target with
|
|
43
|
+
* `Cannot find package '@celilo/capabilities'` (celilo#1310).
|
|
44
|
+
*
|
|
45
|
+
* The hook runtime is not build input, so `files` has no business filtering it.
|
|
46
|
+
* Symlinks are materialised for the same reason the `cpSync` branch does it: a
|
|
47
|
+
* `file:` workspace link would point at a path the target does not have.
|
|
48
|
+
*/
|
|
49
|
+
function stageHookRuntime(sourceDir: string, buildDir: string): void {
|
|
50
|
+
const from = join(sourceDir, 'scripts', 'node_modules');
|
|
51
|
+
if (!existsSync(from)) return;
|
|
52
|
+
cpSync(from, join(buildDir, 'scripts', 'node_modules'), {
|
|
53
|
+
recursive: true,
|
|
54
|
+
dereference: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
26
58
|
/**
|
|
27
59
|
* Module build options
|
|
28
60
|
*/
|
|
@@ -215,6 +247,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
215
247
|
timeout: 60_000,
|
|
216
248
|
});
|
|
217
249
|
rmSync(tarballs[0], { force: true });
|
|
250
|
+
stageHookRuntime(sourceDir, buildDir);
|
|
218
251
|
} catch (err) {
|
|
219
252
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
220
253
|
return {
|
|
@@ -258,6 +291,33 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
258
291
|
if (manifest.build?.command || manifest.build?.script) {
|
|
259
292
|
log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
|
|
260
293
|
|
|
294
|
+
// Publish-time gate on the build command's paths (D3 of
|
|
295
|
+
// control-plane-stops-building-modules). A `cd` whose target does not
|
|
296
|
+
// resolve where the command runs means the module depends on inputs it
|
|
297
|
+
// does not carry. celilo-registry did exactly this for four months
|
|
298
|
+
// (`cd ../../packages/registry-server`) and the failure surfaced on a
|
|
299
|
+
// production control plane, months after publish, as a /bin/sh error.
|
|
300
|
+
// Refuse here, at the only point where a human is watching, and name the
|
|
301
|
+
// path. Targets the gate cannot resolve (untracked shell variables,
|
|
302
|
+
// command substitution) are skipped, never guessed at.
|
|
303
|
+
if (manifest.build.command) {
|
|
304
|
+
const pathViolations = resolveBuildCommandPaths(manifest.build.command, {
|
|
305
|
+
moduleSourceDir: sourceDir,
|
|
306
|
+
buildDir,
|
|
307
|
+
});
|
|
308
|
+
if (pathViolations.length > 0) {
|
|
309
|
+
return {
|
|
310
|
+
success: false,
|
|
311
|
+
error: [
|
|
312
|
+
`Refusing to build ${moduleId}: the build command references a path that does not exist where the command runs.`,
|
|
313
|
+
...pathViolations.map((v) => ` ${v.rawPath}\n resolves to: ${v.resolvedPath}`),
|
|
314
|
+
'',
|
|
315
|
+
'A build input that does not resolve at publish time makes the module unbuildable wherever it is installed. Fix the path in manifest.yml (keep it module-local, or reach the monorepo through $CELILO_MODULE_SOURCE_DIR) and re-publish.',
|
|
316
|
+
].join('\n'),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
261
321
|
// Resolve `workspace:` build deps (ISS-0147 / celilo#216). The staged
|
|
262
322
|
// buildDir has no workspace root, so a sibling monorepo package pulled in
|
|
263
323
|
// via `workspace:^` won't resolve during the build's `bun install`.
|
|
@@ -19,12 +19,8 @@ export interface IntegrityViolation {
|
|
|
19
19
|
* being old rather than by the files having changed. Only `auditModule`
|
|
20
20
|
* produces it; package verification compares a package to its own manifest
|
|
21
21
|
* and cannot be stale in this sense.
|
|
22
|
-
*
|
|
23
|
-
* `stale-generated` is the second plane (D3): a verbatim role asset in
|
|
24
|
-
* `generated/` whose bytes are not the installed module's. That is what
|
|
25
|
-
* celilo#925 was, and what would ship on the next deploy.
|
|
26
22
|
*/
|
|
27
|
-
type: 'missing' | 'modified' | 'extra' | 'stale-baseline'
|
|
23
|
+
type: 'missing' | 'modified' | 'extra' | 'stale-baseline';
|
|
28
24
|
/**
|
|
29
25
|
* What the baseline says the file should hash to, and what it actually
|
|
30
26
|
* hashes to. Both optional because not every violation is about a digest —
|
|
@@ -344,12 +344,6 @@ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
|
|
|
344
344
|
reason:
|
|
345
345
|
'hook-owned-state 5.5 / survey class G: machine list becomes a machine-pool read capability',
|
|
346
346
|
},
|
|
347
|
-
{
|
|
348
|
-
module: 'caddy-internal',
|
|
349
|
-
file: 'scripts/private-web-functions.ts',
|
|
350
|
-
matches: 1,
|
|
351
|
-
reason: 'hook-owned-state 5.5 (routes, capability-owned-tables Group B1)',
|
|
352
|
-
},
|
|
353
347
|
{
|
|
354
348
|
module: 'dnsmasq-dhcp',
|
|
355
349
|
file: 'scripts/dhcp-server-functions.ts',
|
|
@@ -389,34 +383,6 @@ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
|
|
|
389
383
|
reason:
|
|
390
384
|
'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
|
|
391
385
|
},
|
|
392
|
-
{
|
|
393
|
-
module: 'wireguard',
|
|
394
|
-
file: 'scripts/control-plane-vpn-functions.ts',
|
|
395
|
-
matches: 1,
|
|
396
|
-
reason:
|
|
397
|
-
'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
|
|
398
|
-
},
|
|
399
|
-
{
|
|
400
|
-
module: 'wireguard',
|
|
401
|
-
file: 'scripts/health-check.ts',
|
|
402
|
-
matches: 1,
|
|
403
|
-
reason:
|
|
404
|
-
'survey class C: system config get becomes an injected read; not in hook-owned-state 5.5 — reconcile there',
|
|
405
|
-
},
|
|
406
|
-
{
|
|
407
|
-
module: 'wireguard',
|
|
408
|
-
file: 'scripts/on_install.ts',
|
|
409
|
-
matches: 1,
|
|
410
|
-
reason:
|
|
411
|
-
'hook-owned-state 5.5 (registered_peers, Group B2); THE five-suite blocker per the wave-0 census',
|
|
412
|
-
},
|
|
413
|
-
{
|
|
414
|
-
module: 'wireguard-manager',
|
|
415
|
-
file: 'scripts/setup.ts',
|
|
416
|
-
matches: 3,
|
|
417
|
-
reason:
|
|
418
|
-
'hook-owned-state 5.5 (vpn_endpoint/vpn_server_public_key/client_pool, category 3; secret set)',
|
|
419
|
-
},
|
|
420
386
|
];
|
|
421
387
|
|
|
422
388
|
/**
|
|
@@ -439,12 +405,6 @@ export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [
|
|
|
439
405
|
reason:
|
|
440
406
|
'hook-owned-state 5.5 / survey class G: machine list becomes a typed-absent machine-pool read',
|
|
441
407
|
},
|
|
442
|
-
{
|
|
443
|
-
module: 'wireguard',
|
|
444
|
-
file: 'scripts/health-check.ts',
|
|
445
|
-
matches: 1,
|
|
446
|
-
reason: 'survey class C: system config get becomes an injected typed-absent read',
|
|
447
|
-
},
|
|
448
408
|
];
|
|
449
409
|
|
|
450
410
|
/**
|