specrails-core 5.2.3 → 5.4.0
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 +3 -0
- package/bin/specrails-core.mjs +5 -0
- package/dist/agent-runtime/capabilities.d.ts +27 -0
- package/dist/agent-runtime/capabilities.js +73 -0
- package/dist/agent-runtime/capabilities.js.map +1 -0
- package/dist/agent-runtime/cli-executor.d.ts +43 -0
- package/dist/agent-runtime/cli-executor.js +405 -0
- package/dist/agent-runtime/cli-executor.js.map +1 -0
- package/dist/agent-runtime/cli-process.d.ts +32 -0
- package/dist/agent-runtime/cli-process.js +159 -0
- package/dist/agent-runtime/cli-process.js.map +1 -0
- package/dist/agent-runtime/cli.d.ts +39 -0
- package/dist/agent-runtime/cli.js +216 -0
- package/dist/agent-runtime/cli.js.map +1 -0
- package/dist/agent-runtime/codex-schema.d.ts +4 -0
- package/dist/agent-runtime/codex-schema.js +31 -0
- package/dist/agent-runtime/codex-schema.js.map +1 -0
- package/dist/agent-runtime/config.d.ts +11 -0
- package/dist/agent-runtime/config.js +263 -0
- package/dist/agent-runtime/config.js.map +1 -0
- package/dist/agent-runtime/core-host.d.ts +41 -0
- package/dist/agent-runtime/core-host.js +132 -0
- package/dist/agent-runtime/core-host.js.map +1 -0
- package/dist/agent-runtime/durable-store.d.ts +22 -0
- package/dist/agent-runtime/durable-store.js +205 -0
- package/dist/agent-runtime/durable-store.js.map +1 -0
- package/dist/agent-runtime/efficiency-summary.d.ts +67 -0
- package/dist/agent-runtime/efficiency-summary.js +52 -0
- package/dist/agent-runtime/efficiency-summary.js.map +1 -0
- package/dist/agent-runtime/efficiency-types.d.ts +60 -0
- package/dist/agent-runtime/efficiency-types.js +8 -0
- package/dist/agent-runtime/efficiency-types.js.map +1 -0
- package/dist/agent-runtime/efficiency.d.ts +5 -0
- package/dist/agent-runtime/efficiency.js +35 -0
- package/dist/agent-runtime/efficiency.js.map +1 -0
- package/dist/agent-runtime/evaluation-corpus.d.ts +13 -0
- package/dist/agent-runtime/evaluation-corpus.js +8 -0
- package/dist/agent-runtime/evaluation-corpus.js.map +1 -0
- package/dist/agent-runtime/evaluation.d.ts +43 -0
- package/dist/agent-runtime/evaluation.js +171 -0
- package/dist/agent-runtime/evaluation.js.map +1 -0
- package/dist/agent-runtime/executor-types.d.ts +125 -0
- package/dist/agent-runtime/executor-types.js +29 -0
- package/dist/agent-runtime/executor-types.js.map +1 -0
- package/dist/agent-runtime/executors.d.ts +23 -0
- package/dist/agent-runtime/executors.js +48 -0
- package/dist/agent-runtime/executors.js.map +1 -0
- package/dist/agent-runtime/gemini-policy.d.ts +8 -0
- package/dist/agent-runtime/gemini-policy.js +36 -0
- package/dist/agent-runtime/gemini-policy.js.map +1 -0
- package/dist/agent-runtime/graph/artifacts.d.ts +26 -0
- package/dist/agent-runtime/graph/artifacts.js +205 -0
- package/dist/agent-runtime/graph/artifacts.js.map +1 -0
- package/dist/agent-runtime/graph/nodes.d.ts +39 -0
- package/dist/agent-runtime/graph/nodes.js +321 -0
- package/dist/agent-runtime/graph/nodes.js.map +1 -0
- package/dist/agent-runtime/graph/review-policy.d.ts +18 -0
- package/dist/agent-runtime/graph/review-policy.js +32 -0
- package/dist/agent-runtime/graph/review-policy.js.map +1 -0
- package/dist/agent-runtime/graph/roles.d.ts +47 -0
- package/dist/agent-runtime/graph/roles.js +190 -0
- package/dist/agent-runtime/graph/roles.js.map +1 -0
- package/dist/agent-runtime/graph/state.d.ts +83 -0
- package/dist/agent-runtime/graph/state.js +23 -0
- package/dist/agent-runtime/graph/state.js.map +1 -0
- package/dist/agent-runtime/graph-checkpointer.d.ts +61 -0
- package/dist/agent-runtime/graph-checkpointer.js +137 -0
- package/dist/agent-runtime/graph-checkpointer.js.map +1 -0
- package/dist/agent-runtime/index.d.ts +20 -0
- package/dist/agent-runtime/index.js +21 -0
- package/dist/agent-runtime/index.js.map +1 -0
- package/dist/agent-runtime/kimi-acp.d.ts +11 -0
- package/dist/agent-runtime/kimi-acp.js +190 -0
- package/dist/agent-runtime/kimi-acp.js.map +1 -0
- package/dist/agent-runtime/openai-executor.d.ts +26 -0
- package/dist/agent-runtime/openai-executor.js +192 -0
- package/dist/agent-runtime/openai-executor.js.map +1 -0
- package/dist/agent-runtime/openspec-tool-server.d.ts +1 -0
- package/dist/agent-runtime/openspec-tool-server.js +37 -0
- package/dist/agent-runtime/openspec-tool-server.js.map +1 -0
- package/dist/agent-runtime/openspec.d.ts +123 -0
- package/dist/agent-runtime/openspec.js +263 -0
- package/dist/agent-runtime/openspec.js.map +1 -0
- package/dist/agent-runtime/prompts.d.ts +44 -0
- package/dist/agent-runtime/prompts.js +310 -0
- package/dist/agent-runtime/prompts.js.map +1 -0
- package/dist/agent-runtime/provider-diagnostic.d.ts +2 -0
- package/dist/agent-runtime/provider-diagnostic.js +25 -0
- package/dist/agent-runtime/provider-diagnostic.js.map +1 -0
- package/dist/agent-runtime/repository-context.d.ts +25 -0
- package/dist/agent-runtime/repository-context.js +100 -0
- package/dist/agent-runtime/repository-context.js.map +1 -0
- package/dist/agent-runtime/review-context.d.ts +14 -0
- package/dist/agent-runtime/review-context.js +41 -0
- package/dist/agent-runtime/review-context.js.map +1 -0
- package/dist/agent-runtime/role-routing.d.ts +10 -0
- package/dist/agent-runtime/role-routing.js +29 -0
- package/dist/agent-runtime/role-routing.js.map +1 -0
- package/dist/agent-runtime/role-state.d.ts +19 -0
- package/dist/agent-runtime/role-state.js +24 -0
- package/dist/agent-runtime/role-state.js.map +1 -0
- package/dist/agent-runtime/runtime-identity.d.ts +11 -0
- package/dist/agent-runtime/runtime-identity.js +33 -0
- package/dist/agent-runtime/runtime-identity.js.map +1 -0
- package/dist/agent-runtime/tool-event.d.ts +3 -0
- package/dist/agent-runtime/tool-event.js +24 -0
- package/dist/agent-runtime/tool-event.js.map +1 -0
- package/dist/agent-runtime/verification-plan.d.ts +55 -0
- package/dist/agent-runtime/verification-plan.js +206 -0
- package/dist/agent-runtime/verification-plan.js.map +1 -0
- package/dist/agent-runtime/workflow-types.d.ts +226 -0
- package/dist/agent-runtime/workflow-types.js +2 -0
- package/dist/agent-runtime/workflow-types.js.map +1 -0
- package/dist/agent-runtime/workflow.d.ts +17 -0
- package/dist/agent-runtime/workflow.js +636 -0
- package/dist/agent-runtime/workflow.js.map +1 -0
- package/dist/agent-runtime/workspace-tools.d.ts +29 -0
- package/dist/agent-runtime/workspace-tools.js +282 -0
- package/dist/agent-runtime/workspace-tools.js.map +1 -0
- package/dist/installer/cli.d.ts +35 -0
- package/dist/installer/cli.js +3 -0
- package/dist/installer/cli.js.map +1 -1
- package/dist/installer/commands/doctor.d.ts +27 -0
- package/dist/installer/commands/framework.d.ts +85 -0
- package/dist/installer/commands/init.d.ts +147 -0
- package/dist/installer/commands/update.d.ts +56 -0
- package/dist/installer/commands/v5-migration.d.ts +32 -0
- package/dist/installer/phases/framework-lifecycle.d.ts +53 -0
- package/dist/installer/phases/install-config.d.ts +64 -0
- package/dist/installer/phases/manifest.d.ts +45 -0
- package/dist/installer/phases/prereqs.d.ts +51 -0
- package/dist/installer/phases/provider-detect.d.ts +89 -0
- package/dist/installer/phases/scaffold.d.ts +211 -0
- package/dist/installer/phases/scaffold.js +39 -109
- package/dist/installer/phases/scaffold.js.map +1 -1
- package/dist/installer/runtime/kimi.d.ts +84 -0
- package/dist/installer/runtime/pipeline-state.d.ts +351 -0
- package/dist/installer/runtime/pipeline-state.js +514 -41
- package/dist/installer/runtime/pipeline-state.js.map +1 -1
- package/dist/installer/util/errors.d.ts +46 -0
- package/dist/installer/util/exec.d.ts +41 -0
- package/dist/installer/util/fs.d.ts +153 -0
- package/dist/installer/util/git.d.ts +44 -0
- package/dist/installer/util/install-transaction.d.ts +29 -0
- package/dist/installer/util/logger.d.ts +31 -0
- package/dist/installer/util/paths.d.ts +34 -0
- package/dist/installer/util/prompts.d.ts +23 -0
- package/dist/installer/util/registry.d.ts +174 -0
- package/dist/installer/util/template.d.ts +23 -0
- package/docs/README.md +1 -0
- package/docs/agent-runtime-efficiency.md +65 -0
- package/docs/agent-runtime.md +339 -0
- package/integration-contract.json +80 -7
- package/package.json +20 -2
- package/schemas/agent-runtime.schema.json +77 -0
- package/schemas/fixtures/runtime-efficiency-summary.v1.json +790 -0
- package/templates/codex-skills/batch-implement/SKILL.md +33 -58
- package/templates/codex-skills/implement/SKILL.md +21 -124
- package/templates/codex-skills/retry/SKILL.md +8 -34
- package/templates/commands/specrails/batch-implement.md +21 -16
- package/templates/commands/specrails/implement.md +17 -276
- package/templates/commands/specrails/retry.md +6 -34
- package/templates/gemini-commands/batch-implement.toml +34 -28
- package/templates/gemini-commands/implement.toml +34 -55
- package/templates/gemini-commands/retry.toml +10 -16
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
5
6
|
const PHASES = ['architect', 'developer', 'reviewer', 'archive', 'ship', 'ci'];
|
|
6
7
|
// Session identity belongs to the agent transport, not the checked application.
|
|
7
8
|
// Remove these from check subprocesses too, so ignored identity cannot become
|
|
@@ -290,15 +291,16 @@ function untrackedAgentMemory(relative) {
|
|
|
290
291
|
// belong to the candidate. Do not hide arbitrary provider directory content.
|
|
291
292
|
return ['.claude', '.codex', '.gemini', '.kimi-code', '.specrails'].some(provider => relative === provider + '/agent-memory' || relative.startsWith(provider + '/agent-memory/'));
|
|
292
293
|
}
|
|
293
|
-
export function
|
|
294
|
+
export function candidateManifest(state) {
|
|
294
295
|
const entries = state.context.repositories.map((repo) => ({
|
|
295
296
|
id: repo.id, path: repo.path,
|
|
296
297
|
files: trackedFiles(repo)
|
|
297
298
|
.filter(({ file, tracked }) => !excluded(state, repo, relativeUnix(file)) && (tracked || !untrackedAgentMemory(relativeUnix(file))))
|
|
298
299
|
.map(({ file }) => [relativeUnix(file), fileFingerprint(path.join(repo.path, file))]),
|
|
299
300
|
}));
|
|
300
|
-
return
|
|
301
|
+
return { schemaVersion: 1, scopeHash: state.scopeHash, repositories: entries };
|
|
301
302
|
}
|
|
303
|
+
export function fingerprintCandidate(state) { return digest(canonical(candidateManifest(state).repositories)); }
|
|
302
304
|
function activeArtifactPath(state) { return state.archivePath ?? path.join(state.context.artifactRoot, 'openspec', 'changes', state.change); }
|
|
303
305
|
function artifactFingerprint(state) {
|
|
304
306
|
const root = activeArtifactPath(state);
|
|
@@ -349,9 +351,46 @@ function environmentHash(keys, overrides = {}, env = process.env) {
|
|
|
349
351
|
const normalized = normalizeVerificationEnvironment(env);
|
|
350
352
|
return digest(canonical(Object.fromEntries(keys.map((key) => [key, overrides[key] ?? normalized[key] ?? null]))));
|
|
351
353
|
}
|
|
354
|
+
/** Host-owned binding, including immutable harness bytes outside the candidate. */
|
|
355
|
+
export function bindVerificationPlan(contextInput, hash, files) {
|
|
356
|
+
const context = validatePipelineContext(contextInput);
|
|
357
|
+
if (!/^[a-f0-9]{64}$/.test(hash) || files.length > 801)
|
|
358
|
+
fail('Invalid verification plan identity');
|
|
359
|
+
for (const file of files) {
|
|
360
|
+
const target = safeChild(pipelineStateDirectory(context), file.path);
|
|
361
|
+
if (!/^[a-f0-9]{64}$/.test(file.hash) || digest(readFileSync(target)) !== file.hash)
|
|
362
|
+
fail('Verification plan source changed');
|
|
363
|
+
}
|
|
364
|
+
locked(context, () => {
|
|
365
|
+
const state = readState(context);
|
|
366
|
+
const binding = { hash, files };
|
|
367
|
+
if (canonical(state.verificationPlan) === canonical(binding))
|
|
368
|
+
return;
|
|
369
|
+
if (state.phases.archive.status === 'done')
|
|
370
|
+
fail('Cannot change the archived verification plan');
|
|
371
|
+
state.verificationPlan = binding;
|
|
372
|
+
state.acceptance = undefined;
|
|
373
|
+
state.archiveApproval = undefined;
|
|
374
|
+
saveState(state);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
function planReasons(state, hash) {
|
|
378
|
+
if (hash !== state.verificationPlan?.hash)
|
|
379
|
+
return ['Verification plan changed'];
|
|
380
|
+
try {
|
|
381
|
+
for (const file of state.verificationPlan?.files ?? []) {
|
|
382
|
+
if (digest(readFileSync(safeChild(pipelineStateDirectory(state.context), file.path))) !== file.hash)
|
|
383
|
+
return ['Verification plan or harness source changed'];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return ['Verification plan or harness source unavailable'];
|
|
388
|
+
}
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
352
391
|
function inspectReceipt(state, env = process.env) {
|
|
353
392
|
const receipt = state.verification;
|
|
354
|
-
const reasons =
|
|
393
|
+
const reasons = planReasons(state, receipt?.planHash);
|
|
355
394
|
if (!receipt)
|
|
356
395
|
return { valid: false, reasons: ['No verification receipt'] };
|
|
357
396
|
if (!receipt.valid || receipt.kind !== 'full')
|
|
@@ -361,6 +400,12 @@ function inspectReceipt(state, env = process.env) {
|
|
|
361
400
|
if (receipt.candidateHash !== fingerprintCandidate(state))
|
|
362
401
|
reasons.push('Candidate files changed');
|
|
363
402
|
for (const command of receipt.commands) {
|
|
403
|
+
if (command.snapshot?.eligible) {
|
|
404
|
+
// The frozen logical command is recorded with each execution for inspection.
|
|
405
|
+
const snapshot = command.checkDefinition ? verificationSnapshot(state.context, command.checkDefinition) : undefined;
|
|
406
|
+
if (!snapshot?.eligible || snapshot.inputHash !== command.snapshot.inputHash || snapshot.toolchainHash !== command.snapshot.toolchainHash)
|
|
407
|
+
reasons.push('Verification inputs or toolchain changed');
|
|
408
|
+
}
|
|
364
409
|
if (command.exitCode !== 0)
|
|
365
410
|
reasons.push('Command failed: ' + command.command);
|
|
366
411
|
if (command.environmentPolicy !== 'isolated-transport-v1')
|
|
@@ -417,6 +462,14 @@ function frozenCriteria(context) {
|
|
|
417
462
|
return context.specs.flatMap(spec => (spec.acceptanceCriteria?.length ? spec.acceptanceCriteria : [spec.description || spec.title])
|
|
418
463
|
.map((requirement, criterionIndex) => ({ specId: String(spec.id), criterionIndex, requirement })));
|
|
419
464
|
}
|
|
465
|
+
/** The frozen requirements an acceptance report must certify, in scope order. */
|
|
466
|
+
export function frozenAcceptanceCriteria(contextInput) {
|
|
467
|
+
return frozenCriteria(validatePipelineContext(contextInput));
|
|
468
|
+
}
|
|
469
|
+
/** Validates a report against the frozen scope without recording it; throws the same errors `recordAcceptance` would. */
|
|
470
|
+
export function validateAcceptanceReport(contextInput, input) {
|
|
471
|
+
return acceptanceReport(validatePipelineContext(contextInput), input);
|
|
472
|
+
}
|
|
420
473
|
function nonempty(value) { return typeof value === 'string' && value.trim().length > 0; }
|
|
421
474
|
function evidence(value) { return Array.isArray(value) && value.length > 0 && value.every(nonempty); }
|
|
422
475
|
function acceptanceReport(context, input) {
|
|
@@ -470,6 +523,7 @@ function inspectAcceptance(state) {
|
|
|
470
523
|
catch (error) {
|
|
471
524
|
return { valid: false, status: 'blocked', reasons: [error instanceof Error ? error.message : String(error)] };
|
|
472
525
|
}
|
|
526
|
+
reasons.push(...planReasons(state, receipt.planHash));
|
|
473
527
|
if (receipt.scopeHash !== state.scopeHash || receipt.candidateHash !== fingerprintCandidate(state) || receipt.artifactHash !== artifactFingerprint(state))
|
|
474
528
|
reasons.push('Acceptance evidence is stale');
|
|
475
529
|
for (const row of receipt.criteria ?? [])
|
|
@@ -493,7 +547,7 @@ export function recordAcceptance(contextInput, input) {
|
|
|
493
547
|
const state = readState(context);
|
|
494
548
|
if (state.phases.developer.status !== 'done' || state.phases.archive.status === 'done')
|
|
495
549
|
fail('Record acceptance after development and before archive');
|
|
496
|
-
state.acceptance = { ...report, scopeHash: state.scopeHash, candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), recordedAt: new Date().toISOString() };
|
|
550
|
+
state.acceptance = { ...report, ...(state.verificationPlan ? { planHash: state.verificationPlan.hash } : {}), scopeHash: state.scopeHash, candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), recordedAt: new Date().toISOString() };
|
|
497
551
|
// Replacing even just an exception decision requires a new review/approval.
|
|
498
552
|
for (const phase of PHASES.slice(2)) {
|
|
499
553
|
if (phase === 'reviewer' && state.phases[phase].status === 'running')
|
|
@@ -643,7 +697,7 @@ export function inspectPipeline(contextInput) {
|
|
|
643
697
|
delivery: context.ownership.git === 'host' ? 'pending-host' : state.phases.ship.status === 'done' && state.phases.ci.status === 'done' ? 'complete' : 'pending',
|
|
644
698
|
reasons: [...verification.reasons, ...acceptance.reasons],
|
|
645
699
|
};
|
|
646
|
-
return { schemaVersion: 1, runId: context.runId, change: state.change, context, stateDir: pipelineStateDirectory(context), resumePhase, phases: state.phases, verification, acceptance, completion };
|
|
700
|
+
return { schemaVersion: 1, runId: context.runId, change: state.change, context, stateDir: pipelineStateDirectory(context), planHash: state.verificationPlan?.hash, candidateHash: fingerprintCandidate(state), resumePhase, phases: state.phases, verification, acceptance, completion };
|
|
647
701
|
}
|
|
648
702
|
function validateCommand(context, raw) {
|
|
649
703
|
const command = object(raw);
|
|
@@ -659,7 +713,26 @@ function validateCommand(context, raw) {
|
|
|
659
713
|
const timeoutMs = command.timeoutMs === undefined ? 15 * 60_000 : Number(command.timeoutMs);
|
|
660
714
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2 * 60 * 60_000)
|
|
661
715
|
fail('Invalid verification timeout');
|
|
662
|
-
|
|
716
|
+
if (command.policy !== undefined) {
|
|
717
|
+
const policy = object(command.policy);
|
|
718
|
+
if (Object.keys(policy).some(key => !['reuse', 'inputs', 'deterministic', 'readOnly', 'toolchainInputs', 'independentGroup', 'resources'].includes(key)))
|
|
719
|
+
fail('Invalid verification policy');
|
|
720
|
+
if (policy.reuse !== undefined && !['never', 'snapshot-local'].includes(String(policy.reuse)))
|
|
721
|
+
fail('Invalid verification reuse policy');
|
|
722
|
+
for (const key of ['deterministic', 'readOnly'])
|
|
723
|
+
if (policy[key] !== undefined && typeof policy[key] !== 'boolean')
|
|
724
|
+
fail('Invalid verification policy flag');
|
|
725
|
+
if (policy.independentGroup !== undefined && (typeof policy.independentGroup !== 'string' || !ID.test(policy.independentGroup)))
|
|
726
|
+
fail('Invalid verification independence group');
|
|
727
|
+
for (const key of ['inputs', 'toolchainInputs', 'resources'])
|
|
728
|
+
if (policy[key] !== undefined && (!Array.isArray(policy[key]) || policy[key].length > 256 || policy[key].some((value) => typeof value !== 'string' || !value.trim() || value.length > 4096 || value.includes('\0'))))
|
|
729
|
+
fail('Invalid verification policy inputs');
|
|
730
|
+
}
|
|
731
|
+
if (command.key !== undefined && (typeof command.key !== 'string' || !ID.test(command.key)))
|
|
732
|
+
fail('Invalid verification key');
|
|
733
|
+
if (command.label !== undefined && (typeof command.label !== 'string' || !command.label.trim() || command.label.length > 256 || command.label.includes('\0')))
|
|
734
|
+
fail('Invalid verification label');
|
|
735
|
+
return { ...(command.key === undefined ? {} : { key: command.key }), ...(command.label === undefined ? {} : { label: command.label }), ...(command.policy === undefined ? {} : { policy: command.policy }), repositoryId: repo.id, command: command.command, args: command.args, cwd, ...(env ? { env: env } : {}), timeoutMs };
|
|
663
736
|
}
|
|
664
737
|
/**
|
|
665
738
|
* Native executables retain structured argv; Windows script shims need cmd.
|
|
@@ -694,7 +767,22 @@ export function verificationInvocation(command, args, cwd, platform = process.pl
|
|
|
694
767
|
const quote = (value) => '"' + value + '"';
|
|
695
768
|
return { command: env.ComSpec ?? env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', '"' + [resolved, ...args].map(quote).join(' ') + '"'], windowsVerbatimArguments: true };
|
|
696
769
|
}
|
|
697
|
-
|
|
770
|
+
/** Shared persisted-output/provider diagnostic redaction; preserves line structure. */
|
|
771
|
+
export function redactRuntimeText(text, env = process.env) {
|
|
772
|
+
for (const [key, value] of Object.entries(env))
|
|
773
|
+
if (/(token|secret|password|api_?key|credential)/i.test(key) && value && value.length >= 4)
|
|
774
|
+
text = text.split(value).join('[redacted]');
|
|
775
|
+
return text.replace(/Bearer\s+[^\s"']+/gi, 'Bearer [redacted]')
|
|
776
|
+
.replace(/((?:api[_-]?key|(?:access[_-]?)?token|password|secret|authorization)["']?\s*[:=]\s*["']?)[^\s,"'}]+/gi, '$1[redacted]')
|
|
777
|
+
.replace(/https?:\/\/[^\s"'<>]+/gi, value => { try {
|
|
778
|
+
const url = new URL(value);
|
|
779
|
+
return url.origin + url.pathname;
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return '[url]';
|
|
783
|
+
} });
|
|
784
|
+
}
|
|
785
|
+
async function executeCheck(command, log, signal, deadline, evidenceId = digest(randomUUID())) {
|
|
698
786
|
const started = Date.now();
|
|
699
787
|
const overrides = normalizeVerificationEnvironment(command.env ?? {});
|
|
700
788
|
const overrideKeys = Object.keys(overrides).sort();
|
|
@@ -702,15 +790,55 @@ async function executeCheck(command, log) {
|
|
|
702
790
|
const hash = environmentHash(keys);
|
|
703
791
|
const overridesHash = digest(canonical(overrides));
|
|
704
792
|
const env = verificationEnvironment(process.env, overrides);
|
|
793
|
+
const configuredTimeoutMs = command.timeoutMs ?? 15 * 60_000;
|
|
794
|
+
const appliedTimeoutMs = Math.max(0, Math.min(configuredTimeoutMs, deadline === undefined ? Infinity : deadline - Date.now()));
|
|
795
|
+
let outcome;
|
|
705
796
|
let output = '';
|
|
797
|
+
const streams = { stdout: '', stderr: '' };
|
|
798
|
+
let outputTruncated = false;
|
|
799
|
+
const redact = (text) => redactRuntimeText(text, { ...process.env, ...overrides });
|
|
706
800
|
let exitCode = -1;
|
|
707
801
|
await new Promise((resolve) => {
|
|
708
802
|
let child;
|
|
709
803
|
let timer;
|
|
710
804
|
let done = false;
|
|
805
|
+
let stopping = false;
|
|
806
|
+
let termination;
|
|
711
807
|
const finish = (code) => { if (done)
|
|
712
|
-
return; done = true; exitCode = code; if (
|
|
713
|
-
clearTimeout(
|
|
808
|
+
return; done = true; exitCode = stopping ? -1 : code; outcome ??= code === 0 ? 'passed' : 'failed'; if (termination)
|
|
809
|
+
clearTimeout(termination); if (timer)
|
|
810
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort); resolve(); };
|
|
811
|
+
const stop = (reason) => {
|
|
812
|
+
if (done || stopping)
|
|
813
|
+
return;
|
|
814
|
+
stopping = true;
|
|
815
|
+
output = (output + '\n' + reason).slice(-32_000);
|
|
816
|
+
if (child?.pid) {
|
|
817
|
+
if (process.platform === 'win32')
|
|
818
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
|
|
819
|
+
else {
|
|
820
|
+
try {
|
|
821
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
822
|
+
}
|
|
823
|
+
catch { /* already exited */ }
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// Never publish a receipt while a normally terminating child is alive.
|
|
827
|
+
if (!child?.pid)
|
|
828
|
+
finish(-1);
|
|
829
|
+
else
|
|
830
|
+
termination = setTimeout(() => { outcome = 'interrupted'; child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); finish(-1); }, 5000);
|
|
831
|
+
};
|
|
832
|
+
const abort = () => { outcome = 'cancelled'; stop('Verification command cancelled'); };
|
|
833
|
+
if (signal?.aborted) {
|
|
834
|
+
abort();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (appliedTimeoutMs <= 0) {
|
|
838
|
+
outcome = 'timed-out';
|
|
839
|
+
stop('Workflow verification deadline exhausted');
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
714
842
|
try {
|
|
715
843
|
const invocation = verificationInvocation(command.command, command.args, command.cwd, process.platform, env);
|
|
716
844
|
child = spawn(invocation.command, invocation.args, { windowsVerbatimArguments: invocation.windowsVerbatimArguments, cwd: command.cwd, env, shell: false, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
@@ -720,62 +848,279 @@ async function executeCheck(command, log) {
|
|
|
720
848
|
finish(-1);
|
|
721
849
|
return;
|
|
722
850
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
851
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
852
|
+
const decoder = new StringDecoder('utf8');
|
|
853
|
+
let liveLine = '', droppingLine = false;
|
|
854
|
+
const receive = (text) => {
|
|
855
|
+
const remaining = 1024 * 1024 - Buffer.byteLength(streams[stream]);
|
|
856
|
+
if (Buffer.byteLength(text) > remaining)
|
|
857
|
+
outputTruncated = true;
|
|
858
|
+
if (remaining > 0) {
|
|
859
|
+
let kept = text.slice(0, remaining);
|
|
860
|
+
while (Buffer.byteLength(kept) > remaining || /[\uD800-\uDBFF]$/.test(kept))
|
|
861
|
+
kept = kept.slice(0, -1);
|
|
862
|
+
streams[stream] += kept;
|
|
863
|
+
}
|
|
864
|
+
output = (output + text).slice(-32_000);
|
|
865
|
+
// Redact whole bounded lines, so chunk boundaries cannot split credentials.
|
|
866
|
+
for (const part of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
867
|
+
if (!droppingLine)
|
|
868
|
+
liveLine += part;
|
|
869
|
+
if (liveLine.length > 16384) {
|
|
870
|
+
liveLine = '';
|
|
871
|
+
droppingLine = true;
|
|
872
|
+
}
|
|
873
|
+
if (part.endsWith('\n')) {
|
|
874
|
+
try {
|
|
875
|
+
log(droppingLine ? '[Long output line omitted; inspect bounded evidence]\n' : redact(liveLine));
|
|
876
|
+
}
|
|
877
|
+
catch { /* observer */ }
|
|
878
|
+
liveLine = '';
|
|
879
|
+
droppingLine = false;
|
|
736
880
|
}
|
|
737
|
-
catch { /* already exited */ }
|
|
738
881
|
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
|
|
882
|
+
};
|
|
883
|
+
child[stream]?.on('data', (chunk) => receive(decoder.write(chunk)));
|
|
884
|
+
child[stream]?.on('end', () => { receive(decoder.end()); if (liveLine) {
|
|
885
|
+
try {
|
|
886
|
+
log(redact(liveLine));
|
|
887
|
+
}
|
|
888
|
+
catch { /* observer */ }
|
|
889
|
+
} });
|
|
890
|
+
}
|
|
891
|
+
child.on('error', (error) => { output = (output + error.message).slice(-32_000); if (!child.pid)
|
|
892
|
+
finish(-1); });
|
|
893
|
+
child.on('close', (code) => finish(code ?? -1));
|
|
894
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
895
|
+
timer = setTimeout(() => { outcome = 'timed-out'; stop('Verification command timed out'); }, appliedTimeoutMs);
|
|
896
|
+
if (signal?.aborted)
|
|
897
|
+
abort();
|
|
742
898
|
});
|
|
743
|
-
|
|
899
|
+
const persisted = (text) => {
|
|
900
|
+
const bytes = Buffer.from(redact(text));
|
|
901
|
+
if (bytes.length > 1024 * 1024)
|
|
902
|
+
outputTruncated = true;
|
|
903
|
+
return new StringDecoder('utf8').write(bytes.subarray(0, 1024 * 1024));
|
|
904
|
+
};
|
|
905
|
+
return { evidenceId, stdout: persisted(streams.stdout), stderr: persisted(streams.stderr), outputTruncated, ...(command.key ? { key: command.key } : {}), ...(command.label ? { label: command.label } : {}), configuredTimeoutMs, appliedTimeoutMs, ...(deadline === undefined ? {} : { deadline }), outcome, repositoryId: command.repositoryId, command: command.command, args: command.args, cwd: command.cwd, environmentPolicy: 'isolated-transport-v1', environmentHash: hash, environmentKeys: keys, environmentOverrideKeys: overrideKeys, environmentOverridesHash: overridesHash, exitCode, durationMs: Date.now() - started, output: redact(output) };
|
|
744
906
|
}
|
|
745
|
-
|
|
907
|
+
/** Host opt-in is a declaration of determinism, not inferred hermeticity. */
|
|
908
|
+
export function verificationSnapshot(context, command) {
|
|
909
|
+
const policy = command.policy;
|
|
910
|
+
if (policy?.reuse !== 'snapshot-local')
|
|
911
|
+
return { eligible: false, reason: 'reuse-never' };
|
|
912
|
+
if (policy.resources?.length)
|
|
913
|
+
return { eligible: false, reason: 'reuse-ineligible-external-resources' };
|
|
914
|
+
if (!policy.deterministic || !policy.readOnly || !policy.inputs?.length || !policy.toolchainInputs?.length)
|
|
915
|
+
return { eligible: false, reason: 'reuse-ineligible-incomplete-declaration' };
|
|
916
|
+
const repository = context.repositories.find(repo => repo.id === command.repositoryId);
|
|
917
|
+
if (!repository || !path.isAbsolute(command.command))
|
|
918
|
+
return { eligible: false, reason: 'reuse-ineligible-executable-identity' };
|
|
919
|
+
try {
|
|
920
|
+
let count = 0, total = 0;
|
|
921
|
+
const inventory = (inputs, tools) => {
|
|
922
|
+
const entries = [];
|
|
923
|
+
const seen = new Set();
|
|
924
|
+
const visit = (file) => {
|
|
925
|
+
if (seen.has(file))
|
|
926
|
+
return;
|
|
927
|
+
seen.add(file);
|
|
928
|
+
if (++count > 10000)
|
|
929
|
+
fail('reuse-ineligible-inventory-limit');
|
|
930
|
+
const stat = lstatSync(file);
|
|
931
|
+
if (stat.isSymbolicLink() || realpathSync(file) !== path.resolve(file))
|
|
932
|
+
fail('reuse-ineligible-symlink');
|
|
933
|
+
if (stat.isDirectory()) {
|
|
934
|
+
entries.push([file, 'directory']);
|
|
935
|
+
for (const name of readdirSync(file).sort())
|
|
936
|
+
visit(path.join(file, name));
|
|
937
|
+
}
|
|
938
|
+
else if (stat.isFile()) {
|
|
939
|
+
total += stat.size;
|
|
940
|
+
if (total > 256 * 1024 * 1024 || stat.size > 128 * 1024 * 1024)
|
|
941
|
+
fail('reuse-ineligible-inventory-limit');
|
|
942
|
+
entries.push([file, digest(readFileSync(file)) + ':' + stat.mode]);
|
|
943
|
+
}
|
|
944
|
+
else
|
|
945
|
+
fail('reuse-ineligible-nonfile-input');
|
|
946
|
+
};
|
|
947
|
+
for (const input of inputs) {
|
|
948
|
+
const file = path.resolve(repository.path, input);
|
|
949
|
+
if (!tools && !within(repository.path, file))
|
|
950
|
+
fail('reuse-ineligible-input-scope');
|
|
951
|
+
visit(file);
|
|
952
|
+
}
|
|
953
|
+
return entries.sort(([left], [right]) => left.localeCompare(right));
|
|
954
|
+
};
|
|
955
|
+
const inputs = inventory(policy.inputs, false);
|
|
956
|
+
const tools = inventory(policy.toolchainInputs, true);
|
|
957
|
+
if (!tools.some(([file]) => file === path.resolve(command.command)))
|
|
958
|
+
return { eligible: false, reason: 'reuse-ineligible-executable-not-declared' };
|
|
959
|
+
// A lockfile alone cannot certify ignored dependency contents. Host manifests
|
|
960
|
+
// must include each installed dependency directory this convention exposes.
|
|
961
|
+
for (const directory of ['node_modules', '.venv', 'venv', 'vendor']) {
|
|
962
|
+
const file = path.join(repository.path, directory);
|
|
963
|
+
if (existsSync(file) && ![...inputs, ...tools].some(([item]) => item === file))
|
|
964
|
+
return { eligible: false, reason: 'reuse-ineligible-dependencies-not-declared' };
|
|
965
|
+
}
|
|
966
|
+
return { eligible: true, reason: 'snapshot-local-host-declaration', inputHash: digest(canonical(inputs)), toolchainHash: digest(canonical(tools)) };
|
|
967
|
+
}
|
|
968
|
+
catch (error) {
|
|
969
|
+
return { eligible: false, reason: error instanceof Error && error.message.startsWith('reuse-ineligible-') ? error.message : 'reuse-ineligible-input-unavailable' };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/** Contiguous waves preserve baseline order; an uncertain check is a barrier. */
|
|
973
|
+
export function verificationWaves(commands, maxConcurrency = 1) {
|
|
974
|
+
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4)
|
|
975
|
+
fail('Verification concurrency must be 1–4');
|
|
976
|
+
const waves = [];
|
|
977
|
+
let current = [];
|
|
978
|
+
for (const command of commands) {
|
|
979
|
+
const policy = command.policy;
|
|
980
|
+
const independent = Boolean(policy?.independentGroup && policy.resources !== undefined);
|
|
981
|
+
const fits = independent && current.length < maxConcurrency && current.every(previous => previous.repositoryId !== command.repositoryId && previous.policy?.independentGroup === policy.independentGroup && previous.policy?.resources !== undefined && !previous.policy.resources.some(resource => policy.resources.includes(resource)));
|
|
982
|
+
if (!fits && current.length) {
|
|
983
|
+
waves.push(current);
|
|
984
|
+
current = [];
|
|
985
|
+
}
|
|
986
|
+
current.push(command);
|
|
987
|
+
if (!independent || current.length === maxConcurrency) {
|
|
988
|
+
waves.push(current);
|
|
989
|
+
current = [];
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (current.length)
|
|
993
|
+
waves.push(current);
|
|
994
|
+
return waves;
|
|
995
|
+
}
|
|
996
|
+
export async function verifyPipeline(contextInput, raw, log = () => { }, signal, options = {}) {
|
|
746
997
|
const context = validatePipelineContext(contextInput);
|
|
747
|
-
const { request, commands } = verificationPlan(context, raw);
|
|
748
|
-
|
|
998
|
+
const { request, commands, unverifiedRepositories } = verificationPlan(context, raw);
|
|
999
|
+
let previous;
|
|
1000
|
+
const state = locked(context, () => {
|
|
1001
|
+
const current = readState(context);
|
|
1002
|
+
const problems = planReasons(current, typeof request.planHash === 'string' ? request.planHash : undefined);
|
|
1003
|
+
if (problems.length)
|
|
1004
|
+
fail(problems.join('; '));
|
|
1005
|
+
previous = current.verification;
|
|
1006
|
+
if (current.verification)
|
|
1007
|
+
current.verification = { ...current.verification, valid: false, reason: 'Verification started; previous receipt is no longer current' };
|
|
1008
|
+
current.acceptance = undefined;
|
|
1009
|
+
current.archiveApproval = undefined;
|
|
1010
|
+
saveState(current);
|
|
1011
|
+
return current;
|
|
1012
|
+
});
|
|
1013
|
+
const identityProblems = planReasons(state, typeof request.planHash === 'string' ? request.planHash : undefined);
|
|
1014
|
+
if (identityProblems.length)
|
|
1015
|
+
fail(identityProblems.join('; '));
|
|
749
1016
|
const candidateHash = fingerprintCandidate(state);
|
|
750
1017
|
const results = [];
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1018
|
+
const controller = new AbortController();
|
|
1019
|
+
const abort = () => controller.abort();
|
|
1020
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
1021
|
+
if (signal?.aborted)
|
|
1022
|
+
abort();
|
|
1023
|
+
try {
|
|
1024
|
+
for (const wave of verificationWaves(commands, options.maxConcurrency)) {
|
|
1025
|
+
if (controller.signal.aborted)
|
|
1026
|
+
break;
|
|
1027
|
+
const settled = await Promise.allSettled(wave.map(async (command) => {
|
|
1028
|
+
try {
|
|
1029
|
+
const prefix = `[verification ${command.repositoryId}/${command.label ?? command.key ?? command.command}] `;
|
|
1030
|
+
const snapshot = verificationSnapshot(context, command);
|
|
1031
|
+
const old = previous?.valid && previous.kind === 'full' && previous.planHash === request.planHash && previous.candidateHash === candidateHash ? previous.commands.find(item => item.key === command.key && command.key !== undefined) : undefined;
|
|
1032
|
+
const reuse = snapshot.eligible && old?.snapshot?.eligible && old.exitCode === 0 && old.checkDefinitionHash === digest(canonical(command)) && old.snapshot.inputHash === snapshot.inputHash && old.snapshot.toolchainHash === snapshot.toolchainHash && old.environmentHash === environmentHash(verificationEnvironmentKeys(process.env, old.environmentOverrideKeys)) && old.environmentOverridesHash === digest(canonical(normalizeVerificationEnvironment(command.env ?? {})));
|
|
1033
|
+
const evidenceId = digest(randomUUID());
|
|
1034
|
+
if (!reuse) {
|
|
1035
|
+
const pending = { repositoryId: command.repositoryId, key: command.key, label: command.label, command: command.command, args: command.args, cwd: command.cwd, environmentHash: '', environmentKeys: [], environmentOverrideKeys: [], environmentOverridesHash: '', evidenceId, pending: true, disposition: 'executed', outcome: 'interrupted', exitCode: -1, durationMs: 0, output: '' };
|
|
1036
|
+
persistCheckEvidence(context, pending, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1037
|
+
await options.onEvidence?.('check-started', { executionId: evidenceId, repositoryId: command.repositoryId, checkId: command.key ?? '', label: command.label ?? command.command });
|
|
1038
|
+
}
|
|
1039
|
+
const result = reuse ? { ...old, evidenceId, disposition: 'reused', reusedFrom: old.evidenceId, durationMs: 0, snapshot, reuseReason: 'snapshot-local-identities-match' } : { ...await executeCheck(command, text => log(prefix + text), controller.signal, options.deadline, evidenceId), disposition: 'executed', snapshot, reuseReason: snapshot.eligible ? 'snapshot-local-no-current-match' : snapshot.reason };
|
|
1040
|
+
const { env: _env, ...definition } = command;
|
|
1041
|
+
result.checkDefinition = definition;
|
|
1042
|
+
result.checkDefinitionHash = digest(canonical(command));
|
|
1043
|
+
const after = verificationSnapshot(context, command);
|
|
1044
|
+
if (snapshot.eligible && (!after.eligible || after.inputHash !== snapshot.inputHash || after.toolchainHash !== snapshot.toolchainHash)) {
|
|
1045
|
+
result.exitCode = -1;
|
|
1046
|
+
result.outcome = 'failed';
|
|
1047
|
+
result.reuseReason = 'snapshot-inputs-changed-during-verification';
|
|
1048
|
+
}
|
|
1049
|
+
if (result.exitCode !== 0)
|
|
1050
|
+
controller.abort();
|
|
1051
|
+
persistCheckEvidence(context, result, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1052
|
+
await options.onEvidence?.(reuse ? 'check-reused' : 'check-finished', { executionId: evidenceId, repositoryId: command.repositoryId, checkId: command.key ?? '', label: command.label ?? command.command, exitCode: result.exitCode, durationMs: result.durationMs, reason: result.reuseReason ?? null });
|
|
1053
|
+
return result;
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
controller.abort();
|
|
1057
|
+
throw error;
|
|
1058
|
+
}
|
|
1059
|
+
}));
|
|
1060
|
+
for (const result of settled) {
|
|
1061
|
+
if (result.status === 'fulfilled')
|
|
1062
|
+
results.push(result.value);
|
|
1063
|
+
else
|
|
1064
|
+
controller.abort();
|
|
1065
|
+
}
|
|
1066
|
+
const rejected = settled.find(result => result.status === 'rejected');
|
|
1067
|
+
if (rejected?.status === 'rejected')
|
|
1068
|
+
throw rejected.reason;
|
|
1069
|
+
if (results.some(result => result.exitCode !== 0))
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
755
1072
|
}
|
|
756
|
-
|
|
1073
|
+
finally {
|
|
1074
|
+
signal?.removeEventListener('abort', abort);
|
|
1075
|
+
}
|
|
1076
|
+
const notRunEvidenceIds = [];
|
|
1077
|
+
for (const command of commands.slice(results.length)) {
|
|
1078
|
+
const id = digest(randomUUID());
|
|
1079
|
+
const skipped = { repositoryId: command.repositoryId, key: command.key, label: command.label, command: command.command, args: command.args, cwd: command.cwd, environmentHash: '', environmentKeys: [], environmentOverrideKeys: [], environmentOverridesHash: '', evidenceId: id, disposition: 'not-run', outcome: 'cancelled', exitCode: -1, durationMs: 0, output: 'Not run after cancellation or an earlier failed check' };
|
|
1080
|
+
persistCheckEvidence(context, skipped, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1081
|
+
notRunEvidenceIds.push(id);
|
|
1082
|
+
}
|
|
1083
|
+
const receipt = locked(context, () => {
|
|
757
1084
|
const current = readState(context);
|
|
758
|
-
const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision;
|
|
1085
|
+
const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision || planReasons(current, request.planHash).length > 0;
|
|
759
1086
|
const receipt = {
|
|
760
|
-
id: randomUUID(), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results,
|
|
761
|
-
completedAt: new Date().toISOString(), valid: !changed && results.length === commands.length && results.every((result) => result.exitCode === 0),
|
|
1087
|
+
id: randomUUID(), ...(state.verificationPlan ? { planHash: state.verificationPlan.hash } : {}), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results, ...(notRunEvidenceIds.length ? { notRunEvidenceIds } : {}),
|
|
1088
|
+
completedAt: new Date().toISOString(), valid: !changed && !signal?.aborted && (options.deadline === undefined || Date.now() <= options.deadline) && results.length === commands.length && results.every((result) => result.exitCode === 0),
|
|
762
1089
|
...(changed ? { reason: 'Candidate changed during verification' } : results.some((result) => result.exitCode !== 0) ? { reason: 'A verification command failed' } : {}),
|
|
1090
|
+
...(unverifiedRepositories.length ? { unverifiedRepositories } : {}),
|
|
763
1091
|
};
|
|
764
1092
|
atomicJson(safeChild(pipelineStateDirectory(context), 'receipts/' + receipt.id + '.json'), receipt);
|
|
765
1093
|
if (receipt.kind === 'full' || !receipt.valid || !current.verification)
|
|
766
1094
|
current.verification = receipt;
|
|
1095
|
+
else if (previous?.valid && previous.candidateHash === candidateHash)
|
|
1096
|
+
current.verification = previous;
|
|
767
1097
|
saveState(current);
|
|
768
1098
|
return receipt;
|
|
769
1099
|
});
|
|
1100
|
+
if (!receipt.valid)
|
|
1101
|
+
for (const result of results) {
|
|
1102
|
+
await options.onEvidence?.('check-invalidated', { executionId: result.evidenceId ?? '', repositoryId: result.repositoryId, checkId: result.key ?? '', label: result.label ?? result.command, reason: receipt.reason ?? 'Verification did not complete successfully' });
|
|
1103
|
+
}
|
|
1104
|
+
return receipt;
|
|
770
1105
|
}
|
|
771
1106
|
function verificationPlan(context, raw) {
|
|
772
1107
|
const request = object(raw);
|
|
773
|
-
if (
|
|
1108
|
+
if (request.planHash !== undefined && (typeof request.planHash !== 'string' || !/^[a-f0-9]{64}$/.test(request.planHash)))
|
|
1109
|
+
fail('Invalid verification plan hash');
|
|
1110
|
+
const unverified = request.unverified === true;
|
|
1111
|
+
if (request.unverified !== undefined && typeof request.unverified !== 'boolean')
|
|
1112
|
+
fail('Verification unverified flag must be boolean');
|
|
1113
|
+
if (!['full', 'scoped'].includes(String(request.kind)) || !Array.isArray(request.commands) || (request.commands.length === 0 && !unverified) || request.commands.length > 100)
|
|
774
1114
|
fail('Verification requires bounded structured commands');
|
|
775
1115
|
const commands = request.commands.map((command) => validateCommand(context, command));
|
|
776
|
-
|
|
1116
|
+
const unverifiedRepositories = context.repositories.filter((repo) => !commands.some((command) => command.repositoryId === repo.id)).map((repo) => repo.id);
|
|
1117
|
+
if (request.kind === 'full' && !unverified && unverifiedRepositories.length)
|
|
777
1118
|
fail('Full verification must cover every selected repository');
|
|
778
|
-
return { request, commands };
|
|
1119
|
+
return { request, commands, unverifiedRepositories: request.kind === 'full' && unverified ? unverifiedRepositories : [] };
|
|
1120
|
+
}
|
|
1121
|
+
/** Validate a check plan without creating state or executing commands. */
|
|
1122
|
+
export function validateVerificationRequest(contextInput, raw) {
|
|
1123
|
+
verificationPlan(validatePipelineContext(contextInput), raw);
|
|
779
1124
|
}
|
|
780
1125
|
export function preparePreview(contextInput, raw) {
|
|
781
1126
|
const context = validatePipelineContext(contextInput);
|
|
@@ -974,4 +1319,132 @@ export async function runPipelineCli(argv) {
|
|
|
974
1319
|
return 1;
|
|
975
1320
|
}
|
|
976
1321
|
}
|
|
1322
|
+
function evidenceSummary(document) {
|
|
1323
|
+
const { stdout: _out, stderr: _err, sources, ...summary } = document;
|
|
1324
|
+
return { ...summary, sources: sources.map(({ text: _text, ...source }) => source) };
|
|
1325
|
+
}
|
|
1326
|
+
function persistCheckEvidence(context, result, planHash, candidateHash) {
|
|
1327
|
+
const id = result.evidenceId;
|
|
1328
|
+
const planPath = safeChild(pipelineStateDirectory(context), 'verification/plan.json');
|
|
1329
|
+
const plan = existsSync(planPath) ? object(readJson(planPath)) : undefined;
|
|
1330
|
+
const entries = Array.isArray(plan?.entries) ? plan.entries : [];
|
|
1331
|
+
const item = entries.find(entry => object(entry).id === result.key);
|
|
1332
|
+
const sources = [];
|
|
1333
|
+
for (const source of item?.harness?.sources ?? []) {
|
|
1334
|
+
if (sources.length >= 8)
|
|
1335
|
+
fail('Too many evidence sources');
|
|
1336
|
+
const file = safeChild(pipelineStateDirectory(context), `verification/harnesses/${item.id}/${item.harness.hash}/${source.path}`);
|
|
1337
|
+
if (lstatSync(file).size > 65536)
|
|
1338
|
+
fail('Evidence source exceeds size limit');
|
|
1339
|
+
const bytes = readFileSync(file);
|
|
1340
|
+
if (digest(bytes) !== source.hash)
|
|
1341
|
+
fail('Verification source changed during execution');
|
|
1342
|
+
sources.push({ id: digest(id + '\0' + source.path), displayPath: source.path, hash: source.hash, byteCount: bytes.length, text: bytes.toString('utf8') });
|
|
1343
|
+
}
|
|
1344
|
+
const stdout = result.stdout ?? '', stderr = result.stderr ?? '';
|
|
1345
|
+
const document = {
|
|
1346
|
+
schemaVersion: 1, runId: context.runId, id, executionId: id, checkId: result.key ?? digest(canonical([result.repositoryId, result.command, result.args])), repositoryId: result.repositoryId,
|
|
1347
|
+
label: result.label ?? result.command, origin: item?.origins ?? ['host'], required: true, disposition: result.disposition ?? 'executed', ...(result.reusedFrom ? { reusedFrom: result.reusedFrom } : {}),
|
|
1348
|
+
status: result.outcome === 'cancelled' ? 'cancelled' : result.outcome === 'interrupted' ? 'interrupted' : result.exitCode === 0 ? 'passed' : 'failed',
|
|
1349
|
+
...(result.reuseReason === undefined ? {} : { reuseReason: result.reuseReason }), recordedAt: new Date().toISOString(),
|
|
1350
|
+
execution: { command: redactRuntimeText(result.command), args: result.args.map(arg => redactRuntimeText(arg)), cwd: result.cwd, configuredTimeoutMs: result.configuredTimeoutMs ?? null, appliedTimeoutMs: result.appliedTimeoutMs ?? null, deadline: result.deadline ?? null, outcome: result.outcome ?? null },
|
|
1351
|
+
planHash, candidateHash, exitCode: result.pending ? null : result.exitCode, durationMs: result.pending ? null : result.durationMs, outputTruncated: result.outputTruncated ?? false,
|
|
1352
|
+
stdout, stderr, stdoutBytes: Buffer.byteLength(stdout), stderrBytes: Buffer.byteLength(stderr), stdoutHash: digest(stdout), stderrHash: digest(stderr), sources,
|
|
1353
|
+
};
|
|
1354
|
+
atomicJson(safeChild(pipelineStateDirectory(context), 'verification/evidence/' + id + '.json'), { document, integrity: digest(canonical(document)) });
|
|
1355
|
+
const summary = evidenceSummary(document);
|
|
1356
|
+
atomicJson(safeChild(pipelineStateDirectory(context), 'verification/evidence-index/' + id + '.json'), { summary, integrity: digest(canonical(summary)) });
|
|
1357
|
+
// Receipt/status payloads carry references and short tails only.
|
|
1358
|
+
delete result.stdout;
|
|
1359
|
+
delete result.stderr;
|
|
1360
|
+
}
|
|
1361
|
+
/** Historical read: no live repository validation, source execution or lifecycle mutation. */
|
|
1362
|
+
export function readVerificationEvidence(context, query = {}) {
|
|
1363
|
+
if (!ID.test(context.runId) || !path.isAbsolute(context.backlogRoot))
|
|
1364
|
+
fail('Invalid evidence scope');
|
|
1365
|
+
if (Object.keys(query).some(key => !['id', 'section', 'sourceId', 'cursor', 'limit'].includes(key)))
|
|
1366
|
+
fail('Invalid evidence query');
|
|
1367
|
+
const section = query.section ?? 'summary', limit = query.limit ?? 25;
|
|
1368
|
+
if (!['summary', 'stdout', 'stderr', 'source'].includes(section) || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1369
|
+
fail('Invalid evidence section or limit');
|
|
1370
|
+
if ((section === 'source') !== (query.sourceId !== undefined) || (!query.id && section !== 'summary'))
|
|
1371
|
+
fail('Source section requires a registered source ID and evidence ID');
|
|
1372
|
+
for (const value of [query.id, query.sourceId])
|
|
1373
|
+
if (value !== undefined && !/^[a-f0-9]{64}$/.test(value))
|
|
1374
|
+
fail('Invalid opaque evidence ID');
|
|
1375
|
+
const directory = safeChild(context.backlogRoot, '.specrails/pipeline/' + context.runId + '/verification/evidence');
|
|
1376
|
+
const binding = digest(canonical({ runId: context.runId, id: query.id ?? null, section, sourceId: query.sourceId ?? null }));
|
|
1377
|
+
let offset = 0;
|
|
1378
|
+
if (query.cursor !== undefined) {
|
|
1379
|
+
if (typeof query.cursor !== 'string' || query.cursor.length > 1024)
|
|
1380
|
+
fail('Invalid evidence cursor');
|
|
1381
|
+
try {
|
|
1382
|
+
const cursor = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8'));
|
|
1383
|
+
if (cursor.binding !== binding || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0)
|
|
1384
|
+
fail('Invalid evidence cursor');
|
|
1385
|
+
offset = cursor.offset;
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
fail('Invalid evidence cursor');
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const cursor = (next) => Buffer.from(JSON.stringify({ binding, offset: next })).toString('base64url');
|
|
1392
|
+
const load = (id) => {
|
|
1393
|
+
const file = safeChild(directory, id + '.json');
|
|
1394
|
+
if (lstatSync(file).size > 16 * 1024 * 1024)
|
|
1395
|
+
fail('Evidence exceeds size limit');
|
|
1396
|
+
const envelope = object(readJson(file)), document = envelope.document;
|
|
1397
|
+
if (!document || document.schemaVersion !== 1 || document.runId !== context.runId || document.id !== id || digest(canonical(document)) !== envelope.integrity)
|
|
1398
|
+
fail('Evidence integrity check failed');
|
|
1399
|
+
return document;
|
|
1400
|
+
};
|
|
1401
|
+
if (!existsSync(directory) || (query.id && !existsSync(safeChild(directory, query.id + '.json'))))
|
|
1402
|
+
return { schemaVersion: 1, available: false, items: [], truncated: false };
|
|
1403
|
+
if (!query.id) {
|
|
1404
|
+
const indexDirectory = safeChild(context.backlogRoot, '.specrails/pipeline/' + context.runId + '/verification/evidence-index');
|
|
1405
|
+
if (!existsSync(indexDirectory))
|
|
1406
|
+
return { schemaVersion: 1, available: false, items: [], truncated: false };
|
|
1407
|
+
const ids = readdirSync(indexDirectory).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).sort();
|
|
1408
|
+
if (ids.length > 10000)
|
|
1409
|
+
fail('Evidence index exceeds limit');
|
|
1410
|
+
const selected = ids.slice(offset, offset + limit);
|
|
1411
|
+
return { schemaVersion: 1, available: true, items: selected.map(file => {
|
|
1412
|
+
const indexFile = safeChild(indexDirectory, file);
|
|
1413
|
+
if (lstatSync(indexFile).size > 65536)
|
|
1414
|
+
fail('Evidence summary exceeds limit');
|
|
1415
|
+
const index = object(readJson(indexFile)), summary = object(index.summary);
|
|
1416
|
+
if (summary.id !== file.slice(0, -5) || summary.runId !== context.runId || index.integrity !== digest(canonical(summary)))
|
|
1417
|
+
fail('Evidence index integrity check failed');
|
|
1418
|
+
return summary;
|
|
1419
|
+
}), truncated: offset + selected.length < ids.length, ...(offset + selected.length < ids.length ? { nextCursor: cursor(offset + selected.length) } : {}) };
|
|
1420
|
+
}
|
|
1421
|
+
const document = load(query.id);
|
|
1422
|
+
if (section === 'summary')
|
|
1423
|
+
return { schemaVersion: 1, available: true, items: [evidenceSummary(document)], truncated: false };
|
|
1424
|
+
const source = section === 'source' ? document.sources.find(source => source.id === query.sourceId) : undefined;
|
|
1425
|
+
if (section === 'source' && !source)
|
|
1426
|
+
fail('Source does not belong to this evidence');
|
|
1427
|
+
let outputDocument = document;
|
|
1428
|
+
const visited = new Set([document.id]);
|
|
1429
|
+
while (outputDocument.reusedFrom && section !== 'source') {
|
|
1430
|
+
if (visited.has(outputDocument.reusedFrom) || visited.size > 100)
|
|
1431
|
+
fail('Invalid reused evidence chain');
|
|
1432
|
+
visited.add(outputDocument.reusedFrom);
|
|
1433
|
+
outputDocument = load(outputDocument.reusedFrom);
|
|
1434
|
+
}
|
|
1435
|
+
const content = source?.text ?? (section === 'stdout' ? outputDocument.stdout : outputDocument.stderr);
|
|
1436
|
+
// Character offsets are opaque. Stop at code-point boundaries within 64 KiB.
|
|
1437
|
+
if (offset > content.length || (offset > 0 && /[\uDC00-\uDFFF]/.test(content[offset] ?? '')))
|
|
1438
|
+
fail('Invalid evidence text cursor');
|
|
1439
|
+
let text = '', bytes = 0;
|
|
1440
|
+
for (const character of content.slice(offset)) {
|
|
1441
|
+
const size = Buffer.byteLength(character);
|
|
1442
|
+
if (bytes + size > 65536)
|
|
1443
|
+
break;
|
|
1444
|
+
text += character;
|
|
1445
|
+
bytes += size;
|
|
1446
|
+
}
|
|
1447
|
+
const next = offset + text.length;
|
|
1448
|
+
return { schemaVersion: 1, available: true, text, byteCount: bytes, totalBytes: Buffer.byteLength(content), truncated: next < content.length || document.outputTruncated, ...(next < content.length ? { nextCursor: cursor(next) } : {}) };
|
|
1449
|
+
}
|
|
977
1450
|
//# sourceMappingURL=pipeline-state.js.map
|