From 319381fdd26a5067cce94b7512487a093afa9b0c Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 13:15:04 +0000 Subject: [PATCH] feat: gate the Agent declaration behind --agent-configuration assets/config.json declares one personal model connection over openai-completions or anthropic-messages. With --agent-configuration every package carries it, the release reads it back from each archive, binds its digest in [agent_configuration] and publishes the static copy; without the flag the release is unchanged. release.yml does not pass the flag until hosts that resolve the declaration are deployed. --- .github/workflows/marketplace.yml | 16 ++ .github/workflows/release.yml | 12 +- AGENTS.md | 30 +++- assets/config.json | 1 + deno.json | 14 +- deno.lock | 13 +- scripts/package.ts | 268 +++++++++++++++++++++++++++--- tests/package_test.ts | 160 ++++++++++++++++++ 8 files changed, 481 insertions(+), 33 deletions(-) create mode 100644 assets/config.json create mode 100644 tests/package_test.ts diff --git a/.github/workflows/marketplace.yml b/.github/workflows/marketplace.yml index 38544d0..62b4262 100644 --- a/.github/workflows/marketplace.yml +++ b/.github/workflows/marketplace.yml @@ -118,6 +118,15 @@ jobs: echo "::error::${TAG} ships a manifest for ${MANIFEST_VERSION}" exit 1 fi + # The static declaration copy is attached only by releases that bound one; `gh release + # download` fails when no asset matches, so absence is expected, not an error. + if grep -q '^\[agent_configuration\]' dist/manifest.toml; then + gh release download "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern agent-configuration.json \ + --dir dist \ + --clobber + fi # `GITHUB_TOKEN` cannot write to another repository, so the push borrows a short-lived # installation token from the organization's app rather than a long-lived PAT. @@ -175,6 +184,13 @@ jobs: mkdir -p "$DIR" cp ../dist/manifest.toml "${DIR}/orax.toml" cp ../README.md "${DIR}/README.md" + # The declaration copy the manifest's `[agent_configuration]` names, when this release + # bound one; the sync reads it relative to this entry directory. + if [ -f ../dist/agent-configuration.json ]; then + cp ../dist/agent-configuration.json "${DIR}/agent-configuration.json" + else + rm -f "${DIR}/agent-configuration.json" + fi # The bare logo, plus any themed variant (`logo.dark.svg`) and raster format the plugin # carries. Cleared first so a variant the plugin has since dropped does not linger. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5cc401..d1b44b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,10 @@ jobs: # Writes dist/packages/*.orax (one per declared target) and dist/manifest.toml, reproducing # exactly what upstream.lock.json records rather than resolving anything itself. + # With `--agent-configuration` every package carries assets/config.json, the Agent + # declaration, and the release binds it; the flag waits until the Ora hosts that resolve the + # declaration are deployed — current hosts install an `agent`-kind package carrying it but + # then refuse to activate the plugin, so the bytes must not ship early. - name: Package env: GH_TOKEN: ${{ github.token }} @@ -78,8 +82,12 @@ jobs: TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail - gh release create "$TAG" \ - dist/packages/*.orax dist/manifest.toml \ + ASSETS=(dist/packages/*.orax dist/manifest.toml) + # The static declaration copy only exists when the release bound one. + if [ -f dist/agent-configuration.json ]; then + ASSETS+=(dist/agent-configuration.json) + fi + gh release create "$TAG" "${ASSETS[@]}" \ --title "$TAG" \ --generate-notes diff --git a/AGENTS.md b/AGENTS.md index 1a99f05..ab46c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,6 +270,25 @@ Bump `orax.toml` `version` before tagging: `install_local` refuses a version that is already installed and never retires older ones, so reusing a number silently leaves the old code running. +### The Agent declaration, and when a release ships it + +`assets/config.json` is the Agent declaration of what this plugin asks the host +to configure: one personal model connection over `openai-completions` or +`anthropic-messages`. It only ships when packaging runs with +`deno task package --agent-configuration`: every `.orax` then carries it, the +script reads it back from each finished archive and refuses a release whose +packages disagree, writes the static copy `dist/agent-configuration.json`, and +adds `[agent_configuration]` to `dist/manifest.toml`. `release.yml` attaches the +copy and `marketplace.yml` places it beside the registry entry. A release binds +exactly one declaration digest, and `tests/package_test.ts` pins it, so +reformatting the file is a conscious, release-affecting change. + +**The flag waits for host support.** A current Ora host installs an `agent`-kind +package carrying a declaration it cannot compile and then refuses to activate +it, so `release.yml` passes the flag only once hosts resolving the declaration +are deployed — an owner decision made by editing one line. Without the flag the +release is exactly what it was before. + ## Logging Everything this plugin says goes through the SDK's `plugin.logger`, which writes @@ -346,11 +365,12 @@ the run. - `deno task check` / `lint` / `format` / `simulate` / `build` / `sync` / `package`. -- There is no unit test suite here; `tests/host-simulator.ts` drives the plugin - the way Ora's host does and needs a real CLI, staged or on PATH. CI runs only - `check` and `lint`, deliberately: type checking resolves the whole module - graph, so it is also what catches an SDK version that is not published yet, - and lint stays meaningful even while that fails. +- `deno task test` runs `tests/package_test.ts`, the packaging checks that need + no CLI. `tests/host-simulator.ts` drives the plugin the way Ora's host does + and needs a real CLI, staged or on PATH. CI runs only `check` and `lint`, + deliberately: type checking resolves the whole module graph, so it is also + what catches an SDK version that is not published yet, and lint stays + meaningful even while that fails. - The SDK is imported from its published JSR package and pinned in `deno.json`; keep `deno.lock` synchronized when changing the SDK version. - `scripts/package.ts` and `scripts/upstream.ts` know nothing about OpenCode diff --git a/assets/config.json b/assets/config.json new file mode 100644 index 0000000..87ca872 --- /dev/null +++ b/assets/config.json @@ -0,0 +1 @@ +{"schemaVersion":2,"agent":{"modelAccess":{"contractVersion":1,"protocols":["openai-completions","anthropic-messages"],"modelCardinality":"single"}}} \ No newline at end of file diff --git a/deno.json b/deno.json index 94661b7..39856fd 100644 --- a/deno.json +++ b/deno.json @@ -5,15 +5,17 @@ "minimumDependencyAge": 0, "imports": { "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.10.0", + "@std/assert": "jsr:@std/assert@^1.0.0", "@std/cli": "jsr:@std/cli@^1.0.32", "@std/path": "jsr:@std/path@^1.1.6", "@std/tar": "jsr:@std/tar@^0.1.10", "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts", + "check": "deno check src/main.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts tests/package_test.ts", "lint": "deno lint src scripts tests bundle.config.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json README.md AGENTS.md", + "test": "deno test --allow-read --allow-write tests/package_test.ts", "simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts", "dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts", "build": "deno bundle src/main.ts -o dist/main.js", @@ -22,7 +24,11 @@ }, "compilerOptions": { "strict": true, - "lib": ["deno.ns", "dom", "esnext"] + "lib": [ + "deno.ns", + "dom", + "esnext" + ] }, "fmt": { "lineWidth": 80, @@ -31,7 +37,9 @@ }, "lint": { "rules": { - "tags": ["recommended"] + "tags": [ + "recommended" + ] } } } diff --git a/deno.lock b/deno.lock index cae57f3..2808c61 100644 --- a/deno.lock +++ b/deno.lock @@ -2,8 +2,10 @@ "version": "5", "specifiers": { "jsr:@ora-space/plugin-sdk@0.10.0": "0.10.0", + "jsr:@std/assert@1": "1.0.19", "jsr:@std/cli@^1.0.32": "1.0.32", "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/streams@^1.0.17": "1.1.2", @@ -14,11 +16,17 @@ "@ora-space/plugin-sdk@0.10.0": { "integrity": "f678d20ea69f3da2a692faa174743818806c95b998ae23e5b11284c7a6d37c94" }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/cli@1.0.32": { "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", "dependencies": [ "jsr:@std/fmt", - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/fmt@1.0.10": { @@ -30,7 +38,7 @@ "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/streams@1.1.2": { @@ -49,6 +57,7 @@ "workspace": { "dependencies": [ "jsr:@ora-space/plugin-sdk@0.10.0", + "jsr:@std/assert@1", "jsr:@std/cli@^1.0.32", "jsr:@std/path@^1.1.6", "jsr:@std/tar@~0.1.10", diff --git a/scripts/package.ts b/scripts/package.ts index 0caa07b..36a39d5 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -10,10 +10,21 @@ * * Usage: * deno task package --tag v1.2.3 --repo owner/name + * deno task package --tag v1.2.3 --repo owner/name --agent-configuration + * # also ship and bind the Agent declaration * * Produces `dist/packages/--.orax` plus `dist/manifest.toml`, the release * form of the manifest the marketplace index needs. `gh` is the only external tool required: * archives are read and written in-process so a maintainer can run this anywhere CI can. + * + * `--agent-configuration` is the publish-order switch for the Agent declaration + * (`assets/config.json`): with it, every package carries the declaration, the release manifest + * gains an `[agent_configuration]` section binding its digest, and `dist/agent-configuration.json` + * — the static copy the marketplace reads beside the entry — is written. Without it, packages + * carry no declaration and current Ora hosts install the release exactly as before. An + * `agent`-kind package whose declaration a host cannot compile installs but then refuses to + * activate, so the switch is flipped only once the hosts that resolve it are deployed — an owner + * decision made by editing one line of the release workflow, not something this script detects. */ import { parseArgs } from "@std/cli/parse-args"; import { basename, dirname, join, relative } from "@std/path"; @@ -76,6 +87,15 @@ const PACKAGES_DIR = join(DIST, "packages"); const DOWNLOAD_DIR = join(DIST, "download"); const STAGE_DIR = join(DIST, "stage"); +/** Package-relative path of the Agent declaration a release carries when it binds one. */ +const DECLARATION_ENTRY = "assets/config.json"; + +/** The static copy's name, both as a release asset and inside a marketplace registry entry. */ +const DECLARATION_COPY = "agent-configuration.json"; + +/** The declaration size bound the host's compiler enforces; a larger file could never install. */ +const MAX_DECLARATION_BYTES = 256 * 1024; + /** Runs one command, failing loudly rather than letting a broken package be published. */ async function run(command: string, ...args: string[]): Promise { const { code, stdout, stderr } = await new Deno.Command(command, { @@ -212,10 +232,12 @@ function relativeSlashPath(root: string, path: string): string { /** Returns the lowercase hex SHA-256 of one file, the spelling `sha256` takes in a manifest. */ async function sha256Hex(path: string): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - await Deno.readFile(path), - ); + return sha256Bytes(await Deno.readFile(path)); +} + +/** Returns the lowercase hex SHA-256 of exact bytes. */ +async function sha256Bytes(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); @@ -245,13 +267,23 @@ async function findLogoFiles(): Promise { * verify after extraction that the package it downloaded is really the one built for this machine. * A universal package declares none: it carries no binary whose host compatibility could be wrong, * and an `[artifact]` section on it would make Ora check the one thing it cannot promise. + * `includeDeclaration` is the `--agent-configuration` switch described at the top of this file. */ -async function stagePluginFiles(target: string | undefined): Promise { +async function stagePluginFiles( + target: string | undefined, + includeDeclaration: boolean, +): Promise { await Deno.mkdir(STAGE_DIR, { recursive: true }); await Deno.copyFile(join(DIST, "main.js"), join(STAGE_DIR, "main.js")); for (const extra of [...await findLogoFiles(), "README.md"]) { await Deno.copyFile(extra, join(STAGE_DIR, extra)).catch(() => {}); } + if (includeDeclaration) { + await Deno.mkdir(dirname(join(STAGE_DIR, DECLARATION_ENTRY)), { + recursive: true, + }); + await Deno.copyFile(DECLARATION_ENTRY, join(STAGE_DIR, DECLARATION_ENTRY)); + } const manifest = (await Deno.readTextFile("orax.toml")).trimEnd(); const artifact = target === undefined ? "" @@ -269,6 +301,7 @@ async function buildBundledPackage( upstreamTag: string, expectedSha256: string, fileName: string, + includeDeclaration: boolean, ): Promise { await Deno.remove(STAGE_DIR, { recursive: true }).catch(() => {}); const staged = join(STAGE_DIR, plan.binaryPath); @@ -301,16 +334,19 @@ async function buildBundledPackage( // package uses for it. await extractEntry(archive, basename(plan.binaryPath), staged); - await stagePluginFiles(plan.triple); + await stagePluginFiles(plan.triple, includeDeclaration); await writeOrax(STAGE_DIR, join(PACKAGES_DIR, fileName), plan.binaryPath); await Deno.remove(STAGE_DIR, { recursive: true }); await Deno.remove(archive).catch(() => {}); } /** Stages the one package every host can install, which runs the user's own CLI. */ -async function buildUniversalPackage(fileName: string): Promise { +async function buildUniversalPackage( + fileName: string, + includeDeclaration: boolean, +): Promise { await Deno.remove(STAGE_DIR, { recursive: true }).catch(() => {}); - await stagePluginFiles(/*target*/ undefined); + await stagePluginFiles(/*target*/ undefined, includeDeclaration); await writeOrax( STAGE_DIR, join(PACKAGES_DIR, fileName), @@ -320,7 +356,7 @@ async function buildUniversalPackage(fileName: string): Promise { } /** - * Builds every package this release publishes and returns the release-form manifest body. + * Builds every package this release publishes and returns where each one is downloaded from. * * The two shapes produce different release sources — one `[[targets]]` entry per bundled package, * or a single `url`/`sha256` pair — and Ora rejects a manifest carrying both, which is why the @@ -330,17 +366,26 @@ async function buildRelease( identifier: string, tag: string, repo: string, -): Promise { + includeDeclaration: boolean, +): Promise<{ source: ReleaseSource; packages: string[] }> { const base = `https://github.com/${repo}/releases/download/${tag}`; - let manifest = (await Deno.readTextFile("orax.toml")).trimEnd(); if (config.cli === "user_installed") { const fileName = `${identifier}-${tag}.orax`; - await buildUniversalPackage(fileName); - const digest = await sha256Hex(join(PACKAGES_DIR, fileName)); + await buildUniversalPackage(fileName, includeDeclaration); + const path = join(PACKAGES_DIR, fileName); console.log(`packaged ${fileName} (no bundled CLI)`); - return `${manifest}\n\nurl = "${base}/${fileName}"\nsha256 = "${digest}"`; + return { + source: { + kind: "universal", + url: `${base}/${fileName}`, + sha256: await sha256Hex(path), + }, + packages: [path], + }; } + const targets: ReleaseTarget[] = []; + const packages: string[] = []; await Deno.mkdir(DOWNLOAD_DIR, { recursive: true }); // Read, never resolved here: `deno task sync` is the only thing allowed to ask GitHub what @@ -374,20 +419,183 @@ async function buildRelease( lock.tag, pinned.sha256, fileName, + includeDeclaration, ); - const digest = await sha256Hex(join(PACKAGES_DIR, fileName)); - manifest += - `\n\n[[targets]]\ntarget = "${triple}"\nurl = "${base}/${fileName}"\nsha256 = "${digest}"`; + const path = join(PACKAGES_DIR, fileName); + targets.push({ + triple, + url: `${base}/${fileName}`, + sha256: await sha256Hex(path), + }); + packages.push(path); console.log(`packaged ${fileName}`); } await Deno.remove(DOWNLOAD_DIR, { recursive: true }).catch(() => {}); console.log(`\nUpstream CLI: ${lock.tag}`); - return manifest; + return { source: { kind: "targets", targets }, packages }; +} + +/** One built per-target package, as the release form of the manifest advertises it. */ +export interface ReleaseTarget { + /** The canonical Rust triple this package installs on. */ + triple: string; + url: string; + sha256: string; +} + +/** + * Where a release's packages are downloaded from: one universal package, or one per target. + * + * Ora rejects a manifest carrying both, which is why a release is exactly one of the two. + */ +export type ReleaseSource = + | { kind: "universal"; url: string; sha256: string } + | { kind: "targets"; targets: ReleaseTarget[] }; + +/** One release's verified Agent declaration, as the marketplace entry binds it. */ +export interface AgentConfigurationAssociation { + /** Declaration schema version the published copy declares. */ + schemaVersion: number; + /** Lowercase hex SHA-256 of the exact declaration bytes every package of this release carries. */ + declarationSha256: string; + /** Registry-entry-relative path of the static copy the marketplace sync reads. */ + copy: string; +} + +/** + * Renders the release form of the manifest, which is what a marketplace entry is copied from. + * + * TOML binds a bare key to the table header above it, so the order is load bearing: the installed + * manifest's own keys, then a universal package's top-level `url`/`sha256`, then the + * `[agent_configuration]` table when the release binds a declaration, then the `[[targets]]` + * blocks. A universal `url` written below a table would silently become part of that table. + */ +export function releaseManifest( + installed: string, + source: ReleaseSource, + agentConfiguration?: AgentConfigurationAssociation, +): string { + const sections = [installed.trimEnd()]; + if (source.kind === "universal") { + sections[0] += `\n\nurl = "${source.url}"\nsha256 = "${source.sha256}"`; + } + if (agentConfiguration !== undefined) { + sections.push( + `[agent_configuration]\nschema_version = ${agentConfiguration.schemaVersion}\n` + + `declaration_sha256 = "${agentConfiguration.declarationSha256}"\n` + + `copy = "${agentConfiguration.copy}"`, + ); + } + if (source.kind === "targets") { + sections.push( + ...source.targets.map((target) => + `[[targets]]\ntarget = "${target.triple}"\nurl = "${target.url}"\n` + + `sha256 = "${target.sha256}"` + ), + ); + } + return `${sections.join("\n\n")}\n`; +} + +/** + * Reads the declaration bytes back out of a built package, or `undefined` when it carries none. + * + * The release association is derived from what the archive actually carries, not from the file + * that was staged into it: reading the entry back is what proves the copy a marketplace will + * publish is byte-identical to the declaration every target installs. + */ +export async function packagedDeclaration( + archive: string, +): Promise | undefined> { + const zip = new ZipReader(new BlobReader(await openBlob(archive))); + try { + for (const entry of await zip.getEntries()) { + if (entry.filename === DECLARATION_ENTRY && !entry.directory) { + return new Uint8Array(await entry.arrayBuffer()); + } + } + return undefined; + } finally { + await zip.close(); + } +} + +/** + * Builds the release association from the bytes the packages actually carry. + * + * The digest is over the exact bytes, so a reformatted declaration is a different release + * identity by construction. Only what this script owns is checked — readable UTF-8, a positive + * integer `schemaVersion` and the host's size bound; whether the declaration is legal is proven by + * the host compilers at marketplace sync and install, not re-implemented here. + */ +export async function declarationAssociation( + declaration: Uint8Array, +): Promise { + if (declaration.byteLength > MAX_DECLARATION_BYTES) { + throw new Error( + `${DECLARATION_ENTRY} is ${declaration.byteLength} bytes, over the ` + + `${MAX_DECLARATION_BYTES}-byte bound every host enforces`, + ); + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(declaration); + const parsed = JSON.parse(text) as { schemaVersion?: unknown } | null; + if ( + typeof parsed !== "object" || parsed === null || + typeof parsed.schemaVersion !== "number" || + !Number.isSafeInteger(parsed.schemaVersion) || parsed.schemaVersion < 1 + ) { + throw new Error( + `${DECLARATION_ENTRY} declares no positive integer schemaVersion`, + ); + } + return { + schemaVersion: parsed.schemaVersion, + declarationSha256: await sha256Bytes(declaration), + copy: DECLARATION_COPY, + }; +} + +/** + * Proves every package carries the same declaration, or none, and returns its association. + * + * A release binds exactly one declaration digest, so one package with different bytes — or one + * missing them — would install a declaration identity the manifest does not name. + */ +async function bindDeclaration( + packages: readonly string[], +): Promise { + let bound: Uint8Array | undefined; + for (const archive of packages) { + const carried = await packagedDeclaration(archive); + if (carried === undefined) { + throw new Error(`${archive} carries no ${DECLARATION_ENTRY}`); + } + if (bound !== undefined && !bytesEqual(bound, carried)) { + throw new Error( + `${archive} carries different ${DECLARATION_ENTRY} bytes`, + ); + } + bound = carried; + } + if (bound === undefined) { + throw new Error("the release built no package to bind a declaration to"); + } + await Deno.writeFile(join(DIST, DECLARATION_COPY), bound); + return await declarationAssociation(bound); +} + +/** Compares two byte sequences; `Uint8Array` has no equality of its own. */ +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && + left.every((byte, index) => byte === right[index]); } async function main(): Promise { - const flags = parseArgs(Deno.args, { string: ["tag", "repo"] }); + const flags = parseArgs(Deno.args, { + string: ["tag", "repo"], + boolean: ["agent-configuration"], + }); const tag = flags.tag ?? Deno.env.get("GITHUB_REF_NAME"); const repo = flags.repo ?? Deno.env.get("GITHUB_REPOSITORY"); if (tag === undefined || repo === undefined) { @@ -397,12 +605,30 @@ async function main(): Promise { const identifier = await manifestField("identifier"); await Deno.mkdir(PACKAGES_DIR, { recursive: true }); - const manifest = await buildRelease(identifier, tag, repo); + const includeDeclaration = flags["agent-configuration"]; + // A stale copy from an earlier declaration-binding build must not be published by this one. + await Deno.remove(join(DIST, DECLARATION_COPY)).catch(() => {}); + const { source, packages } = await buildRelease( + identifier, + tag, + repo, + includeDeclaration, + ); + const association = includeDeclaration + ? await bindDeclaration(packages) + : undefined; // The marketplace index needs the release form of the manifest, which carries the download URLs // and digests. It is only knowable once the packages exist, so it is generated here rather than // committed. - await Deno.writeTextFile(join(DIST, "manifest.toml"), `${manifest}\n`); + await Deno.writeTextFile( + join(DIST, "manifest.toml"), + releaseManifest( + await Deno.readTextFile("orax.toml"), + source, + association, + ), + ); } if (import.meta.main) { diff --git a/tests/package_test.ts b/tests/package_test.ts new file mode 100644 index 0000000..ec53ab7 --- /dev/null +++ b/tests/package_test.ts @@ -0,0 +1,160 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { BlobReader, ZipWriter } from "@zip-js/zip-js"; +import { + declarationAssociation, + packagedDeclaration, + releaseManifest, +} from "../scripts/package.ts"; + +const DECLARATION_SHA256 = + "f1186c1e0f210b3a1c9d4db7b4929183313288d97246e4d5088a342a02defc49"; + +const encode = (text: string) => new TextEncoder().encode(text); + +Deno.test("the shipped declaration is the pinned single-model shape over both protocols", async () => { + const bytes = await Deno.readFile( + new URL("../assets/config.json", import.meta.url), + ); + assertEquals(JSON.parse(new TextDecoder().decode(bytes)), { + schemaVersion: 2, + agent: { + modelAccess: { + contractVersion: 1, + protocols: ["openai-completions", "anthropic-messages"], + modelCardinality: "single", + }, + }, + }); + // The digest is the declaration's published identity: reformatting the file is a + // release-affecting change this test exists to make someone notice. + assertEquals(await declarationAssociation(bytes), { + schemaVersion: 2, + declarationSha256: DECLARATION_SHA256, + copy: "agent-configuration.json", + }); +}); + +Deno.test("packagedDeclaration reads bound bytes back, and reports their absence", async () => { + const declaration = encode('{"schemaVersion":2}'); + const writePackage = async (entries: [string, Uint8Array][]) => { + const path = await Deno.makeTempFile(); + const file = await Deno.open(path, { write: true, truncate: true }); + const writer = new ZipWriter(file.writable); + for (const [name, bytes] of entries) { + await writer.add( + name, + new BlobReader(new Blob([bytes as Uint8Array])), + ); + } + await writer.close(); + return path; + }; + const bound = await writePackage([ + ["orax.toml", encode("resolver = 1")], + ["assets/config.json", declaration], + ]); + const unbound = await writePackage([["orax.toml", encode("resolver = 1")]]); + try { + assertEquals(await packagedDeclaration(bound), declaration); + assertEquals(await packagedDeclaration(unbound), undefined); + } finally { + await Deno.remove(bound); + await Deno.remove(unbound); + } +}); + +Deno.test("a declaration no host compiler would accept cannot be bound", async () => { + for ( + const bytes of [ + encode("not json"), + encode('{"agent":{}}'), + encode('{"schemaVersion":0}'), + new Uint8Array(256 * 1024 + 1), + ] + ) { + await assertRejects(() => declarationAssociation(bytes), Error); + } +}); + +Deno.test("without a declaration the manifest renders exactly as before", () => { + const installed = 'resolver = 1\nidentifier = "ora-space.opencode"\n'; + assertEquals( + releaseManifest(installed, { + kind: "targets", + targets: [{ + triple: "x86_64-unknown-linux-gnu", + url: "https://example.invalid/a.orax", + sha256: "aa", + }], + }), + `resolver = 1 +identifier = "ora-space.opencode" + +[[targets]] +target = "x86_64-unknown-linux-gnu" +url = "https://example.invalid/a.orax" +sha256 = "aa" +`, + ); + assertEquals( + releaseManifest(installed, { + kind: "universal", + url: "https://example.invalid/u.orax", + sha256: "bb", + }), + `resolver = 1 +identifier = "ora-space.opencode" + +url = "https://example.invalid/u.orax" +sha256 = "bb" +`, + ); +}); + +Deno.test("a bound declaration renders after top-level keys and before the targets", () => { + const association = { + schemaVersion: 2, + declarationSha256: DECLARATION_SHA256, + copy: "agent-configuration.json", + }; + assertEquals( + releaseManifest("resolver = 1", { + kind: "targets", + targets: [{ + triple: "x86_64-unknown-linux-gnu", + url: "https://example.invalid/a.orax", + sha256: "aa", + }], + }, association), + `resolver = 1 + +[agent_configuration] +schema_version = 2 +declaration_sha256 = "${DECLARATION_SHA256}" +copy = "agent-configuration.json" + +[[targets]] +target = "x86_64-unknown-linux-gnu" +url = "https://example.invalid/a.orax" +sha256 = "aa" +`, + ); + // A universal url below the table would silently become one of its keys. + assertEquals( + releaseManifest("resolver = 1", { + kind: "universal", + url: "https://example.invalid/u.orax", + sha256: "bb", + }, association), + `resolver = 1 + +url = "https://example.invalid/u.orax" +sha256 = "bb" + +[agent_configuration] +schema_version = 2 +declaration_sha256 = "${DECLARATION_SHA256}" +copy = "agent-configuration.json" +`, + ); +});