chati-dev 4.5.16 → 4.5.27
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/README.md +8 -3
- package/bin/chati.js +124 -66
- package/framework/agents/build/dev.md +5 -5
- package/framework/agents/deploy/devops.md +7 -7
- package/framework/agents/discover/brief.md +4 -4
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +2 -2
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/agents/plan/ux.md +2 -2
- package/framework/agents/quality/qa-implementation.md +11 -10
- package/framework/agents/quality/qa-planning.md +3 -3
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +18 -18
- package/framework/context/protocols.md +1 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +4 -4
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/orchestrator.yaml +2 -2
- package/framework/domains/constitution.yaml +1 -1
- package/framework/hooks/advance-trigger.js +4 -6
- package/framework/hooks/git-push-authority.js +45 -37
- package/framework/hooks/mode-governance.js +149 -40
- package/framework/hooks/model-governance.js +13 -20
- package/framework/hooks/prism-engine.js +74 -92
- package/framework/hooks/reasoning-escalator.js +23 -40
- package/framework/hooks/session-digest.js +12 -13
- package/framework/hooks/session-reader.js +224 -0
- package/framework/hooks/session-writer.js +195 -0
- package/framework/hooks/team-quality-gate.js +34 -24
- package/framework/i18n/en.yaml +2 -2
- package/framework/i18n/es.yaml +2 -2
- package/framework/i18n/fr.yaml +2 -2
- package/framework/i18n/pt.yaml +2 -2
- package/framework/intelligence/context-engine.md +4 -4
- package/framework/intelligence/memory-layer.md +1 -1
- package/framework/manifest.json +129 -119
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +252 -25
- package/framework/orchestrator/chati.md +238 -70
- package/framework/schemas/session.schema.json +1 -1
- package/framework/tasks/orchestrator-deviation.md +1 -1
- package/framework/tasks/orchestrator-escalate.md +1 -1
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +5 -9
- package/framework/tasks/orchestrator-mode-switch.md +3 -7
- package/framework/tasks/orchestrator-resume.md +10 -14
- package/framework/tasks/orchestrator-route.md +3 -3
- package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
- package/framework/tasks/orchestrator-status.md +9 -9
- package/framework/tasks/orchestrator-suggest-mode.md +1 -1
- package/framework/tasks/qa-impl-consolidate.md +2 -2
- package/framework/tasks/qa-impl-performance-test.md +4 -4
- package/framework/tasks/qa-impl-regression-check.md +4 -4
- package/framework/tasks/qa-impl-sast-scan.md +1 -1
- package/framework/tasks/qa-impl-test-execute.md +1 -1
- package/framework/tasks/qa-impl-verdict.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +2 -2
- package/framework/tasks/qa-planning-gate-define.md +4 -4
- package/framework/tasks/qa-planning-risk-matrix.md +4 -4
- package/framework/tasks/qa-planning-test-strategy.md +2 -2
- package/node_modules/@chati/browser-capability/src/index.js +12 -2
- package/node_modules/@chati/planning/src/index.js +24 -4
- package/node_modules/@chati/provider-registry/src/index.js +11 -0
- package/node_modules/@chati/rail/src/index.js +1967 -83
- package/node_modules/@chati/release-lane/README.md +12 -8
- package/node_modules/@chati/release-lane/package.json +1 -1
- package/node_modules/@chati/release-lane/src/index.js +1426 -58
- package/node_modules/@chati/review-council/src/index.js +63 -0
- package/node_modules/@chati/tracking-clickup/README.md +13 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
- package/package-artifact-manifest.json +1 -0
- package/package-artifact-manifest.sig +1 -0
- package/package.json +16 -7
- package/scripts/verify-real-harness-e2e.js +1581 -0
- package/src/config/framework-adapter.js +4 -4
- package/src/installer/core.js +148 -53
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +65 -20
- package/src/installer-v2/catalog-client.js +531 -23
- package/src/installer-v2/index.js +91 -34
- package/src/installer-v2/installation-authority.js +327 -0
- package/src/installer-v2/provider-executable.js +235 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1545 -174
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +16 -3
- package/src/orchestrator/index.js +16 -0
- package/src/orchestrator/planning-runtime.js +20 -2
- package/src/orchestrator/rail-adjudication-evidence.js +234 -0
- package/src/orchestrator/rail-evidence-authority.js +147 -0
- package/src/orchestrator/rail-execution-evidence.js +45 -0
- package/src/orchestrator/rail-runtime.js +836 -56
- package/src/orchestrator/release-runtime.js +65 -6
- package/src/orchestrator/review-runtime.js +186 -41
- package/src/orchestrator/runtime-installation-v2.js +236 -20
- package/src/orchestrator/session-manager.js +1167 -53
- package/src/terminal/adapters/claude-adapter.js +3 -1
- package/src/terminal/adapters/codex-adapter.js +2 -0
- package/src/terminal/adapters/grok-adapter.js +7 -4
- package/src/terminal/handoff-parser.js +19 -1
- package/src/terminal/prompt-builder.js +9 -1
- package/src/terminal/provider-preflight.js +36 -3
- package/src/terminal/rail-execution-worktree.js +213 -0
- package/src/terminal/rail-prompts.js +169 -0
- package/src/terminal/rail-readonly-workspace.js +324 -0
- package/src/terminal/run-agent.js +384 -24
- package/src/terminal/run-parallel.js +5 -0
- package/src/terminal/run-rail-adjudication.js +323 -0
- package/src/terminal/run-rail-review.js +291 -0
- package/src/terminal/run-rail-rework.js +325 -0
- package/src/terminal/run-rail-task.js +380 -0
- package/src/terminal/run-team.js +5 -0
- package/src/terminal/spawner.js +1225 -77
- package/src/wizard/index.js +3 -2
|
@@ -6,10 +6,16 @@
|
|
|
6
6
|
* are explicit through installV2 or authorized migrateV2. It never invokes a
|
|
7
7
|
* provider CLI or reads credentials.
|
|
8
8
|
*/
|
|
9
|
-
import { existsSync
|
|
10
|
-
import {
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
11
11
|
import { canonicalize, canonicalSerialize, sha256, ContractError } from '@chati/core';
|
|
12
12
|
import { validateInstallation } from '@chati/provider-registry';
|
|
13
|
+
import {
|
|
14
|
+
persistImmutableInstallationFile, readSafeInstallationStorageFile,
|
|
15
|
+
replaceInstallationStorageFile, verifyInstallationCatalogProvenance,
|
|
16
|
+
} from './catalog-client.js';
|
|
17
|
+
import { authorizeInstallationArtifact } from './installation-authority.js';
|
|
18
|
+
import { validateProviderExecutableBinding } from './provider-executable.js';
|
|
13
19
|
|
|
14
20
|
export const INSTALLATION_SCHEMA_VERSION = 2;
|
|
15
21
|
export const INSTALLATION_ARTIFACT_PATH = '.chati/v2/installation.json';
|
|
@@ -56,8 +62,9 @@ const INSTALLATION_KEYS = new Set([
|
|
|
56
62
|
]);
|
|
57
63
|
const BINDING_KEYS = new Set([
|
|
58
64
|
'provider_id', 'harness_id', 'allowed_models', 'allowed_reasoning_configurations',
|
|
65
|
+
'executable_path', 'executable_sha256', 'supporting_executables',
|
|
59
66
|
]);
|
|
60
|
-
const SNAPSHOT_KEYS = new Set(['snapshot_id', 'expires_at', 'models']);
|
|
67
|
+
const SNAPSHOT_KEYS = new Set(['snapshot_id', 'source_catalog_ref', 'expires_at', 'models']);
|
|
61
68
|
const MODEL_KEYS = new Set([
|
|
62
69
|
'provider_id', 'model_id', 'actions', 'adjudication_priority',
|
|
63
70
|
'highest_reasoning_configuration', 'routing_priority', 'tier', 'natural_role',
|
|
@@ -97,7 +104,8 @@ function ensureRelativePath(path) {
|
|
|
97
104
|
assertString(path, 'INVALID_MANAGED_PATH', 'managed path');
|
|
98
105
|
const normalized = path.replaceAll('\\', '/');
|
|
99
106
|
const root = normalized.split('/')[0];
|
|
100
|
-
if (normalized.startsWith('/') || normalized.
|
|
107
|
+
if (normalized === '.' || normalized.startsWith('/') || normalized.includes('\0') || normalized.includes(':')
|
|
108
|
+
|| normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
101
109
|
throw new ContractError('INVALID_MANAGED_PATH', 'managed path must remain inside the project');
|
|
102
110
|
}
|
|
103
111
|
if (['.claude', '.codex', '.grok'].includes(root)) {
|
|
@@ -109,6 +117,21 @@ function ensureRelativePath(path) {
|
|
|
109
117
|
return normalized;
|
|
110
118
|
}
|
|
111
119
|
|
|
120
|
+
function validateInstallationExecutableBindings(installation) {
|
|
121
|
+
if (!Array.isArray(installation?.enabled_providers)) return;
|
|
122
|
+
for (const binding of installation.enabled_providers) {
|
|
123
|
+
assertExactKeys(binding, BINDING_KEYS, 'enabled provider binding');
|
|
124
|
+
const hasBindingMaterial = binding?.executable_path !== undefined
|
|
125
|
+
|| binding?.executable_sha256 !== undefined
|
|
126
|
+
|| binding?.supporting_executables !== undefined;
|
|
127
|
+
if (!hasBindingMaterial) continue;
|
|
128
|
+
validateProviderExecutableBinding(binding, {
|
|
129
|
+
harnessId: binding.harness_id,
|
|
130
|
+
requireCodexSupportingExecutable: binding.harness_id === 'codex',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
112
135
|
function normalizeBrainReadCapability(capability) {
|
|
113
136
|
if (capability === undefined || capability === null || capability === false) return { status: 'not-configured' };
|
|
114
137
|
assertObject(capability, 'INVALID_BRAIN_CAPABILITY', 'brain_read_capability');
|
|
@@ -123,6 +146,7 @@ function artifactCore(input, createdAt) {
|
|
|
123
146
|
assertObject(input.installation, 'INVALID_INSTALLATION', 'installation');
|
|
124
147
|
assertObject(input.capability_snapshot, 'INVALID_CATALOG', 'capability_snapshot');
|
|
125
148
|
assertString(input.project_id, 'INVALID_INSTALLATION', 'project_id');
|
|
149
|
+
validateInstallationExecutableBindings(input.installation);
|
|
126
150
|
const installation = validateInstallation(input.installation, input.capability_snapshot, { clock: () => createdAt });
|
|
127
151
|
const brain_read_capability = normalizeBrainReadCapability(input.brain_read_capability);
|
|
128
152
|
const managed_paths = (input.managed_paths ?? [INSTALLATION_ARTIFACT_PATH]).map(ensureRelativePath);
|
|
@@ -135,6 +159,9 @@ function artifactCore(input, createdAt) {
|
|
|
135
159
|
// This is a projection, not a catalog copy. Keep only attributes needed by
|
|
136
160
|
// the installation contract and only models explicitly selected by a binding.
|
|
137
161
|
snapshot_id: input.capability_snapshot.snapshot_id,
|
|
162
|
+
...(input.capability_snapshot.source_catalog_ref !== undefined
|
|
163
|
+
? { source_catalog_ref: input.capability_snapshot.source_catalog_ref }
|
|
164
|
+
: {}),
|
|
138
165
|
expires_at: input.capability_snapshot.expires_at,
|
|
139
166
|
models: input.capability_snapshot.models
|
|
140
167
|
.filter((model) => selectedModelKeys.has(`${model.provider_id}:${model.model_id}`))
|
|
@@ -185,6 +212,11 @@ export function doctorV2(artifact, { clock = () => new Date() } = {}) {
|
|
|
185
212
|
assertString(installation_artifact_id, 'INVALID_ARTIFACT', 'installation_artifact_id');
|
|
186
213
|
if (sha256(core) !== digest) throw new ContractError('INSTALLATION_ARTIFACT_DRIFT', 'installation artifact digest does not match its content');
|
|
187
214
|
validateInstallation(artifact.installation, artifact.capability_snapshot, { clock: () => readClock(clock) });
|
|
215
|
+
validateInstallationExecutableBindings(artifact.installation);
|
|
216
|
+
if (artifact.capability_snapshot.snapshot_id.startsWith('wizard-catalog-')
|
|
217
|
+
&& !artifact.capability_snapshot.source_catalog_ref) {
|
|
218
|
+
throw new ContractError('MISSING_CATALOG_PROVENANCE', 'wizard installation artifact must retain its signed source catalog reference');
|
|
219
|
+
}
|
|
188
220
|
normalizeBrainReadCapability(artifact.brain_read_capability);
|
|
189
221
|
if (!Array.isArray(artifact.managed_paths) || !artifact.managed_paths.every((path) => ensureRelativePath(path))) {
|
|
190
222
|
throw new ContractError('INVALID_MANAGED_PATH', 'managed_paths must be valid project-relative paths');
|
|
@@ -251,6 +283,10 @@ export function dryRunV2(input, { projectDir, clock = () => new Date(), exists =
|
|
|
251
283
|
assertString(projectDir, 'INVALID_PROJECT_DIR', 'projectDir');
|
|
252
284
|
const artifact = createInstallationArtifact(input, { clock });
|
|
253
285
|
const doctor = doctorV2(artifact, { clock });
|
|
286
|
+
if (!doctor.passed) {
|
|
287
|
+
const failure = doctor.checks.find((check) => check.status === 'fail') || {};
|
|
288
|
+
throw new ContractError(failure.code || 'INVALID_INSTALLATION_ARTIFACT', failure.message || 'installation artifact failed doctor');
|
|
289
|
+
}
|
|
254
290
|
const detector = detectV1Installation(projectDir, { exists });
|
|
255
291
|
const backup_plan = createBackupPlan(projectDir, detector, artifact);
|
|
256
292
|
const rollback_plan = createRollbackPlan(artifact, backup_plan);
|
|
@@ -264,21 +300,34 @@ export function dryRunV2(input, { projectDir, clock = () => new Date(), exists =
|
|
|
264
300
|
}
|
|
265
301
|
|
|
266
302
|
/** Explicit v2 installation. It writes only the provider-neutral artifact path. */
|
|
267
|
-
export function installV2(input, {
|
|
303
|
+
export function installV2(input, {
|
|
304
|
+
projectDir, clock = () => new Date(), exists = existsSync, authorityDirectory,
|
|
305
|
+
} = {}) {
|
|
268
306
|
const report = dryRunV2(input, { projectDir, clock, exists });
|
|
307
|
+
verifyInstallationCatalogProvenance(projectDir, report.artifact.capability_snapshot, { clock });
|
|
269
308
|
const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
|
|
270
|
-
const directory = join(projectDir, '.chati', 'v2');
|
|
271
|
-
mkdirSync(directory, { recursive: true });
|
|
272
309
|
const serialized = `${canonicalSerialize(report.artifact)}\n`;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (existing === serialized)
|
|
310
|
+
const existing = readSafeInstallationStorageFile(artifactPath, { allowMissing: true });
|
|
311
|
+
if (existing !== null) {
|
|
312
|
+
if (existing.content === serialized) {
|
|
313
|
+
const authority = authorizeInstallationArtifact(projectDir, report.artifact, { authorityDirectory });
|
|
314
|
+
return Object.freeze(canonicalize({
|
|
315
|
+
...report, mode: 'installed', writes_performed: false,
|
|
316
|
+
artifact_path: INSTALLATION_ARTIFACT_PATH,
|
|
317
|
+
authority_receipt_path: authority.receipt_path,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
276
320
|
throw new ContractError('INSTALLATION_ARTIFACT_EXISTS', 'existing v2 artifact differs; use an explicit migration or rollback flow');
|
|
277
321
|
}
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
322
|
+
const authority = authorizeInstallationArtifact(projectDir, report.artifact, { authorityDirectory });
|
|
323
|
+
persistImmutableInstallationFile(artifactPath, serialized, {
|
|
324
|
+
conflictCode: 'INSTALLATION_ARTIFACT_EXISTS', label: 'installation artifact',
|
|
325
|
+
});
|
|
326
|
+
return Object.freeze(canonicalize({
|
|
327
|
+
...report, mode: 'installed', writes_performed: true,
|
|
328
|
+
artifact_path: INSTALLATION_ARTIFACT_PATH,
|
|
329
|
+
authority_receipt_path: authority.receipt_path,
|
|
330
|
+
}));
|
|
282
331
|
}
|
|
283
332
|
|
|
284
333
|
/**
|
|
@@ -286,13 +335,16 @@ export function installV2(input, { projectDir, clock = () => new Date(), exists
|
|
|
286
335
|
* bytes in the project-local backup store. Provider-specific files are never
|
|
287
336
|
* read or rewritten by this operation.
|
|
288
337
|
*/
|
|
289
|
-
export function reconfigureV2(input, {
|
|
338
|
+
export function reconfigureV2(input, {
|
|
339
|
+
projectDir, clock = () => new Date(), exists = existsSync, authorityDirectory,
|
|
340
|
+
} = {}) {
|
|
290
341
|
const report = dryRunV2(input, { projectDir, clock, exists });
|
|
342
|
+
verifyInstallationCatalogProvenance(projectDir, report.artifact.capability_snapshot, { clock });
|
|
291
343
|
const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
|
|
292
|
-
|
|
293
|
-
|
|
344
|
+
const existing = readSafeInstallationStorageFile(artifactPath, { allowMissing: true });
|
|
345
|
+
if (existing === null) return installV2(input, { projectDir, clock, exists, authorityDirectory });
|
|
294
346
|
let existingArtifact;
|
|
295
|
-
try { existingArtifact = JSON.parse(existing); } catch (error) {
|
|
347
|
+
try { existingArtifact = JSON.parse(existing.content); } catch (error) {
|
|
296
348
|
throw new ContractError('EXISTING_INSTALLATION_CORRUPT', `existing v2 artifact is not valid JSON: ${error.message}`);
|
|
297
349
|
}
|
|
298
350
|
if (existingArtifact?.schema_version !== INSTALLATION_SCHEMA_VERSION || existingArtifact?.artifact_type !== 'chati-installation') {
|
|
@@ -300,20 +352,18 @@ export function reconfigureV2(input, { projectDir, clock = () => new Date(), exi
|
|
|
300
352
|
}
|
|
301
353
|
const backupId = `reconfigure-${sha256({ previous: existingArtifact.digest, next: report.artifact.digest }).slice(0, 20)}`;
|
|
302
354
|
const backupPath = join(projectDir, '.chati', 'v2', 'backups', backupId, 'installation.json');
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
if (!existsSync(backupPath)) writeFileSync(backupPath, existing, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
355
|
+
persistImmutableInstallationFile(backupPath, existing.content, {
|
|
356
|
+
conflictCode: 'RECONFIGURE_BACKUP_CONFLICT', label: 'reconfiguration backup',
|
|
357
|
+
});
|
|
358
|
+
const authority = authorizeInstallationArtifact(projectDir, report.artifact, { authorityDirectory });
|
|
308
359
|
const serialized = `${canonicalSerialize(report.artifact)}\n`;
|
|
309
|
-
|
|
310
|
-
writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
311
|
-
renameSync(temporary, artifactPath);
|
|
360
|
+
replaceInstallationStorageFile(artifactPath, serialized, existing);
|
|
312
361
|
return Object.freeze(canonicalize({
|
|
313
362
|
...report,
|
|
314
363
|
mode: 'reconfigured',
|
|
315
364
|
writes_performed: true,
|
|
316
365
|
artifact_path: INSTALLATION_ARTIFACT_PATH,
|
|
366
|
+
authority_receipt_path: authority.receipt_path,
|
|
317
367
|
backup_path: relative(projectDir, backupPath).replaceAll('\\', '/'),
|
|
318
368
|
provider_specific_files_rewritten: false,
|
|
319
369
|
}));
|
|
@@ -324,16 +374,19 @@ export function reconfigureV2(input, { projectDir, clock = () => new Date(), exi
|
|
|
324
374
|
* legacy markers, writes the provider-neutral artifact, and never rewrites a
|
|
325
375
|
* provider-specific configuration file.
|
|
326
376
|
*/
|
|
327
|
-
export function migrateV2(input, {
|
|
377
|
+
export function migrateV2(input, {
|
|
378
|
+
projectDir, authorize = false, clock = () => new Date(), exists = existsSync, authorityDirectory,
|
|
379
|
+
} = {}) {
|
|
328
380
|
const preview = dryRunV2(input, { projectDir, clock, exists });
|
|
329
381
|
if (authorize !== true) {
|
|
330
382
|
return Object.freeze(canonicalize({ ...preview, mode: 'migration-preview', writes_performed: false, requires_explicit_authorization: true }));
|
|
331
383
|
}
|
|
332
384
|
|
|
333
385
|
const artifactPath = join(projectDir, INSTALLATION_ARTIFACT_PATH);
|
|
334
|
-
|
|
386
|
+
const existingArtifact = readSafeInstallationStorageFile(artifactPath, { allowMissing: true });
|
|
387
|
+
if (existingArtifact !== null) {
|
|
335
388
|
const expected = `${canonicalSerialize(preview.artifact)}\n`;
|
|
336
|
-
if (
|
|
389
|
+
if (existingArtifact.content !== expected) {
|
|
337
390
|
throw new ContractError('INSTALLATION_ARTIFACT_EXISTS', 'existing v2 artifact differs; use an explicit rollback flow');
|
|
338
391
|
}
|
|
339
392
|
}
|
|
@@ -342,18 +395,22 @@ export function migrateV2(input, { projectDir, authorize = false, clock = () =>
|
|
|
342
395
|
for (const entry of preview.backup_plan.entries) {
|
|
343
396
|
const source = join(projectDir, entry.source_path);
|
|
344
397
|
const destination = join(projectDir, entry.backup_path);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
398
|
+
const sourceFile = readSafeInstallationStorageFile(source, {
|
|
399
|
+
allowMissing: true, encoding: null, label: 'legacy migration source',
|
|
400
|
+
});
|
|
401
|
+
if (sourceFile === null) continue;
|
|
402
|
+
if (persistImmutableInstallationFile(destination, sourceFile.content, {
|
|
403
|
+
conflictCode: 'MIGRATION_BACKUP_CONFLICT', label: 'legacy migration backup',
|
|
404
|
+
})) backed_up.push(entry);
|
|
349
405
|
}
|
|
350
|
-
const installed = installV2(input, { projectDir, clock, exists });
|
|
406
|
+
const installed = installV2(input, { projectDir, clock, exists, authorityDirectory });
|
|
351
407
|
return Object.freeze(canonicalize({
|
|
352
408
|
...preview,
|
|
353
409
|
mode: 'migrated',
|
|
354
410
|
writes_performed: installed.writes_performed || backed_up.length > 0,
|
|
355
411
|
backed_up,
|
|
356
412
|
artifact_path: installed.artifact_path,
|
|
413
|
+
authority_receipt_path: installed.authority_receipt_path,
|
|
357
414
|
provider_specific_files_rewritten: false,
|
|
358
415
|
}));
|
|
359
416
|
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash, createPrivateKey, createPublicKey, generateKeyPairSync,
|
|
3
|
+
sign as cryptoSign, verify as cryptoVerify,
|
|
4
|
+
} from 'node:crypto';
|
|
5
|
+
import { userInfo } from 'node:os';
|
|
6
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
7
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { canonicalSerialize, ContractError } from '@chati/core';
|
|
9
|
+
import {
|
|
10
|
+
persistImmutableInstallationFile, readSafeInstallationStorageFile,
|
|
11
|
+
} from './catalog-client.js';
|
|
12
|
+
|
|
13
|
+
export const INSTALLATION_AUTHORITY_RECEIPT_VERSION = 1;
|
|
14
|
+
export const INSTALLATION_AUTHORITY_RECEIPT_TYPE = 'chati-installation-authority';
|
|
15
|
+
export const INSTALLATION_AUTHORITY_DIRECTORY = '.chati/v2/authorities';
|
|
16
|
+
|
|
17
|
+
const PRIVATE_KEY_FILENAME = 'installation-ed25519-private.pem';
|
|
18
|
+
const PUBLIC_KEY_FILENAME = 'installation-ed25519-public.pem';
|
|
19
|
+
const RECEIPT_KEYS = new Set([
|
|
20
|
+
'schema_version', 'receipt_type', 'authority_id', 'project_binding',
|
|
21
|
+
'installation_artifact_id', 'installation_digest', 'artifact_created_at',
|
|
22
|
+
'issued_at', 'signature',
|
|
23
|
+
]);
|
|
24
|
+
const PROJECT_BINDING_KEYS = new Set(['project_id', 'realpath_sha256', 'device', 'inode']);
|
|
25
|
+
|
|
26
|
+
function fail(code, message, details = {}) {
|
|
27
|
+
throw new ContractError(code, message, details);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function exactKeys(value, expected, label) {
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
32
|
+
fail('INVALID_INSTALLATION_AUTHORITY_RECEIPT', `${label} must be an object`);
|
|
33
|
+
}
|
|
34
|
+
const actual = Object.keys(value);
|
|
35
|
+
if (actual.length !== expected.size || actual.some((key) => !expected.has(key))) {
|
|
36
|
+
fail('INVALID_INSTALLATION_AUTHORITY_RECEIPT', `${label} has an invalid closed schema`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function authorityRoot(authorityDirectory) {
|
|
41
|
+
// Never derive a trust anchor from caller-controlled HOME. userInfo() is
|
|
42
|
+
// backed by the operating-system account database on supported hosts.
|
|
43
|
+
return resolve(authorityDirectory || join(userInfo().homedir, '.chati-dev', 'authority'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function installationAuthorityPublicKeyPath(authorityDirectory) {
|
|
47
|
+
const privateRoot = authorityRoot(authorityDirectory);
|
|
48
|
+
return join(dirname(privateRoot), `${basename(privateRoot)}-public`, PUBLIC_KEY_FILENAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function legacyInstallationAuthorityPublicKeyPath(authorityDirectory) {
|
|
52
|
+
return join(authorityRoot(authorityDirectory), PUBLIC_KEY_FILENAME);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function keyFingerprint(publicKey) {
|
|
56
|
+
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
57
|
+
return createHash('sha256').update(der).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assertPrivateKeyPermissions(source) {
|
|
61
|
+
if (process.platform === 'win32') return;
|
|
62
|
+
const mode = Number(source.stat.mode & 0o777n);
|
|
63
|
+
if ((mode & 0o077) !== 0) fail('UNSAFE_INSTALLATION_AUTHORITY_KEY', 'installation authority private key must not be accessible by group or others');
|
|
64
|
+
if (typeof process.geteuid === 'function' && source.stat.uid !== BigInt(process.geteuid())) {
|
|
65
|
+
fail('UNSAFE_INSTALLATION_AUTHORITY_KEY', 'installation authority private key must be owned by the current user');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readOrCreateAuthorityKeyPair(authorityDirectory) {
|
|
70
|
+
const root = authorityRoot(authorityDirectory);
|
|
71
|
+
const privatePath = join(root, PRIVATE_KEY_FILENAME);
|
|
72
|
+
const publicPath = installationAuthorityPublicKeyPath(authorityDirectory);
|
|
73
|
+
let privateSource = readSafeInstallationStorageFile(privatePath, {
|
|
74
|
+
allowMissing: true, label: 'installation authority private key', encoding: 'utf8', maxBytes: 64 * 1024,
|
|
75
|
+
});
|
|
76
|
+
if (privateSource === null) {
|
|
77
|
+
const generated = generateKeyPairSync('ed25519', {
|
|
78
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
79
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
persistImmutableInstallationFile(privatePath, generated.privateKey, {
|
|
83
|
+
conflictCode: 'INSTALLATION_AUTHORITY_KEY_CONFLICT',
|
|
84
|
+
label: 'installation authority private key', maxBytes: 64 * 1024,
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code !== 'INSTALLATION_AUTHORITY_KEY_CONFLICT') throw error;
|
|
88
|
+
}
|
|
89
|
+
privateSource = readSafeInstallationStorageFile(privatePath, {
|
|
90
|
+
label: 'installation authority private key', encoding: 'utf8', maxBytes: 64 * 1024,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
assertPrivateKeyPermissions(privateSource);
|
|
94
|
+
|
|
95
|
+
let privateKey;
|
|
96
|
+
let publicKey;
|
|
97
|
+
try {
|
|
98
|
+
privateKey = createPrivateKey(privateSource.content);
|
|
99
|
+
publicKey = createPublicKey(privateKey);
|
|
100
|
+
} catch {
|
|
101
|
+
fail('INVALID_INSTALLATION_AUTHORITY_KEY', 'installation authority private key is invalid');
|
|
102
|
+
}
|
|
103
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') {
|
|
104
|
+
fail('INVALID_INSTALLATION_AUTHORITY_KEY', 'installation authority key must be Ed25519');
|
|
105
|
+
}
|
|
106
|
+
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
|
|
107
|
+
const existingPublic = readSafeInstallationStorageFile(publicPath, {
|
|
108
|
+
allowMissing: true, label: 'installation authority public key', encoding: 'utf8', maxBytes: 64 * 1024,
|
|
109
|
+
});
|
|
110
|
+
if (existingPublic === null) {
|
|
111
|
+
persistImmutableInstallationFile(publicPath, publicPem, {
|
|
112
|
+
conflictCode: 'INSTALLATION_AUTHORITY_KEY_CONFLICT',
|
|
113
|
+
label: 'installation authority public key', maxBytes: 64 * 1024,
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
let storedPublic;
|
|
117
|
+
try { storedPublic = createPublicKey(existingPublic.content); }
|
|
118
|
+
catch { fail('INVALID_INSTALLATION_AUTHORITY_KEY', 'installation authority public key is invalid'); }
|
|
119
|
+
if (keyFingerprint(storedPublic) !== keyFingerprint(publicKey)) {
|
|
120
|
+
fail('INSTALLATION_AUTHORITY_KEY_CONFLICT', 'installation authority public key does not match its private key');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Object.freeze({ privateKey, publicKey, authority_id: `host-ed25519-${keyFingerprint(publicKey)}` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readAuthorityPublicKey(authorityDirectory) {
|
|
127
|
+
const locations = [
|
|
128
|
+
{
|
|
129
|
+
path: installationAuthorityPublicKeyPath(authorityDirectory),
|
|
130
|
+
allowDirectDirectoryFallback: true,
|
|
131
|
+
},
|
|
132
|
+
// Installations created before the public-only authority directory keep
|
|
133
|
+
// their verification key beside the private key. This fallback is never
|
|
134
|
+
// granted the strict-runtime directory escape used by the public path.
|
|
135
|
+
{
|
|
136
|
+
path: legacyInstallationAuthorityPublicKeyPath(authorityDirectory),
|
|
137
|
+
allowDirectDirectoryFallback: false,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
let source = null;
|
|
141
|
+
for (const location of locations) {
|
|
142
|
+
try {
|
|
143
|
+
source = readSafeInstallationStorageFile(location.path, {
|
|
144
|
+
label: 'installation authority public key', encoding: 'utf8', maxBytes: 64 * 1024,
|
|
145
|
+
allowDirectDirectoryFallback: location.allowDirectDirectoryFallback,
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (source === null) fail('INSTALLATION_AUTHORITY_REQUIRED', 'installation authority public key is missing on this host');
|
|
153
|
+
let publicKey;
|
|
154
|
+
try { publicKey = createPublicKey(source.content); }
|
|
155
|
+
catch { fail('INVALID_INSTALLATION_AUTHORITY_KEY', 'installation authority public key is invalid'); }
|
|
156
|
+
if (publicKey.type !== 'public' || publicKey.asymmetricKeyType !== 'ed25519') {
|
|
157
|
+
fail('INVALID_INSTALLATION_AUTHORITY_KEY', 'installation authority public key must be Ed25519');
|
|
158
|
+
}
|
|
159
|
+
return Object.freeze({ publicKey, authority_id: `host-ed25519-${keyFingerprint(publicKey)}` });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function projectBinding(projectDir, projectId) {
|
|
163
|
+
const physical = realpathSync(resolve(projectDir));
|
|
164
|
+
const stat = lstatSync(physical, { bigint: true });
|
|
165
|
+
if (!stat.isDirectory()) fail('INVALID_INSTALLATION_PROJECT', 'installation authority requires a real project directory');
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
project_id: projectId,
|
|
168
|
+
realpath_sha256: createHash('sha256').update(physical).digest('hex'),
|
|
169
|
+
device: String(stat.dev),
|
|
170
|
+
inode: String(stat.ino),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Return the host-stable, non-secret identity of a real project directory.
|
|
176
|
+
*
|
|
177
|
+
* This is intentionally narrower than an installation receipt binding: a
|
|
178
|
+
* short-lived runtime receipt has no installation artifact or project id to
|
|
179
|
+
* carry, but it must still be unusable if copied to a different project.
|
|
180
|
+
*/
|
|
181
|
+
export function installationAuthorityProjectScope(projectDir) {
|
|
182
|
+
const binding = projectBinding(projectDir, 'runtime-script-receipt');
|
|
183
|
+
return Object.freeze({
|
|
184
|
+
realpath_sha256: binding.realpath_sha256,
|
|
185
|
+
device: binding.device,
|
|
186
|
+
inode: binding.inode,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function receiptPath(projectDir, artifact) {
|
|
191
|
+
if (!/^[a-f0-9]{64}$/.test(artifact?.digest || '')) {
|
|
192
|
+
fail('INVALID_INSTALLATION_ARTIFACT', 'installation authority requires a valid artifact digest');
|
|
193
|
+
}
|
|
194
|
+
return join(resolve(projectDir), INSTALLATION_AUTHORITY_DIRECTORY, `${artifact.digest}.json`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function unsignedReceipt(receipt) {
|
|
198
|
+
const payload = { ...receipt };
|
|
199
|
+
delete payload.signature;
|
|
200
|
+
return payload;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Mint a host-authority receipt after an explicit install/reconfigure action. */
|
|
204
|
+
export function authorizeInstallationArtifact(projectDir, artifact, {
|
|
205
|
+
authorityDirectory,
|
|
206
|
+
} = {}) {
|
|
207
|
+
const authority = readOrCreateAuthorityKeyPair(authorityDirectory);
|
|
208
|
+
const payload = {
|
|
209
|
+
schema_version: INSTALLATION_AUTHORITY_RECEIPT_VERSION,
|
|
210
|
+
receipt_type: INSTALLATION_AUTHORITY_RECEIPT_TYPE,
|
|
211
|
+
authority_id: authority.authority_id,
|
|
212
|
+
project_binding: projectBinding(projectDir, artifact.project_id),
|
|
213
|
+
installation_artifact_id: artifact.installation_artifact_id,
|
|
214
|
+
installation_digest: artifact.digest,
|
|
215
|
+
artifact_created_at: artifact.created_at,
|
|
216
|
+
issued_at: artifact.created_at,
|
|
217
|
+
};
|
|
218
|
+
const receipt = Object.freeze({
|
|
219
|
+
...payload,
|
|
220
|
+
signature: cryptoSign(null, Buffer.from(canonicalSerialize(payload)), authority.privateKey).toString('base64'),
|
|
221
|
+
});
|
|
222
|
+
const path = receiptPath(projectDir, artifact);
|
|
223
|
+
persistImmutableInstallationFile(path, `${canonicalSerialize(receipt)}\n`, {
|
|
224
|
+
conflictCode: 'INSTALLATION_AUTHORITY_RECEIPT_CONFLICT',
|
|
225
|
+
label: 'installation authority receipt', maxBytes: 256 * 1024,
|
|
226
|
+
});
|
|
227
|
+
return Object.freeze({
|
|
228
|
+
receipt,
|
|
229
|
+
receipt_path: relative(resolve(projectDir), path).replaceAll('\\', '/'),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Verify runtime installation authority against the machine trust anchor. */
|
|
234
|
+
export function verifyInstallationArtifactAuthority(projectDir, artifact, { authorityDirectory } = {}) {
|
|
235
|
+
let source;
|
|
236
|
+
try {
|
|
237
|
+
source = readSafeInstallationStorageFile(receiptPath(projectDir, artifact), {
|
|
238
|
+
label: 'installation authority receipt', encoding: 'utf8', maxBytes: 256 * 1024,
|
|
239
|
+
});
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code === 'ENOENT') fail('INSTALLATION_AUTHORITY_REQUIRED', 'installation artifact has no host-authority receipt');
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
let receipt;
|
|
245
|
+
try { receipt = JSON.parse(source.content); }
|
|
246
|
+
catch { fail('INVALID_INSTALLATION_AUTHORITY_RECEIPT', 'installation authority receipt is not valid JSON'); }
|
|
247
|
+
exactKeys(receipt, RECEIPT_KEYS, 'installation authority receipt');
|
|
248
|
+
exactKeys(receipt.project_binding, PROJECT_BINDING_KEYS, 'installation authority project binding');
|
|
249
|
+
if (receipt.schema_version !== INSTALLATION_AUTHORITY_RECEIPT_VERSION
|
|
250
|
+
|| receipt.receipt_type !== INSTALLATION_AUTHORITY_RECEIPT_TYPE
|
|
251
|
+
|| !Number.isFinite(Date.parse(receipt.issued_at))
|
|
252
|
+
|| !Number.isFinite(Date.parse(receipt.artifact_created_at))) {
|
|
253
|
+
fail('INVALID_INSTALLATION_AUTHORITY_RECEIPT', 'installation authority receipt metadata is invalid');
|
|
254
|
+
}
|
|
255
|
+
const authority = readAuthorityPublicKey(authorityDirectory);
|
|
256
|
+
if (receipt.authority_id !== authority.authority_id) {
|
|
257
|
+
fail('INSTALLATION_AUTHORITY_MISMATCH', 'installation authority receipt was minted by a different host authority');
|
|
258
|
+
}
|
|
259
|
+
const expectedBinding = projectBinding(projectDir, artifact.project_id);
|
|
260
|
+
if (canonicalSerialize(receipt.project_binding) !== canonicalSerialize(expectedBinding)
|
|
261
|
+
|| receipt.installation_artifact_id !== artifact.installation_artifact_id
|
|
262
|
+
|| receipt.installation_digest !== artifact.digest
|
|
263
|
+
|| receipt.artifact_created_at !== artifact.created_at) {
|
|
264
|
+
fail('INSTALLATION_AUTHORITY_BINDING_MISMATCH', 'installation authority receipt does not bind this artifact and project root');
|
|
265
|
+
}
|
|
266
|
+
if (typeof receipt.signature !== 'string'
|
|
267
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(receipt.signature)
|
|
268
|
+
|| Buffer.from(receipt.signature, 'base64').length !== 64) {
|
|
269
|
+
fail('INVALID_INSTALLATION_AUTHORITY_RECEIPT', 'installation authority receipt signature is malformed');
|
|
270
|
+
}
|
|
271
|
+
const verified = cryptoVerify(
|
|
272
|
+
null,
|
|
273
|
+
Buffer.from(canonicalSerialize(unsignedReceipt(receipt))),
|
|
274
|
+
authority.publicKey,
|
|
275
|
+
Buffer.from(receipt.signature, 'base64'),
|
|
276
|
+
);
|
|
277
|
+
if (!verified) fail('INSTALLATION_AUTHORITY_SIGNATURE_INVALID', 'installation authority receipt signature is invalid');
|
|
278
|
+
return Object.freeze({ valid: true, authority_id: authority.authority_id, receipt_path: relative(resolve(projectDir), receiptPath(projectDir, artifact)).replaceAll('\\', '/') });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function installationAuthorityStateDirectory(authorityDirectory) {
|
|
282
|
+
return authorityRoot(authorityDirectory);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Sign a domain-separated payload with the persistent host authority. */
|
|
286
|
+
export function signHostAuthorityPayload(payload, {
|
|
287
|
+
domain,
|
|
288
|
+
authorityDirectory,
|
|
289
|
+
} = {}) {
|
|
290
|
+
if (typeof domain !== 'string' || !/^[a-z0-9._-]{1,128}$/.test(domain)) {
|
|
291
|
+
fail('INVALID_HOST_AUTHORITY_DOMAIN', 'host authority signature requires a path-inert domain');
|
|
292
|
+
}
|
|
293
|
+
const authority = readOrCreateAuthorityKeyPair(authorityDirectory);
|
|
294
|
+
const envelope = { domain, authority_id: authority.authority_id, payload };
|
|
295
|
+
return Object.freeze({
|
|
296
|
+
authority_id: authority.authority_id,
|
|
297
|
+
signature: cryptoSign(
|
|
298
|
+
null,
|
|
299
|
+
Buffer.from(canonicalSerialize(envelope)),
|
|
300
|
+
authority.privateKey,
|
|
301
|
+
).toString('base64'),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Verify a domain-separated payload using this host's persistent authority. */
|
|
306
|
+
export function verifyHostAuthorityPayload(payload, {
|
|
307
|
+
domain,
|
|
308
|
+
authorityId,
|
|
309
|
+
signature,
|
|
310
|
+
authorityDirectory,
|
|
311
|
+
} = {}) {
|
|
312
|
+
if (typeof domain !== 'string' || !/^[a-z0-9._-]{1,128}$/.test(domain)
|
|
313
|
+
|| typeof authorityId !== 'string'
|
|
314
|
+
|| typeof signature !== 'string'
|
|
315
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(signature)
|
|
316
|
+
|| Buffer.from(signature, 'base64').length !== 64) return false;
|
|
317
|
+
let authority;
|
|
318
|
+
try { authority = readAuthorityPublicKey(authorityDirectory); }
|
|
319
|
+
catch { return false; }
|
|
320
|
+
if (authority.authority_id !== authorityId) return false;
|
|
321
|
+
return cryptoVerify(
|
|
322
|
+
null,
|
|
323
|
+
Buffer.from(canonicalSerialize({ domain, authority_id: authorityId, payload })),
|
|
324
|
+
authority.publicKey,
|
|
325
|
+
Buffer.from(signature, 'base64'),
|
|
326
|
+
);
|
|
327
|
+
}
|