chati-dev 4.5.16 → 4.5.28

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.
Files changed (122) hide show
  1. package/README.md +8 -3
  2. package/bin/chati.js +124 -66
  3. package/framework/agents/build/dev.md +5 -5
  4. package/framework/agents/deploy/devops.md +7 -7
  5. package/framework/agents/discover/brief.md +4 -4
  6. package/framework/agents/discover/brownfield-wu.md +3 -3
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +4 -4
  10. package/framework/agents/plan/phases.md +2 -2
  11. package/framework/agents/plan/tasks.md +2 -2
  12. package/framework/agents/plan/ux.md +2 -2
  13. package/framework/agents/quality/qa-implementation.md +11 -10
  14. package/framework/agents/quality/qa-planning.md +3 -3
  15. package/framework/agents/quality/qa-visual.md +1 -1
  16. package/framework/config.yaml +3 -3
  17. package/framework/constitution.md +18 -18
  18. package/framework/context/protocols.md +1 -1
  19. package/framework/context/quality.md +1 -1
  20. package/framework/context/root.md +4 -4
  21. package/framework/data/entity-registry.yaml +1 -1
  22. package/framework/domains/agents/orchestrator.yaml +2 -2
  23. package/framework/domains/constitution.yaml +1 -1
  24. package/framework/hooks/advance-trigger.js +4 -6
  25. package/framework/hooks/git-push-authority.js +45 -37
  26. package/framework/hooks/mode-governance.js +149 -40
  27. package/framework/hooks/model-governance.js +13 -20
  28. package/framework/hooks/prism-engine.js +74 -92
  29. package/framework/hooks/reasoning-escalator.js +23 -40
  30. package/framework/hooks/session-digest.js +12 -13
  31. package/framework/hooks/session-reader.js +224 -0
  32. package/framework/hooks/session-writer.js +195 -0
  33. package/framework/hooks/team-quality-gate.js +34 -24
  34. package/framework/i18n/en.yaml +2 -2
  35. package/framework/i18n/es.yaml +2 -2
  36. package/framework/i18n/fr.yaml +2 -2
  37. package/framework/i18n/pt.yaml +2 -2
  38. package/framework/intelligence/context-engine.md +4 -4
  39. package/framework/intelligence/memory-layer.md +1 -1
  40. package/framework/manifest.json +129 -119
  41. package/framework/manifest.sig +1 -1
  42. package/framework/orchestrator/chati-router.js +252 -25
  43. package/framework/orchestrator/chati.md +238 -70
  44. package/framework/schemas/session.schema.json +1 -1
  45. package/framework/tasks/orchestrator-deviation.md +1 -1
  46. package/framework/tasks/orchestrator-escalate.md +1 -1
  47. package/framework/tasks/orchestrator-handoff.md +6 -6
  48. package/framework/tasks/orchestrator-health.md +5 -9
  49. package/framework/tasks/orchestrator-mode-switch.md +3 -7
  50. package/framework/tasks/orchestrator-resume.md +10 -14
  51. package/framework/tasks/orchestrator-route.md +3 -3
  52. package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
  53. package/framework/tasks/orchestrator-status.md +9 -9
  54. package/framework/tasks/orchestrator-suggest-mode.md +1 -1
  55. package/framework/tasks/qa-impl-consolidate.md +2 -2
  56. package/framework/tasks/qa-impl-performance-test.md +4 -4
  57. package/framework/tasks/qa-impl-regression-check.md +4 -4
  58. package/framework/tasks/qa-impl-sast-scan.md +1 -1
  59. package/framework/tasks/qa-impl-test-execute.md +1 -1
  60. package/framework/tasks/qa-impl-verdict.md +2 -2
  61. package/framework/tasks/qa-planning-consolidate.md +3 -3
  62. package/framework/tasks/qa-planning-coverage-plan.md +2 -2
  63. package/framework/tasks/qa-planning-gate-define.md +4 -4
  64. package/framework/tasks/qa-planning-risk-matrix.md +4 -4
  65. package/framework/tasks/qa-planning-test-strategy.md +2 -2
  66. package/node_modules/@chati/browser-capability/src/index.js +12 -2
  67. package/node_modules/@chati/planning/src/index.js +24 -4
  68. package/node_modules/@chati/provider-registry/src/index.js +11 -0
  69. package/node_modules/@chati/rail/src/index.js +1967 -83
  70. package/node_modules/@chati/release-lane/README.md +12 -8
  71. package/node_modules/@chati/release-lane/package.json +1 -1
  72. package/node_modules/@chati/release-lane/src/index.js +1426 -58
  73. package/node_modules/@chati/review-council/src/index.js +63 -0
  74. package/node_modules/@chati/tracking-clickup/README.md +13 -0
  75. package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
  76. package/package-artifact-manifest.json +1 -0
  77. package/package-artifact-manifest.sig +1 -0
  78. package/package.json +16 -7
  79. package/scripts/verify-real-harness-e2e.js +1581 -0
  80. package/src/config/framework-adapter.js +4 -4
  81. package/src/installer/core.js +148 -53
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +65 -20
  85. package/src/installer-v2/catalog-client.js +531 -23
  86. package/src/installer-v2/index.js +91 -34
  87. package/src/installer-v2/installation-authority.js +327 -0
  88. package/src/installer-v2/provider-executable.js +247 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/orchestrator/browser-runtime.js +44 -13
  91. package/src/orchestrator/cli.js +1545 -174
  92. package/src/orchestrator/clickup-projection.js +43 -1
  93. package/src/orchestrator/clickup-runtime.js +355 -39
  94. package/src/orchestrator/doctor.js +16 -3
  95. package/src/orchestrator/index.js +16 -0
  96. package/src/orchestrator/planning-runtime.js +20 -2
  97. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  98. package/src/orchestrator/rail-evidence-authority.js +147 -0
  99. package/src/orchestrator/rail-execution-evidence.js +45 -0
  100. package/src/orchestrator/rail-runtime.js +836 -56
  101. package/src/orchestrator/release-runtime.js +65 -6
  102. package/src/orchestrator/review-runtime.js +186 -41
  103. package/src/orchestrator/runtime-installation-v2.js +236 -20
  104. package/src/orchestrator/session-manager.js +1167 -53
  105. package/src/terminal/adapters/claude-adapter.js +3 -1
  106. package/src/terminal/adapters/codex-adapter.js +2 -0
  107. package/src/terminal/adapters/grok-adapter.js +7 -4
  108. package/src/terminal/handoff-parser.js +19 -1
  109. package/src/terminal/prompt-builder.js +9 -1
  110. package/src/terminal/provider-preflight.js +36 -3
  111. package/src/terminal/rail-execution-worktree.js +213 -0
  112. package/src/terminal/rail-prompts.js +169 -0
  113. package/src/terminal/rail-readonly-workspace.js +324 -0
  114. package/src/terminal/run-agent.js +384 -24
  115. package/src/terminal/run-parallel.js +5 -0
  116. package/src/terminal/run-rail-adjudication.js +323 -0
  117. package/src/terminal/run-rail-review.js +291 -0
  118. package/src/terminal/run-rail-rework.js +325 -0
  119. package/src/terminal/run-rail-task.js +380 -0
  120. package/src/terminal/run-team.js +5 -0
  121. package/src/terminal/spawner.js +1225 -77
  122. 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, mkdirSync, writeFileSync, renameSync, readFileSync, copyFileSync } from 'node:fs';
10
- import { dirname, join, relative, resolve, sep } from 'node:path';
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.split('/').includes('..')) {
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, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
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
- if (existsSync(artifactPath)) {
274
- const existing = readFileSync(artifactPath, 'utf8');
275
- if (existing === serialized) return Object.freeze(canonicalize({ ...report, mode: 'installed', writes_performed: false, artifact_path: INSTALLATION_ARTIFACT_PATH }));
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 temporary = `${artifactPath}.tmp-${process.pid}`;
279
- writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx' });
280
- renameSync(temporary, artifactPath);
281
- return Object.freeze(canonicalize({ ...report, mode: 'installed', writes_performed: true, artifact_path: INSTALLATION_ARTIFACT_PATH }));
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, { projectDir, clock = () => new Date(), exists = existsSync } = {}) {
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
- if (!existsSync(artifactPath)) return installV2(input, { projectDir, clock, exists });
293
- const existing = readFileSync(artifactPath, 'utf8');
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
- mkdirSync(dirname(backupPath), { recursive: true });
304
- if (existsSync(backupPath) && readFileSync(backupPath, 'utf8') !== existing) {
305
- throw new ContractError('RECONFIGURE_BACKUP_CONFLICT', 'reconfiguration backup path contains different material');
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
- const temporary = `${artifactPath}.tmp-${process.pid}`;
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, { projectDir, authorize = false, clock = () => new Date(), exists = existsSync } = {}) {
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
- if (existsSync(artifactPath)) {
386
+ const existingArtifact = readSafeInstallationStorageFile(artifactPath, { allowMissing: true });
387
+ if (existingArtifact !== null) {
335
388
  const expected = `${canonicalSerialize(preview.artifact)}\n`;
336
- if (readFileSync(artifactPath, 'utf8') !== expected) {
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
- if (!existsSync(source) || existsSync(destination)) continue;
346
- mkdirSync(dirname(destination), { recursive: true });
347
- copyFileSync(source, destination, 0);
348
- backed_up.push(entry);
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
+ }