Skip to content
Draft
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
19 changes: 9 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,12 @@ nothing.
every `console` method through the plugin logger before any plugin code runs.
A single `console.log` reaching stdout is read by the host as a corrupt frame
and takes the plugin down.
- **ACP payloads are never parsed** on the bridge. Frames are re-framed between
Ora's binary envelope and the CLI's NDJSON and otherwise passed through
verbatim. `handlers/effects.ts` is the one exception, and a deliberately
narrow one: it reads `method` and `id` off the envelope to track turns, and
never looks at `params`.
- **ACP routing** stays opaque for legacy starts and unrelated frames. When
`modelContext` is present, `services/model-adapter.ts` owns the
model/authentication guards and safe error envelopes; read
[Platform model bindings](README.md#platform-model-bindings) before changing
this seam. Effect tracking reads only envelope `method`/`id`. Keep payload
values out of logs; private provider diagnostics stay suppressed.
- **Throw the right error code.** `AGENT_NOT_INSTALLED` (`-32001`) is retried
quietly by Ora as expected local configuration; `AGENT_UNUSABLE` (`-32002`) is
reported once and not retried; `-32000` is how an Effect Consumer says "not
Expand Down Expand Up @@ -346,11 +347,9 @@ 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.
- Unit tests use synthetic model bindings and declaration archives. The host
simulator needs a real CLI. Run check, lint and test as the development gate;
an unpublished SDK dependency remains a visible type-check failure in CI.
- 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
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,29 @@ Answers are reused for five minutes per workspace, so a picker that re-renders
does not restart the CLI, while a model that appears after a provider login
shows up the next time the picker is opened.

## Platform model bindings

With the SDK 0.11 startup contract, a host can supply a frozen `modelContext`.
The adapter creates one OpenAI Chat Completions or Anthropic Messages provider
with the exact model ID, including embedded slashes. Primary and auxiliary
requests use that binding. Configuration content references an environment
token; private execution HOME/state and disabled project configuration keep
routing separate from ambient profiles. Discovery uses the active binding.

Declared execution requires the bundled CLI and refuses authentication or model
replacement. Private diagnostics are suppressed while protocol error codes are
preserved. Node owns renewal, revocation and cleanup. The declaration
contributes no additional Settings; the exact Settings JSON startup field
remains available for future declared options. Legacy starts retain their
existing path.

Packaging adds a declaration only with `--agent-configuration`, for bundled
artifacts after compatible hosts are deployed. Each target is read back and
checked against the same immutable bytes. Upload the generated
`dist/agent-configuration.json` with the release and its declaration
association. Default packaging publishes no declaration. Unit tests use
synthetic bindings and archives and require no installed CLI.

## Known limits

- Each model discovery leaves one empty session behind in OpenCode's own session
Expand Down
1 change: 1 addition & 0 deletions assets/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schemaVersion":2,"agent":{"modelAccess":{"contractVersion":1,"protocols":["openai-completions","anthropic-messages"],"modelCardinality":"single"}}}
6 changes: 4 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
{
"name": "@ora-space/opencode-agent",
"version": "0.6.3",
"version": "0.6.12",
"exports": "./src/main.ts",
"minimumDependencyAge": 0,
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.0",
"@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.10.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/model_adapter_test.ts tests/agent_configuration_test.ts",
"lint": "deno lint src scripts tests bundle.config.ts",
"test": "deno test --allow-read --allow-write --allow-env tests/model_adapter_test.ts tests/agent_configuration_test.ts",
"format": "deno fmt src scripts tests bundle.config.ts deno.json README.md AGENTS.md",
"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",
Expand Down
13 changes: 11 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.6.12"
description = "Ora Space OpenCode Agent"
homepage = "https://github.com/ora-space/opencode-agent"
license = "Apache-2.0"
81 changes: 81 additions & 0 deletions scripts/agent-configuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { BlobReader, Uint8ArrayWriter, ZipReader } from "@zip-js/zip-js";

/** The package-relative declaration validated by the host's shared compiler. */
export const AGENT_DECLARATION_ENTRY = "assets/config.json";
/** Static copy distributed alongside an explicitly opted-in release. */
export const AGENT_DECLARATION_COPY = "agent-configuration.json";

/** Exact bytes held once for every target, its copy and the final release association. */
export interface AgentDeclarationPublication {
bytes: Uint8Array;
schemaVersion: number;
sha256: string;
}

/** Reads only an explicitly requested declaration; default packaging never calls this function. */
export async function readAgentDeclaration(
path: string,
): Promise<AgentDeclarationPublication> {
const stat = await Deno.lstat(path);
if (!stat.isFile || stat.size > 256 * 1024) {
throw new Error("invalid Agent declaration file");
}
const bytes = await Deno.readFile(path);
if (bytes.length > 256 * 1024) {
throw new Error("Agent declaration exceeds size limit");
}
const parsed: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
if (
typeof parsed !== "object" || parsed === null ||
!("schemaVersion" in parsed) || parsed.schemaVersion !== 2
) throw new Error("unsupported Agent declaration schema");
return { bytes, schemaVersion: 2, sha256: await digest(bytes) };
}

/** Emits no association until the caller explicitly provided verified publication bytes. */
export function agentDeclarationManifest(
publication: AgentDeclarationPublication | undefined,
): string {
return publication
? `\n\n[agent_configuration]\nschema_version = ${publication.schemaVersion}\ndeclaration_sha256 = "${publication.sha256}"\ncopy = "${AGENT_DECLARATION_COPY}"`
: "";
}

/** Rejects missing, duplicate or modified packaged copies before their association is published. */
export async function verifyPackagedAgentDeclaration(
path: string,
publication: AgentDeclarationPublication,
): Promise<void> {
const archive = await Deno.readFile(path);
const reader = new ZipReader(new BlobReader(new Blob([archive])));
try {
const entries = (await reader.getEntries()).filter((entry) =>
entry.filename === AGENT_DECLARATION_ENTRY
);
const entry = entries[0];
if (
entries.length !== 1 || !entry || entry.directory ||
entry.uncompressedSize > 256 * 1024
) throw new Error("package Agent declaration is missing or invalid");
const bytes = await entry.getData(new Uint8ArrayWriter());
if (await digest(bytes) !== publication.sha256) {
throw new Error("package Agent declaration differs from publication");
}
} finally {
await reader.close();
}
}

/** Hashes immutable source bytes, rather than a parsed/reserialized approximation. */
async function digest(bytes: Uint8Array): Promise<string> {
const value = await crypto.subtle.digest(
"SHA-256",
new Uint8Array(bytes).buffer,
);
return Array.from(
new Uint8Array(value),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
}
56 changes: 51 additions & 5 deletions scripts/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ 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 {
AGENT_DECLARATION_COPY,
AGENT_DECLARATION_ENTRY,
agentDeclarationManifest,
type AgentDeclarationPublication,
readAgentDeclaration,
verifyPackagedAgentDeclaration,
} from "./agent-configuration.ts";

/** Operating systems a package can be built for, as `Deno.build.os` spells them. */
export type TargetOs = typeof Deno.build.os;
Expand Down Expand Up @@ -246,9 +254,21 @@ async function findLogoFiles(): Promise<string[]> {
* 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.
*/
async function stagePluginFiles(target: string | undefined): Promise<void> {
async function stagePluginFiles(
target: string | undefined,
publication?: AgentDeclarationPublication,
): Promise<void> {
await Deno.mkdir(STAGE_DIR, { recursive: true });
await Deno.copyFile(join(DIST, "main.js"), join(STAGE_DIR, "main.js"));
if (publication) {
await Deno.mkdir(dirname(join(STAGE_DIR, AGENT_DECLARATION_ENTRY)), {
recursive: true,
});
await Deno.writeFile(
join(STAGE_DIR, AGENT_DECLARATION_ENTRY),
publication.bytes,
);
}
for (const extra of [...await findLogoFiles(), "README.md"]) {
await Deno.copyFile(extra, join(STAGE_DIR, extra)).catch(() => {});
}
Expand All @@ -269,6 +289,7 @@ async function buildBundledPackage(
upstreamTag: string,
expectedSha256: string,
fileName: string,
publication?: AgentDeclarationPublication,
): Promise<void> {
await Deno.remove(STAGE_DIR, { recursive: true }).catch(() => {});
const staged = join(STAGE_DIR, plan.binaryPath);
Expand Down Expand Up @@ -301,8 +322,14 @@ async function buildBundledPackage(
// package uses for it.
await extractEntry(archive, basename(plan.binaryPath), staged);

await stagePluginFiles(plan.triple);
await stagePluginFiles(plan.triple, publication);
await writeOrax(STAGE_DIR, join(PACKAGES_DIR, fileName), plan.binaryPath);
if (publication) {
await verifyPackagedAgentDeclaration(
join(PACKAGES_DIR, fileName),
publication,
);
}
await Deno.remove(STAGE_DIR, { recursive: true });
await Deno.remove(archive).catch(() => {});
}
Expand Down Expand Up @@ -330,11 +357,15 @@ async function buildRelease(
identifier: string,
tag: string,
repo: string,
publication?: AgentDeclarationPublication,
): Promise<string> {
const base = `https://github.com/${repo}/releases/download/${tag}`;
let manifest = (await Deno.readTextFile("orax.toml")).trimEnd();

if (config.cli === "user_installed") {
if (publication) {
throw new Error("declared model adapters require bundled artifacts");
}
const fileName = `${identifier}-${tag}.orax`;
await buildUniversalPackage(fileName);
const digest = await sha256Hex(join(PACKAGES_DIR, fileName));
Expand Down Expand Up @@ -374,6 +405,7 @@ async function buildRelease(
lock.tag,
pinned.sha256,
fileName,
publication,
);

const digest = await sha256Hex(join(PACKAGES_DIR, fileName));
Expand All @@ -383,11 +415,15 @@ async function buildRelease(
}
await Deno.remove(DOWNLOAD_DIR, { recursive: true }).catch(() => {});
console.log(`\nUpstream CLI: ${lock.tag}`);
return manifest;
return manifest + agentDeclarationManifest(publication);
}

async function main(): Promise<void> {
const flags = parseArgs(Deno.args, { string: ["tag", "repo"] });
const flags = parseArgs(Deno.args, {
string: ["tag", "repo"],
boolean: ["agent-configuration"],
default: { "agent-configuration": false },
});
const tag = flags.tag ?? Deno.env.get("GITHUB_REF_NAME");
const repo = flags.repo ?? Deno.env.get("GITHUB_REPOSITORY");
if (tag === undefined || repo === undefined) {
Expand All @@ -397,7 +433,17 @@ async function main(): Promise<void> {
const identifier = await manifestField("identifier");
await Deno.mkdir(PACKAGES_DIR, { recursive: true });

const manifest = await buildRelease(identifier, tag, repo);
const publication = flags["agent-configuration"]
? await readAgentDeclaration(AGENT_DECLARATION_ENTRY)
: undefined;
const manifest = await buildRelease(identifier, tag, repo, publication);
const copy = join(DIST, AGENT_DECLARATION_COPY);
if (publication) await Deno.writeFile(copy, publication.bytes);
else {try {
await Deno.remove(copy);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}}

// 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
Expand Down
6 changes: 4 additions & 2 deletions src/base/agent-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AGENT_METHODS,
type AgentEffectDefinition,
type AgentModel,
type AgentModelApplied,
type AgentStartContext,
createHostProcesses,
defineAgent,
Expand Down Expand Up @@ -93,7 +94,7 @@ export abstract class AgentPlugin {
abstract onStart(
context: AgentStartContext,
send: AcpSender,
): void | Promise<void>;
): void | AgentModelApplied | Promise<void | AgentModelApplied>;

/** [agent/acp] Receives one ACP frame the host is forwarding to the agent. */
abstract onAcp(frame: JsonValue): void | Promise<void>;
Expand Down Expand Up @@ -145,7 +146,8 @@ export async function runAgentPlugin(
() =>
invoke(routes, AGENT_METHOD_ROUTES.onStart, startContext, send) as
| void
| Promise<void>,
| AgentModelApplied
| Promise<void | AgentModelApplied>,
),
stop: () =>
traced(
Expand Down
Loading
Loading