{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/discovery/transport.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,eAAe,EAAwB,MAAM,iBAAiB,CAAC;AAG7E,OAAO,EAAE,KAAK,gBAAgB,EAAyB,MAAM,eAAe,CAAC;AAE7E,eAAO,MAAM,oCAAoC,QAAkB,CAAC;AACpE,eAAO,MAAM,oCAAoC,QAAmB,CAAC;AACrE,eAAO,MAAM,oCAAoC,OAAQ,CAAC;AAC1D,eAAO,MAAM,gCAAgC,QAAS,CAAC;AAEvD,MAAM,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEtF,MAAM,WAAW,gCAAgC;IAChD,KAAK,EAAE,iBAAiB,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,eAAe,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACnB;AAwDD;;;GAGG;AACH,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAkCjF;AAgED,kGAAkG;AAClG,qBAAa,yBAAyB;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoB;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,YAAY,OAAO,EAAE,gCAAgC,EAOpD;YAEa,SAAS;IAkCjB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CASvF;IAEK,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CASvF;IAED;;;OAGG;IACG,eAAe,CAAC,OAAO,EAAE;QAC9B,MAAM,EAAE,gBAAgB,CAAC;QACzB,gBAAgB,EAAE,eAAe,CAAC;QAClC,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,CAAC,EAAE,WAAW,CAAC;KACrB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiG/B;CACD","sourcesContent":["import { mkdir, rm, writeFile } from \"node:fs/promises\";\nimport { dirname, join, posix } from \"node:path\";\nimport { parseEvoComponentManifest } from \"../components/manifest.ts\";\nimport { type EvoPackManifest, parseEvoPackManifest } from \"../pack/pack.ts\";\nimport { canonicalJson } from \"../storage.ts\";\nimport { EVO_DISCOVERY_PACK_MANIFEST_MAX_BYTES, EVO_PACK_REGISTRY_MAX_BYTES } from \"./client.ts\";\nimport { type EvoRawFileSource, parseEvoRawFileSource } from \"./registry.ts\";\n\nexport const EVO_DISCOVERY_DEFAULT_MAX_FILE_BYTES = 8 * 1024 * 1024;\nexport const EVO_DISCOVERY_DEFAULT_MAX_PACK_BYTES = 64 * 1024 * 1024;\nexport const EVO_DISCOVERY_DEFAULT_MAX_PACK_FILES = 1_000;\nexport const EVO_DISCOVERY_DEFAULT_TIMEOUT_MS = 30_000;\n\nexport type EvoDiscoveryFetch = (url: string, init: RequestInit) => Promise<Response>;\n\nexport interface EvoPackDiscoveryTransportOptions {\n\tfetch: EvoDiscoveryFetch;\n\tmaxFileBytes?: number;\n\tmaxPackBytes?: number;\n\tmaxPackFiles?: number;\n\ttimeoutMs?: number;\n}\n\nexport interface EvoMaterializedPack {\n\tdirectory: string;\n\tmanifest: EvoPackManifest;\n\tfileCount: number;\n\ttotalBytes: number;\n}\n\nfunction positiveInteger(value: number | undefined, fallback: number, label: string): number {\n\tconst resolved = value ?? fallback;\n\tif (!Number.isSafeInteger(resolved) || resolved <= 0) {\n\t\tthrow new Error(`discovery ${label} must be a positive safe integer`);\n\t}\n\treturn resolved;\n}\n\nfunction canonicalRelativePath(value: string, label: string): string {\n\tif (\n\t\t!value ||\n\t\tvalue.startsWith(\"/\") ||\n\t\tvalue.includes(\"\\\\\") ||\n\t\tvalue.includes(\"\\0\") ||\n\t\tvalue.split(\"/\").some((segment) => !segment || segment === \".\" || segment === \"..\")\n\t) {\n\t\tthrow new Error(`${label} must be a canonical relative POSIX path`);\n\t}\n\treturn value;\n}\n\nfunction encodePath(value: string): string {\n\treturn value\n\t\t.split(\"/\")\n\t\t.map((segment) => encodeURIComponent(segment))\n\t\t.join(\"/\");\n}\n\nfunction githubRepositoryIdentity(repository: string): { owner: string; name: string } {\n\tconst url = new URL(repository);\n\tif (url.origin !== \"https://github.com\") {\n\t\tthrow new Error(`unsupported immutable git repository host: ${url.hostname}`);\n\t}\n\tconst match = /^\\/([A-Za-z0-9_.-]+)\\/([A-Za-z0-9_.-]+?)(?:\\.git)?$/.exec(url.pathname);\n\tif (!match) throw new Error(\"immutable git repository must identify one GitHub repository\");\n\treturn { owner: match[1], name: match[2] };\n}\n\nfunction gitlabRepositoryIdentity(repository: string): string {\n\tconst url = new URL(repository);\n\tif (url.origin !== \"https://gitlab.com\") {\n\t\tthrow new Error(`unsupported immutable git repository host: ${url.hostname}`);\n\t}\n\tconst segments = url.pathname.slice(1).split(\"/\");\n\tif (segments.length < 2 || segments.some((segment) => !/^[A-Za-z0-9_.-]+$/.test(segment))) {\n\t\tthrow new Error(\"immutable git repository must identify one GitLab repository\");\n\t}\n\tconst last = segments.at(-1);\n\tif (!last) throw new Error(\"immutable git repository must identify one GitLab repository\");\n\tsegments[segments.length - 1] = last.endsWith(\".git\") ? last.slice(0, -4) : last;\n\tif (!segments.at(-1)) throw new Error(\"immutable git repository must identify one GitLab repository\");\n\treturn segments.join(\"/\");\n}\n\n/**\n * Verify that an immutable locator's retrieval URL is derived from its stated\n * repository/gist revision. Unknown git hosting conventions fail closed.\n */\nexport function assertEvoRawFileSourceProvenance(value: unknown): EvoRawFileSource {\n\tconst source = parseEvoRawFileSource(value);\n\tif (source.kind === \"https\") return source;\n\tif (source.kind === \"gist\") {\n\t\tconst url = new URL(source.rawUrl);\n\t\tconst match = /^\\/([A-Za-z0-9_.-]+)\\/([0-9a-f]+)\\/raw\\/([0-9a-f]+)\\/(.+)$/.exec(url.pathname);\n\t\tif (\n\t\t\turl.origin !== \"https://gist.githubusercontent.com\" ||\n\t\t\t!match ||\n\t\t\tmatch[2] !== source.gistId ||\n\t\t\tmatch[3] !== source.revision ||\n\t\t\tmatch[4] !== encodePath(canonicalRelativePath(source.file, \"gist source file\"))\n\t\t) {\n\t\t\tthrow new Error(\"gist rawUrl does not match its immutable gistId, revision, and file provenance\");\n\t\t}\n\t\treturn source;\n\t}\n\n\tconst path = encodePath(canonicalRelativePath(source.path, \"git source path\"));\n\tconst repository = new URL(source.repository);\n\tlet expected: string;\n\tif (repository.origin === \"https://github.com\") {\n\t\tconst identity = githubRepositoryIdentity(source.repository);\n\t\texpected = `https://raw.githubusercontent.com/${identity.owner}/${identity.name}/${source.revision}/${path}`;\n\t} else if (repository.origin === \"https://gitlab.com\") {\n\t\tconst identity = gitlabRepositoryIdentity(source.repository);\n\t\texpected = `https://gitlab.com/${identity}/-/raw/${source.revision}/${path}`;\n\t} else {\n\t\tthrow new Error(`unsupported immutable git repository host: ${repository.hostname}`);\n\t}\n\tif (source.rawUrl !== expected) {\n\t\tthrow new Error(\"git rawUrl does not match its immutable repository, revision, and path provenance\");\n\t}\n\treturn source;\n}\n\nfunction resolvePackFileSource(sourceValue: EvoRawFileSource, relativePath: string): EvoRawFileSource {\n\tconst source = assertEvoRawFileSourceProvenance(sourceValue);\n\tconst relative = canonicalRelativePath(relativePath, \"pack file path\");\n\tconst rawUrl = new URL(encodePath(relative), source.rawUrl).href;\n\tif (new URL(rawUrl).origin !== new URL(source.rawUrl).origin) {\n\t\tthrow new Error(`pack file escaped its source origin: ${relative}`);\n\t}\n\tif (source.kind === \"https\") return { kind: \"https\", rawUrl };\n\tif (source.kind === \"git\") {\n\t\treturn assertEvoRawFileSourceProvenance({\n\t\t\t...source,\n\t\t\tpath: posix.join(posix.dirname(source.path), relative),\n\t\t\trawUrl,\n\t\t});\n\t}\n\treturn assertEvoRawFileSourceProvenance({\n\t\t...source,\n\t\tfile: posix.join(posix.dirname(source.file), relative),\n\t\trawUrl,\n\t});\n}\n\nfunction parseJson(text: string, label: string): unknown {\n\ttry {\n\t\treturn JSON.parse(text) as unknown;\n\t} catch {\n\t\tthrow new Error(`${label} is not valid JSON`);\n\t}\n}\n\nfunction sameStringSet(left: readonly string[], right: readonly string[]): boolean {\n\treturn [...left].sort().join(\"\\n\") === [...right].sort().join(\"\\n\");\n}\n\nasync function boundedResponseBody(response: Response, maximumBytes: number, label: string): Promise<Buffer> {\n\tconst contentLength = response.headers.get(\"content-length\");\n\tif (contentLength !== null) {\n\t\tif (!/^(?:0|[1-9][0-9]*)$/.test(contentLength)) {\n\t\t\tthrow new Error(`${label} returned an invalid content-length`);\n\t\t}\n\t\tconst declared = Number(contentLength);\n\t\tif (!Number.isSafeInteger(declared) || declared > maximumBytes) {\n\t\t\tthrow new Error(`${label} exceeds ${maximumBytes} bytes`);\n\t\t}\n\t}\n\tif (!response.body) return Buffer.alloc(0);\n\tconst reader = response.body.getReader();\n\tconst chunks: Buffer[] = [];\n\tlet bytes = 0;\n\twhile (true) {\n\t\tconst next = await reader.read();\n\t\tif (next.done) break;\n\t\tbytes += next.value.byteLength;\n\t\tif (bytes > maximumBytes) {\n\t\t\tawait reader.cancel();\n\t\t\tthrow new Error(`${label} exceeds ${maximumBytes} bytes`);\n\t\t}\n\t\tchunks.push(Buffer.from(next.value));\n\t}\n\treturn Buffer.concat(chunks, bytes);\n}\n\n/** Bounded, redirect-free raw-file retrieval over an explicitly injected fetch implementation. */\nexport class EvoPackDiscoveryTransport {\n\tprivate readonly fetchImpl: EvoDiscoveryFetch;\n\tprivate readonly maxFileBytes: number;\n\tprivate readonly maxPackBytes: number;\n\tprivate readonly maxPackFiles: number;\n\tprivate readonly timeoutMs: number;\n\n\tconstructor(options: EvoPackDiscoveryTransportOptions) {\n\t\tif (typeof options.fetch !== \"function\") throw new Error(\"discovery fetch must be a function\");\n\t\tthis.fetchImpl = options.fetch;\n\t\tthis.maxFileBytes = positiveInteger(options.maxFileBytes, EVO_DISCOVERY_DEFAULT_MAX_FILE_BYTES, \"maxFileBytes\");\n\t\tthis.maxPackBytes = positiveInteger(options.maxPackBytes, EVO_DISCOVERY_DEFAULT_MAX_PACK_BYTES, \"maxPackBytes\");\n\t\tthis.maxPackFiles = positiveInteger(options.maxPackFiles, EVO_DISCOVERY_DEFAULT_MAX_PACK_FILES, \"maxPackFiles\");\n\t\tthis.timeoutMs = positiveInteger(options.timeoutMs, EVO_DISCOVERY_DEFAULT_TIMEOUT_MS, \"timeoutMs\");\n\t}\n\n\tprivate async readBytes(\n\t\tsourceValue: EvoRawFileSource,\n\t\tmaximumBytes: number,\n\t\tlabel: string,\n\t\tsignal?: AbortSignal,\n\t): Promise<Buffer> {\n\t\tconst source = assertEvoRawFileSourceProvenance(sourceValue);\n\t\tsignal?.throwIfAborted();\n\t\tconst controller = new AbortController();\n\t\tconst abort = (): void => controller.abort(signal?.reason);\n\t\tsignal?.addEventListener(\"abort\", abort, { once: true });\n\t\tconst timer = setTimeout(() => controller.abort(new Error(`${label} timed out`)), this.timeoutMs);\n\t\ttimer.unref();\n\t\ttry {\n\t\t\tconst response = await this.fetchImpl(source.rawUrl, {\n\t\t\t\tmethod: \"GET\",\n\t\t\t\tredirect: \"error\",\n\t\t\t\tsignal: controller.signal,\n\t\t\t\theaders: {\n\t\t\t\t\taccept: \"application/json, text/plain;q=0.9, application/octet-stream;q=0.8\",\n\t\t\t\t\t\"user-agent\": \"Evo-Pi pack discovery\",\n\t\t\t\t},\n\t\t\t});\n\t\t\tif (response.url && response.url !== source.rawUrl) {\n\t\t\t\tthrow new Error(`${label} redirected away from its verified source URL`);\n\t\t\t}\n\t\t\tif (response.status !== 200) throw new Error(`${label} returned HTTP ${response.status}`);\n\t\t\treturn await boundedResponseBody(response, maximumBytes, label);\n\t\t} finally {\n\t\t\tclearTimeout(timer);\n\t\t\tsignal?.removeEventListener(\"abort\", abort);\n\t\t}\n\t}\n\n\tasync readRegistryIndex(source: EvoRawFileSource, signal?: AbortSignal): Promise<string> {\n\t\treturn (\n\t\t\tawait this.readBytes(\n\t\t\t\tsource,\n\t\t\t\tMath.min(this.maxFileBytes, EVO_PACK_REGISTRY_MAX_BYTES),\n\t\t\t\t`pack registry ${source.rawUrl}`,\n\t\t\t\tsignal,\n\t\t\t)\n\t\t).toString(\"utf8\");\n\t}\n\n\tasync fetchPackManifest(source: EvoRawFileSource, signal?: AbortSignal): Promise<string> {\n\t\treturn (\n\t\t\tawait this.readBytes(\n\t\t\t\tsource,\n\t\t\t\tMath.min(this.maxFileBytes, EVO_DISCOVERY_PACK_MANIFEST_MAX_BYTES),\n\t\t\t\t`pack manifest ${source.rawUrl}`,\n\t\t\t\tsignal,\n\t\t\t)\n\t\t).toString(\"utf8\");\n\t}\n\n\t/**\n\t * Materialize the exact v1 pack file set needed by the existing importer.\n\t * The destination must not exist and is removed on every failure.\n\t */\n\tasync materializePack(options: {\n\t\tsource: EvoRawFileSource;\n\t\texpectedManifest: EvoPackManifest;\n\t\tdestination: string;\n\t\tsignal?: AbortSignal;\n\t}): Promise<EvoMaterializedPack> {\n\t\tconst source = assertEvoRawFileSourceProvenance(options.source);\n\t\tconst expectedManifest = parseEvoPackManifest(options.expectedManifest);\n\t\tif (!expectedManifest.integrity) throw new Error(\"remote pack manifest must declare integrity\");\n\t\tlet totalBytes = 0;\n\t\tlet fileCount = 0;\n\t\tconst written = new Set<string>();\n\n\t\tconst fetchFile = async (\n\t\t\trelativePath: string,\n\t\t\tlabel: string,\n\t\t\tmaximumBytes = this.maxFileBytes,\n\t\t): Promise<Buffer> => {\n\t\t\tconst relative = canonicalRelativePath(relativePath, label);\n\t\t\tif (written.has(relative)) throw new Error(`remote pack has overlapping file reference: ${relative}`);\n\t\t\tif (fileCount >= this.maxPackFiles) {\n\t\t\t\tthrow new Error(`remote pack exceeds ${this.maxPackFiles} files`);\n\t\t\t}\n\t\t\tconst remaining = this.maxPackBytes - totalBytes;\n\t\t\tif (remaining < 0) throw new Error(`remote pack exceeds ${this.maxPackBytes} bytes`);\n\t\t\tconst bytes = await this.readBytes(\n\t\t\t\trelative === \"pack.json\" ? source : resolvePackFileSource(source, relative),\n\t\t\t\tMath.min(maximumBytes, remaining),\n\t\t\t\tlabel,\n\t\t\t\toptions.signal,\n\t\t\t);\n\t\t\ttotalBytes += bytes.byteLength;\n\t\t\tfileCount += 1;\n\t\t\twritten.add(relative);\n\t\t\tawait mkdir(dirname(join(options.destination, relative)), { recursive: true, mode: 0o700 });\n\t\t\tawait writeFile(join(options.destination, relative), bytes, { flag: \"wx\", mode: 0o600 });\n\t\t\treturn bytes;\n\t\t};\n\n\t\tawait mkdir(options.destination, { mode: 0o700 });\n\t\ttry {\n\t\t\tconst manifestBytes = await fetchFile(\n\t\t\t\t\"pack.json\",\n\t\t\t\t`pack manifest ${source.rawUrl}`,\n\t\t\t\tMath.min(this.maxFileBytes, EVO_DISCOVERY_PACK_MANIFEST_MAX_BYTES),\n\t\t\t);\n\t\t\tconst fetchedManifest = parseEvoPackManifest(\n\t\t\t\tparseJson(manifestBytes.toString(\"utf8\"), `pack manifest ${source.rawUrl}`),\n\t\t\t);\n\t\t\tif (canonicalJson(fetchedManifest) !== canonicalJson(expectedManifest)) {\n\t\t\t\tthrow new Error(\"pack manifest changed after registry preflight\");\n\t\t\t}\n\n\t\t\tfor (const prompt of fetchedManifest.contents.prompts) {\n\t\t\t\tawait fetchFile(prompt.file, `pack prompt ${prompt.file}`);\n\t\t\t}\n\t\t\tfor (const skill of fetchedManifest.contents.skills) {\n\t\t\t\tawait fetchFile(`${skill.dir}/SKILL.md`, `pack skill ${skill.name}`);\n\t\t\t}\n\t\t\tfor (const memory of fetchedManifest.contents.memory) {\n\t\t\t\tawait fetchFile(memory.file, `pack memory ${memory.file}`);\n\t\t\t}\n\n\t\t\tconst codeParts = [\n\t\t\t\t...fetchedManifest.contents.components.map((part) => ({ part, kind: \"component\" as const })),\n\t\t\t\t...fetchedManifest.contents.workflows.map((part) => ({ part, kind: \"workflow\" as const })),\n\t\t\t];\n\t\t\tfor (const codePart of codeParts) {\n\t\t\t\tconst manifestPath = `${codePart.part.artifact}/manifest.json`;\n\t\t\t\tconst artifactManifestBytes = await fetchFile(\n\t\t\t\t\tmanifestPath,\n\t\t\t\t\t`pack ${codePart.kind} manifest ${codePart.part.id}`,\n\t\t\t\t\tEVO_DISCOVERY_PACK_MANIFEST_MAX_BYTES,\n\t\t\t\t);\n\t\t\t\tconst artifactManifest = parseEvoComponentManifest(\n\t\t\t\t\tparseJson(artifactManifestBytes.toString(\"utf8\"), `component manifest ${codePart.part.id}`),\n\t\t\t\t);\n\t\t\t\tif (\n\t\t\t\t\tartifactManifest.id !== codePart.part.id ||\n\t\t\t\t\tartifactManifest.abi !== codePart.part.abi ||\n\t\t\t\t\t!sameStringSet(artifactManifest.capabilities, codePart.part.capabilities)\n\t\t\t\t) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`pack ${codePart.kind} declaration does not match artifact manifest: ${codePart.part.id}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tawait fetchFile(\n\t\t\t\t\t`${codePart.part.artifact}/${artifactManifest.entrypoint}`,\n\t\t\t\t\t`pack ${codePart.kind} entrypoint ${codePart.part.id}`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tdirectory: options.destination,\n\t\t\t\tmanifest: fetchedManifest,\n\t\t\t\tfileCount,\n\t\t\t\ttotalBytes,\n\t\t\t};\n\t\t} catch (error) {\n\t\t\tawait rm(options.destination, { recursive: true, force: true });\n\t\t\tthrow error;\n\t\t}\n\t}\n}\n"]}