Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ jobs:
# step above cannot run.
- name: Lint
run: deno task lint

- name: Archive integrity tests
run: deno test scripts/upstream.test.ts
44 changes: 38 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,23 @@ cmd /c mklink /J "<ORA_DATA_DIR>\plugins" "%USERPROFILE%\.ora\plugins\installed"

## Building from source

This prerelease pins OpenCode **2.0.32** from the official `@opencode/cli` npm
distribution. `bundle.config.ts` selects an explicit version and platform
packages; `deno task sync` records their tarball URLs and SHA-512 integrity in
`upstream.lock.json`. Packaging verifies integrity before extracting
`package/bin/opencode[.exe]`; it never resolves a newer version. The GitHub
Release source remains supported for configurations without `npm`.

```
deno task sync --check
deno task build
deno task package --tag v0.2.4 --repo ora-space/opencode-agent
deno task package --tag v0.7.0-opencode2.1 --repo ora-space/opencode-agent
```

This produces one `.orax` package per target platform, with the matching
OpenCode CLI bundled inside. `gh` is the only external tool required.
OpenCode CLI bundled inside. npm packaging downloads from `registry.npmjs.org`;
GitHub-based configurations additionally require `gh`. Generated download URLs
describe a future release; building packages does not upload or publish them.

`bundle.config.ts` decides which of the two release shapes is built, and it is
the only file to change to switch:
Expand Down Expand Up @@ -124,6 +134,18 @@ for a package that bundles no CLI, and the run exercises the `PATH` fallback
against your own OpenCode instead.

`deno task check` type checks and `deno task lint` lints the sources.
`deno test scripts/upstream.test.ts` checks archive integrity rejection.

To exercise the bundled entrypoint as well as the CLI, extract a complete
package to a separate directory and run:

```
deno task simulate --package-root /absolute/extracted-package --cwd /absolute/workspace
```

The simulator fails on ACP error responses and covers startup, model discovery,
Effect coordination/restart/readiness and stop. It does not install the package
into a user's Ora profile or replace the marketplace version.

### SDK

Expand Down Expand Up @@ -151,9 +173,19 @@ shows up the next time the picker is opened.

## Known limits

- Each model discovery leaves one empty session behind in OpenCode's own session
list. OpenCode advertises `session/close`, `session/fork`, `session/list`, and
`session/resume`, but not `session/delete`, so the probe has nothing to clean
up with; the five-minute cache is what keeps the count down.
- OpenCode v2 supports `session/delete`, which model discovery uses to remove
its temporary session. Older CLI overrides without that capability leave an
empty session behind.
- V2 runs as `opencode acp` with the host-provided working directory; it does
not accept the v1 `--cwd` flag.
- The plugin transparently forwards child-session metadata. Complete child
details after reload depend on the host preserving that metadata in history.
This prerelease does not implement host history or UI changes and does not
enable the separate `opencode/child-session-updates` extension.
- Nested subagents require OpenCode's `experimental.subagent_depth` to permit
the requested depth. The plugin does not change the user's depth policy.
- Interactive question forms require an ACP client supporting the corresponding
elicitation capability. With no such capability, OpenCode reports that the
question could not be shown; ordinary permission requests remain separate.
- Killing the CLI on agent stop is best effort; Ora retains process-tree reaping
as a backstop.
9 changes: 5 additions & 4 deletions bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import { bundledBinaryPath } from "./src/services/bundled-binary.ts";
export default {
cli: "bundled",
upstream: "anomalyco/opencode",
npm: { package: "@opencode/cli", version: "2.0.32" },
assets: {
"aarch64-apple-darwin": "opencode-darwin-arm64.zip",
"x86_64-unknown-linux-gnu": "opencode-linux-x64.tar.gz",
"aarch64-unknown-linux-gnu": "opencode-linux-arm64.tar.gz",
"x86_64-pc-windows-msvc": "opencode-windows-x64.zip",
"aarch64-apple-darwin": "@opencode/cli-darwin-arm64",
"x86_64-unknown-linux-gnu": "@opencode/cli-linux-x64",
"aarch64-unknown-linux-gnu": "@opencode/cli-linux-arm64",
"x86_64-pc-windows-msvc": "@opencode/cli-windows-x64",
},
binaryPath: bundledBinaryPath,
} satisfies BundleConfig;
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"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",
"sync": "deno run --allow-net --allow-read --allow-write --allow-env scripts/sync-upstream.ts",
"package": "deno run --allow-run --allow-read --allow-write --allow-env scripts/package.ts"
"package": "deno run --allow-run --allow-read --allow-write --allow-env --allow-net=registry.npmjs.org scripts/package.ts"
},
"compilerOptions": {
"strict": true,
Expand Down
2 changes: 1 addition & 1 deletion orax.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title = "OpenCode"
identifier = "ora-space.opencode"
namespace = "official"
kind = "agent"
version = "0.6.11"
version = "0.7.0-opencode2.1"
description = "Ora Space OpenCode Agent"
homepage = "https://github.com/ora-space/opencode-agent"
license = "Apache-2.0"
78 changes: 61 additions & 17 deletions scripts/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { basename, dirname, join, relative } from "@std/path";
import { UntarStream } from "@std/tar";
import { BlobReader, ZipReader, ZipWriter } from "@zip-js/zip-js";
import bundle from "../bundle.config.ts";
import { LOCK_PATH, readLock } from "./upstream.ts";
import { LOCK_PATH, readLock, verifyIntegrity } from "./upstream.ts";

/** Operating systems a package can be built for, as `Deno.build.os` spells them. */
export type TargetOs = typeof Deno.build.os;
Expand All @@ -51,6 +51,8 @@ export type BundleConfig =
cli: "bundled";
/** GitHub `owner/name` the CLI is released from. */
upstream: string;
/** An explicitly selected npm distribution; never resolves latest at package time. */
npm?: { package: string; version: string };
/** Release asset serving each canonical Rust target triple. */
assets: Record<string, string>;
/**
Expand Down Expand Up @@ -299,40 +301,60 @@ async function buildBundledPackage(
plan: TargetPlan,
upstream: string,
upstreamTag: string,
expectedSha256: string,
expectedSha256: string | undefined,
fileName: string,
includeDeclaration: boolean,
npmAsset?: { url: string; integrity: string },
): Promise<void> {
await Deno.remove(STAGE_DIR, { recursive: true }).catch(() => {});
const staged = join(STAGE_DIR, plan.binaryPath);
await Deno.mkdir(dirname(staged), { recursive: true });

const archive = join(DOWNLOAD_DIR, plan.asset);
await run(
"gh",
"release",
"download",
upstreamTag,
"--repo",
upstream,
"--pattern",
plan.asset,
"--dir",
const archive = join(
DOWNLOAD_DIR,
"--clobber",
npmAsset ? `${plan.triple}.tgz` : plan.asset,
);
if (npmAsset) {
const response = await fetch(npmAsset.url, {
signal: AbortSignal.timeout(180_000),
});
if (!response.ok) throw new Error(`download failed: ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
await verifyIntegrity(bytes, npmAsset.integrity);
await Deno.writeFile(archive, bytes);
} else {
await run(
"gh",
"release",
"download",
upstreamTag,
"--repo",
upstream,
"--pattern",
plan.asset,
"--dir",
DOWNLOAD_DIR,
"--clobber",
);
}
// Verified against the digest `deno task sync` recorded from GitHub's own Releases API, the
// same role npm's `dist.integrity` plays for the sibling `claude-code-agent` and `codex-agent`
// — this catches upstream replacing an asset under a tag this plugin already pinned.
const actualSha256 = await sha256Hex(archive);
if (actualSha256 !== expectedSha256) {
const actualSha256 = npmAsset ? undefined : await sha256Hex(archive);
if (!npmAsset && actualSha256 !== expectedSha256) {
throw new Error(
`${plan.asset} sha256 ${actualSha256} does not match ${LOCK_PATH}'s pinned ${expectedSha256}; run 'deno task sync'`,
);
}
// Upstream ships the CLI as the sole entry at the archive root, under the same name this
// package uses for it.
await extractEntry(archive, basename(plan.binaryPath), staged);
await extractEntry(
archive,
npmAsset
? `package/bin/${basename(plan.binaryPath)}`
: basename(plan.binaryPath),
staged,
);

await stagePluginFiles(plan.triple, includeDeclaration);
await writeOrax(STAGE_DIR, join(PACKAGES_DIR, fileName), plan.binaryPath);
Expand Down Expand Up @@ -393,6 +415,18 @@ async function buildRelease(
// shipped, and a "latest" lookup at package time could straddle an upstream release and ship a
// version skew a rebuild would not repeat.
const lock = await readLock();
if (
config.npm &&
(lock.npm?.package !== config.npm.package ||
lock.npm.version !== config.npm.version)
) {
throw new Error(
"npm configuration differs from upstream.lock.json; run 'deno task sync'",
);
}
if (!config.npm && lock.npm) {
throw new Error("unexpected npm lock for GitHub configuration");
}
console.log(`Bundling ${lock.repo} ${lock.tag}`);

for (const [triple, asset] of Object.entries(config.assets)) {
Expand All @@ -407,6 +441,15 @@ async function buildRelease(
`${LOCK_PATH} pins ${pinned.name} for ${triple}, but bundle.config.ts now names ${asset}; run 'deno task sync'`,
);
}
if (config.npm ? !pinned.npm : !pinned.sha256 || pinned.npm) {
throw new Error(`missing or mismatched integrity source for ${triple}`);
}
if (
pinned.npm &&
new URL(pinned.npm.url).origin !== "https://registry.npmjs.org"
) {
throw new Error(`unexpected npm download origin for ${triple}`);
}
const plan: TargetPlan = {
triple,
asset,
Expand All @@ -420,6 +463,7 @@ async function buildRelease(
pinned.sha256,
fileName,
includeDeclaration,
pinned.npm,
);

const path = join(PACKAGES_DIR, fileName);
Expand Down
11 changes: 9 additions & 2 deletions scripts/sync-upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import bundle from "../bundle.config.ts";
import {
LOCK_PATH,
readLock,
resolveNpmUpstream,
resolveUpstream,
type UpstreamLock,
writeLock,
Expand All @@ -27,7 +28,9 @@ async function main(): Promise<void> {
}

const before = await currentLock();
const resolved = await resolveUpstream(bundle.upstream, bundle.assets);
const resolved = bundle.npm
? await resolveNpmUpstream(bundle.upstream, bundle.npm, bundle.assets)
: await resolveUpstream(bundle.upstream, bundle.assets);
const unchanged = before !== undefined &&
JSON.stringify(before) === JSON.stringify(resolved);
const summary = `${resolved.repo}@${resolved.tag}`;
Expand All @@ -46,7 +49,11 @@ async function main(): Promise<void> {
await writeLock(resolved);
console.log(`${LOCK_PATH} now pins ${summary}`);
for (const [triple, asset] of Object.entries(resolved.assets)) {
console.log(` ${triple}: ${asset.name} sha256:${asset.sha256}`);
console.log(
` ${triple}: ${asset.name} ${
asset.npm?.integrity ?? `sha256:${asset.sha256}`
}`,
);
}
}

Expand Down
22 changes: 22 additions & 0 deletions scripts/upstream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { verifyIntegrity } from "./upstream.ts";

Deno.test("npm archives must match the pinned SHA-512 before extraction", async () => {
const bytes = new TextEncoder().encode("release archive");
const digest = new Uint8Array(await crypto.subtle.digest("SHA-512", bytes));
const integrity = `sha512-${btoa(String.fromCharCode(...digest))}`;
await verifyIntegrity(bytes, integrity);
for (
const [content, sri] of [
[new TextEncoder().encode("replaced archive"), integrity],
[bytes, "sha256-not-a-sha512-pin"],
] as const
) {
let rejected = false;
try {
await verifyIntegrity(content, sri);
} catch {
rejected = true;
}
if (!rejected) throw new Error("unverified archive was accepted");
}
});
63 changes: 60 additions & 3 deletions scripts/upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ export const LOCK_PATH = "upstream.lock.json";
export interface PinnedAsset {
/** The release asset's file name, matching `bundle.config.ts`'s `assets` entry for this target. */
name: string;
size: number;
sha256: string;
size?: number;
sha256?: string;
npm?: { url: string; integrity: string };
}

export interface UpstreamLock {
/** GitHub `owner/name` the CLI is released from. */
repo: string;
tag: string;
publishedAt: string;
publishedAt?: string;
npm?: { package: string; version: string };
/** Keyed by target triple, matching `bundle.config.ts`'s `assets`. */
assets: Record<string, PinnedAsset>;
}
Expand Down Expand Up @@ -105,3 +107,58 @@ export async function resolveUpstream(
assets,
};
}

/** Verifies the registry's SHA-512 SRI before any archive is extracted. */
export async function verifyIntegrity(
bytes: Uint8Array<ArrayBuffer>,
integrity: string,
): Promise<void> {
if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) {
throw new Error("invalid npm SHA-512 integrity");
}
const hash = new Uint8Array(await crypto.subtle.digest("SHA-512", bytes));
const actual = `sha512-${btoa(String.fromCharCode(...hash))}`;
if (actual !== integrity) throw new Error("npm archive integrity mismatch");
}

/** Pins one explicit npm version and its per-platform archives, without following latest. */
export async function resolveNpmUpstream(
repo: string,
npm: { package: string; version: string },
assetsByTriple: Record<string, string>,
): Promise<UpstreamLock> {
const read = async (name: string): Promise<{
name: string;
version: string;
optionalDependencies?: Record<string, string>;
dist: { tarball: string; integrity: string };
}> => {
const response = await fetch(
`https://registry.npmjs.org/${encodeURIComponent(name)}/${
encodeURIComponent(npm.version)
}`,
);
if (!response.ok) {
throw new Error(`npm ${name}@${npm.version}: ${response.status}`);
}
return await response.json();
};
const parent = await read(npm.package);
const assets: Record<string, PinnedAsset> = {};
for (const [triple, name] of Object.entries(assetsByTriple)) {
if (parent.optionalDependencies?.[name] !== npm.version) {
throw new Error(`unlisted platform ${name}`);
}
const metadata = await read(name);
const { tarball: url, integrity } = metadata.dist;
if (
metadata.name !== name || metadata.version !== npm.version ||
new URL(url).origin !== "https://registry.npmjs.org" ||
!/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)
) {
throw new Error(`invalid npm metadata for ${name}`);
}
assets[triple] = { name, npm: { url, integrity } };
}
return { repo, tag: npm.version, npm, assets };
}
2 changes: 1 addition & 1 deletion src/handlers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ async function discoverModels(
// Resolution failures — no CLI on PATH, a bundled one that cannot run — are raised here as the
// classified errors Ora acts on, before any probe exists to blur them into a timeout.
const child = await spawnOpenCode(processes, {
args: ["acp", "--cwd", cwd],
args: ["acp"],
cwd,
});
const probe = AcpProbe.attach(child);
Expand Down
Loading
Loading