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 @@ -32,5 +32,8 @@ jobs:
- name: Lint
run: deno task lint

- name: Test
run: deno task test

- name: Archive integrity tests
run: deno test scripts/upstream.test.ts
65 changes: 59 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ a build-time flag; `bundle.config.ts` picks which one a release is. See
silently running a different CLI than the one a developer named is worse than
failing.

A platform model route is the exception to all three: `spawnBundledOpenCode`
asks the host for the bundled CLI alone — no `ORA_OPENCODE_BIN`, no PATH — and
fails as `AGENT_UNUSABLE` when the package cannot run it. The route's
configuration was written for the pinned build, and another version may rank
configuration sources differently.

`assets/bin/` is git-ignored. It is staged only so `deno task simulate` can run
against a real binary; released packages have `scripts/package.ts` download the
CLI per target at package time. With nothing staged there the simulator answers
Expand All @@ -186,6 +192,51 @@ it:
Invocation env wins over provider env
(`{ ...providerEnv(), ...invocation.env }`), so a caller can always override.

## Platform model route

On an Ora Node, `agent/start` can carry a frozen `modelContext`: one model over
`openai-completions` or `anthropic-messages`, the platform proxy URL and a
temporary token. `src/services/model-route.ts` turns it into the OpenCode
provider configuration the Node used to write itself, now owned by the plugin
that drives the CLI, and the SDK refuses the start unless `onStart` returns the
exact `AgentModelApplied`.

- The configuration travels as `OPENCODE_CONFIG_CONTENT` and names the token as
`{env:ORA_MODEL_ACCESS_TOKEN}`, so the text can be logged or persisted without
the token; only the CLI's own environment holds it.
- `OPENCODE_CONFIG_DIR` points at the session's private `XDG_CONFIG_HOME` and
project configuration is disabled, so neither the repository's nor the host's
`opencode.json` can replace the provider. A start whose host provides no
private configuration root is refused rather than run on the host's own.
- The spawn environment only adds to what the host inherits, so configuration
variables that could reach the provider are overridden rather than removed:
`OPENCODE_CONFIG` (one more file to merge), `OPENCODE_MODELS_URL` and
`OPENCODE_MODELS_PATH` are set empty, which OpenCode 2.0.32 reads as unset,
and `OPENCODE_CONFIG_PROJECT_DISABLE` is set alongside
`OPENCODE_DISABLE_PROJECT_CONFIG` because it takes precedence over it.
- Settings must be empty — this release declares none — and the proxy must be an
HTTPS URL without credentials, query or fragment.
- `OpenCodeClient.useRoute` keeps the route for every later start, so an Effect
`reactivate` restart runs with the same configuration.
- The same ACP connection could ask the CLI to leave that provider, so on a
route `services/model-guard.ts` answers these requests itself and the CLI
never sees them: `authenticate`, `logout`, `auth/*`, `_auth/*`, `providers/*`,
and `session/set_model` or the `model` config option for any model but the
frozen `ora-model/<id>` (an effort variant of it is allowed). OpenCode 2.0.32
serves only `authenticate`, as a no-op; the guard is what keeps a later pin
that serves the rest from becoming a way around the route.
- Only the frozen model is ever offered. `agent/list_models` answers it without
a discovery probe, which would run on the host's own configuration, and every
`configOptions` model entry in a result or `config_option_update` is narrowed
to it — `enabled_providers` already limits the catalog, but managed settings
outrank this plugin's configuration.
- Nothing the CLI prints is trusted to keep the route's secrets. Every stdout
and stderr line has the token and the gateway URL, origin, host and hostname
replaced with `[redacted]` before it is parsed, forwarded or logged, and every
CLI error response keeps its `code` but loses its `message` and `data`, which
can carry a provider response. Diagnostics stay in the log rather than being
dropped.

## Process ownership

Every subprocess goes through the host: `createHostProcesses(plugin)` →
Expand Down Expand Up @@ -365,12 +416,14 @@ the run.

- `deno task check` / `lint` / `format` / `simulate` / `build` / `sync` /
`package`.
- `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.
- `deno task test` runs the tests that need no CLI: `tests/package_test.ts`
covers packaging and `tests/model_route_test.ts` drives the platform model
route against a scripted spawn. `tests/host-simulator.ts` drives the plugin
the way Ora's host does and needs a real CLI, staged or on PATH. CI runs
`check`, `lint`, `test` and the archive integrity tests, none of which needs a
CLI. 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
Expand Down
6 changes: 3 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
"exports": "./src/main.ts",
"minimumDependencyAge": 0,
"imports": {
"@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.10.0",
"@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.11.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 tests/package_test.ts",
"check": "deno check src/main.ts scripts/package.ts scripts/sync-upstream.ts tests/host-simulator.ts tests/package_test.ts tests/model_route_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",
"test": "deno test --allow-read --allow-write --allow-env tests/package_test.ts tests/model_route_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",
Expand Down
8 changes: 4 additions & 4 deletions deno.lock

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

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
21 changes: 18 additions & 3 deletions src/handlers/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { AgentStartContext } from "@ora-space/plugin-sdk";
import type {
AgentModelApplied,
AgentStartContext,
} from "@ora-space/plugin-sdk";
import { INVALID_PARAMS, PluginMethodError } from "@ora-space/plugin-sdk";
import type { OpenCodeClient } from "../services/opencode-client.ts";
import { logger } from "../services/log.ts";
import { modelRoute } from "../services/model-route.ts";

const log = logger("lifecycle");

Expand All @@ -11,22 +15,33 @@ const log = logger("lifecycle");
* Ora calls this once per connection, before any session exists, so the CLI is already accepting
* ACP frames when the host runs its own `initialize` handshake. Per-session directories travel
* later in ACP `session/new`, not here.
*
* A platform-model start carries a frozen model context, which this plugin turns into OpenCode's
* provider configuration and confirms once the CLI started with it; a start without one keeps the
* CLI's own configuration, exactly as on a local desktop. `configDirectory` is the private
* configuration root of the session home the host provides.
*/
export async function startOpenCode(
client: OpenCodeClient,
context: AgentStartContext,
): Promise<void> {
configDirectory: string | undefined,
): Promise<AgentModelApplied | void> {
if (context.cwd.trim() === "") {
log.warn("agent/start refused: empty cwd");
throw new PluginMethodError(
INVALID_PARAMS,
"agent/start requires a non-empty cwd",
);
}
const route = context.modelContext === undefined
? undefined
: modelRoute(context.modelContext, configDirectory ?? "");
client.useRoute(route);
await client.start(context.cwd);
log.info("OpenCode CLI started for agent/start", {
context: { cwd: context.cwd },
context: { cwd: context.cwd, platformRoute: route !== undefined },
});
return route?.applied;
}

/**
Expand Down
26 changes: 24 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AcpSender,
AgentModel,
AgentModelApplied,
AgentStartContext,
HostProcesses,
JsonValue,
Expand Down Expand Up @@ -90,7 +91,7 @@ class OpenCodeAgentPlugin extends AgentPlugin {
override onStart = async (
context: AgentStartContext,
send: AcpSender,
): Promise<void> => {
): Promise<AgentModelApplied | void> => {
this.#log.info("agent start requested", {
context: { cwd: context.cwd, previousCwd: this.#cwd },
});
Expand All @@ -100,7 +101,11 @@ class OpenCodeAgentPlugin extends AgentPlugin {
this.#send = send;
this.#cwd = context.cwd;
invalidateOpenCodeModels(context.cwd);
await startOpenCode(this.#client, context);
return await startOpenCode(
this.#client,
context,
readEnv("XDG_CONFIG_HOME"),
);
};

override onStop = async (): Promise<void> => {
Expand All @@ -114,6 +119,14 @@ class OpenCodeAgentPlugin extends AgentPlugin {
override onListModels = (
context: AgentListModelsContext,
): Promise<AgentModel[]> => {
// A platform route offers exactly its frozen model. Probing would start a CLI on the host's
// own configuration and credentials — a run nobody admitted — and list what that finds.
const frozen = this.#client.frozenModel;
if (frozen !== undefined) {
return Promise.resolve([
{ id: frozen.id, displayName: frozen.name, default: true },
]);
}
if (this.#processes === undefined) {
throw new Error(
`${AGENT_METHODS.listModels} was called before activation`,
Expand Down Expand Up @@ -152,4 +165,13 @@ function acpFrameSummary(frame: JsonValue): Record<string, unknown> {
};
}

/** Reads an env var, treating a missing read permission as an unset value. */
function readEnv(name: string): string | undefined {
try {
return Deno.env.get(name);
} catch {
return undefined;
}
}

await runAgentPlugin(new OpenCodeAgentPlugin(), { pluginId: PLUGIN_ID });
34 changes: 33 additions & 1 deletion src/services/command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AGENT_NOT_INSTALLED,
AGENT_UNUSABLE,
type AgentInvocation,
type HostChildProcess,
type HostProcesses,
Expand Down Expand Up @@ -38,7 +39,7 @@ function pathCommands(): string[] {
}

/** Full path to a locally built OpenCode, overriding both ways of resolving one. */
const BIN_OVERRIDE_ENV = "ORA_OPENCODE_BIN";
export const BIN_OVERRIDE_ENV = "ORA_OPENCODE_BIN";

/**
* Spawns the OpenCode CLI, whichever way this package was built to reach one.
Expand Down Expand Up @@ -87,6 +88,37 @@ export function spawnOpenCode(
);
}

/**
* Spawns only the CLI this package ships, for a platform-model execution.
*
* The platform route is configuration written for, and tested against, the pinned OpenCode build
* this package bundles; `ORA_OPENCODE_BIN` or an install on PATH could be any other version, with
* its own idea of which configuration sources outrank the one this plugin supplies. So neither is
* consulted, and a package that cannot run its own CLI fails the start as `AGENT_UNUSABLE` rather
* than looking further.
*/
export async function spawnBundledOpenCode(
processes: HostProcesses,
invocation: AgentInvocation,
): Promise<HostChildProcess> {
const packageCommand = bundledBinaryPath();
log.info("spawning the bundled OpenCode CLI for a platform route", {
context: { cwd: invocation.cwd, packageCommand },
});
try {
return await processes.spawn({ packageCommand, ...invocation });
} catch (error) {
log.warn("the bundled OpenCode CLI could not be spawned", {
context: { cwd: invocation.cwd, packageCommand },
error,
});
throw new PluginMethodError(
AGENT_UNUSABLE,
"this package's bundled OpenCode CLI cannot run, and a platform model route uses no other",
);
}
}

/**
* Spawns the CLI a developer named explicitly, reporting an absent one as local configuration.
*
Expand Down
Loading
Loading