chati-dev 4.5.28 → 4.5.29
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/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +117 -12
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
|
@@ -257,12 +257,60 @@ function extractArchiveIntoPinnedSubject(archivePath, subjectDir) {
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
function archiveVerificationCommit({ projectDir, root, resultCommitRef, archivePath }) {
|
|
261
|
+
// A separate empty metadata repository prevents local/info/global attributes,
|
|
262
|
+
// export-ignore and export-subst from changing the committed verification
|
|
263
|
+
// subject. Only the object database is read, never checked-out metadata.
|
|
264
|
+
const metadata = join(root, 'export.git');
|
|
265
|
+
const attributes = join(root, 'empty-attributes');
|
|
266
|
+
mkdirSync(attributes, { mode: 0o700 });
|
|
267
|
+
const environment = trustedGitEnvironment();
|
|
268
|
+
execFileSync('git', ['init', '--quiet', '--bare', '--template=',
|
|
269
|
+
`--object-format=${resultCommitRef.length === 64 ? 'sha256' : 'sha1'}`, metadata], { env: environment, stdio: 'pipe' });
|
|
270
|
+
const objects = git(projectDir, ['rev-parse', '--git-path', 'objects'], { encoding: 'utf8' }).trim();
|
|
271
|
+
execFileSync('git', ['--git-dir', metadata, '--work-tree', attributes,
|
|
272
|
+
'-c', 'core.attributesFile=/dev/null', 'archive', '--worktree-attributes',
|
|
273
|
+
'--format=tar', '--output', archivePath, resultCommitRef], {
|
|
274
|
+
env: { ...environment, GIT_OBJECT_DIRECTORY: realpathSync(resolve(projectDir, objects)) }, stdio: 'pipe',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const RAIL_READ_ONLY_WORKSPACE_PREFIX = 'chati-rail-review-subject-';
|
|
279
|
+
export const RETAINED_WORKSPACE_SWEEP_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
280
|
+
|
|
281
|
+
/** Darwin retains finished review/verification workspaces because scoped group
|
|
282
|
+
* completion cannot prove that every detached reader died. Without a reaper
|
|
283
|
+
* they accumulate on every ordinary run. Reclaim retained roots that are older
|
|
284
|
+
* than a generous bound: any reader still alive that long is not a legitimate
|
|
285
|
+
* review process. Best effort, never throws, never touches recent roots. */
|
|
286
|
+
export function sweepRetainedReadOnlyWorkspaces({
|
|
287
|
+
directory = tmpdir(), prefixes = [RAIL_READ_ONLY_WORKSPACE_PREFIX],
|
|
288
|
+
olderThanMs = RETAINED_WORKSPACE_SWEEP_AFTER_MS, now = Date.now(),
|
|
289
|
+
} = {}) {
|
|
290
|
+
const removed = [];
|
|
291
|
+
let entries;
|
|
292
|
+
try { entries = readdirSync(directory); } catch { return removed; }
|
|
293
|
+
for (const name of entries) {
|
|
294
|
+
if (!prefixes.some((prefix) => name.startsWith(prefix))) continue;
|
|
295
|
+
const path = join(directory, name);
|
|
296
|
+
try {
|
|
297
|
+
const stat = lstatSync(path);
|
|
298
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.()) continue;
|
|
299
|
+
if (now - stat.mtimeMs < olderThanMs) continue;
|
|
300
|
+
rmSync(path, { recursive: true, force: true });
|
|
301
|
+
removed.push(path);
|
|
302
|
+
} catch { /* best effort: a concurrent owner or a permission boundary keeps it */ }
|
|
303
|
+
}
|
|
304
|
+
return removed;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Materialize one immutable commit without linking its .git dir to the child. */
|
|
308
|
+
export function createRailReadOnlyCommitWorkspace({ projectDir, baseCommitRef, resultCommitRef, verificationSubject = false } = {}) {
|
|
262
309
|
const canonicalProject = realpathSync(resolve(projectDir));
|
|
263
310
|
const resolvedBase = resolveExactCommit(canonicalProject, baseCommitRef, 'RAIL_REVIEW_BASE_COMMIT_INVALID', 'base');
|
|
264
311
|
const resolvedCommit = resolveExactCommit(canonicalProject, resultCommitRef, 'RAIL_REVIEW_RESULT_COMMIT_INVALID', 'result');
|
|
265
|
-
|
|
312
|
+
if (process.platform === 'darwin') sweepRetainedReadOnlyWorkspaces();
|
|
313
|
+
const root = mkdtempSync(join(tmpdir(), RAIL_READ_ONLY_WORKSPACE_PREFIX));
|
|
266
314
|
const rootStat = lstatSync(root);
|
|
267
315
|
const rootIdentity = {
|
|
268
316
|
dev: rootStat.dev,
|
|
@@ -275,10 +323,11 @@ export function createRailReadOnlyCommitWorkspace({ projectDir, baseCommitRef, r
|
|
|
275
323
|
try {
|
|
276
324
|
mkdirSync(subjectDir, { mode: 0o700 });
|
|
277
325
|
runReadOnlyWorkspaceTestHook('afterSubjectDirectoryCreate', { root, subjectDir });
|
|
278
|
-
|
|
326
|
+
if (verificationSubject) archiveVerificationCommit({ projectDir: canonicalProject, root, resultCommitRef, archivePath });
|
|
327
|
+
else git(canonicalProject, ['archive', '--format=tar', '--output', archivePath, resultCommitRef], { stdio: 'ignore' });
|
|
279
328
|
extractArchiveIntoPinnedSubject(archivePath, subjectDir);
|
|
280
329
|
rmSync(archivePath);
|
|
281
|
-
materializeReviewDelta({
|
|
330
|
+
if (!verificationSubject) materializeReviewDelta({
|
|
282
331
|
projectDir: canonicalProject, baseCommitRef: resolvedBase, resultCommitRef: resolvedCommit, subjectDir,
|
|
283
332
|
});
|
|
284
333
|
const baseline = captureCommitSubject(subjectDir, resolvedCommit, resolvedBase);
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
|
3
|
+
import { basename, join, parse, resolve, sep } from 'node:path';
|
|
4
|
+
import { canonicalize, canonicalSerialize, sha256, openAtSync, lstatAtSync, readDirectorySync, readlinkAtSync } from '@chati/core';
|
|
5
|
+
import { persistImmutableInstallationFile, readSafeInstallationStorageFile } from '../installer-v2/catalog-client.js';
|
|
6
|
+
import { signHostAuthorityPayload, verifyHostAuthorityPayload } from '../installer-v2/installation-authority.js';
|
|
7
|
+
import { railRepositoryIdentity, readRailGitState as git } from './rail-execution-worktree.js';
|
|
8
|
+
import { verifyTerminalAuthorityReceipt } from './spawner.js';
|
|
9
|
+
|
|
10
|
+
const DOMAIN = 'chati.rail-worktree-checkpoint.v1';
|
|
11
|
+
const MAX_BYTES = 128 * 1024 * 1024;
|
|
12
|
+
const MAX_ENTRIES = 10_000;
|
|
13
|
+
const MANIFEST_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const O_DIRECTORY = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
|
15
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
16
|
+
const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
17
|
+
const hooks = Symbol.for('chati.checkpoint.security-test-hooks');
|
|
18
|
+
const rawHash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
19
|
+
function fail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
20
|
+
function hook(name, detail) { globalThis[hooks]?.[name]?.(detail); }
|
|
21
|
+
function decode(bytes) {
|
|
22
|
+
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
23
|
+
catch { return fail('CHECKPOINT_PATH_ENCODING_UNSUPPORTED', 'checkpoint paths must be losslessly representable as UTF-8'); }
|
|
24
|
+
}
|
|
25
|
+
function excluded(path) { return ['.chati', '.chati.dev'].includes(path.split('/')[0]); }
|
|
26
|
+
function components(path) {
|
|
27
|
+
const parts = path.split('/');
|
|
28
|
+
if (!path || parts.some((part) => !part || ['.', '..', '.git'].includes(part) || /[\\\0]/.test(part) || Buffer.byteLength(part) > 255)) {
|
|
29
|
+
fail('CHECKPOINT_UNSAFE_PATH', 'checkpoint path must stay inside the worktree without Git control entries');
|
|
30
|
+
}
|
|
31
|
+
return parts;
|
|
32
|
+
}
|
|
33
|
+
function sameSnapshot(a, b) {
|
|
34
|
+
return ['dev', 'ino', 'mode', 'nlink', 'size', 'mtimeNs', 'ctimeNs'].every((key) => a[key] === b[key]);
|
|
35
|
+
}
|
|
36
|
+
function openDirectory(path) {
|
|
37
|
+
const absolute = resolve(path);
|
|
38
|
+
const root = parse(absolute).root;
|
|
39
|
+
let fd = openSync(root, O_DIRECTORY);
|
|
40
|
+
try {
|
|
41
|
+
for (const part of absolute.slice(root.length).split(sep).filter(Boolean)) {
|
|
42
|
+
const next = openAtSync(fd, part, O_DIRECTORY);
|
|
43
|
+
closeSync(fd); fd = next;
|
|
44
|
+
}
|
|
45
|
+
return fd;
|
|
46
|
+
} catch (error) { closeSync(fd); throw error; }
|
|
47
|
+
}
|
|
48
|
+
function readFileAt(fd, name, { allowMissing = false } = {}) {
|
|
49
|
+
let source;
|
|
50
|
+
try { source = openAtSync(fd, name, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW); }
|
|
51
|
+
catch (error) { if (allowMissing && error.code === 'ENOENT') return null; throw error; }
|
|
52
|
+
try {
|
|
53
|
+
const before = fstatSync(source, { bigint: true });
|
|
54
|
+
if (!before.isFile() || before.nlink !== 1n) fail('CHECKPOINT_UNSAFE_FILE', 'checkpoint input must be a regular single-link file');
|
|
55
|
+
if (before.size > BigInt(MAX_BYTES)) fail('CHECKPOINT_SIZE_LIMIT', 'checkpoint input exceeds 128 MiB');
|
|
56
|
+
const chunks = []; let total = 0;
|
|
57
|
+
while (total <= MAX_BYTES) {
|
|
58
|
+
const bytes = Buffer.allocUnsafe(Math.min(65536, MAX_BYTES + 1 - total));
|
|
59
|
+
const count = readSync(source, bytes, 0, bytes.length, null);
|
|
60
|
+
if (!count) break;
|
|
61
|
+
chunks.push(bytes.subarray(0, count)); total += count;
|
|
62
|
+
}
|
|
63
|
+
if (total > MAX_BYTES) fail('CHECKPOINT_SIZE_LIMIT', 'checkpoint input grew beyond 128 MiB');
|
|
64
|
+
if (!sameSnapshot(before, fstatSync(source, { bigint: true }))) fail('CHECKPOINT_SOURCE_CHANGED', 'file changed during checkpoint capture');
|
|
65
|
+
return { bytes: Buffer.concat(chunks, total), mode: Number(before.mode & 0o777n) };
|
|
66
|
+
} finally { closeSync(source); }
|
|
67
|
+
}
|
|
68
|
+
function readGitFile(repository, path, options) {
|
|
69
|
+
const parent = resolve(path, '..');
|
|
70
|
+
if (![repository.git_dir.path, repository.common_git_dir.path].includes(parent)) {
|
|
71
|
+
fail('CHECKPOINT_GIT_STORAGE_MISMATCH', 'Git metadata must belong to the signed repository storage');
|
|
72
|
+
}
|
|
73
|
+
const fd = openDirectory(parent);
|
|
74
|
+
try { return readFileAt(fd, basename(path), options); } finally { closeSync(fd); }
|
|
75
|
+
}
|
|
76
|
+
function nulEntries(bytes) {
|
|
77
|
+
const value = decode(bytes);
|
|
78
|
+
if (value && !value.endsWith('\0')) fail('CHECKPOINT_GIT_OUTPUT_INVALID', 'Git inventory must be NUL terminated');
|
|
79
|
+
return value ? value.slice(0, -1).split('\0') : [];
|
|
80
|
+
}
|
|
81
|
+
function checkpointId(dispatch) {
|
|
82
|
+
return sha256({ attempt_id: dispatch.payload.attempt_id, dispatch_event_id: dispatch.event_id,
|
|
83
|
+
launch_nonce: dispatch.payload.invocation.launch_nonce });
|
|
84
|
+
}
|
|
85
|
+
function storage(projectDir, id) {
|
|
86
|
+
if (!HASH.test(id)) fail('CHECKPOINT_REFERENCE_INVALID', 'checkpoint ID must be a SHA-256 digest');
|
|
87
|
+
return join(resolve(projectDir), '.chati/v2/rail/worktree-checkpoints', id);
|
|
88
|
+
}
|
|
89
|
+
function blobReader(directory) {
|
|
90
|
+
return (ref) => {
|
|
91
|
+
if (!ref || !HASH.test(ref.sha256) || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > MAX_BYTES) {
|
|
92
|
+
fail('CHECKPOINT_BLOB_INVALID', 'checkpoint blob reference is invalid');
|
|
93
|
+
}
|
|
94
|
+
const bytes = readSafeInstallationStorageFile(join(directory, 'blobs', ref.sha256), { encoding: null, maxBytes: MAX_BYTES }).content;
|
|
95
|
+
if (bytes.length !== ref.size || rawHash(bytes) !== ref.sha256) fail('CHECKPOINT_BLOB_MISMATCH', 'checkpoint blob is missing or changed');
|
|
96
|
+
return bytes;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkpointConfiguration(projectDir) {
|
|
101
|
+
// Inspect the same effective configuration Git loads, including includes and
|
|
102
|
+
// enabled worktree config. Do not disclose values in receipts or diagnostics.
|
|
103
|
+
const bytes = git(projectDir, ['config', '--null', '--list', '--includes'], { encoding: null });
|
|
104
|
+
if (bytes.length && bytes.at(-1) !== 0) fail('CHECKPOINT_GIT_CONFIG_INVALID', 'Git configuration inventory is incomplete');
|
|
105
|
+
let offset = 0;
|
|
106
|
+
while (offset < bytes.length) {
|
|
107
|
+
const end = bytes.indexOf(0, offset);
|
|
108
|
+
const entry = bytes.subarray(offset, end);
|
|
109
|
+
const newline = entry.indexOf(10);
|
|
110
|
+
const key = entry.subarray(0, newline < 0 ? entry.length : newline).toString('latin1');
|
|
111
|
+
if (/^(?:extensions\.partialclone|remote\..*\.(?:promisor|partialclonefilter))$/i.test(key)) {
|
|
112
|
+
fail('CHECKPOINT_PARTIAL_CLONE_UNSUPPORTED', 'checkpoint does not support partial-clone/promisor configuration; Git and worktree were not changed');
|
|
113
|
+
}
|
|
114
|
+
offset = end + 1;
|
|
115
|
+
}
|
|
116
|
+
return rawHash(bytes);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function captureState(projectDir, repository, base, blob, configurationDigest) {
|
|
120
|
+
if (checkpointConfiguration(projectDir) !== configurationDigest) fail('CHECKPOINT_SOURCE_CHANGED', 'Git configuration changed before capture');
|
|
121
|
+
const head = git(projectDir, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
|
|
122
|
+
if (!OID.test(head)) fail('CHECKPOINT_GIT_OUTPUT_INVALID', 'checkpoint requires an exact partial HEAD');
|
|
123
|
+
const headFile = readGitFile(repository, join(repository.git_dir.path, 'HEAD'));
|
|
124
|
+
const rawHead = decode(headFile.bytes).trim();
|
|
125
|
+
const headRef = rawHead.startsWith('ref: ') ? rawHead.slice(5) : null;
|
|
126
|
+
if ((headRef !== null && !/^refs\/[^\s\0]+$/.test(headRef)) || (headRef === null && rawHead !== head)) {
|
|
127
|
+
fail('CHECKPOINT_GIT_OUTPUT_INVALID', 'HEAD must have a recoverable symbolic or detached identity');
|
|
128
|
+
}
|
|
129
|
+
const indexPath = resolve(projectDir, git(projectDir, ['rev-parse', '--git-path', 'index']).trim());
|
|
130
|
+
const index = readGitFile(repository, indexPath, { allowMissing: true });
|
|
131
|
+
const sharedPath = git(projectDir, ['rev-parse', '--shared-index-path']).trim();
|
|
132
|
+
let shared = null;
|
|
133
|
+
if (sharedPath) {
|
|
134
|
+
if (!/^sharedindex\.(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(basename(sharedPath))) fail('CHECKPOINT_GIT_OUTPUT_INVALID', 'split index dependency is invalid');
|
|
135
|
+
shared = { name: basename(sharedPath), blob: blob(readGitFile(repository, resolve(projectDir, sharedPath)).bytes) };
|
|
136
|
+
}
|
|
137
|
+
const shallowPath = resolve(projectDir, git(projectDir, ['rev-parse', '--git-path', 'shallow']).trim());
|
|
138
|
+
const shallow = readGitFile(repository, shallowPath, { allowMissing: true });
|
|
139
|
+
const stageBytes = git(projectDir, ['ls-files', '--stage', '-z'], { encoding: null });
|
|
140
|
+
const paths = new Set(); const objectIds = new Set([base, head]);
|
|
141
|
+
const addPath = (path) => { if (!excluded(path)) { components(path); paths.add(path); } };
|
|
142
|
+
for (const entry of nulEntries(stageBytes)) {
|
|
143
|
+
const match = /^(\d{6}) ([a-f0-9]{40,64}) [0-3]\t([\s\S]+)$/.exec(entry);
|
|
144
|
+
if (!match || !OID.test(match[2])) fail('CHECKPOINT_GIT_OUTPUT_INVALID', 'Git index inventory is malformed');
|
|
145
|
+
if (match[1] === '160000') fail('CHECKPOINT_GITLINK_UNSUPPORTED', 'submodule storage requires an explicit recursive checkpoint before restoration');
|
|
146
|
+
if (!/^0+$/.test(match[2])) objectIds.add(match[2]);
|
|
147
|
+
addPath(match[3]);
|
|
148
|
+
}
|
|
149
|
+
for (const commit of [base, head]) {
|
|
150
|
+
for (const entry of nulEntries(git(projectDir, ['ls-tree', '-r', '-z', '--full-tree', commit], { encoding: null }))) {
|
|
151
|
+
const match = /^(\d{6}) (?:blob|commit) [a-f0-9]{40,64}\t([\s\S]+)$/.exec(entry);
|
|
152
|
+
if (!match) fail('CHECKPOINT_GIT_OUTPUT_INVALID', 'Git tree inventory is malformed');
|
|
153
|
+
if (match[1] === '160000') fail('CHECKPOINT_GITLINK_UNSUPPORTED', 'submodule storage requires an explicit recursive checkpoint before restoration');
|
|
154
|
+
addPath(match[2]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const path of nulEntries(git(projectDir, ['ls-files', '--others', '--exclude-standard', '-z'], { encoding: null }))) addPath(path);
|
|
158
|
+
// Include cache-tree and resolve-undo dependencies, not only visible staged
|
|
159
|
+
// blobs. Restrict enumeration to this index, never other linked worktrees.
|
|
160
|
+
const reachable = git(projectDir, ['rev-list', '--objects', '--no-object-names', '--single-worktree', '--indexed-objects', base, head]);
|
|
161
|
+
for (const oid of reachable.trim().split('\n').filter(Boolean)) {
|
|
162
|
+
if (!OID.test(oid)) fail('CHECKPOINT_GIT_OUTPUT_INVALID', 'Git object closure is malformed');
|
|
163
|
+
objectIds.add(oid);
|
|
164
|
+
}
|
|
165
|
+
if (paths.size > MAX_ENTRIES) fail('CHECKPOINT_ENTRY_LIMIT', 'checkpoint exceeds 10,000 paths');
|
|
166
|
+
const trie = { children: new Map(), full: false };
|
|
167
|
+
for (const path of paths) {
|
|
168
|
+
let node = trie;
|
|
169
|
+
for (const part of components(path)) {
|
|
170
|
+
if (!node.children.has(part)) node.children.set(part, { children: new Map(), full: false });
|
|
171
|
+
node = node.children.get(part);
|
|
172
|
+
}
|
|
173
|
+
node.full = true;
|
|
174
|
+
}
|
|
175
|
+
const files = [];
|
|
176
|
+
const walk = (parent, name, path, node, inheritedFull = false) => {
|
|
177
|
+
if (files.length >= MAX_ENTRIES) fail('CHECKPOINT_ENTRY_LIMIT', 'checkpoint exceeds 10,000 entries');
|
|
178
|
+
components(path);
|
|
179
|
+
let before;
|
|
180
|
+
try { before = lstatAtSync(parent, name, { bigint: true }); }
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code !== 'ENOENT') throw error;
|
|
183
|
+
files.push({ path, type: 'absent' }); return;
|
|
184
|
+
}
|
|
185
|
+
if (before.isSymbolicLink()) {
|
|
186
|
+
const bytes = readlinkAtSync(parent, name);
|
|
187
|
+
if (!sameSnapshot(before, lstatAtSync(parent, name, { bigint: true }))) fail('CHECKPOINT_SOURCE_CHANGED', 'symlink changed while captured');
|
|
188
|
+
files.push({ path, type: 'symlink', blob: blob(bytes) });
|
|
189
|
+
} else if (before.isFile()) {
|
|
190
|
+
const content = readFileAt(parent, name);
|
|
191
|
+
if (!sameSnapshot(before, lstatAtSync(parent, name, { bigint: true }))) fail('CHECKPOINT_SOURCE_CHANGED', 'file path changed while captured');
|
|
192
|
+
files.push({ path, type: 'file', mode: content.mode, blob: blob(content.bytes) });
|
|
193
|
+
} else if (before.isDirectory()) {
|
|
194
|
+
const fd = openAtSync(parent, name, O_DIRECTORY);
|
|
195
|
+
try {
|
|
196
|
+
const pinned = fstatSync(fd, { bigint: true });
|
|
197
|
+
if (pinned.dev !== before.dev || pinned.ino !== before.ino) fail('CHECKPOINT_SOURCE_CHANGED', 'directory changed before capture');
|
|
198
|
+
const full = inheritedFull || node.full;
|
|
199
|
+
const names = full ? readDirectorySync(fd, { encoding: 'buffer' }).map(decode).sort() : [...node.children.keys()].sort();
|
|
200
|
+
files.push({ path, type: 'directory', mode: Number(before.mode & 0o777n), complete: full });
|
|
201
|
+
for (const child of names) walk(fd, child, `${path}/${child}`, node.children.get(child) ?? { children: new Map(), full: true }, full);
|
|
202
|
+
if (!sameSnapshot(before, fstatSync(fd, { bigint: true }))
|
|
203
|
+
|| !sameSnapshot(before, lstatAtSync(parent, name, { bigint: true }))) fail('CHECKPOINT_SOURCE_CHANGED', 'directory changed while captured');
|
|
204
|
+
} finally { closeSync(fd); }
|
|
205
|
+
} else fail('CHECKPOINT_UNSAFE_FILE', 'special files cannot be silently omitted from a checkpoint');
|
|
206
|
+
};
|
|
207
|
+
const rootFd = openDirectory(projectDir);
|
|
208
|
+
try { for (const name of [...trie.children.keys()].sort()) walk(rootFd, name, name, trie.children.get(name)); }
|
|
209
|
+
finally { closeSync(rootFd); }
|
|
210
|
+
if (sha256(railRepositoryIdentity(projectDir)) !== sha256(repository)) fail('CHECKPOINT_REPOSITORY_CHANGED', 'repository identity changed during capture');
|
|
211
|
+
if (checkpointConfiguration(projectDir) !== configurationDigest) fail('CHECKPOINT_SOURCE_CHANGED', 'Git configuration changed during capture');
|
|
212
|
+
return canonicalize({ git: { head, head_ref: headRef, head_file: blob(headFile.bytes), index: index ? blob(index.bytes) : null,
|
|
213
|
+
shared_index: shared, shallow: shallow ? blob(shallow.bytes) : null, index_listing: blob(stageBytes),
|
|
214
|
+
configuration_digest: configurationDigest,
|
|
215
|
+
object_format: git(projectDir, ['rev-parse', '--show-object-format']).trim(), object_ids: [...objectIds].sort() }, files });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function readRailWorktreeCheckpoint({ projectDir, receipt, expected = {} }) {
|
|
219
|
+
const { authority_id, authority_signature, ...payload } = receipt ?? {};
|
|
220
|
+
if (payload.schema_version !== 1 || payload.purpose !== 'rail_execution_checkpoint'
|
|
221
|
+
|| !verifyHostAuthorityPayload(payload, { domain: DOMAIN, authorityId: authority_id, signature: authority_signature })
|
|
222
|
+
|| payload.project_dir !== resolve(projectDir)
|
|
223
|
+
|| Object.entries(expected).some(([key, value]) => sha256(payload[key] ?? null) !== sha256(value))) {
|
|
224
|
+
fail('CHECKPOINT_AUTHORITY_MISMATCH', 'checkpoint receipt must authenticate the exact canonical execution');
|
|
225
|
+
}
|
|
226
|
+
const directory = storage(projectDir, payload.checkpoint_id);
|
|
227
|
+
const bytes = readSafeInstallationStorageFile(join(directory, 'manifest.json'), { encoding: null, maxBytes: MANIFEST_BYTES }).content;
|
|
228
|
+
if (rawHash(bytes) !== payload.manifest_sha256) fail('CHECKPOINT_MANIFEST_MISMATCH', 'checkpoint manifest changed');
|
|
229
|
+
const manifest = JSON.parse(decode(bytes));
|
|
230
|
+
if (!manifest.state || !Array.isArray(manifest.state.files) || manifest.state.files.length > MAX_ENTRIES
|
|
231
|
+
|| sha256(manifest.binding) !== sha256(payload.binding) || sha256(manifest.state) !== payload.source_state_digest) {
|
|
232
|
+
fail('CHECKPOINT_MANIFEST_MISMATCH', 'checkpoint state or binding does not match its receipt');
|
|
233
|
+
}
|
|
234
|
+
const readBlob = blobReader(directory);
|
|
235
|
+
const refs = [manifest.pack, manifest.state.git.head_file, manifest.state.git.index_listing,
|
|
236
|
+
manifest.state.git.index, manifest.state.git.shared_index?.blob, manifest.state.git.shallow,
|
|
237
|
+
...manifest.state.files.map((file) => file.blob)].filter(Boolean);
|
|
238
|
+
const seen = new Set(); let size = 0;
|
|
239
|
+
for (const ref of refs) {
|
|
240
|
+
if (seen.has(ref.sha256)) continue;
|
|
241
|
+
seen.add(ref.sha256); size += ref.size;
|
|
242
|
+
if (size > MAX_BYTES) fail('CHECKPOINT_SIZE_LIMIT', 'checkpoint exceeds 128 MiB');
|
|
243
|
+
readBlob(ref);
|
|
244
|
+
}
|
|
245
|
+
return Object.freeze({ receipt, manifest: Object.freeze(manifest), readBlob });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Capture only. No reset, clean, stash, index write, ref write or provider launch. */
|
|
249
|
+
export function captureRailWorktreeCheckpoint({ projectDir, dispatch, interruption }) {
|
|
250
|
+
const launch = dispatch?.payload?.launch_receipt;
|
|
251
|
+
const nonce = dispatch?.payload?.invocation?.launch_nonce;
|
|
252
|
+
if (!verifyTerminalAuthorityReceipt(launch, { purpose: 'terminal_launch', authority_kind: 'rail_execution',
|
|
253
|
+
working_dir: resolve(projectDir), launch_nonce: nonce })
|
|
254
|
+
|| interruption?.payload?.dispatch_event_id !== dispatch.event_id
|
|
255
|
+
|| interruption.payload.launch_receipt_digest !== sha256(launch)
|
|
256
|
+
|| interruption.payload.process?.status !== 'exited') {
|
|
257
|
+
fail('CHECKPOINT_DISPATCH_MISMATCH', 'checkpoint requires the canonical launch and its observed interruption');
|
|
258
|
+
}
|
|
259
|
+
// Must precede every object-resolution command. Transport denial alone is
|
|
260
|
+
// insufficient on old Git: lazy fetch can modify config before it is denied.
|
|
261
|
+
const configurationDigest = checkpointConfiguration(projectDir);
|
|
262
|
+
const repository = railRepositoryIdentity(projectDir);
|
|
263
|
+
if (sha256(repository) !== launch.execution_repository_identity_digest) fail('CHECKPOINT_REPOSITORY_CHANGED', 'repository differs from its signed launch identity');
|
|
264
|
+
const binding = canonicalize({ attempt_id: dispatch.payload.attempt_id, dispatch_event_id: dispatch.event_id,
|
|
265
|
+
interruption_event_id: interruption.event_id, launch_nonce: nonce, claim_id: dispatch.payload.claim_id,
|
|
266
|
+
launch_receipt_digest: sha256(launch), base_commit_ref: launch.execution_base_commit_ref,
|
|
267
|
+
repository_identity_digest: sha256(repository) });
|
|
268
|
+
const id = checkpointId(dispatch); const directory = storage(projectDir, id);
|
|
269
|
+
const existing = readSafeInstallationStorageFile(join(directory, 'receipt.json'), { allowMissing: true });
|
|
270
|
+
if (existing) return readRailWorktreeCheckpoint({ projectDir, receipt: JSON.parse(existing.content), expected: { binding } }).receipt;
|
|
271
|
+
const stored = new Map(); let total = 0;
|
|
272
|
+
const blob = (bytes, persist = true) => {
|
|
273
|
+
const digest = rawHash(bytes);
|
|
274
|
+
if (!stored.has(digest)) {
|
|
275
|
+
total += bytes.length;
|
|
276
|
+
if (total > MAX_BYTES) fail('CHECKPOINT_SIZE_LIMIT', 'checkpoint exceeds its 128 MiB total byte budget');
|
|
277
|
+
stored.set(digest, bytes.length);
|
|
278
|
+
if (persist) persistImmutableInstallationFile(join(directory, 'blobs', digest), bytes, { maxBytes: MAX_BYTES, label: 'worktree checkpoint blob' });
|
|
279
|
+
}
|
|
280
|
+
return { sha256: digest, size: bytes.length };
|
|
281
|
+
};
|
|
282
|
+
const state = captureState(projectDir, repository, binding.base_commit_ref, blob, configurationDigest);
|
|
283
|
+
const pack = blob(git(projectDir, ['pack-objects', '--stdout', '--threads=1', '--window=0', '--no-reuse-delta', '--no-reuse-object'], {
|
|
284
|
+
encoding: null, input: `${state.git.object_ids.join('\n')}\n`,
|
|
285
|
+
}));
|
|
286
|
+
hook('afterCheckpointCapture', { projectDir });
|
|
287
|
+
const after = captureState(projectDir, repository, binding.base_commit_ref, (bytes) => ({ sha256: rawHash(bytes), size: bytes.length }), configurationDigest);
|
|
288
|
+
if (sha256(state) !== sha256(after)) fail('CHECKPOINT_SOURCE_CHANGED', 'worktree or index changed before checkpoint commit');
|
|
289
|
+
const manifest = canonicalize({ schema_version: 1, binding, state, pack });
|
|
290
|
+
const bytes = Buffer.from(canonicalSerialize(manifest));
|
|
291
|
+
const material = { schema_version: 1, purpose: 'rail_execution_checkpoint', project_dir: resolve(projectDir),
|
|
292
|
+
checkpoint_id: id, binding, manifest_sha256: rawHash(bytes), source_state_digest: sha256(state) };
|
|
293
|
+
const signed = signHostAuthorityPayload(material, { domain: DOMAIN });
|
|
294
|
+
const receipt = canonicalize({ ...material, authority_id: signed.authority_id, authority_signature: signed.signature });
|
|
295
|
+
persistImmutableInstallationFile(join(directory, 'manifest.json'), bytes, { maxBytes: MANIFEST_BYTES, label: 'worktree checkpoint manifest' });
|
|
296
|
+
readRailWorktreeCheckpoint({ projectDir, receipt, expected: { binding } });
|
|
297
|
+
persistImmutableInstallationFile(join(directory, 'receipt.json'), canonicalSerialize(receipt));
|
|
298
|
+
return Object.freeze(receipt);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Used only by the host restoration adapter, before committing its intent. */
|
|
302
|
+
export function assertRailCheckpointSourceUnchanged(projectDir, checkpoint) {
|
|
303
|
+
const repository = railRepositoryIdentity(projectDir);
|
|
304
|
+
const binding = checkpoint.manifest.binding;
|
|
305
|
+
if (sha256(repository) !== binding.repository_identity_digest) fail('CHECKPOINT_REPOSITORY_CHANGED', 'checkpoint repository identity changed');
|
|
306
|
+
const configuration = checkpointConfiguration(projectDir);
|
|
307
|
+
const state = captureState(projectDir, repository, binding.base_commit_ref,
|
|
308
|
+
(bytes) => ({ sha256: rawHash(bytes), size: bytes.length }), configuration);
|
|
309
|
+
if (sha256(state) !== checkpoint.receipt.source_state_digest) fail('CHECKPOINT_SOURCE_CHANGED', 'current source differs from the original checkpoint');
|
|
310
|
+
return repository;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Internal shared I/O, not a new public recovery entrypoint.
|
|
314
|
+
export { openDirectory as openCheckpointDirectory, readFileAt as readCheckpointFileAt,
|
|
315
|
+
checkpointConfiguration as readCheckpointConfiguration, components as checkpointPathComponents };
|