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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

# Runs the repository's own tasks rather than spelling out commands. None of them needs the Claude
# CLI; type checking also catches an @ora-space/plugin-sdk version that is not published yet.

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x

- name: Type check
run: deno task check

- name: Lint
run: deno task lint

- name: Test
run: deno task test
119 changes: 109 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ The published adapter is a Node program that finds its CLI with
`createRequire(...).resolve("@anthropic-ai/claude-agent-sdk-<platform>/claude")`
— a `node_modules` lookup that cannot work inside a compiled single-file binary.
`scripts/adapter-entry.mjs` answers that question before handing control to the
adapter unchanged, by setting `CLAUDE_CODE_EXECUTABLE` from its own ladder:
adapter unchanged, by setting `CLAUDE_CODE_EXECUTABLE` from the ladder in
`scripts/resolve-claude-cli.mjs` (a module of its own because importing the
entry point starts the adapter, and the ladder is what tests have to run;
packaging copies both beside the installed dependencies):

1. **`ORA_CLAUDE_BIN`** — pins one exact CLI, and fails if it points at nothing.
2. **the bundled CLI beside it**, found through `process.execPath`, which is the
Expand Down Expand Up @@ -349,6 +352,98 @@ must never clear the new process's tracking or fire `onExited`. The shared
`#expectedExit` flag reflects the newer generation's intent by then, which is
why the identity check comes first.

## Platform model route

On an Ora Node, `agent/start` can carry a frozen `modelContext`: one
`anthropic-messages` model, the platform proxy URL and a temporary token. The
plugin, not the host, turns that into Claude Code's routing
(`src/services/model-route.ts`), and the SDK refuses the start unless `onStart`
returns the exact `AgentModelApplied` — so ACP never begins on an unapplied
route.

The route is applied through the adapter's own `providers/set`, sent by the
bridge right after spawn and **before** `agent/start` returns:

- `claude-agent-acp` puts a provider route into Claude Code's programmatic
settings tier with `apiKeyHelper` cleared. That is the only reason a
repository's `.claude/settings.json` (or a user setting) cannot restore a
different `ANTHROPIC_BASE_URL` and send the token there. Setting
`ANTHROPIC_BASE_URL` in the spawn environment would not have that property.
- The token travels as the `x-api-key` header of that request, which the
platform gateway reads first on its Messages route. This plugin never puts it
in the adapter's spawn environment or a log, but the pinned adapter does not
keep it in that request either: it merges the header into the Claude Code
process environment (`ANTHROPIC_CUSTOM_HEADERS`) **and** into the `env` of the
programmatic settings, and the Claude Agent SDK passes those settings to the
CLI as a `--settings` argument. The token is therefore readable in the CLI's
command line by anyone who can list that process. It is valid for one
execution only, but it is not confined to memory and the execution's own
environment, and that is a known gap until the route stops reaching the
settings.
- The model aliases Claude Code resolves by itself (main, small/fast, subagent,
`opus`/`sonnet`/`haiku`) are pinned to the frozen model in the spawn
environment. A setting that overrides one fails closed at the gateway, which
serves only the frozen model id. `CLAUDE_CODE_MAX_OUTPUT_TOKENS` is the frozen
model's own `maxTokens`.
- A context frozen for another plugin id or another target triple, a token with
a control character in it (a header cannot carry one, and the adapter would
only fail later, retryably), or a non-positive `maxTokens` is refused with
`INVALID_PARAMS` before anything starts.
- `ClaudeClient.useRoute` keeps the route for every later start, so an Effect
`reactivate` restart reapplies it before the adapter counts as ready.
`ClaudeClient.ready`, not `running`, is what readiness and the Effect replay
read: a process waiting for `providers/set` is running but on no route yet.
- A route that fails is classified by the adapter's code alone. `invalid_params`
or `method_not_found` means this adapter refuses the configuration and fails
as `AGENT_UNUSABLE`, which Ora does not retry. Anything else, or no answer
within ten seconds, fails with `-32603`, which Ora retries: the pinned adapter
lists the gateway's models inside `providers/set`, and a listing that failed
(a 503, a timeout) answers as an internal error that the next start can pass.
- Only the pair this package ships runs on a route. `spawnBundledClaude` asks
the host for the bundled adapter alone — no `ORA_CLAUDE_ACP_BIN`, no PATH —
and fails as `AGENT_UNUSABLE` when the package cannot run it;
`ORA_CLAUDE_BUNDLED_CLI_ONLY=1` in the route environment makes
`scripts/resolve-claude-cli.mjs` refuse `ORA_CLAUDE_BIN` and a local install
the same way.

This is the second narrow exception to "ACP payloads are never parsed": the
bridge writes one request of its own (`id` prefixed `ora-claude-model-route-`)
and consumes its answer by id before forwarding anything, so Ora never sees
either frame. A different `claude-agent-acp` pin must be checked for the same
`providers/set` behaviour before it ships with a declaration.

Applying the route once is not enough: the same ACP connection could undo it. On
a platform route `services/model-guard.ts` reads Ora's frames before they reach
the adapter and answers these itself, so the adapter never sees them:

- `authenticate`, `logout`, `auth/*`, `_auth/*`, `providers/*` and
`_providers/*` — `logout` and `providers/disable` clear the route and send
Claude Code back to its own login, the others install a different upstream.
- `session/set_model` and the `model` config option for anything but the frozen
model. Re-selecting the frozen model is answered as a no-op.
- `_meta.claudeCode.options` on session setup loses `model`, `fallbackModel`,
`env`, `settings`, `extraArgs` and the executable overrides: the adapter
spreads them over its own environment, where they would outrank the pinned
aliases.

The adapter's own model selector is narrowed the same way: every `configOptions`
model entry (and a `models` state, should a pin send one) in a result or a
`config_option_update` offers only the frozen model, and `agent/list_models`
answers that model without starting a discovery probe, which would run on the
host's own credentials and routing.

Nothing the adapter prints on a route is trusted. Its stderr is not logged at
all, only how many lines it wrote, and a stdout line that is not a frame is
logged by length, not content: the adapter logs `[providers/set] ... baseUrl=`,
and Claude Code echoes a failed request with the upstream's response body, its
host and path, and paths of the machine it runs on. Scrubbing known secrets out
of that text would still keep the rest. Every adapter error response keeps its
`code` but loses its `message` and `data`. ACP frames are still forwarded, with
the token, the gateway base URL, its host and its hostname replaced with
`[redacted]`; an agent message can quote an upstream error in its own text, and
nothing distinguishes that from the model's answer, so such text is scrubbed but
not removed. On a local host all of this is logged verbatim, as before.

## Model discovery

`claude-agent-acp` is a pure ACP server: it takes no subcommand, and its
Expand Down Expand Up @@ -386,9 +481,9 @@ a start/stop cycle around a picker opening makes the next open pay full price.
Ora's binary envelope and the adapter's NDJSON and otherwise passed through
verbatim — this is also what lets Claude Code's ACP extensions (session modes,
steering, its `_meta` capabilities) reach Ora without this plugin knowing they
exist. `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`.
exist. `handlers/effects.ts` is one exception, and a deliberately narrow one:
it reads `method` and `id` off the envelope to track turns, and never looks at
`params`; the platform model route above is the other.
- **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 All @@ -408,11 +503,12 @@ Targets are how a `plugin.log` is read: `plugin` (activation, start/stop,
deactivation), `host-call` (every `agent/*` method with duration and outcome),
`lifecycle`, `acp` (frame envelopes only — `method` and `id`, never `params`),
`claude-client` (spawn, pid, exit, pipe failures), `claude-agent-acp` (the live
adapter's own stderr, one record per line at `info`), `effects` (barrier, drain,
restart, readiness), `models` and `acp-probe` (discovery; the probe adapter's
stderr is `claude-agent-acp.probe` at `debug`), and `command` (which adapter was
resolved and how). ACP payloads are never logged: a `session/prompt` carries the
user's text, and the log has no business keeping it.
adapter's own stderr, one record per line at `info`; nothing on a platform
route, where `claude-client` records only a line count), `effects` (barrier,
drain, restart, readiness), `models` and `acp-probe` (discovery; the probe
adapter's stderr is `claude-agent-acp.probe` at `debug`), and `command` (which
adapter was resolved and how). ACP payloads are never logged: a `session/prompt`
carries the user's text, and the log has no business keeping it.

## Manifest

Expand Down Expand Up @@ -441,10 +537,13 @@ Ora parses and validates that table today but does not yet enforce it.
`HostProcesses` that scripts an ACP peer, and effect coordination against a
`ClaudeClient` with a scripted `spawn`. `deno task simulate` needs a real
`claude-agent-acp` on PATH (`npm i -g @agentclientprotocol/claude-agent-acp`).
- CI (`.github/workflows/ci.yml`) runs `check`, `lint` and `test` on every pull
request and push to `main`; none of them needs a CLI.
- One test reads `src/main.ts` as text to assert the effect wiring is present.
That is deliberate: the entrypoint calls `runAgentPlugin` at module scope, so
importing it would start serving the host, and the wiring it guards is exactly
the kind whose absence is invisible at runtime.
the kind whose absence is invisible at runtime. Anything else `main.ts` would
decide belongs in a handler a test can call, as `agent/list_models` does.
- Bump `orax.toml` `version` before handing someone a `.orax` to import.
`install_local` refuses a version that is already installed and never retires
older ones, so reusing a number silently leaves the old code running. Keep
Expand Down
6 changes: 3 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"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/crypto": "jsr:@std/crypto@^1.0.5",
Expand All @@ -14,10 +14,10 @@
"@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61"
},
"tasks": {
"check": "deno check src/main.ts tests/main_test.ts tests/host-simulator.ts scripts/package.ts scripts/sync-upstream.ts",
"check": "deno check src/main.ts tests/main_test.ts tests/model_route_test.ts tests/host-simulator.ts scripts/package.ts scripts/sync-upstream.ts",
"lint": "deno lint src scripts tests bundle.config.ts",
"format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md AGENTS.md",
"test": "deno test --allow-read --allow-env --allow-write tests/main_test.ts",
"test": "deno test --allow-read --allow-env --allow-write tests/main_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.

122 changes: 7 additions & 115 deletions scripts/adapter-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,121 +11,8 @@
* too: stdout is the ACP channel and nothing but ACP frames may ever be written to it, and every
* diagnostic goes to stderr.
*/
import { existsSync, statSync } from "node:fs";
import { delimiter, dirname, join } from "node:path";
import { homedir } from "node:os";
import { ADAPTER_BINARY_NAME, CLAUDE_BINARY_NAME } from "./paths.generated.mjs";

/** Pins one exact Claude Code CLI, outranking both the bundled copy and any local install. */
const BIN_ENV_VAR = "ORA_CLAUDE_BIN";

/**
* Resolves the Claude Code CLI this adapter should drive, preferring the one shipped beside it.
*
* The bundled CLI comes first because it is the build the adapter was published against: the
* adapter's own `package.json` pins an exact Claude Agent SDK version, and that SDK names one
* exact CLI build, so the pair in this package is the pair upstream tested. A local install is a
* fallback for the cases where the bundled one cannot run at all, and it is deliberately not
* preferred over the bundled one even when it is newer — the bridge between adapter and CLI is an
* internal interface, and an unpaired combination fails in ways that only reproduce on the
* machine that has it.
*/
function resolveClaudeCli() {
const pinned = process.env[BIN_ENV_VAR]?.trim();
if (pinned) {
if (!isExecutableFile(pinned)) {
throw new Error(
`${BIN_ENV_VAR} points at ${pinned}, which does not exist`,
);
}
return { path: pinned, source: "pinned" };
}

// `process.execPath` is this compiled binary's own absolute path, whatever the working
// directory is, which is the only way to find the package this binary was installed into: a
// plugin is told no host path, and the package-relative paths the host understands are not
// resolvable from inside a process it spawned.
const bundled = join(dirname(process.execPath), CLAUDE_BINARY_NAME);
if (isExecutableFile(bundled) && !isUnrunnableHere(bundled)) {
return { path: bundled, source: "bundled" };
}

const local = findLocalClaude();
if (local) {
return { path: local, source: "local" };
}

throw new Error(
`no Claude Code CLI to run: this package ships none at ${bundled}, none was found on PATH, ` +
`and ${BIN_ENV_VAR} is not set`,
);
}

/**
* Reports whether the bundled CLI is one this machine cannot execute despite it being present.
*
* Only Linux can get here. Packages are built per target triple and Ora refuses to install one
* built for another, so on Windows and macOS a bundled binary that exists also runs. Linux is the
* exception because libc is not part of the triple: the `-gnu` build this package ships is a
* dynamically linked binary whose loader (`/lib/ld-musl-*` vs `/lib64/ld-linux-*`) does not exist
* on a musl system, so it fails at exec time with an error that says nothing about libc. Checking
* up front turns that into a fallback rather than a crash.
*/
function isUnrunnableHere(bundled) {
if (process.platform !== "linux") return false;
const loader = process.arch === "arm64"
? "/lib/ld-musl-aarch64.so.1"
: "/lib/ld-musl-x86_64.so.1";
if (!existsSync(loader)) return false;
warn(
`the bundled Claude Code CLI at ${bundled} is a glibc build and this system uses musl; ` +
`looking for a local install instead`,
);
return true;
}

/**
* Finds a Claude Code CLI the user installed themselves.
*
* Every spelling a real installer produces is tried, not just the bare name: the native installer
* writes `claude`/`claude.exe`, while npm and bun leave a `.cmd`/`.bat` shim on Windows. Omitting
* one is indistinguishable from "not installed" and fails far from its cause. `.exe` is tried
* first, following Windows' own `PATHEXT` precedence, so what gets spawned is the CLI itself
* rather than a shell wrapper around it.
*/
function findLocalClaude() {
const names = process.platform === "win32"
? ["claude.exe", "claude.cmd", "claude.bat", "claude"]
: ["claude"];
// The native installer's location is searched too: it is not always on the PATH of a process
// spawned by a desktop application, which inherits a login environment rather than a shell one.
const directories = [
...(process.env.PATH ?? "").split(delimiter).filter(Boolean),
join(homedir(), ".local", "bin"),
];
for (const directory of directories) {
for (const name of names) {
const candidate = join(directory, name);
if (isExecutableFile(candidate)) {
warn(
`using the Claude Code CLI found at ${candidate}; it is not the build this adapter ` +
`was published with, so report any protocol failure with both versions`,
);
return candidate;
}
}
}
return undefined;
}

/** Reports whether a path names a regular file, the only thing worth trying to spawn. */
function isExecutableFile(path) {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
import { resolveClaudeCli } from "./resolve-claude-cli.mjs";

/** Writes one diagnostic to stderr; stdout carries ACP frames and nothing else. */
function warn(message) {
Expand All @@ -136,6 +23,11 @@ function warn(message) {
// the `node_modules` lookup that a compiled binary cannot perform. It is set unconditionally:
// `ORA_CLAUDE_BIN` is this package's own knob, and honoring a stray `CLAUDE_CODE_EXECUTABLE` from
// the surrounding environment would silently run a different CLI than the package ships.
process.env.CLAUDE_CODE_EXECUTABLE = resolveClaudeCli().path;
process.env.CLAUDE_CODE_EXECUTABLE = resolveClaudeCli({
env: process.env,
execPath: process.execPath,
claudeBinaryName: CLAUDE_BINARY_NAME,
warn,
}).path;

await import("@agentclientprotocol/claude-agent-acp/dist/index.js");
5 changes: 5 additions & 0 deletions scripts/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ async function compileAdapter(plan: TargetPlan, staged: string): Promise<void> {
join("scripts", "adapter-entry.mjs"),
join(ADAPTER_DIR, "entry.mjs"),
);
// The entry point imports the CLI resolver by this relative name.
await Deno.copyFile(
join("scripts", "resolve-claude-cli.mjs"),
join(ADAPTER_DIR, "resolve-claude-cli.mjs"),
);
await Deno.mkdir(dirname(staged), { recursive: true });
await run(
"bun",
Expand Down
Loading
Loading