Official

opencode

OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/opencode@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${settings.binaryPath}
  • Network access netMediumConnects to the listed hosts.Talk to the OpenCode server it starts on this computer, at the port it picks for each launch, and to the external server you choose in SettingsHosts: localhost:*${settings.serverUrl}
  • Environment variables envMediumReads the listed environment variables.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

Files

client.test.ts11.7 KB
import { test } from "node:test";
import assert from "node:assert/strict";
import * as Effect from "effect/Effect";
import {
  LocalServer,
  Trouble,
  basicAuth,
  checkVersion,
  failure,
  generatedPassword,
  isLoopback,
  listensOn,
  openEvents,
  parseListenUrl,
  passwordFor,
  queryString,
  randomPort,
  request,
  semver,
  speaksOurApi,
  troubleOf,
  versionIn,
} from "./client.ts";
import { fakeApi } from "../sdk/testing.ts";
import type { FakePeer } from "../sdk/testing.ts";
import type { SpawnOptions } from "../sdk/process.ts";
import { testRuntime } from "./testing.test.ts";
import { errorMessage } from "../sdk/errors.ts";
import { obj } from "./map.ts";

/// Every program the test starts is played by `script(program, args,
/// options, peer)`, which writes its output and ends it.
function scripted(
  api: ReturnType<typeof fakeApi>,
  script: (program: string, args: readonly string[], options: SpawnOptions, peer: FakePeer) => void,
) {
  const spawn = api.process.spawn;
  api.process.spawn = async (program, args = [], options = {}) => {
    const child = await spawn(program, args, options);
    const peer = api.peers.at(-1);
    assert.ok(peer);
    queueMicrotask(() => script(program, args, options, peer));
    return child;
  };
  return api;
}

test("a local password never reaches a remote server", () => {
  assert.equal(passwordFor(false, null, "local-secret"), "local-secret");
  assert.equal(passwordFor(true, null, "local-secret"), null, "the ambient password must not be forwarded");
  // A password the user chose applies wherever the server runs.
  assert.equal(passwordFor(true, "configured", "local-secret"), "configured");
  assert.equal(passwordFor(false, "configured", "local-secret"), "configured");
  assert.equal(passwordFor(true, null, null), null);
});

test("a server we start always gets a password we know, new every launch", () => {
  const one = generatedPassword();
  const two = generatedPassword();
  assert.match(one, /^[0-9a-f]{64}$/);
  assert.notEqual(one, two, "a generated password must not repeat between launches");
  assert.equal(
    basicAuth({ username: "opencode", password: "secret" }),
    `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
  );
  assert.equal(
    basicAuth({ username: "opencode", password: "pässword" }),
    `Basic ${Buffer.from("opencode:pässword").toString("base64")}`,
  );
  assert.equal(basicAuth(null), null);
});

test("the reported address must match the requested port", () => {
  assert.ok(listensOn("http://127.0.0.1:51574", 51574));
  assert.ok(listensOn("http://127.0.0.1:51574/", 51574));
  assert.ok(!listensOn("http://127.0.0.1:4096", 51574), "the default port is not the one we asked for");
  for (let i = 0; i < 50; i += 1) {
    const port = randomPort();
    assert.ok(port >= 20000 && port < 50000, `${port}`);
  }
});

test("a 2.x binary is named by its version, not guessed at", () => {
  assert.equal(versionIn("opencode v2.0.1\n"), "2.0.1");
  assert.equal(versionIn("1.18.29\n"), "1.18.29");
  assert.equal(versionIn("opencode (unknown)"), null);
  assert.ok(speaksOurApi("1.18.29"));
  assert.ok(!speaksOurApi("2.0.1"));
  assert.ok(speaksOurApi("weird-build"), "an unparsable version is trusted, as checkVersion does");
  const message = new Trouble("unsupported", { binary: "/u/.local/bin/opencode", version: "2.0.1" }).message;
  assert.match(message, /2\.0\.1/);
  assert.match(message, /\/u\/\.local\/bin\/opencode/);
  assert.match(message, /1\.x `opencode` first on your login PATH/);
});

test("the event stream is authorized like every other request", async () => {
  const api = fakeApi({ onFetch: () => ({ status: 200, body: "" }) });
  const { run } = testRuntime(api);
  await run(openEvents("http://127.0.0.1:1", { username: "opencode", password: "secret" }, "/w"));
  const [call] = api.fetches;
  assert.ok(call);
  assert.equal(call.url, "http://127.0.0.1:1/event?directory=%2Fw");
  assert.equal(
    obj(call.headers)["authorization"],
    `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
    "the stream must send the password",
  );
  assert.equal(obj(call.headers)["accept"], "text/event-stream");
  await run(openEvents("http://127.0.0.1:1", null, "/w"));
  assert.equal(obj(api.fetches[1]?.headers)["authorization"], undefined);
});

test("a rejected password is told apart from any other failure", async () => {
  assert.equal(troubleOf(failure(401, "GET", "/session", "no"))?.kind, "auth_rejected");
  assert.equal(troubleOf(failure(403, "GET", "/session", "no"))?.kind, "auth_rejected");
  assert.equal(troubleOf(failure(404, "GET", "/session", "gone")), null);
  const api = fakeApi({ onFetch: () => ({ status: 401, body: "Unauthorized" }) });
  const { run } = testRuntime(api);
  const error = await run(request("http://127.0.0.1:1", null, "GET", "/session", { query: { directory: "/w" } })).catch(
    (e) => e,
  );
  assert.equal(troubleOf(error)?.kind, "auth_rejected");
  assert.match(errorMessage(error), /GET \/session failed with 401: Unauthorized/);
  const old = (() => {
    try {
      checkVersion("1.9.0");
    } catch (e) {
      return e;
    }
  })();
  assert.equal(troubleOf(old)?.kind, "too_old");
  assert.match(errorMessage(old), /1\.14\.19/, "the message names the version needed");
});

test("requests carry JSON, credentials and the folder; an answer that is not JSON says what came", async () => {
  const api = fakeApi({
    onFetch: (url) =>
      url.includes("/html")
        ? { status: 200, body: "<!doctype html>", headers: { "content-type": "text/html" } }
        : { status: 200, body: { ok: 1 } },
  });
  const { run } = testRuntime(api);
  const auth = { username: "opencode", password: "p" };
  assert.deepEqual(
    await run(
      request("http://h:1", auth, "POST", "/session", { query: { directory: "/w a", roots: null }, body: { a: 1 } }),
    ),
    { ok: 1 },
  );
  const [call] = api.fetches;
  assert.ok(call);
  assert.equal(call.url, "http://h:1/session?directory=%2Fw%20a");
  assert.equal(call.method, "POST");
  assert.equal(call.body, '{"a":1}');
  assert.equal(obj(call.headers)["content-type"], "application/json");
  assert.ok(String(obj(call.headers)["authorization"]).startsWith("Basic "));
  await assert.rejects(
    run(request("http://h:1", auth, "GET", "/html")),
    /decoding the response of GET \/html \(status 200, text\/html, 15 bytes: "<!doctype html>"\)/,
  );
  assert.equal(await run(request("http://h:1", auth, "POST", "/x", { body: {}, empty: true })), null);
  assert.equal(queryString({}), "");
});

test("the listen address is anchored on the scheme", () => {
  assert.equal(parseListenUrl("opencode server listening on http://127.0.0.1:52341"), "http://127.0.0.1:52341");
  assert.equal(parseListenUrl("listening on http://127.0.0.1:1/ ready"), "http://127.0.0.1:1");
  assert.equal(parseListenUrl("see https://opencode.ai/docs for help."), "https://opencode.ai/docs");
  assert.equal(parseListenUrl("starting server"), null);
  assert.equal(parseListenUrl("http://"), null);
});

test("versions compare by number, not by text", () => {
  assert.deepEqual(semver("1.18.29"), [1, 18, 29]);
  assert.deepEqual(semver("v1.2"), [1, 2, 0]);
  assert.deepEqual(semver("1.19.0-beta.1"), [1, 19, 0]);
  assert.equal(semver("nightly"), null);
  // "1.9.0" sorts after "1.14.19" as text but is older as a version.
  assert.throws(() => checkVersion("1.9.0"));
  checkVersion("1.14.19");
  checkVersion("1.18.29");
  checkVersion("2.0.0");
  checkVersion("nightly");
});

test("only loopback servers can reach the host's tools server", () => {
  assert.ok(isLoopback("http://127.0.0.1:4096"));
  assert.ok(isLoopback("http://localhost:1"));
  assert.ok(isLoopback("http://[::1]:1"));
  assert.ok(!isLoopback("https://opencode.example:4096"));
  assert.ok(!isLoopback("http://127.0.0.1.evil.example"));
});

test("a local server starts on the port it picked, with the password in its environment, and answers its health check", async () => {
  const api = scripted(
    fakeApi({
      onFetch: (url) =>
        url.endsWith("/global/health") ? { body: { healthy: true, version: "1.18.29" } } : { status: 404 },
    }),
    (program, args, options, peer) => {
      if (args[0] === "--version") {
        peer.stdout.push("1.18.29\n");
        peer.exit(0);
        return;
      }
      const port = args[args.indexOf("--port") + 1];
      peer.stdout.push(`opencode server listening on http://127.0.0.1:${port}\n`);
    },
  );
  const { run } = testRuntime(api);
  const auth = { username: "opencode", password: "secret" };
  const server = await run(LocalServer.start(auth));
  const serve = api.peers[1];
  assert.ok(serve);
  assert.equal(serve.program, "opencode");
  assert.deepEqual(serve.args.slice(0, 3), ["serve", "--hostname", "127.0.0.1"]);
  assert.deepEqual(serve.options.env, { OPENCODE_SERVER_PASSWORD: "secret" });
  assert.equal(server.base, `http://127.0.0.1:${server.port}`);
  assert.equal(server.version, "1.18.29");
  assert.ok(server.alive);
  assert.equal(obj(api.fetches[0]?.headers)["authorization"], basicAuth(auth));
  await run(server.stop());
  assert.deepEqual(serve.kills, ["SIGTERM"]);
  assert.ok(!server.alive);
});

test("a 2.x binary is refused before it starts; a server that reports another port is stopped", async () => {
  const two = scripted(
    fakeApi({ onHost: () => ({ path: "/u/.local/bin/opencode", realPath: "/u/x" }) }),
    (program, args, options, peer) => {
      peer.stdout.push("opencode v2.0.16\n");
      peer.exit(0);
    },
  );
  const { run: runTwo } = testRuntime(two);
  const error = await runTwo(
    LocalServer.start(null, { describe: () => Effect.succeed("/u/.local/bin/opencode") }),
  ).catch((e) => e);
  assert.equal(troubleOf(error)?.kind, "unsupported");
  assert.match(errorMessage(error), /\/u\/\.local\/bin\/opencode is OpenCode 2\.0\.16/);
  assert.equal(two.peers.length, 1, "no server was started");

  const elsewhere = scripted(fakeApi({ onFetch: () => ({ status: 404 }) }), (program, args, options, peer) => {
    if (args[0] === "--version") {
      peer.stdout.push("1.18.29\n");
      peer.exit(0);
      return;
    }
    peer.stdout.push("opencode server listening on http://127.0.0.1:4096\n");
  });
  const { run: runElsewhere } = testRuntime(elsewhere);
  await assert.rejects(runElsewhere(LocalServer.start(null)), /another server may own that address/);
  // Each of the three attempts was stopped.
  assert.equal(elsewhere.peers.filter((peer) => peer.args[0] === "serve").length, 3);
  assert.ok(elsewhere.peers.filter((peer) => peer.args[0] === "serve").every((peer) => peer.kills.length));
});

test("a server that exits before its address names what it printed; a missing binary is `not installed`", async () => {
  const dies = scripted(fakeApi(), (program, args, options, peer) => {
    if (args[0] === "--version") {
      peer.stdout.push("1.18.29\n");
      peer.exit(0);
      return;
    }
    peer.stderr.push("Error: database is locked\n");
    peer.exit(1);
  });
  const { run: runDies } = testRuntime(dies);
  await assert.rejects(
    runDies(LocalServer.start(null)),
    /exited before it reported a listen address; last output: Error: database is locked/,
  );

  const missing = fakeApi({ onSpawn: () => new Error("opencode is not on the PATH") });
  const { run: runMissing } = testRuntime(missing);
  const error = await runMissing(LocalServer.start(null)).catch((e) => e);
  assert.equal(troubleOf(error)?.kind, "not_installed");

  const path = fakeApi({
    onSpawn: () =>
      Object.assign(new Error("PermissionNotGranted: opencode has no grant for process /opt/oc/opencode"), {
        name: "PermissionNotGranted",
      }),
  });
  const { run: runPath } = testRuntime(path);
  await assert.rejects(
    runPath(LocalServer.start(null, { binary: "/opt/oc/opencode" })),
    /only as `opencode` from the login PATH; starting \/opt\/oc\/opencode needs a grant/,
  );
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.