1272 lines
37 KiB
TypeScript
1272 lines
37 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { inflateRawSync } from 'node:zlib'
|
|
|
|
const encoder = new TextEncoder()
|
|
const utf8 = new TextDecoder('utf-8', { fatal: true })
|
|
const sha256Pattern = /^[a-f0-9]{64}$/
|
|
const semverPattern =
|
|
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
|
const safePathPattern = /^[A-Za-z0-9._/-]+$/
|
|
const windowsReservedName = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i
|
|
|
|
export const defaultRunPackLimits = Object.freeze({
|
|
maxArchiveBytes: 10 * 1024 * 1024,
|
|
maxExpandedBytes: 50 * 1024 * 1024,
|
|
maxFiles: 500,
|
|
maxFileBytes: 5 * 1024 * 1024,
|
|
maxManifestFiles: 100,
|
|
})
|
|
|
|
export interface RunPackLimits {
|
|
readonly maxArchiveBytes: number
|
|
readonly maxExpandedBytes: number
|
|
readonly maxFiles: number
|
|
readonly maxFileBytes: number
|
|
readonly maxManifestFiles: number
|
|
}
|
|
|
|
export interface RunPackRunMetadata {
|
|
readonly id: string
|
|
readonly playbookId: string
|
|
readonly playbookVersion: string
|
|
readonly playbookDigest: string
|
|
readonly repositoryProfileDigest?: string | null
|
|
readonly renderDigest: string
|
|
readonly generatedAt: string
|
|
readonly platformVersion: string
|
|
}
|
|
|
|
export interface RunPackManifestFile {
|
|
readonly path: string
|
|
readonly mediaType: string
|
|
readonly sizeBytes: number
|
|
readonly sha256: string
|
|
}
|
|
|
|
export interface RunPackManifest {
|
|
readonly apiVersion: 'devrunbook.io/v1alpha1'
|
|
readonly kind: 'RunPackManifest'
|
|
readonly run: RunPackRunMetadata
|
|
readonly files: readonly RunPackManifestFile[]
|
|
readonly manifestDigest: string
|
|
}
|
|
|
|
export interface RunPackAdditionalFile {
|
|
readonly path: string
|
|
readonly mediaType: string
|
|
readonly content: string | Uint8Array
|
|
}
|
|
|
|
export interface CreateRunPackInput {
|
|
readonly slug: string
|
|
readonly run: RunPackRunMetadata
|
|
readonly renderedPrompt: string
|
|
readonly runbook?: string
|
|
readonly repositoryContext?: string
|
|
readonly validation?: string
|
|
readonly handoffTemplate?: string
|
|
readonly additionalFiles?: readonly RunPackAdditionalFile[]
|
|
readonly limits?: Partial<RunPackLimits>
|
|
}
|
|
|
|
export interface CreatedRunPack {
|
|
readonly filename: string
|
|
readonly rootDirectory: string
|
|
readonly bytes: Uint8Array
|
|
readonly sha256: string
|
|
readonly manifest: RunPackManifest
|
|
readonly taskMarkdown: string
|
|
}
|
|
|
|
export interface VerifiedRunPack {
|
|
readonly rootDirectory: string
|
|
readonly manifest: RunPackManifest
|
|
readonly files: ReadonlyMap<string, Uint8Array>
|
|
readonly archiveSha256: string
|
|
}
|
|
|
|
export type RunPackErrorCode =
|
|
| 'run_pack_input_invalid'
|
|
| 'run_pack_archive_limit'
|
|
| 'run_pack_path_unsafe'
|
|
| 'run_pack_archive_invalid'
|
|
| 'run_pack_manifest_invalid'
|
|
| 'run_pack_inventory_mismatch'
|
|
| 'run_pack_file_integrity_failed'
|
|
| 'run_pack_manifest_digest_failed'
|
|
|
|
export class RunPackError extends Error {
|
|
constructor(
|
|
readonly code: RunPackErrorCode,
|
|
readonly path: string,
|
|
message: string,
|
|
) {
|
|
super(`${path}: ${message}`)
|
|
this.name = 'RunPackError'
|
|
}
|
|
}
|
|
|
|
type JsonValue =
|
|
| null
|
|
| boolean
|
|
| number
|
|
| string
|
|
| JsonValue[]
|
|
| { readonly [key: string]: JsonValue }
|
|
|
|
function canonicalJson(value: JsonValue): string {
|
|
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
return `{${Object.keys(value)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key]!)}`)
|
|
.join(',')}}`
|
|
}
|
|
|
|
function digest(content: Uint8Array | string): string {
|
|
return createHash('sha256').update(content).digest('hex')
|
|
}
|
|
|
|
function normalizeText(value: string): string {
|
|
const normalized = value
|
|
.replace(/^\uFEFF/u, '')
|
|
.normalize('NFC')
|
|
.replace(/\r\n?/g, '\n')
|
|
.split('\n')
|
|
.map((line) => line.replace(/[\t ]+$/u, ''))
|
|
.join('\n')
|
|
.replace(/\n*$/u, '')
|
|
return `${normalized}\n`
|
|
}
|
|
|
|
function mergeLimits(overrides?: Partial<RunPackLimits>): RunPackLimits {
|
|
const maxFiles = overrides?.maxFiles ?? defaultRunPackLimits.maxFiles
|
|
const limits = {
|
|
...defaultRunPackLimits,
|
|
...overrides,
|
|
maxFiles,
|
|
maxManifestFiles:
|
|
overrides?.maxManifestFiles ??
|
|
Math.min(defaultRunPackLimits.maxManifestFiles, maxFiles - 1),
|
|
}
|
|
for (const [name, value] of Object.entries(limits)) {
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
`limits.${name}`,
|
|
'must be a positive safe integer',
|
|
)
|
|
}
|
|
}
|
|
if (limits.maxManifestFiles > limits.maxFiles - 1) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
'limits.maxManifestFiles',
|
|
'must leave room for manifest.json within maxFiles',
|
|
)
|
|
}
|
|
return limits
|
|
}
|
|
|
|
function assertSafePath(value: string, label: string): void {
|
|
if (
|
|
value.length === 0 ||
|
|
value.length > 500 ||
|
|
!safePathPattern.test(value) ||
|
|
value.startsWith('/') ||
|
|
value.endsWith('/') ||
|
|
value.includes('//') ||
|
|
value.includes('\\') ||
|
|
value.includes('\0')
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_path_unsafe',
|
|
label,
|
|
'must be a normalized relative ASCII file path',
|
|
)
|
|
}
|
|
for (const segment of value.split('/')) {
|
|
if (
|
|
segment === '.' ||
|
|
segment === '..' ||
|
|
segment.endsWith('.') ||
|
|
segment.endsWith(' ') ||
|
|
windowsReservedName.test(segment)
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_path_unsafe',
|
|
label,
|
|
`contains unsafe path segment ${JSON.stringify(segment)}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function sanitizeNamePart(value: string, fallback: string): string {
|
|
const result = value
|
|
.normalize('NFKD')
|
|
.replace(/[^A-Za-z0-9-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.replace(/-+/g, '-')
|
|
.slice(0, 80)
|
|
const safe = result.length > 0 ? result : fallback
|
|
return windowsReservedName.test(safe) ? `run-${safe}` : safe
|
|
}
|
|
|
|
function assertDigest(value: unknown, label: string): asserts value is string {
|
|
if (typeof value !== 'string' || !sha256Pattern.test(value)) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
label,
|
|
'must be a lowercase SHA-256 digest',
|
|
)
|
|
}
|
|
}
|
|
|
|
function assertRunMetadata(run: RunPackRunMetadata): void {
|
|
if (
|
|
typeof run.id !== 'string' ||
|
|
run.id.length < 8 ||
|
|
run.id.length > 100 ||
|
|
Array.from(run.id).some((character) => character.charCodeAt(0) < 0x20)
|
|
) {
|
|
throw new RunPackError('run_pack_input_invalid', 'run.id', 'is invalid')
|
|
}
|
|
if (
|
|
typeof run.playbookId !== 'string' ||
|
|
run.playbookId.length < 3 ||
|
|
run.playbookId.length > 120
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
'run.playbookId',
|
|
'is invalid',
|
|
)
|
|
}
|
|
if (
|
|
typeof run.playbookVersion !== 'string' ||
|
|
!semverPattern.test(run.playbookVersion)
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
'run.playbookVersion',
|
|
'must be a semantic version',
|
|
)
|
|
}
|
|
assertDigest(run.playbookDigest, 'run.playbookDigest')
|
|
assertDigest(run.renderDigest, 'run.renderDigest')
|
|
if (
|
|
run.repositoryProfileDigest !== undefined &&
|
|
run.repositoryProfileDigest !== null
|
|
) {
|
|
assertDigest(run.repositoryProfileDigest, 'run.repositoryProfileDigest')
|
|
}
|
|
if (
|
|
typeof run.generatedAt !== 'string' ||
|
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u.test(
|
|
run.generatedAt,
|
|
) ||
|
|
!Number.isFinite(Date.parse(run.generatedAt))
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
'run.generatedAt',
|
|
'must be an RFC 3339 UTC timestamp',
|
|
)
|
|
}
|
|
if (
|
|
typeof run.platformVersion !== 'string' ||
|
|
run.platformVersion.length < 1 ||
|
|
run.platformVersion.length > 80
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
'run.platformVersion',
|
|
'is invalid',
|
|
)
|
|
}
|
|
}
|
|
|
|
export function createTaskMarkdown(
|
|
run: RunPackRunMetadata,
|
|
renderedPrompt: string,
|
|
): string {
|
|
assertRunMetadata(run)
|
|
const prompt = normalizeText(renderedPrompt)
|
|
if (digest(prompt) !== run.renderDigest) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
'renderedPrompt',
|
|
'normalized prompt bytes do not match run.renderDigest',
|
|
)
|
|
}
|
|
const metadata = JSON.stringify(taskMetadata(run), null, 2)
|
|
return normalizeText(
|
|
`<!-- DevRunbook immutable generated task -->\n\n## Run metadata\n\n\`\`\`json\n${metadata}\n\`\`\`\n\n${prompt}`,
|
|
)
|
|
}
|
|
|
|
function taskMetadata(
|
|
run: RunPackRunMetadata,
|
|
): Readonly<Record<string, string | null>> {
|
|
return {
|
|
generatedAt: run.generatedAt,
|
|
platformVersion: run.platformVersion,
|
|
playbookDigest: run.playbookDigest,
|
|
playbookId: run.playbookId,
|
|
playbookVersion: run.playbookVersion,
|
|
renderDigest: run.renderDigest,
|
|
repositoryProfileDigest: run.repositoryProfileDigest ?? null,
|
|
runId: run.id,
|
|
}
|
|
}
|
|
|
|
function defaultRunbook(run: RunPackRunMetadata): string {
|
|
return normalizeText(
|
|
`# DevRunbook Run Pack\n\nUse \`TASK.md\` as the authoritative generated task. ` +
|
|
`Consult \`REPOSITORY_CONTEXT.md\`, \`VALIDATION.md\`, and ` +
|
|
`\`HANDOFF_TEMPLATE.md\` only for their named purposes.\n\n` +
|
|
`Run ID: \`${run.id.replace(/`/g, '')}\`\n`,
|
|
)
|
|
}
|
|
|
|
const defaultValidation = normalizeText(
|
|
'# Validation\n\nFollow the validation contract in `TASK.md`. Record the exact commands run, their outcomes, and any skipped checks with reasons.\n',
|
|
)
|
|
|
|
const defaultHandoff = normalizeText(
|
|
'# Handoff\n\n## Outcome\n\n## Changed files\n\n## Validation evidence\n\n## Risks and limitations\n\n## Unresolved items\n\n## Recommended follow-up\n',
|
|
)
|
|
|
|
interface PayloadFile {
|
|
readonly path: string
|
|
readonly mediaType: string
|
|
readonly bytes: Uint8Array
|
|
}
|
|
|
|
function bytesFor(content: string | Uint8Array): Uint8Array {
|
|
return typeof content === 'string'
|
|
? encoder.encode(normalizeText(content))
|
|
: content.slice()
|
|
}
|
|
|
|
function compareUtf8(left: string, right: string): number {
|
|
return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'))
|
|
}
|
|
|
|
function addPayloadFile(
|
|
files: PayloadFile[],
|
|
seen: Set<string>,
|
|
file: RunPackAdditionalFile,
|
|
limits: RunPackLimits,
|
|
): void {
|
|
assertSafePath(file.path, `files.${file.path}`)
|
|
if (file.path.toLowerCase() === 'manifest.json') {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
`files.${file.path}`,
|
|
'manifest.json is generated by the platform',
|
|
)
|
|
}
|
|
const collisionKey = file.path.toLowerCase()
|
|
if (seen.has(collisionKey)) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
`files.${file.path}`,
|
|
'duplicates another file on a case-insensitive filesystem',
|
|
)
|
|
}
|
|
if (
|
|
file.mediaType.length < 3 ||
|
|
file.mediaType.length > 120 ||
|
|
/[\r\n]/u.test(file.mediaType)
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_input_invalid',
|
|
`files.${file.path}.mediaType`,
|
|
'is invalid',
|
|
)
|
|
}
|
|
const bytes = bytesFor(file.content)
|
|
if (bytes.byteLength > limits.maxFileBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
`files.${file.path}`,
|
|
`exceeds the ${limits.maxFileBytes} byte single-file limit`,
|
|
)
|
|
}
|
|
seen.add(collisionKey)
|
|
files.push({ path: file.path, mediaType: file.mediaType, bytes })
|
|
}
|
|
|
|
function manifestWithoutDigest(
|
|
run: RunPackRunMetadata,
|
|
files: readonly RunPackManifestFile[],
|
|
): Omit<RunPackManifest, 'manifestDigest'> {
|
|
return {
|
|
apiVersion: 'devrunbook.io/v1alpha1',
|
|
kind: 'RunPackManifest',
|
|
run: {
|
|
id: run.id,
|
|
playbookId: run.playbookId,
|
|
playbookVersion: run.playbookVersion,
|
|
playbookDigest: run.playbookDigest,
|
|
repositoryProfileDigest: run.repositoryProfileDigest ?? null,
|
|
renderDigest: run.renderDigest,
|
|
generatedAt: run.generatedAt,
|
|
platformVersion: run.platformVersion,
|
|
},
|
|
files,
|
|
}
|
|
}
|
|
|
|
export function createRunPack(input: CreateRunPackInput): CreatedRunPack {
|
|
const limits = mergeLimits(input.limits)
|
|
assertRunMetadata(input.run)
|
|
const taskMarkdown = createTaskMarkdown(input.run, input.renderedPrompt)
|
|
const files: PayloadFile[] = []
|
|
const seen = new Set<string>()
|
|
const standard: RunPackAdditionalFile[] = [
|
|
{
|
|
path: 'RUNBOOK.md',
|
|
mediaType: 'text/markdown; charset=utf-8',
|
|
content: input.runbook ?? defaultRunbook(input.run),
|
|
},
|
|
{
|
|
path: 'TASK.md',
|
|
mediaType: 'text/markdown; charset=utf-8',
|
|
content: taskMarkdown,
|
|
},
|
|
...(input.repositoryContext === undefined
|
|
? []
|
|
: [
|
|
{
|
|
path: 'REPOSITORY_CONTEXT.md',
|
|
mediaType: 'text/markdown; charset=utf-8',
|
|
content: input.repositoryContext,
|
|
},
|
|
]),
|
|
{
|
|
path: 'VALIDATION.md',
|
|
mediaType: 'text/markdown; charset=utf-8',
|
|
content: input.validation ?? defaultValidation,
|
|
},
|
|
{
|
|
path: 'HANDOFF_TEMPLATE.md',
|
|
mediaType: 'text/markdown; charset=utf-8',
|
|
content: input.handoffTemplate ?? defaultHandoff,
|
|
},
|
|
]
|
|
for (const file of [...standard, ...(input.additionalFiles ?? [])]) {
|
|
addPayloadFile(files, seen, file, limits)
|
|
}
|
|
if (files.length > limits.maxManifestFiles) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'files',
|
|
`exceeds the ${limits.maxManifestFiles} manifest-file limit`,
|
|
)
|
|
}
|
|
const expandedBytes = files.reduce(
|
|
(total, file) => total + file.bytes.byteLength,
|
|
0,
|
|
)
|
|
if (expandedBytes > limits.maxExpandedBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'files',
|
|
`exceeds the ${limits.maxExpandedBytes} byte expanded limit`,
|
|
)
|
|
}
|
|
files.sort((left, right) => compareUtf8(left.path, right.path))
|
|
const manifestFiles: RunPackManifestFile[] = files.map((file) => ({
|
|
path: file.path,
|
|
mediaType: file.mediaType,
|
|
sizeBytes: file.bytes.byteLength,
|
|
sha256: digest(file.bytes),
|
|
}))
|
|
const unsigned = manifestWithoutDigest(input.run, manifestFiles)
|
|
const manifest: RunPackManifest = {
|
|
...unsigned,
|
|
manifestDigest: digest(canonicalJson(unsigned as unknown as JsonValue)),
|
|
}
|
|
const manifestBytes = encoder.encode(`${JSON.stringify(manifest, null, 2)}\n`)
|
|
if (manifestBytes.byteLength > limits.maxFileBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'manifest.json',
|
|
'exceeds the single-file limit',
|
|
)
|
|
}
|
|
if (expandedBytes + manifestBytes.byteLength > limits.maxExpandedBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'archive',
|
|
`exceeds the ${limits.maxExpandedBytes} byte expanded limit`,
|
|
)
|
|
}
|
|
const rootDirectory = `DevRunbook-${sanitizeNamePart(input.slug, 'run')}-${sanitizeNamePart(input.run.id.slice(0, 12), 'task')}`
|
|
assertSafePath(`${rootDirectory}/TASK.md`, 'rootDirectory')
|
|
const entries = [
|
|
...files.map((file) => ({
|
|
path: `${rootDirectory}/${file.path}`,
|
|
bytes: file.bytes,
|
|
})),
|
|
{ path: `${rootDirectory}/manifest.json`, bytes: manifestBytes },
|
|
].sort((left, right) => compareUtf8(left.path, right.path))
|
|
const archive = encodeDeterministicZip(entries)
|
|
if (archive.byteLength > limits.maxArchiveBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'archive',
|
|
`exceeds the ${limits.maxArchiveBytes} byte compressed limit`,
|
|
)
|
|
}
|
|
return Object.freeze({
|
|
filename: `${rootDirectory}.zip`,
|
|
rootDirectory,
|
|
bytes: archive,
|
|
sha256: digest(archive),
|
|
manifest: Object.freeze(manifest),
|
|
taskMarkdown,
|
|
})
|
|
}
|
|
|
|
const crcTable = Uint32Array.from({ length: 256 }, (_, index) => {
|
|
let value = index
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
|
}
|
|
return value >>> 0
|
|
})
|
|
|
|
function crc32(bytes: Uint8Array): number {
|
|
let crc = 0xffffffff
|
|
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8)
|
|
return (crc ^ 0xffffffff) >>> 0
|
|
}
|
|
|
|
function localHeader(name: Buffer, bytes: Uint8Array): Buffer {
|
|
const header = Buffer.alloc(30)
|
|
header.writeUInt32LE(0x04034b50, 0)
|
|
header.writeUInt16LE(20, 4)
|
|
header.writeUInt16LE(0x0800, 6)
|
|
header.writeUInt16LE(0, 8)
|
|
header.writeUInt16LE(0, 10)
|
|
header.writeUInt16LE(0x0021, 12)
|
|
header.writeUInt32LE(crc32(bytes), 14)
|
|
header.writeUInt32LE(bytes.byteLength, 18)
|
|
header.writeUInt32LE(bytes.byteLength, 22)
|
|
header.writeUInt16LE(name.byteLength, 26)
|
|
header.writeUInt16LE(0, 28)
|
|
return header
|
|
}
|
|
|
|
function centralHeader(
|
|
name: Buffer,
|
|
bytes: Uint8Array,
|
|
offset: number,
|
|
): Buffer {
|
|
const header = Buffer.alloc(46)
|
|
header.writeUInt32LE(0x02014b50, 0)
|
|
header.writeUInt16LE(0x0314, 4)
|
|
header.writeUInt16LE(20, 6)
|
|
header.writeUInt16LE(0x0800, 8)
|
|
header.writeUInt16LE(0, 10)
|
|
header.writeUInt16LE(0, 12)
|
|
header.writeUInt16LE(0x0021, 14)
|
|
header.writeUInt32LE(crc32(bytes), 16)
|
|
header.writeUInt32LE(bytes.byteLength, 20)
|
|
header.writeUInt32LE(bytes.byteLength, 24)
|
|
header.writeUInt16LE(name.byteLength, 28)
|
|
header.writeUInt16LE(0, 30)
|
|
header.writeUInt16LE(0, 32)
|
|
header.writeUInt16LE(0, 34)
|
|
header.writeUInt16LE(0, 36)
|
|
header.writeUInt32LE((0o100644 * 65_536) >>> 0, 38)
|
|
header.writeUInt32LE(offset, 42)
|
|
return header
|
|
}
|
|
|
|
export function encodeDeterministicZip(
|
|
entries: readonly { readonly path: string; readonly bytes: Uint8Array }[],
|
|
): Uint8Array {
|
|
const localParts: Buffer[] = []
|
|
const centralParts: Buffer[] = []
|
|
let offset = 0
|
|
for (const entry of entries) {
|
|
const name = Buffer.from(entry.path, 'utf8')
|
|
const local = localHeader(name, entry.bytes)
|
|
localParts.push(local, name, Buffer.from(entry.bytes))
|
|
centralParts.push(centralHeader(name, entry.bytes, offset), name)
|
|
offset += local.byteLength + name.byteLength + entry.bytes.byteLength
|
|
}
|
|
const centralSize = centralParts.reduce(
|
|
(total, part) => total + part.byteLength,
|
|
0,
|
|
)
|
|
const end = Buffer.alloc(22)
|
|
end.writeUInt32LE(0x06054b50, 0)
|
|
end.writeUInt16LE(0, 4)
|
|
end.writeUInt16LE(0, 6)
|
|
end.writeUInt16LE(entries.length, 8)
|
|
end.writeUInt16LE(entries.length, 10)
|
|
end.writeUInt32LE(centralSize, 12)
|
|
end.writeUInt32LE(offset, 16)
|
|
end.writeUInt16LE(0, 20)
|
|
return new Uint8Array(Buffer.concat([...localParts, ...centralParts, end]))
|
|
}
|
|
|
|
export interface BoundedZipEntry {
|
|
readonly path: string
|
|
readonly bytes: Uint8Array
|
|
}
|
|
|
|
function archiveFailure(path: string, message: string): never {
|
|
throw new RunPackError('run_pack_archive_invalid', path, message)
|
|
}
|
|
|
|
export function readBoundedZip(
|
|
bytes: Uint8Array,
|
|
limits: Pick<
|
|
RunPackLimits,
|
|
'maxArchiveBytes' | 'maxExpandedBytes' | 'maxFiles' | 'maxFileBytes'
|
|
>,
|
|
): BoundedZipEntry[] {
|
|
if (bytes.byteLength > limits.maxArchiveBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'archive',
|
|
'compressed archive limit exceeded',
|
|
)
|
|
}
|
|
if (bytes.byteLength < 22)
|
|
archiveFailure('archive', 'ZIP end record is missing')
|
|
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
const endOffset = buffer.byteLength - 22
|
|
if (
|
|
buffer.readUInt32LE(endOffset) !== 0x06054b50 ||
|
|
buffer.readUInt16LE(endOffset + 20) !== 0
|
|
) {
|
|
archiveFailure(
|
|
'archive',
|
|
'ZIP comments, trailing data, and missing end records are rejected',
|
|
)
|
|
}
|
|
const disk = buffer.readUInt16LE(endOffset + 4)
|
|
const centralDisk = buffer.readUInt16LE(endOffset + 6)
|
|
const diskEntries = buffer.readUInt16LE(endOffset + 8)
|
|
const entryCount = buffer.readUInt16LE(endOffset + 10)
|
|
const centralSize = buffer.readUInt32LE(endOffset + 12)
|
|
const centralOffset = buffer.readUInt32LE(endOffset + 16)
|
|
if (disk !== 0 || centralDisk !== 0 || diskEntries !== entryCount) {
|
|
archiveFailure('archive', 'multi-disk ZIP archives are rejected')
|
|
}
|
|
if (entryCount === 0 || entryCount > limits.maxFiles) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'archive.files',
|
|
'file-count limit exceeded',
|
|
)
|
|
}
|
|
if (centralOffset + centralSize !== endOffset)
|
|
archiveFailure('archive', 'central directory bounds are invalid')
|
|
const entries: BoundedZipEntry[] = []
|
|
const seen = new Set<string>()
|
|
const offsets = new Set<number>()
|
|
const occupiedLocalRegions: {
|
|
readonly start: number
|
|
readonly end: number
|
|
readonly path: string
|
|
}[] = []
|
|
let expandedTotal = 0
|
|
let cursor = centralOffset
|
|
for (let index = 0; index < entryCount; index += 1) {
|
|
if (cursor + 46 > endOffset || buffer.readUInt32LE(cursor) !== 0x02014b50) {
|
|
archiveFailure(
|
|
`archive.entries[${index}]`,
|
|
'central-directory entry is invalid',
|
|
)
|
|
}
|
|
const madeBy = buffer.readUInt16LE(cursor + 4)
|
|
const flags = buffer.readUInt16LE(cursor + 8)
|
|
const method = buffer.readUInt16LE(cursor + 10)
|
|
const declaredCrc = buffer.readUInt32LE(cursor + 16)
|
|
const compressedSize = buffer.readUInt32LE(cursor + 20)
|
|
const expandedSize = buffer.readUInt32LE(cursor + 24)
|
|
const nameLength = buffer.readUInt16LE(cursor + 28)
|
|
const extraLength = buffer.readUInt16LE(cursor + 30)
|
|
const commentLength = buffer.readUInt16LE(cursor + 32)
|
|
const diskStart = buffer.readUInt16LE(cursor + 34)
|
|
const externalAttributes = buffer.readUInt32LE(cursor + 38)
|
|
const localOffset = buffer.readUInt32LE(cursor + 42)
|
|
const next = cursor + 46 + nameLength + extraLength + commentLength
|
|
if (next > endOffset)
|
|
archiveFailure(
|
|
`archive.entries[${index}]`,
|
|
'entry metadata exceeds archive bounds',
|
|
)
|
|
let entryPath: string
|
|
try {
|
|
entryPath = utf8.decode(
|
|
buffer.subarray(cursor + 46, cursor + 46 + nameLength),
|
|
)
|
|
} catch {
|
|
archiveFailure(
|
|
`archive.entries[${index}].path`,
|
|
'path is not valid UTF-8',
|
|
)
|
|
}
|
|
assertSafePath(entryPath!, `archive.entries[${index}].path`)
|
|
const collisionKey = entryPath!.toLowerCase()
|
|
if (seen.has(collisionKey))
|
|
archiveFailure(entryPath!, 'duplicate or case-colliding path')
|
|
seen.add(collisionKey)
|
|
if (diskStart !== 0 || (flags & 0x0001) !== 0 || (flags & 0x0008) !== 0) {
|
|
archiveFailure(
|
|
entryPath!,
|
|
'encrypted, split, or data-descriptor entries are rejected',
|
|
)
|
|
}
|
|
if (method !== 0 && method !== 8)
|
|
archiveFailure(entryPath!, 'unsupported compression method')
|
|
const allowedFlags = 0x0800 | (method === 8 ? 0x0006 : 0)
|
|
if ((flags & 0x0800) === 0 || (flags & ~allowedFlags) !== 0) {
|
|
archiveFailure(
|
|
entryPath!,
|
|
'unsupported or ambiguous ZIP flags are rejected',
|
|
)
|
|
}
|
|
const origin = madeBy >>> 8
|
|
const unixMode = externalAttributes >>> 16
|
|
if (
|
|
(externalAttributes & 0x10) !== 0 ||
|
|
(origin === 3 && (unixMode & 0o170000) !== 0o100000)
|
|
) {
|
|
archiveFailure(
|
|
entryPath!,
|
|
'directory, symlink, device, and non-regular entries are rejected',
|
|
)
|
|
}
|
|
if (expandedSize > limits.maxFileBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
entryPath!,
|
|
'single-file expanded limit exceeded',
|
|
)
|
|
}
|
|
expandedTotal += expandedSize
|
|
if (expandedTotal > limits.maxExpandedBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_archive_limit',
|
|
'archive',
|
|
'expanded archive limit exceeded',
|
|
)
|
|
}
|
|
if (offsets.has(localOffset))
|
|
archiveFailure(entryPath!, 'multiple entries reference one local header')
|
|
offsets.add(localOffset)
|
|
if (
|
|
localOffset + 30 > centralOffset ||
|
|
buffer.readUInt32LE(localOffset) !== 0x04034b50
|
|
) {
|
|
archiveFailure(entryPath!, 'local header is invalid')
|
|
}
|
|
const localFlags = buffer.readUInt16LE(localOffset + 6)
|
|
const localMethod = buffer.readUInt16LE(localOffset + 8)
|
|
const localCrc = buffer.readUInt32LE(localOffset + 14)
|
|
const localCompressedSize = buffer.readUInt32LE(localOffset + 18)
|
|
const localExpandedSize = buffer.readUInt32LE(localOffset + 22)
|
|
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
|
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
|
const dataOffset = localOffset + 30 + localNameLength + localExtraLength
|
|
const dataEnd = dataOffset + compressedSize
|
|
const overlap = occupiedLocalRegions.find(
|
|
(region) => localOffset < region.end && dataEnd > region.start,
|
|
)
|
|
if (overlap) {
|
|
archiveFailure(entryPath!, `local data overlaps ${overlap.path}`)
|
|
}
|
|
occupiedLocalRegions.push({
|
|
start: localOffset,
|
|
end: dataEnd,
|
|
path: entryPath!,
|
|
})
|
|
if (
|
|
dataEnd > centralOffset ||
|
|
localFlags !== flags ||
|
|
localMethod !== method ||
|
|
localCrc !== declaredCrc ||
|
|
localCompressedSize !== compressedSize ||
|
|
localExpandedSize !== expandedSize ||
|
|
!buffer
|
|
.subarray(localOffset + 30, localOffset + 30 + localNameLength)
|
|
.equals(buffer.subarray(cursor + 46, cursor + 46 + nameLength))
|
|
) {
|
|
archiveFailure(entryPath!, 'local and central metadata do not match')
|
|
}
|
|
const compressed = buffer.subarray(dataOffset, dataEnd)
|
|
let content: Buffer
|
|
try {
|
|
content =
|
|
method === 0
|
|
? Buffer.from(compressed)
|
|
: inflateRawSync(compressed, { maxOutputLength: limits.maxFileBytes })
|
|
} catch {
|
|
archiveFailure(
|
|
entryPath!,
|
|
'compressed payload is invalid or exceeds limits',
|
|
)
|
|
}
|
|
if (content.byteLength !== expandedSize || crc32(content) !== declaredCrc) {
|
|
archiveFailure(entryPath!, 'expanded size or CRC does not match')
|
|
}
|
|
entries.push({ path: entryPath!, bytes: new Uint8Array(content) })
|
|
cursor = next
|
|
}
|
|
if (cursor !== endOffset)
|
|
archiveFailure('archive', 'central directory contains undeclared bytes')
|
|
return entries
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function assertNoDuplicateJsonKeys(source: string): void {
|
|
let cursor = 0
|
|
const fail = (): never => {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
'manifest.json',
|
|
'contains duplicate keys or invalid JSON structure',
|
|
)
|
|
}
|
|
const whitespace = (): void => {
|
|
while (/\s/u.test(source[cursor] ?? '')) cursor += 1
|
|
}
|
|
const stringToken = (): string => {
|
|
if (source[cursor] !== '"') fail()
|
|
const start = cursor
|
|
cursor += 1
|
|
while (cursor < source.length) {
|
|
const character = source[cursor++]!
|
|
if (character === '"') {
|
|
try {
|
|
return JSON.parse(source.slice(start, cursor)) as string
|
|
} catch {
|
|
fail()
|
|
}
|
|
}
|
|
if (character === '\\') cursor += 1
|
|
else if (character < ' ') fail()
|
|
}
|
|
return fail()
|
|
}
|
|
const value = (): void => {
|
|
whitespace()
|
|
const character = source[cursor]
|
|
if (character === '{') {
|
|
cursor += 1
|
|
whitespace()
|
|
const keys = new Set<string>()
|
|
if (source[cursor] === '}') {
|
|
cursor += 1
|
|
return
|
|
}
|
|
while (true) {
|
|
whitespace()
|
|
const key = stringToken()
|
|
if (keys.has(key)) fail()
|
|
keys.add(key)
|
|
whitespace()
|
|
if (source[cursor++] !== ':') fail()
|
|
value()
|
|
whitespace()
|
|
const separator = source[cursor++]
|
|
if (separator === '}') return
|
|
if (separator !== ',') fail()
|
|
}
|
|
}
|
|
if (character === '[') {
|
|
cursor += 1
|
|
whitespace()
|
|
if (source[cursor] === ']') {
|
|
cursor += 1
|
|
return
|
|
}
|
|
while (true) {
|
|
value()
|
|
whitespace()
|
|
const separator = source[cursor++]
|
|
if (separator === ']') return
|
|
if (separator !== ',') fail()
|
|
}
|
|
}
|
|
if (character === '"') {
|
|
stringToken()
|
|
return
|
|
}
|
|
const match = source
|
|
.slice(cursor)
|
|
.match(
|
|
/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/u,
|
|
)
|
|
if (match === null) return fail()
|
|
cursor += match[0].length
|
|
}
|
|
value()
|
|
whitespace()
|
|
if (cursor !== source.length) fail()
|
|
}
|
|
|
|
function assertExactKeys(
|
|
value: Record<string, unknown>,
|
|
keys: readonly string[],
|
|
label: string,
|
|
): void {
|
|
const actual = Object.keys(value).sort()
|
|
const expected = [...keys].sort()
|
|
if (
|
|
actual.length !== expected.length ||
|
|
actual.some((key, index) => key !== expected[index])
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
label,
|
|
'contains missing or unknown properties',
|
|
)
|
|
}
|
|
}
|
|
|
|
function parseManifest(
|
|
bytes: Uint8Array,
|
|
limits: RunPackLimits,
|
|
): RunPackManifest {
|
|
let value: unknown
|
|
try {
|
|
const source = utf8.decode(bytes)
|
|
assertNoDuplicateJsonKeys(source)
|
|
value = JSON.parse(source)
|
|
} catch {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
'manifest.json',
|
|
'must be valid UTF-8 JSON',
|
|
)
|
|
}
|
|
if (!isRecord(value))
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
'manifest.json',
|
|
'must be an object',
|
|
)
|
|
assertExactKeys(
|
|
value,
|
|
['apiVersion', 'kind', 'run', 'files', 'manifestDigest'],
|
|
'manifest.json',
|
|
)
|
|
if (
|
|
value.apiVersion !== 'devrunbook.io/v1alpha1' ||
|
|
value.kind !== 'RunPackManifest'
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
'manifest.json',
|
|
'has an unsupported identity',
|
|
)
|
|
}
|
|
if (!isRecord(value.run))
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
'manifest.json.run',
|
|
'must be an object',
|
|
)
|
|
const runKeys = [
|
|
'id',
|
|
'playbookId',
|
|
'playbookVersion',
|
|
'playbookDigest',
|
|
'renderDigest',
|
|
'generatedAt',
|
|
'platformVersion',
|
|
]
|
|
if ('repositoryProfileDigest' in value.run)
|
|
runKeys.push('repositoryProfileDigest')
|
|
assertExactKeys(value.run, runKeys, 'manifest.json.run')
|
|
const run = value.run as unknown as RunPackRunMetadata
|
|
try {
|
|
assertRunMetadata(run)
|
|
} catch (error) {
|
|
if (error instanceof RunPackError) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
`manifest.json.${error.path}`,
|
|
error.message.slice(error.message.indexOf(':') + 2),
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
if (
|
|
!Array.isArray(value.files) ||
|
|
value.files.length < 1 ||
|
|
value.files.length > limits.maxManifestFiles
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
'manifest.json.files',
|
|
'has an invalid file count',
|
|
)
|
|
}
|
|
const files: RunPackManifestFile[] = []
|
|
const seen = new Set<string>()
|
|
for (const [index, candidate] of value.files.entries()) {
|
|
const label = `manifest.json.files[${index}]`
|
|
if (!isRecord(candidate))
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
label,
|
|
'must be an object',
|
|
)
|
|
assertExactKeys(
|
|
candidate,
|
|
['path', 'mediaType', 'sizeBytes', 'sha256'],
|
|
label,
|
|
)
|
|
if (typeof candidate.path !== 'string')
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
`${label}.path`,
|
|
'must be a string',
|
|
)
|
|
assertSafePath(candidate.path, `${label}.path`)
|
|
if (candidate.path.toLowerCase() === 'manifest.json') {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
`${label}.path`,
|
|
'must not declare manifest.json',
|
|
)
|
|
}
|
|
const collisionKey = candidate.path.toLowerCase()
|
|
if (seen.has(collisionKey))
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
`${label}.path`,
|
|
'duplicates another path',
|
|
)
|
|
seen.add(collisionKey)
|
|
if (
|
|
typeof candidate.mediaType !== 'string' ||
|
|
candidate.mediaType.length < 3 ||
|
|
candidate.mediaType.length > 120 ||
|
|
/[\r\n]/u.test(candidate.mediaType)
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
`${label}.mediaType`,
|
|
'is invalid',
|
|
)
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(candidate.sizeBytes) ||
|
|
(candidate.sizeBytes as number) < 0 ||
|
|
(candidate.sizeBytes as number) > limits.maxFileBytes
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
`${label}.sizeBytes`,
|
|
'is invalid',
|
|
)
|
|
}
|
|
assertDigest(candidate.sha256, `${label}.sha256`)
|
|
files.push(candidate as unknown as RunPackManifestFile)
|
|
}
|
|
const sorted = [...files].sort((left, right) =>
|
|
compareUtf8(left.path, right.path),
|
|
)
|
|
if (files.some((file, index) => file.path !== sorted[index]!.path)) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_invalid',
|
|
'manifest.json.files',
|
|
'must be sorted by UTF-8 path bytes',
|
|
)
|
|
}
|
|
assertDigest(value.manifestDigest, 'manifest.json.manifestDigest')
|
|
return {
|
|
apiVersion: 'devrunbook.io/v1alpha1',
|
|
kind: 'RunPackManifest',
|
|
run,
|
|
files,
|
|
manifestDigest: value.manifestDigest,
|
|
}
|
|
}
|
|
|
|
export function verifyRunPack(
|
|
archive: Uint8Array,
|
|
limitOverrides?: Partial<RunPackLimits>,
|
|
): VerifiedRunPack {
|
|
const limits = mergeLimits(limitOverrides)
|
|
const entries = readBoundedZip(archive, limits)
|
|
const firstSlash = entries[0]!.path.indexOf('/')
|
|
if (firstSlash <= 0)
|
|
archiveFailure(
|
|
entries[0]!.path,
|
|
'all Run Pack files must be below one root directory',
|
|
)
|
|
const rootDirectory = entries[0]!.path.slice(0, firstSlash)
|
|
assertSafePath(`${rootDirectory}/placeholder`, 'archive.rootDirectory')
|
|
for (const entry of entries) {
|
|
if (
|
|
!entry.path.startsWith(`${rootDirectory}/`) ||
|
|
entry.path.slice(rootDirectory.length + 1).includes('../')
|
|
) {
|
|
archiveFailure(
|
|
entry.path,
|
|
'all Run Pack files must share one root directory',
|
|
)
|
|
}
|
|
}
|
|
const relative = new Map(
|
|
entries.map((entry) => [
|
|
entry.path.slice(rootDirectory.length + 1),
|
|
entry.bytes,
|
|
]),
|
|
)
|
|
const manifestBytes = relative.get('manifest.json')
|
|
if (!manifestBytes)
|
|
throw new RunPackError(
|
|
'run_pack_inventory_mismatch',
|
|
'manifest.json',
|
|
'is missing',
|
|
)
|
|
const manifest = parseManifest(manifestBytes, limits)
|
|
const actualPaths = [...relative.keys()]
|
|
.filter((path) => path !== 'manifest.json')
|
|
.sort(compareUtf8)
|
|
const declaredPaths = manifest.files.map((file) => file.path)
|
|
if (actualPaths.length !== declaredPaths.length) {
|
|
throw new RunPackError(
|
|
'run_pack_inventory_mismatch',
|
|
'manifest.json.files',
|
|
'does not match the archive file set',
|
|
)
|
|
}
|
|
for (let index = 0; index < actualPaths.length; index += 1) {
|
|
if (actualPaths[index] !== declaredPaths[index]) {
|
|
throw new RunPackError(
|
|
'run_pack_inventory_mismatch',
|
|
actualPaths[index] ?? declaredPaths[index]!,
|
|
'is missing or undeclared',
|
|
)
|
|
}
|
|
}
|
|
for (const declared of manifest.files) {
|
|
const content = relative.get(declared.path)!
|
|
if (content.byteLength !== declared.sizeBytes) {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
declared.path,
|
|
'size does not match manifest',
|
|
)
|
|
}
|
|
if (digest(content) !== declared.sha256) {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
declared.path,
|
|
'SHA-256 does not match manifest',
|
|
)
|
|
}
|
|
}
|
|
const { manifestDigest: ignored, ...unsigned } = manifest
|
|
void ignored
|
|
const calculatedManifestDigest = digest(
|
|
canonicalJson(unsigned as unknown as JsonValue),
|
|
)
|
|
if (calculatedManifestDigest !== manifest.manifestDigest) {
|
|
throw new RunPackError(
|
|
'run_pack_manifest_digest_failed',
|
|
'manifest.json.manifestDigest',
|
|
'does not match canonical manifest bytes',
|
|
)
|
|
}
|
|
for (const requiredPath of [
|
|
'RUNBOOK.md',
|
|
'TASK.md',
|
|
'VALIDATION.md',
|
|
'HANDOFF_TEMPLATE.md',
|
|
]) {
|
|
if (!relative.has(requiredPath)) {
|
|
throw new RunPackError(
|
|
'run_pack_inventory_mismatch',
|
|
requiredPath,
|
|
'is required',
|
|
)
|
|
}
|
|
}
|
|
const task = relative.get('TASK.md')!
|
|
let taskText: string
|
|
try {
|
|
taskText = utf8.decode(task)
|
|
} catch {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
'TASK.md',
|
|
'must be valid UTF-8',
|
|
)
|
|
}
|
|
const envelopePrefix =
|
|
'<!-- DevRunbook immutable generated task -->\n\n## Run metadata\n\n```json\n'
|
|
const delimiter = '\n```\n\n'
|
|
if (!taskText.startsWith(envelopePrefix) || !taskText.endsWith('\n')) {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
'TASK.md',
|
|
'has an invalid deterministic metadata envelope',
|
|
)
|
|
}
|
|
const delimiterAt = taskText.indexOf(delimiter, envelopePrefix.length)
|
|
if (delimiterAt < 0) {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
'TASK.md',
|
|
'has an incomplete deterministic metadata envelope',
|
|
)
|
|
}
|
|
let parsedTaskMetadata: unknown
|
|
const metadataSource = taskText.slice(envelopePrefix.length, delimiterAt)
|
|
try {
|
|
assertNoDuplicateJsonKeys(metadataSource)
|
|
parsedTaskMetadata = JSON.parse(metadataSource)
|
|
} catch {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
'TASK.md',
|
|
'metadata is invalid',
|
|
)
|
|
}
|
|
if (
|
|
!isRecord(parsedTaskMetadata) ||
|
|
metadataSource !== JSON.stringify(taskMetadata(manifest.run), null, 2)
|
|
) {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
'TASK.md',
|
|
'metadata does not match the manifest run',
|
|
)
|
|
}
|
|
const embeddedPrompt = taskText.slice(delimiterAt + delimiter.length)
|
|
if (digest(embeddedPrompt) !== manifest.run.renderDigest) {
|
|
throw new RunPackError(
|
|
'run_pack_file_integrity_failed',
|
|
'TASK.md',
|
|
'embedded prompt does not match the historical render digest',
|
|
)
|
|
}
|
|
return Object.freeze({
|
|
rootDirectory,
|
|
manifest: Object.freeze(manifest),
|
|
files: relative,
|
|
archiveSha256: digest(archive),
|
|
})
|
|
}
|