specrails-core 5.3.0 → 5.5.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 +2 -0
- package/dist/agent-runtime/capabilities.d.ts +28 -0
- package/dist/agent-runtime/capabilities.js +81 -0
- package/dist/agent-runtime/capabilities.js.map +1 -0
- package/dist/agent-runtime/cli-executor.d.ts +9 -0
- package/dist/agent-runtime/cli-executor.js +109 -34
- package/dist/agent-runtime/cli-executor.js.map +1 -1
- package/dist/agent-runtime/cli-process.d.ts +1 -0
- package/dist/agent-runtime/cli-process.js +13 -2
- package/dist/agent-runtime/cli-process.js.map +1 -1
- package/dist/agent-runtime/cli.d.ts +1 -0
- package/dist/agent-runtime/cli.js +60 -11
- package/dist/agent-runtime/cli.js.map +1 -1
- 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/compact/architect.d.ts +349 -0
- package/dist/agent-runtime/compact/architect.js +371 -0
- package/dist/agent-runtime/compact/architect.js.map +1 -0
- package/dist/agent-runtime/compact/chat-client.d.ts +83 -0
- package/dist/agent-runtime/compact/chat-client.js +307 -0
- package/dist/agent-runtime/compact/chat-client.js.map +1 -0
- package/dist/agent-runtime/compact/developer.d.ts +160 -0
- package/dist/agent-runtime/compact/developer.js +823 -0
- package/dist/agent-runtime/compact/developer.js.map +1 -0
- package/dist/agent-runtime/compact/environment.d.ts +62 -0
- package/dist/agent-runtime/compact/environment.js +213 -0
- package/dist/agent-runtime/compact/environment.js.map +1 -0
- package/dist/agent-runtime/compact/exit-code-honesty.d.ts +8 -0
- package/dist/agent-runtime/compact/exit-code-honesty.js +28 -0
- package/dist/agent-runtime/compact/exit-code-honesty.js.map +1 -0
- package/dist/agent-runtime/compact/guarded-loop.d.ts +73 -0
- package/dist/agent-runtime/compact/guarded-loop.js +320 -0
- package/dist/agent-runtime/compact/guarded-loop.js.map +1 -0
- package/dist/agent-runtime/compact/prompt-inputs.d.ts +42 -0
- package/dist/agent-runtime/compact/prompt-inputs.js +59 -0
- package/dist/agent-runtime/compact/prompt-inputs.js.map +1 -0
- package/dist/agent-runtime/compact/reviewer.d.ts +24 -0
- package/dist/agent-runtime/compact/reviewer.js +178 -0
- package/dist/agent-runtime/compact/reviewer.js.map +1 -0
- package/dist/agent-runtime/compact/step.d.ts +70 -0
- package/dist/agent-runtime/compact/step.js +136 -0
- package/dist/agent-runtime/compact/step.js.map +1 -0
- package/dist/agent-runtime/compact/test-reachability.d.ts +30 -0
- package/dist/agent-runtime/compact/test-reachability.js +147 -0
- package/dist/agent-runtime/compact/test-reachability.js.map +1 -0
- package/dist/agent-runtime/config.d.ts +4 -0
- package/dist/agent-runtime/config.js +146 -15
- package/dist/agent-runtime/config.js.map +1 -1
- package/dist/agent-runtime/core-host.d.ts +12 -4
- package/dist/agent-runtime/core-host.js +61 -21
- package/dist/agent-runtime/core-host.js.map +1 -1
- 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 +71 -4
- package/dist/agent-runtime/executor-types.js +7 -2
- package/dist/agent-runtime/executor-types.js.map +1 -1
- package/dist/agent-runtime/executors.d.ts +1 -0
- package/dist/agent-runtime/executors.js +4 -0
- package/dist/agent-runtime/executors.js.map +1 -1
- package/dist/agent-runtime/graph/artifacts.d.ts +8 -22
- package/dist/agent-runtime/graph/artifacts.js +99 -73
- package/dist/agent-runtime/graph/artifacts.js.map +1 -1
- package/dist/agent-runtime/graph/nodes.d.ts +7 -2
- package/dist/agent-runtime/graph/nodes.js +207 -53
- package/dist/agent-runtime/graph/nodes.js.map +1 -1
- package/dist/agent-runtime/graph/roles.d.ts +11 -3
- package/dist/agent-runtime/graph/roles.js +109 -37
- package/dist/agent-runtime/graph/roles.js.map +1 -1
- package/dist/agent-runtime/graph/state.d.ts +10 -1
- package/dist/agent-runtime/graph/state.js +2 -1
- package/dist/agent-runtime/graph/state.js.map +1 -1
- package/dist/agent-runtime/guardrails.d.ts +15 -0
- package/dist/agent-runtime/guardrails.js +76 -0
- package/dist/agent-runtime/guardrails.js.map +1 -0
- package/dist/agent-runtime/index.d.ts +7 -1
- package/dist/agent-runtime/index.js +7 -1
- package/dist/agent-runtime/index.js.map +1 -1
- package/dist/agent-runtime/kimi-acp.d.ts +4 -0
- package/dist/agent-runtime/kimi-acp.js +20 -9
- package/dist/agent-runtime/kimi-acp.js.map +1 -1
- package/dist/agent-runtime/openai-executor.d.ts +33 -1
- package/dist/agent-runtime/openai-executor.js +76 -113
- package/dist/agent-runtime/openai-executor.js.map +1 -1
- 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 +198 -0
- package/dist/agent-runtime/openspec.js +329 -0
- package/dist/agent-runtime/openspec.js.map +1 -0
- package/dist/agent-runtime/prompts.d.ts +24 -1
- package/dist/agent-runtime/prompts.js +112 -43
- package/dist/agent-runtime/prompts.js.map +1 -1
- 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 +127 -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 +30 -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 +19 -1
- package/dist/agent-runtime/workflow.js +29 -2
- package/dist/agent-runtime/workflow.js.map +1 -1
- package/dist/agent-runtime/workspace-tools.d.ts +4 -0
- package/dist/agent-runtime/workspace-tools.js +182 -20
- package/dist/agent-runtime/workspace-tools.js.map +1 -1
- package/dist/installer/phases/scaffold.js +39 -109
- package/dist/installer/phases/scaffold.js.map +1 -1
- package/dist/installer/runtime/pipeline-state.d.ts +143 -1
- package/dist/installer/runtime/pipeline-state.js +510 -30
- package/dist/installer/runtime/pipeline-state.js.map +1 -1
- package/docs/agent-runtime-efficiency.md +65 -0
- package/docs/agent-runtime.md +86 -15
- package/integration-contract.json +2 -1
- package/package.json +5 -2
- package/schemas/agent-runtime.schema.json +34 -4
- 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,25 @@ 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
|
-
|
|
294
|
+
const GENERATED_OUTPUT_ROOTS = ['node_modules', 'coverage', '.nyc_output', '.jest-cache', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.venv', 'venv', '.tox', '.gradle', '.cache'];
|
|
295
|
+
function untrackedGeneratedOutput(relative) {
|
|
296
|
+
// Dependency trees and test/coverage caches are never candidate content, yet
|
|
297
|
+
// a repository without a .gitignore (a greenfield scaffold) lists them as
|
|
298
|
+
// untracked: `npm test` then writes coverage/ mid-run, the fingerprint moves
|
|
299
|
+
// and every receipt reads "Candidate changed during verification" — green
|
|
300
|
+
// or not. Tracked files under these roots still count.
|
|
301
|
+
return relative.split('/').some(segment => GENERATED_OUTPUT_ROOTS.includes(segment));
|
|
302
|
+
}
|
|
303
|
+
export function candidateManifest(state) {
|
|
294
304
|
const entries = state.context.repositories.map((repo) => ({
|
|
295
305
|
id: repo.id, path: repo.path,
|
|
296
306
|
files: trackedFiles(repo)
|
|
297
|
-
.filter(({ file, tracked }) => !excluded(state, repo, relativeUnix(file)) && (tracked || !untrackedAgentMemory(relativeUnix(file))))
|
|
307
|
+
.filter(({ file, tracked }) => !excluded(state, repo, relativeUnix(file)) && (tracked || (!untrackedAgentMemory(relativeUnix(file)) && !untrackedGeneratedOutput(relativeUnix(file)))))
|
|
298
308
|
.map(({ file }) => [relativeUnix(file), fileFingerprint(path.join(repo.path, file))]),
|
|
299
309
|
}));
|
|
300
|
-
return
|
|
310
|
+
return { schemaVersion: 1, scopeHash: state.scopeHash, repositories: entries };
|
|
301
311
|
}
|
|
312
|
+
export function fingerprintCandidate(state) { return digest(canonical(candidateManifest(state).repositories)); }
|
|
302
313
|
function activeArtifactPath(state) { return state.archivePath ?? path.join(state.context.artifactRoot, 'openspec', 'changes', state.change); }
|
|
303
314
|
function artifactFingerprint(state) {
|
|
304
315
|
const root = activeArtifactPath(state);
|
|
@@ -349,9 +360,46 @@ function environmentHash(keys, overrides = {}, env = process.env) {
|
|
|
349
360
|
const normalized = normalizeVerificationEnvironment(env);
|
|
350
361
|
return digest(canonical(Object.fromEntries(keys.map((key) => [key, overrides[key] ?? normalized[key] ?? null]))));
|
|
351
362
|
}
|
|
363
|
+
/** Host-owned binding, including immutable harness bytes outside the candidate. */
|
|
364
|
+
export function bindVerificationPlan(contextInput, hash, files) {
|
|
365
|
+
const context = validatePipelineContext(contextInput);
|
|
366
|
+
if (!/^[a-f0-9]{64}$/.test(hash) || files.length > 801)
|
|
367
|
+
fail('Invalid verification plan identity');
|
|
368
|
+
for (const file of files) {
|
|
369
|
+
const target = safeChild(pipelineStateDirectory(context), file.path);
|
|
370
|
+
if (!/^[a-f0-9]{64}$/.test(file.hash) || digest(readFileSync(target)) !== file.hash)
|
|
371
|
+
fail('Verification plan source changed');
|
|
372
|
+
}
|
|
373
|
+
locked(context, () => {
|
|
374
|
+
const state = readState(context);
|
|
375
|
+
const binding = { hash, files };
|
|
376
|
+
if (canonical(state.verificationPlan) === canonical(binding))
|
|
377
|
+
return;
|
|
378
|
+
if (state.phases.archive.status === 'done')
|
|
379
|
+
fail('Cannot change the archived verification plan');
|
|
380
|
+
state.verificationPlan = binding;
|
|
381
|
+
state.acceptance = undefined;
|
|
382
|
+
state.archiveApproval = undefined;
|
|
383
|
+
saveState(state);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
function planReasons(state, hash) {
|
|
387
|
+
if (hash !== state.verificationPlan?.hash)
|
|
388
|
+
return ['Verification plan changed'];
|
|
389
|
+
try {
|
|
390
|
+
for (const file of state.verificationPlan?.files ?? []) {
|
|
391
|
+
if (digest(readFileSync(safeChild(pipelineStateDirectory(state.context), file.path))) !== file.hash)
|
|
392
|
+
return ['Verification plan or harness source changed'];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return ['Verification plan or harness source unavailable'];
|
|
397
|
+
}
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
352
400
|
function inspectReceipt(state, env = process.env) {
|
|
353
401
|
const receipt = state.verification;
|
|
354
|
-
const reasons =
|
|
402
|
+
const reasons = planReasons(state, receipt?.planHash);
|
|
355
403
|
if (!receipt)
|
|
356
404
|
return { valid: false, reasons: ['No verification receipt'] };
|
|
357
405
|
if (!receipt.valid || receipt.kind !== 'full')
|
|
@@ -361,6 +409,12 @@ function inspectReceipt(state, env = process.env) {
|
|
|
361
409
|
if (receipt.candidateHash !== fingerprintCandidate(state))
|
|
362
410
|
reasons.push('Candidate files changed');
|
|
363
411
|
for (const command of receipt.commands) {
|
|
412
|
+
if (command.snapshot?.eligible) {
|
|
413
|
+
// The frozen logical command is recorded with each execution for inspection.
|
|
414
|
+
const snapshot = command.checkDefinition ? verificationSnapshot(state.context, command.checkDefinition) : undefined;
|
|
415
|
+
if (!snapshot?.eligible || snapshot.inputHash !== command.snapshot.inputHash || snapshot.toolchainHash !== command.snapshot.toolchainHash)
|
|
416
|
+
reasons.push('Verification inputs or toolchain changed');
|
|
417
|
+
}
|
|
364
418
|
if (command.exitCode !== 0)
|
|
365
419
|
reasons.push('Command failed: ' + command.command);
|
|
366
420
|
if (command.environmentPolicy !== 'isolated-transport-v1')
|
|
@@ -478,6 +532,7 @@ function inspectAcceptance(state) {
|
|
|
478
532
|
catch (error) {
|
|
479
533
|
return { valid: false, status: 'blocked', reasons: [error instanceof Error ? error.message : String(error)] };
|
|
480
534
|
}
|
|
535
|
+
reasons.push(...planReasons(state, receipt.planHash));
|
|
481
536
|
if (receipt.scopeHash !== state.scopeHash || receipt.candidateHash !== fingerprintCandidate(state) || receipt.artifactHash !== artifactFingerprint(state))
|
|
482
537
|
reasons.push('Acceptance evidence is stale');
|
|
483
538
|
for (const row of receipt.criteria ?? [])
|
|
@@ -501,7 +556,7 @@ export function recordAcceptance(contextInput, input) {
|
|
|
501
556
|
const state = readState(context);
|
|
502
557
|
if (state.phases.developer.status !== 'done' || state.phases.archive.status === 'done')
|
|
503
558
|
fail('Record acceptance after development and before archive');
|
|
504
|
-
state.acceptance = { ...report, scopeHash: state.scopeHash, candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), recordedAt: new Date().toISOString() };
|
|
559
|
+
state.acceptance = { ...report, ...(state.verificationPlan ? { planHash: state.verificationPlan.hash } : {}), scopeHash: state.scopeHash, candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), recordedAt: new Date().toISOString() };
|
|
505
560
|
// Replacing even just an exception decision requires a new review/approval.
|
|
506
561
|
for (const phase of PHASES.slice(2)) {
|
|
507
562
|
if (phase === 'reviewer' && state.phases[phase].status === 'running')
|
|
@@ -651,7 +706,7 @@ export function inspectPipeline(contextInput) {
|
|
|
651
706
|
delivery: context.ownership.git === 'host' ? 'pending-host' : state.phases.ship.status === 'done' && state.phases.ci.status === 'done' ? 'complete' : 'pending',
|
|
652
707
|
reasons: [...verification.reasons, ...acceptance.reasons],
|
|
653
708
|
};
|
|
654
|
-
return { schemaVersion: 1, runId: context.runId, change: state.change, context, stateDir: pipelineStateDirectory(context), resumePhase, phases: state.phases, verification, acceptance, completion };
|
|
709
|
+
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 };
|
|
655
710
|
}
|
|
656
711
|
function validateCommand(context, raw) {
|
|
657
712
|
const command = object(raw);
|
|
@@ -667,7 +722,26 @@ function validateCommand(context, raw) {
|
|
|
667
722
|
const timeoutMs = command.timeoutMs === undefined ? 15 * 60_000 : Number(command.timeoutMs);
|
|
668
723
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2 * 60 * 60_000)
|
|
669
724
|
fail('Invalid verification timeout');
|
|
670
|
-
|
|
725
|
+
if (command.policy !== undefined) {
|
|
726
|
+
const policy = object(command.policy);
|
|
727
|
+
if (Object.keys(policy).some(key => !['reuse', 'inputs', 'deterministic', 'readOnly', 'toolchainInputs', 'independentGroup', 'resources'].includes(key)))
|
|
728
|
+
fail('Invalid verification policy');
|
|
729
|
+
if (policy.reuse !== undefined && !['never', 'snapshot-local'].includes(String(policy.reuse)))
|
|
730
|
+
fail('Invalid verification reuse policy');
|
|
731
|
+
for (const key of ['deterministic', 'readOnly'])
|
|
732
|
+
if (policy[key] !== undefined && typeof policy[key] !== 'boolean')
|
|
733
|
+
fail('Invalid verification policy flag');
|
|
734
|
+
if (policy.independentGroup !== undefined && (typeof policy.independentGroup !== 'string' || !ID.test(policy.independentGroup)))
|
|
735
|
+
fail('Invalid verification independence group');
|
|
736
|
+
for (const key of ['inputs', 'toolchainInputs', 'resources'])
|
|
737
|
+
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'))))
|
|
738
|
+
fail('Invalid verification policy inputs');
|
|
739
|
+
}
|
|
740
|
+
if (command.key !== undefined && (typeof command.key !== 'string' || !ID.test(command.key)))
|
|
741
|
+
fail('Invalid verification key');
|
|
742
|
+
if (command.label !== undefined && (typeof command.label !== 'string' || !command.label.trim() || command.label.length > 256 || command.label.includes('\0')))
|
|
743
|
+
fail('Invalid verification label');
|
|
744
|
+
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 };
|
|
671
745
|
}
|
|
672
746
|
/**
|
|
673
747
|
* Native executables retain structured argv; Windows script shims need cmd.
|
|
@@ -702,7 +776,24 @@ export function verificationInvocation(command, args, cwd, platform = process.pl
|
|
|
702
776
|
const quote = (value) => '"' + value + '"';
|
|
703
777
|
return { command: env.ComSpec ?? env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', '"' + [resolved, ...args].map(quote).join(' ') + '"'], windowsVerbatimArguments: true };
|
|
704
778
|
}
|
|
705
|
-
|
|
779
|
+
/** Shared persisted-output/provider diagnostic redaction; preserves line structure. */
|
|
780
|
+
export function redactRuntimeText(text, env = process.env) {
|
|
781
|
+
for (const [key, value] of Object.entries(env))
|
|
782
|
+
if (/(token|secret|password|api_?key|credential)/i.test(key) && value && value.length >= 4)
|
|
783
|
+
text = text.split(value).join('[redacted]');
|
|
784
|
+
return text.replace(/Bearer\s+[^\s"']+/gi, 'Bearer [redacted]')
|
|
785
|
+
.replace(/((?:api[_-]?key|(?:access[_-]?)?token|password|secret|authorization)["']?\s*[:=]\s*["']?)[^\s,"'}]+/gi, '$1[redacted]')
|
|
786
|
+
.replace(/https?:\/\/[^\s"'<>]+/gi, value => { try {
|
|
787
|
+
const url = new URL(value);
|
|
788
|
+
return url.origin + url.pathname;
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
return '[url]';
|
|
792
|
+
} });
|
|
793
|
+
}
|
|
794
|
+
/** Silence a verification command may keep before it counts as hung (env `SPECRAILS_VERIFY_IDLE_TIMEOUT_MS`, default 5 min). */
|
|
795
|
+
function verificationIdleTimeoutMs() { const raw = Number(process.env.SPECRAILS_VERIFY_IDLE_TIMEOUT_MS); return Number.isFinite(raw) && raw >= 100 ? raw : 5 * 60_000; }
|
|
796
|
+
async function executeCheck(command, log, signal, deadline, evidenceId = digest(randomUUID()), idleTimeoutOverrideMs) {
|
|
706
797
|
const started = Date.now();
|
|
707
798
|
const overrides = normalizeVerificationEnvironment(command.env ?? {});
|
|
708
799
|
const overrideKeys = Object.keys(overrides).sort();
|
|
@@ -710,20 +801,41 @@ async function executeCheck(command, log, signal) {
|
|
|
710
801
|
const hash = environmentHash(keys);
|
|
711
802
|
const overridesHash = digest(canonical(overrides));
|
|
712
803
|
const env = verificationEnvironment(process.env, overrides);
|
|
804
|
+
const configuredTimeoutMs = command.timeoutMs ?? 15 * 60_000;
|
|
805
|
+
const appliedTimeoutMs = Math.max(0, Math.min(configuredTimeoutMs, deadline === undefined ? Infinity : deadline - Date.now()));
|
|
806
|
+
// A test that never returns (a `while` that cannot exit) keeps a runner at
|
|
807
|
+
// 100 % CPU and silent until the wall-clock timeout; the silence is the
|
|
808
|
+
// signal. Bounded per command, never longer than the total timeout.
|
|
809
|
+
// 0 (a project that switched the `verify-idle-timeout` guardrail off) disables the silence bound; the wall-clock timeout still applies.
|
|
810
|
+
const idleTimeoutMs = idleTimeoutOverrideMs === 0 ? 0 : Math.min(idleTimeoutOverrideMs ?? verificationIdleTimeoutMs(), appliedTimeoutMs);
|
|
811
|
+
let lastOutputAt = Date.now();
|
|
812
|
+
let lastOutputLine = '';
|
|
813
|
+
let outcome;
|
|
713
814
|
let output = '';
|
|
815
|
+
const streams = { stdout: '', stderr: '' };
|
|
816
|
+
let outputTruncated = false;
|
|
817
|
+
const redact = (text) => redactRuntimeText(text, { ...process.env, ...overrides });
|
|
714
818
|
let exitCode = -1;
|
|
715
819
|
await new Promise((resolve) => {
|
|
716
820
|
let child;
|
|
717
821
|
let timer;
|
|
822
|
+
let idleTimer;
|
|
718
823
|
let done = false;
|
|
824
|
+
let stopping = false;
|
|
825
|
+
let termination;
|
|
719
826
|
const finish = (code) => { if (done)
|
|
720
|
-
return; done = true; exitCode = code; if (
|
|
721
|
-
clearTimeout(
|
|
827
|
+
return; done = true; exitCode = stopping ? -1 : code; outcome ??= code === 0 ? 'passed' : 'failed'; if (termination)
|
|
828
|
+
clearTimeout(termination); if (timer)
|
|
829
|
+
clearTimeout(timer); if (idleTimer)
|
|
830
|
+
clearInterval(idleTimer); signal?.removeEventListener('abort', abort); resolve(); };
|
|
722
831
|
const stop = (reason) => {
|
|
723
|
-
|
|
832
|
+
if (done || stopping)
|
|
833
|
+
return;
|
|
834
|
+
stopping = true;
|
|
835
|
+
output = (output + '\n' + reason).slice(-32_000);
|
|
724
836
|
if (child?.pid) {
|
|
725
837
|
if (process.platform === 'win32')
|
|
726
|
-
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
|
|
838
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
|
|
727
839
|
else {
|
|
728
840
|
try {
|
|
729
841
|
process.kill(-child.pid, 'SIGKILL');
|
|
@@ -731,13 +843,22 @@ async function executeCheck(command, log, signal) {
|
|
|
731
843
|
catch { /* already exited */ }
|
|
732
844
|
}
|
|
733
845
|
}
|
|
734
|
-
|
|
846
|
+
// Never publish a receipt while a normally terminating child is alive.
|
|
847
|
+
if (!child?.pid)
|
|
848
|
+
finish(-1);
|
|
849
|
+
else
|
|
850
|
+
termination = setTimeout(() => { outcome = 'interrupted'; child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); finish(-1); }, 5000);
|
|
735
851
|
};
|
|
736
|
-
const abort = () => stop('Verification command cancelled');
|
|
852
|
+
const abort = () => { outcome = 'cancelled'; stop('Verification command cancelled'); };
|
|
737
853
|
if (signal?.aborted) {
|
|
738
854
|
abort();
|
|
739
855
|
return;
|
|
740
856
|
}
|
|
857
|
+
if (appliedTimeoutMs <= 0) {
|
|
858
|
+
outcome = 'timed-out';
|
|
859
|
+
stop('Workflow verification deadline exhausted');
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
741
862
|
try {
|
|
742
863
|
const invocation = verificationInvocation(command.command, command.args, command.cwd, process.platform, env);
|
|
743
864
|
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 });
|
|
@@ -747,45 +868,276 @@ async function executeCheck(command, log, signal) {
|
|
|
747
868
|
finish(-1);
|
|
748
869
|
return;
|
|
749
870
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
871
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
872
|
+
const decoder = new StringDecoder('utf8');
|
|
873
|
+
let liveLine = '', droppingLine = false;
|
|
874
|
+
const receive = (text) => {
|
|
875
|
+
const remaining = 1024 * 1024 - Buffer.byteLength(streams[stream]);
|
|
876
|
+
if (Buffer.byteLength(text) > remaining)
|
|
877
|
+
outputTruncated = true;
|
|
878
|
+
if (remaining > 0) {
|
|
879
|
+
let kept = text.slice(0, remaining);
|
|
880
|
+
while (Buffer.byteLength(kept) > remaining || /[\uD800-\uDBFF]$/.test(kept))
|
|
881
|
+
kept = kept.slice(0, -1);
|
|
882
|
+
streams[stream] += kept;
|
|
883
|
+
}
|
|
884
|
+
output = (output + text).slice(-32_000);
|
|
885
|
+
lastOutputAt = Date.now();
|
|
886
|
+
const lastLine = text.trim().split('\n').pop()?.trim();
|
|
887
|
+
if (lastLine)
|
|
888
|
+
lastOutputLine = lastLine.slice(0, 200);
|
|
889
|
+
// Redact whole bounded lines, so chunk boundaries cannot split credentials.
|
|
890
|
+
for (const part of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
891
|
+
if (!droppingLine)
|
|
892
|
+
liveLine += part;
|
|
893
|
+
if (liveLine.length > 16384) {
|
|
894
|
+
liveLine = '';
|
|
895
|
+
droppingLine = true;
|
|
896
|
+
}
|
|
897
|
+
if (part.endsWith('\n')) {
|
|
898
|
+
try {
|
|
899
|
+
log(droppingLine ? '[Long output line omitted; inspect bounded evidence]\n' : redact(liveLine));
|
|
900
|
+
}
|
|
901
|
+
catch { /* observer */ }
|
|
902
|
+
liveLine = '';
|
|
903
|
+
droppingLine = false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
child[stream]?.on('data', (chunk) => receive(decoder.write(chunk)));
|
|
908
|
+
child[stream]?.on('end', () => { receive(decoder.end()); if (liveLine) {
|
|
909
|
+
try {
|
|
910
|
+
log(redact(liveLine));
|
|
911
|
+
}
|
|
912
|
+
catch { /* observer */ }
|
|
913
|
+
} });
|
|
914
|
+
}
|
|
915
|
+
child.on('error', (error) => { output = (output + error.message).slice(-32_000); if (!child.pid)
|
|
916
|
+
finish(-1); });
|
|
754
917
|
child.on('close', (code) => finish(code ?? -1));
|
|
755
918
|
signal?.addEventListener('abort', abort, { once: true });
|
|
756
|
-
timer = setTimeout(() => stop('Verification command timed out'),
|
|
919
|
+
timer = setTimeout(() => { outcome = 'timed-out'; stop('Verification command timed out'); }, appliedTimeoutMs);
|
|
920
|
+
if (idleTimeoutMs > 0)
|
|
921
|
+
idleTimer = setInterval(() => {
|
|
922
|
+
if (Date.now() - lastOutputAt < idleTimeoutMs)
|
|
923
|
+
return;
|
|
924
|
+
outcome = 'timed-out';
|
|
925
|
+
stop(`Verification command produced no output for ${idleTimeoutMs >= 60_000 ? `${Math.round(idleTimeoutMs / 60_000)} min` : `${Math.round(idleTimeoutMs / 1000)} s`} and was stopped: a test is probably hanging (infinite loop or an open handle).${lastOutputLine ? ` Last output: ${lastOutputLine}` : ''}`);
|
|
926
|
+
}, Math.max(50, Math.min(5_000, Math.floor(idleTimeoutMs / 4))));
|
|
927
|
+
if (signal?.aborted)
|
|
928
|
+
abort();
|
|
757
929
|
});
|
|
758
|
-
|
|
930
|
+
const persisted = (text) => {
|
|
931
|
+
const bytes = Buffer.from(redact(text));
|
|
932
|
+
if (bytes.length > 1024 * 1024)
|
|
933
|
+
outputTruncated = true;
|
|
934
|
+
return new StringDecoder('utf8').write(bytes.subarray(0, 1024 * 1024));
|
|
935
|
+
};
|
|
936
|
+
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) };
|
|
937
|
+
}
|
|
938
|
+
/** Host opt-in is a declaration of determinism, not inferred hermeticity. */
|
|
939
|
+
export function verificationSnapshot(context, command) {
|
|
940
|
+
const policy = command.policy;
|
|
941
|
+
if (policy?.reuse !== 'snapshot-local')
|
|
942
|
+
return { eligible: false, reason: 'reuse-never' };
|
|
943
|
+
if (policy.resources?.length)
|
|
944
|
+
return { eligible: false, reason: 'reuse-ineligible-external-resources' };
|
|
945
|
+
if (!policy.deterministic || !policy.readOnly || !policy.inputs?.length || !policy.toolchainInputs?.length)
|
|
946
|
+
return { eligible: false, reason: 'reuse-ineligible-incomplete-declaration' };
|
|
947
|
+
const repository = context.repositories.find(repo => repo.id === command.repositoryId);
|
|
948
|
+
if (!repository || !path.isAbsolute(command.command))
|
|
949
|
+
return { eligible: false, reason: 'reuse-ineligible-executable-identity' };
|
|
950
|
+
try {
|
|
951
|
+
let count = 0, total = 0;
|
|
952
|
+
const inventory = (inputs, tools) => {
|
|
953
|
+
const entries = [];
|
|
954
|
+
const seen = new Set();
|
|
955
|
+
const visit = (file) => {
|
|
956
|
+
if (seen.has(file))
|
|
957
|
+
return;
|
|
958
|
+
seen.add(file);
|
|
959
|
+
if (++count > 10000)
|
|
960
|
+
fail('reuse-ineligible-inventory-limit');
|
|
961
|
+
const stat = lstatSync(file);
|
|
962
|
+
if (stat.isSymbolicLink() || realpathSync(file) !== path.resolve(file))
|
|
963
|
+
fail('reuse-ineligible-symlink');
|
|
964
|
+
if (stat.isDirectory()) {
|
|
965
|
+
entries.push([file, 'directory']);
|
|
966
|
+
for (const name of readdirSync(file).sort())
|
|
967
|
+
visit(path.join(file, name));
|
|
968
|
+
}
|
|
969
|
+
else if (stat.isFile()) {
|
|
970
|
+
total += stat.size;
|
|
971
|
+
if (total > 256 * 1024 * 1024 || stat.size > 128 * 1024 * 1024)
|
|
972
|
+
fail('reuse-ineligible-inventory-limit');
|
|
973
|
+
entries.push([file, digest(readFileSync(file)) + ':' + stat.mode]);
|
|
974
|
+
}
|
|
975
|
+
else
|
|
976
|
+
fail('reuse-ineligible-nonfile-input');
|
|
977
|
+
};
|
|
978
|
+
for (const input of inputs) {
|
|
979
|
+
const file = path.resolve(repository.path, input);
|
|
980
|
+
if (!tools && !within(repository.path, file))
|
|
981
|
+
fail('reuse-ineligible-input-scope');
|
|
982
|
+
visit(file);
|
|
983
|
+
}
|
|
984
|
+
return entries.sort(([left], [right]) => left.localeCompare(right));
|
|
985
|
+
};
|
|
986
|
+
const inputs = inventory(policy.inputs, false);
|
|
987
|
+
const tools = inventory(policy.toolchainInputs, true);
|
|
988
|
+
if (!tools.some(([file]) => file === path.resolve(command.command)))
|
|
989
|
+
return { eligible: false, reason: 'reuse-ineligible-executable-not-declared' };
|
|
990
|
+
// A lockfile alone cannot certify ignored dependency contents. Host manifests
|
|
991
|
+
// must include each installed dependency directory this convention exposes.
|
|
992
|
+
for (const directory of ['node_modules', '.venv', 'venv', 'vendor']) {
|
|
993
|
+
const file = path.join(repository.path, directory);
|
|
994
|
+
if (existsSync(file) && ![...inputs, ...tools].some(([item]) => item === file))
|
|
995
|
+
return { eligible: false, reason: 'reuse-ineligible-dependencies-not-declared' };
|
|
996
|
+
}
|
|
997
|
+
return { eligible: true, reason: 'snapshot-local-host-declaration', inputHash: digest(canonical(inputs)), toolchainHash: digest(canonical(tools)) };
|
|
998
|
+
}
|
|
999
|
+
catch (error) {
|
|
1000
|
+
return { eligible: false, reason: error instanceof Error && error.message.startsWith('reuse-ineligible-') ? error.message : 'reuse-ineligible-input-unavailable' };
|
|
1001
|
+
}
|
|
759
1002
|
}
|
|
760
|
-
|
|
1003
|
+
/** Contiguous waves preserve baseline order; an uncertain check is a barrier. */
|
|
1004
|
+
export function verificationWaves(commands, maxConcurrency = 1) {
|
|
1005
|
+
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4)
|
|
1006
|
+
fail('Verification concurrency must be 1–4');
|
|
1007
|
+
const waves = [];
|
|
1008
|
+
let current = [];
|
|
1009
|
+
for (const command of commands) {
|
|
1010
|
+
const policy = command.policy;
|
|
1011
|
+
const independent = Boolean(policy?.independentGroup && policy.resources !== undefined);
|
|
1012
|
+
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)));
|
|
1013
|
+
if (!fits && current.length) {
|
|
1014
|
+
waves.push(current);
|
|
1015
|
+
current = [];
|
|
1016
|
+
}
|
|
1017
|
+
current.push(command);
|
|
1018
|
+
if (!independent || current.length === maxConcurrency) {
|
|
1019
|
+
waves.push(current);
|
|
1020
|
+
current = [];
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (current.length)
|
|
1024
|
+
waves.push(current);
|
|
1025
|
+
return waves;
|
|
1026
|
+
}
|
|
1027
|
+
export async function verifyPipeline(contextInput, raw, log = () => { }, signal, options = {}) {
|
|
761
1028
|
const context = validatePipelineContext(contextInput);
|
|
762
1029
|
const { request, commands, unverifiedRepositories } = verificationPlan(context, raw);
|
|
763
|
-
|
|
1030
|
+
let previous;
|
|
1031
|
+
const state = locked(context, () => {
|
|
1032
|
+
const current = readState(context);
|
|
1033
|
+
const problems = planReasons(current, typeof request.planHash === 'string' ? request.planHash : undefined);
|
|
1034
|
+
if (problems.length)
|
|
1035
|
+
fail(problems.join('; '));
|
|
1036
|
+
previous = current.verification;
|
|
1037
|
+
if (current.verification)
|
|
1038
|
+
current.verification = { ...current.verification, valid: false, reason: 'Verification started; previous receipt is no longer current' };
|
|
1039
|
+
current.acceptance = undefined;
|
|
1040
|
+
current.archiveApproval = undefined;
|
|
1041
|
+
saveState(current);
|
|
1042
|
+
return current;
|
|
1043
|
+
});
|
|
1044
|
+
const identityProblems = planReasons(state, typeof request.planHash === 'string' ? request.planHash : undefined);
|
|
1045
|
+
if (identityProblems.length)
|
|
1046
|
+
fail(identityProblems.join('; '));
|
|
764
1047
|
const candidateHash = fingerprintCandidate(state);
|
|
765
1048
|
const results = [];
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1049
|
+
const controller = new AbortController();
|
|
1050
|
+
const abort = () => controller.abort();
|
|
1051
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
1052
|
+
if (signal?.aborted)
|
|
1053
|
+
abort();
|
|
1054
|
+
try {
|
|
1055
|
+
for (const wave of verificationWaves(commands, options.maxConcurrency)) {
|
|
1056
|
+
if (controller.signal.aborted)
|
|
1057
|
+
break;
|
|
1058
|
+
const settled = await Promise.allSettled(wave.map(async (command) => {
|
|
1059
|
+
try {
|
|
1060
|
+
const prefix = `[verification ${command.repositoryId}/${command.label ?? command.key ?? command.command}] `;
|
|
1061
|
+
const snapshot = verificationSnapshot(context, command);
|
|
1062
|
+
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;
|
|
1063
|
+
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 ?? {})));
|
|
1064
|
+
const evidenceId = digest(randomUUID());
|
|
1065
|
+
if (!reuse) {
|
|
1066
|
+
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: '' };
|
|
1067
|
+
persistCheckEvidence(context, pending, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1068
|
+
await options.onEvidence?.('check-started', { executionId: evidenceId, repositoryId: command.repositoryId, checkId: command.key ?? '', label: command.label ?? command.command });
|
|
1069
|
+
}
|
|
1070
|
+
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, options.idleTimeoutMs), disposition: 'executed', snapshot, reuseReason: snapshot.eligible ? 'snapshot-local-no-current-match' : snapshot.reason };
|
|
1071
|
+
const { env: _env, ...definition } = command;
|
|
1072
|
+
result.checkDefinition = definition;
|
|
1073
|
+
result.checkDefinitionHash = digest(canonical(command));
|
|
1074
|
+
const after = verificationSnapshot(context, command);
|
|
1075
|
+
if (snapshot.eligible && (!after.eligible || after.inputHash !== snapshot.inputHash || after.toolchainHash !== snapshot.toolchainHash)) {
|
|
1076
|
+
result.exitCode = -1;
|
|
1077
|
+
result.outcome = 'failed';
|
|
1078
|
+
result.reuseReason = 'snapshot-inputs-changed-during-verification';
|
|
1079
|
+
}
|
|
1080
|
+
if (result.exitCode !== 0)
|
|
1081
|
+
controller.abort();
|
|
1082
|
+
persistCheckEvidence(context, result, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1083
|
+
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 });
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
controller.abort();
|
|
1088
|
+
throw error;
|
|
1089
|
+
}
|
|
1090
|
+
}));
|
|
1091
|
+
for (const result of settled) {
|
|
1092
|
+
if (result.status === 'fulfilled')
|
|
1093
|
+
results.push(result.value);
|
|
1094
|
+
else
|
|
1095
|
+
controller.abort();
|
|
1096
|
+
}
|
|
1097
|
+
const rejected = settled.find(result => result.status === 'rejected');
|
|
1098
|
+
if (rejected?.status === 'rejected')
|
|
1099
|
+
throw rejected.reason;
|
|
1100
|
+
if (results.some(result => result.exitCode !== 0))
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
770
1103
|
}
|
|
771
|
-
|
|
1104
|
+
finally {
|
|
1105
|
+
signal?.removeEventListener('abort', abort);
|
|
1106
|
+
}
|
|
1107
|
+
const notRunEvidenceIds = [];
|
|
1108
|
+
for (const command of commands.slice(results.length)) {
|
|
1109
|
+
const id = digest(randomUUID());
|
|
1110
|
+
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' };
|
|
1111
|
+
persistCheckEvidence(context, skipped, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1112
|
+
notRunEvidenceIds.push(id);
|
|
1113
|
+
}
|
|
1114
|
+
const receipt = locked(context, () => {
|
|
772
1115
|
const current = readState(context);
|
|
773
|
-
const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision;
|
|
1116
|
+
const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision || planReasons(current, request.planHash).length > 0;
|
|
774
1117
|
const receipt = {
|
|
775
|
-
id: randomUUID(), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results,
|
|
776
|
-
completedAt: new Date().toISOString(), valid: !changed && results.length === commands.length && results.every((result) => result.exitCode === 0),
|
|
1118
|
+
id: randomUUID(), ...(state.verificationPlan ? { planHash: state.verificationPlan.hash } : {}), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results, ...(notRunEvidenceIds.length ? { notRunEvidenceIds } : {}),
|
|
1119
|
+
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),
|
|
777
1120
|
...(changed ? { reason: 'Candidate changed during verification' } : results.some((result) => result.exitCode !== 0) ? { reason: 'A verification command failed' } : {}),
|
|
778
1121
|
...(unverifiedRepositories.length ? { unverifiedRepositories } : {}),
|
|
779
1122
|
};
|
|
780
1123
|
atomicJson(safeChild(pipelineStateDirectory(context), 'receipts/' + receipt.id + '.json'), receipt);
|
|
781
1124
|
if (receipt.kind === 'full' || !receipt.valid || !current.verification)
|
|
782
1125
|
current.verification = receipt;
|
|
1126
|
+
else if (previous?.valid && previous.candidateHash === candidateHash)
|
|
1127
|
+
current.verification = previous;
|
|
783
1128
|
saveState(current);
|
|
784
1129
|
return receipt;
|
|
785
1130
|
});
|
|
1131
|
+
if (!receipt.valid)
|
|
1132
|
+
for (const result of results) {
|
|
1133
|
+
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' });
|
|
1134
|
+
}
|
|
1135
|
+
return receipt;
|
|
786
1136
|
}
|
|
787
1137
|
function verificationPlan(context, raw) {
|
|
788
1138
|
const request = object(raw);
|
|
1139
|
+
if (request.planHash !== undefined && (typeof request.planHash !== 'string' || !/^[a-f0-9]{64}$/.test(request.planHash)))
|
|
1140
|
+
fail('Invalid verification plan hash');
|
|
789
1141
|
const unverified = request.unverified === true;
|
|
790
1142
|
if (request.unverified !== undefined && typeof request.unverified !== 'boolean')
|
|
791
1143
|
fail('Verification unverified flag must be boolean');
|
|
@@ -998,4 +1350,132 @@ export async function runPipelineCli(argv) {
|
|
|
998
1350
|
return 1;
|
|
999
1351
|
}
|
|
1000
1352
|
}
|
|
1353
|
+
function evidenceSummary(document) {
|
|
1354
|
+
const { stdout: _out, stderr: _err, sources, ...summary } = document;
|
|
1355
|
+
return { ...summary, sources: sources.map(({ text: _text, ...source }) => source) };
|
|
1356
|
+
}
|
|
1357
|
+
function persistCheckEvidence(context, result, planHash, candidateHash) {
|
|
1358
|
+
const id = result.evidenceId;
|
|
1359
|
+
const planPath = safeChild(pipelineStateDirectory(context), 'verification/plan.json');
|
|
1360
|
+
const plan = existsSync(planPath) ? object(readJson(planPath)) : undefined;
|
|
1361
|
+
const entries = Array.isArray(plan?.entries) ? plan.entries : [];
|
|
1362
|
+
const item = entries.find(entry => object(entry).id === result.key);
|
|
1363
|
+
const sources = [];
|
|
1364
|
+
for (const source of item?.harness?.sources ?? []) {
|
|
1365
|
+
if (sources.length >= 8)
|
|
1366
|
+
fail('Too many evidence sources');
|
|
1367
|
+
const file = safeChild(pipelineStateDirectory(context), `verification/harnesses/${item.id}/${item.harness.hash}/${source.path}`);
|
|
1368
|
+
if (lstatSync(file).size > 65536)
|
|
1369
|
+
fail('Evidence source exceeds size limit');
|
|
1370
|
+
const bytes = readFileSync(file);
|
|
1371
|
+
if (digest(bytes) !== source.hash)
|
|
1372
|
+
fail('Verification source changed during execution');
|
|
1373
|
+
sources.push({ id: digest(id + '\0' + source.path), displayPath: source.path, hash: source.hash, byteCount: bytes.length, text: bytes.toString('utf8') });
|
|
1374
|
+
}
|
|
1375
|
+
const stdout = result.stdout ?? '', stderr = result.stderr ?? '';
|
|
1376
|
+
const document = {
|
|
1377
|
+
schemaVersion: 1, runId: context.runId, id, executionId: id, checkId: result.key ?? digest(canonical([result.repositoryId, result.command, result.args])), repositoryId: result.repositoryId,
|
|
1378
|
+
label: result.label ?? result.command, origin: item?.origins ?? ['host'], required: true, disposition: result.disposition ?? 'executed', ...(result.reusedFrom ? { reusedFrom: result.reusedFrom } : {}),
|
|
1379
|
+
status: result.outcome === 'cancelled' ? 'cancelled' : result.outcome === 'interrupted' ? 'interrupted' : result.exitCode === 0 ? 'passed' : 'failed',
|
|
1380
|
+
...(result.reuseReason === undefined ? {} : { reuseReason: result.reuseReason }), recordedAt: new Date().toISOString(),
|
|
1381
|
+
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 },
|
|
1382
|
+
planHash, candidateHash, exitCode: result.pending ? null : result.exitCode, durationMs: result.pending ? null : result.durationMs, outputTruncated: result.outputTruncated ?? false,
|
|
1383
|
+
stdout, stderr, stdoutBytes: Buffer.byteLength(stdout), stderrBytes: Buffer.byteLength(stderr), stdoutHash: digest(stdout), stderrHash: digest(stderr), sources,
|
|
1384
|
+
};
|
|
1385
|
+
atomicJson(safeChild(pipelineStateDirectory(context), 'verification/evidence/' + id + '.json'), { document, integrity: digest(canonical(document)) });
|
|
1386
|
+
const summary = evidenceSummary(document);
|
|
1387
|
+
atomicJson(safeChild(pipelineStateDirectory(context), 'verification/evidence-index/' + id + '.json'), { summary, integrity: digest(canonical(summary)) });
|
|
1388
|
+
// Receipt/status payloads carry references and short tails only.
|
|
1389
|
+
delete result.stdout;
|
|
1390
|
+
delete result.stderr;
|
|
1391
|
+
}
|
|
1392
|
+
/** Historical read: no live repository validation, source execution or lifecycle mutation. */
|
|
1393
|
+
export function readVerificationEvidence(context, query = {}) {
|
|
1394
|
+
if (!ID.test(context.runId) || !path.isAbsolute(context.backlogRoot))
|
|
1395
|
+
fail('Invalid evidence scope');
|
|
1396
|
+
if (Object.keys(query).some(key => !['id', 'section', 'sourceId', 'cursor', 'limit'].includes(key)))
|
|
1397
|
+
fail('Invalid evidence query');
|
|
1398
|
+
const section = query.section ?? 'summary', limit = query.limit ?? 25;
|
|
1399
|
+
if (!['summary', 'stdout', 'stderr', 'source'].includes(section) || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1400
|
+
fail('Invalid evidence section or limit');
|
|
1401
|
+
if ((section === 'source') !== (query.sourceId !== undefined) || (!query.id && section !== 'summary'))
|
|
1402
|
+
fail('Source section requires a registered source ID and evidence ID');
|
|
1403
|
+
for (const value of [query.id, query.sourceId])
|
|
1404
|
+
if (value !== undefined && !/^[a-f0-9]{64}$/.test(value))
|
|
1405
|
+
fail('Invalid opaque evidence ID');
|
|
1406
|
+
const directory = safeChild(context.backlogRoot, '.specrails/pipeline/' + context.runId + '/verification/evidence');
|
|
1407
|
+
const binding = digest(canonical({ runId: context.runId, id: query.id ?? null, section, sourceId: query.sourceId ?? null }));
|
|
1408
|
+
let offset = 0;
|
|
1409
|
+
if (query.cursor !== undefined) {
|
|
1410
|
+
if (typeof query.cursor !== 'string' || query.cursor.length > 1024)
|
|
1411
|
+
fail('Invalid evidence cursor');
|
|
1412
|
+
try {
|
|
1413
|
+
const cursor = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8'));
|
|
1414
|
+
if (cursor.binding !== binding || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0)
|
|
1415
|
+
fail('Invalid evidence cursor');
|
|
1416
|
+
offset = cursor.offset;
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
fail('Invalid evidence cursor');
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const cursor = (next) => Buffer.from(JSON.stringify({ binding, offset: next })).toString('base64url');
|
|
1423
|
+
const load = (id) => {
|
|
1424
|
+
const file = safeChild(directory, id + '.json');
|
|
1425
|
+
if (lstatSync(file).size > 16 * 1024 * 1024)
|
|
1426
|
+
fail('Evidence exceeds size limit');
|
|
1427
|
+
const envelope = object(readJson(file)), document = envelope.document;
|
|
1428
|
+
if (!document || document.schemaVersion !== 1 || document.runId !== context.runId || document.id !== id || digest(canonical(document)) !== envelope.integrity)
|
|
1429
|
+
fail('Evidence integrity check failed');
|
|
1430
|
+
return document;
|
|
1431
|
+
};
|
|
1432
|
+
if (!existsSync(directory) || (query.id && !existsSync(safeChild(directory, query.id + '.json'))))
|
|
1433
|
+
return { schemaVersion: 1, available: false, items: [], truncated: false };
|
|
1434
|
+
if (!query.id) {
|
|
1435
|
+
const indexDirectory = safeChild(context.backlogRoot, '.specrails/pipeline/' + context.runId + '/verification/evidence-index');
|
|
1436
|
+
if (!existsSync(indexDirectory))
|
|
1437
|
+
return { schemaVersion: 1, available: false, items: [], truncated: false };
|
|
1438
|
+
const ids = readdirSync(indexDirectory).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).sort();
|
|
1439
|
+
if (ids.length > 10000)
|
|
1440
|
+
fail('Evidence index exceeds limit');
|
|
1441
|
+
const selected = ids.slice(offset, offset + limit);
|
|
1442
|
+
return { schemaVersion: 1, available: true, items: selected.map(file => {
|
|
1443
|
+
const indexFile = safeChild(indexDirectory, file);
|
|
1444
|
+
if (lstatSync(indexFile).size > 65536)
|
|
1445
|
+
fail('Evidence summary exceeds limit');
|
|
1446
|
+
const index = object(readJson(indexFile)), summary = object(index.summary);
|
|
1447
|
+
if (summary.id !== file.slice(0, -5) || summary.runId !== context.runId || index.integrity !== digest(canonical(summary)))
|
|
1448
|
+
fail('Evidence index integrity check failed');
|
|
1449
|
+
return summary;
|
|
1450
|
+
}), truncated: offset + selected.length < ids.length, ...(offset + selected.length < ids.length ? { nextCursor: cursor(offset + selected.length) } : {}) };
|
|
1451
|
+
}
|
|
1452
|
+
const document = load(query.id);
|
|
1453
|
+
if (section === 'summary')
|
|
1454
|
+
return { schemaVersion: 1, available: true, items: [evidenceSummary(document)], truncated: false };
|
|
1455
|
+
const source = section === 'source' ? document.sources.find(source => source.id === query.sourceId) : undefined;
|
|
1456
|
+
if (section === 'source' && !source)
|
|
1457
|
+
fail('Source does not belong to this evidence');
|
|
1458
|
+
let outputDocument = document;
|
|
1459
|
+
const visited = new Set([document.id]);
|
|
1460
|
+
while (outputDocument.reusedFrom && section !== 'source') {
|
|
1461
|
+
if (visited.has(outputDocument.reusedFrom) || visited.size > 100)
|
|
1462
|
+
fail('Invalid reused evidence chain');
|
|
1463
|
+
visited.add(outputDocument.reusedFrom);
|
|
1464
|
+
outputDocument = load(outputDocument.reusedFrom);
|
|
1465
|
+
}
|
|
1466
|
+
const content = source?.text ?? (section === 'stdout' ? outputDocument.stdout : outputDocument.stderr);
|
|
1467
|
+
// Character offsets are opaque. Stop at code-point boundaries within 64 KiB.
|
|
1468
|
+
if (offset > content.length || (offset > 0 && /[\uDC00-\uDFFF]/.test(content[offset] ?? '')))
|
|
1469
|
+
fail('Invalid evidence text cursor');
|
|
1470
|
+
let text = '', bytes = 0;
|
|
1471
|
+
for (const character of content.slice(offset)) {
|
|
1472
|
+
const size = Buffer.byteLength(character);
|
|
1473
|
+
if (bytes + size > 65536)
|
|
1474
|
+
break;
|
|
1475
|
+
text += character;
|
|
1476
|
+
bytes += size;
|
|
1477
|
+
}
|
|
1478
|
+
const next = offset + text.length;
|
|
1479
|
+
return { schemaVersion: 1, available: true, text, byteCount: bytes, totalBytes: Buffer.byteLength(content), truncated: next < content.length || document.outputTruncated, ...(next < content.length ? { nextCursor: cursor(next) } : {}) };
|
|
1480
|
+
}
|
|
1001
1481
|
//# sourceMappingURL=pipeline-state.js.map
|