From e6da5d8c7783964b9390c4830c78eb3a6358209e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 11:13:00 +0000 Subject: [PATCH] opencode gets the skills and a credential, which it had neither of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit an opencode agent could not have used the calendar skill written this morning, and would not have failed usefully either — it had no idea the skill existed and no key to call the platform with. two gaps, one file each. SKILLS: OPENCODE ALREADY HAD THE MECHANISM. it has a `skill` tool, an index compiled into its system prompt, and `skills.paths` in config taking directories of /SKILL.md — the same progressive disclosure claude does over the same layout the platform already stores. so this points it at the SAME FILES rather than compiling a second index in a second format. skills/load.ts says a skill is a path because every harness can read a file; this is the better version of that bet — the harness had the feature all along. verified: `/skill?directory=…` now returns all nine platform skills, "Calendar and contacts" among them. THE CREDENTIAL, AND THE THING TO KNOW ABOUT IT. claude spawns a subprocess per session, so its key is per session and a member's resolves to their own grants. opencode cannot: ONE serve runs every session, so the only environment there is is the serve's own, minted once at sidecar start, belonging to the owner. that is sound today for exactly one reason — opencode is owner-only, enforced at every door in chat.ts because one serve runs as one user. when that lifts, this key becomes a privilege escalation. it is the same blocker as the two already there, not a new one, but it lives in a sidecar where nobody looking at chat.ts would find it, so chat.ts now names it. all three move together. verified end to end: a tool process inside an opencode session ran curl with $OFFICER_AGENT_KEY and got the owner's calendars back. OPENCODE_CONFIG_CONTENT rather than editing ~/.config/opencode/opencode.jsonc — it is inline json, it MERGES into the same chain as the config files (checked in the binary), and it dies with the process. the alternative is the platform editing a dotfile it does not own and leaving state behind that outlives the install. affordances ride the `instructions` key as one composed file, written beside the serve's state. loaded — `/config` shows it — but NOT model-verified: this box has no provider credential in auth.json, so a turn 401s before reaching a model. whoever adds one should check that first. the credential module moved out of sidecar/claude/. it was never claude-shaped; it was just there first, and it had one importer. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/chat/chat.ts | 8 ++ ...sion-credential.ts => agent-credential.ts} | 19 ++- src/servers/sidecar/claude/claude-manager.ts | 2 +- src/servers/sidecar/opencode/index.ts | 6 + .../sidecar/opencode/serve-env.test.ts | 27 ++++ src/servers/sidecar/opencode/serve-env.ts | 116 ++++++++++++++++++ 6 files changed, 175 insertions(+), 3 deletions(-) rename src/servers/sidecar/{claude/session-credential.ts => agent-credential.ts} (83%) create mode 100644 src/servers/sidecar/opencode/serve-env.test.ts create mode 100644 src/servers/sidecar/opencode/serve-env.ts diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index fce5fa11..4b121ebb 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -82,6 +82,14 @@ export async function chatIdentity(user: { id: number; email: string }): Promise // identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because // `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued. // The matching refusal on the execution path is in `websocket.ts` → `handleChat`. +// +// ── A THIRD thing now rides on this restriction (2026-08-27) ── +// +// The serve holds an Officer API credential minted for the OWNER, in its own environment, because one +// process serves every session and there is nowhere else to put one — `sidecar/opencode/serve-env.ts`. +// So lifting `isOwner` here without changing that hands a member the owner's API authority as well as the +// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it +// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together. import { registerAgentPanelRoutes } from './agent-panels-routes'; export const chatRouter = createRouter(); diff --git a/src/servers/sidecar/claude/session-credential.ts b/src/servers/sidecar/agent-credential.ts similarity index 83% rename from src/servers/sidecar/claude/session-credential.ts rename to src/servers/sidecar/agent-credential.ts index e5c1cc57..a48446a2 100644 --- a/src/servers/sidecar/claude/session-credential.ts +++ b/src/servers/sidecar/agent-credential.ts @@ -1,9 +1,12 @@ import { createApiKey, revokeApiKeysByName } from 'officerdb'; import { mintApiKey } from '@@/auth-token'; -import { PORT, OFFICER_API_URL } from '../../officer-url.mjs'; +import { PORT, OFFICER_API_URL } from '../officer-url.mjs'; /** - * A credential for one agent session, carrying its holder's authority minus administration. + * A credential for an agent, carrying its holder's authority minus administration. + * + * Shared by both harnesses, and it lived under `sidecar/claude/` until 2026-08-27 only because Claude was + * the first to need one. Nothing in it is Claude-shaped. * * ── The gap it closes ── * @@ -46,6 +49,18 @@ import { PORT, OFFICER_API_URL } from '../../officer-url.mjs'; * granted, so a chat can reach what its owner allowed and nothing else. That is the shape the owner asked * for — "the features provided by the platform, including the ones that I allow for each user". * + * ── One per SESSION for Claude, one per PROCESS for OpenCode ── + * + * Claude spawns a subprocess per session, so its key is named for the session, dies with it, and a member's + * key resolves to exactly what their role was granted. OpenCode cannot do that: ONE `opencode serve` runs + * every session, so the only environment there is is the serve's own, minted once at sidecar start. + * + * That is sound today for exactly one reason — OpenCode is owner-only, enforced at every door in + * `api/chat/chat.ts`. The key is the owner's because every session is. **When that restriction lifts, this + * key becomes a privilege escalation**: a member's turn would hold the owner's authority. It is not the + * first thing that has to change — a member's OpenCode turn already runs as the service user in the owner's + * home — but it belongs on the same list, and the note in `chat.ts` names it. + * * ── Why an env var rather than a file ── * * It dies with the process. There is no path to leak, nothing to clean up after a crash, and no file for a diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 22a5011f..24159448 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -11,7 +11,7 @@ import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state' import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis'; import { createParseState, processMessage } from './stream-parser'; import { spawnClaudeAsMember } from './spawn-as-member'; -import { mintSessionCredential } from './session-credential'; +import { mintSessionCredential } from '../agent-credential'; import { claudeBinIn } from '@@/os-user-claude'; const SEND_TIMEOUT_MS = 30 * 60 * 1000; diff --git a/src/servers/sidecar/opencode/index.ts b/src/servers/sidecar/opencode/index.ts index 6019ab75..6639db3e 100644 --- a/src/servers/sidecar/opencode/index.ts +++ b/src/servers/sidecar/opencode/index.ts @@ -5,6 +5,7 @@ import { DATA_PATH } from '../../data-path'; import { createSidecarConnector } from '../connect'; import { sweepRecordedServe } from './serve-sweep'; import { connectProviderCredential } from './connect-credential'; +import { buildServeEnv } from './serve-env'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { RunnerMessage } from './serve-runner'; @@ -109,8 +110,13 @@ const port = getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; console.log(`[opencode] starting serve on ${baseUrl} (cwd=${SERVE_CWD})`); +// The credential, the skill roots and the affordances — see serve-env.ts. Built before the spawn +// because a long-lived serve has no later moment to be told any of it. +const serveEnv = await buildServeEnv(SERVE_CWD); + const serve = Bun.spawn([OPENCODE_BIN, 'serve', '--port', String(port), '--hostname', '127.0.0.1'], { cwd: SERVE_CWD, + env: serveEnv, stdout: 'inherit', stderr: 'inherit', }); diff --git a/src/servers/sidecar/opencode/serve-env.test.ts b/src/servers/sidecar/opencode/serve-env.test.ts new file mode 100644 index 00000000..4354a2fc --- /dev/null +++ b/src/servers/sidecar/opencode/serve-env.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import { serveConfigContent, affordancesPath } from './serve-env'; + +// The config shape is a contract with the OpenCode binary, and the cost of a wrong key name is SILENCE: +// no error, no warning, just an agent that never sees a skill. So the keys are asserted literally. + +describe('serveConfigContent', () => { + test('names skills.paths and instructions exactly', () => { + const parsed = JSON.parse(serveConfigContent(['/a/skills', '/b/skills'], ['/tmp/aff.md'])); + expect(parsed).toEqual({ skills: { paths: ['/a/skills', '/b/skills'] }, instructions: ['/tmp/aff.md'] }); + }); + + // An empty array is not the same as an absent key: `skills: {paths: []}` is a claim that there are none, + // and merged over a config that sets some it would erase them. + test('omits a key rather than sending it empty', () => { + expect(JSON.parse(serveConfigContent([], []))).toEqual({}); + expect(JSON.parse(serveConfigContent(['/a'], []))).toEqual({ skills: { paths: ['/a'] } }); + }); + + test('is valid JSON for an env var — no newlines to be mangled by a shell', () => { + expect(serveConfigContent(['/a/skills'], ['/tmp/aff.md'])).not.toContain('\n'); + }); + + test('the affordances file sits beside the serve state, not in a home', () => { + expect(affordancesPath('/data/opencode_server')).toBe('/data/opencode_server/officer-affordances.md'); + }); +}); diff --git a/src/servers/sidecar/opencode/serve-env.ts b/src/servers/sidecar/opencode/serve-env.ts new file mode 100644 index 00000000..a9f3482d --- /dev/null +++ b/src/servers/sidecar/opencode/serve-env.ts @@ -0,0 +1,116 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { getOwnerUser } from 'officerdb'; +import { mintSessionCredential } from '../agent-credential'; +import { affordanceBlock } from '../../affordances/load'; +import { capabilityRoots } from '../../capability-sources'; + +/** + * What an OpenCode agent is told and what it is trusted with — the two things it had neither of. + * + * A Claude session gets both from `claude-manager`, per session, because it spawns a subprocess it can + * hand an environment and a system prompt to. OpenCode has no such moment: one `opencode serve` is + * started here and every session afterwards is an HTTP call into it, so the ONLY environment there will + * ever be is this one. Everything below therefore happens once, at sidecar start. + * + * ── The consequence, stated plainly ── + * + * The credential is minted for the OWNER, and every OpenCode session holds it. That is correct only + * because OpenCode is owner-only — `api/chat/chat.ts` applies `who.isOwner` at every door, for reasons + * that have nothing to do with this file and everything to do with one serve running as one user. When + * that lifts, this has to become per-session or it is a privilege escalation. See `agent-credential.ts`. + * + * ── Skills: OpenCode already has the mechanism ── + * + * It has a `skill` tool, an index compiled into its system prompt, and `skills.paths` in config taking + * directories of `/SKILL.md`. That is the same progressive disclosure Claude does and the same + * layout the platform already stores, so this points OpenCode at the SAME FILES rather than compiling a + * second index in a second format. `skills/load.ts` says a skill is a path because every harness can read + * a file; this is the better version of that bet — the harness had the feature all along. + * + * ── Affordances: one composed file, because they are not optional reading ── + * + * An affordance is the inverse of a skill: short, always relevant, and useless if the agent has to decide + * to open it. OpenCode's `instructions` key takes file paths and concatenates them into the system + * prompt, which is exactly right — so the same text Claude gets is written to one file and named there. + * Composed rather than listed file-by-file so the framing survives, and rewritten each boot so a plugin + * installed since is included. + * + * ── Why `OPENCODE_CONFIG_CONTENT` rather than editing the owner's config ── + * + * It is inline JSON, it MERGES with the config files rather than replacing them (verified in the binary: + * the loader folds it into the same chain as `~/.config/opencode/opencode.json`), and it dies with the + * process. Writing `~/.config/opencode/opencode.jsonc` instead would mean the platform editing a dotfile + * it does not own, leaving state behind that outlives the install and that a person editing the same file + * would silently fight with. + */ + +/** Where the composed affordances land. Beside the serve's own state, not in anyone's home. */ +export const affordancesPath = (serveCwd: string) => join(serveCwd, 'officer-affordances.md'); + +/** + * The config OpenCode is handed. Exported for the test: the shape is a contract with the binary, and the + * cost of getting a key name wrong is silence — no error, just an agent that never sees a skill. + */ +export function serveConfigContent(skillRoots: string[], instructions: string[]): string { + return JSON.stringify({ + ...(skillRoots.length ? { skills: { paths: skillRoots } } : {}), + ...(instructions.length ? { instructions } : {}), + }); +} + +/** + * The environment for `opencode serve`. + * + * Best effort throughout, deliberately. A sidecar that refuses to start because the database was slow, or + * because a capability directory was missing, is worse than one whose agent has no key: the first breaks + * chat entirely, the second is the state everything was in until today. Every failure below degrades and + * logs rather than throwing. + */ +export async function buildServeEnv(serveCwd: string): Promise> { + const env: Record = { ...process.env } as Record; + + try { + const owner = await getOwnerUser(); + if (!owner) { + console.error('[opencode] no owner account — the agent will have no API key'); + } else { + // Named for the process, not a session: there is one serve and it outlives every conversation in it. + // Re-minting on each boot revokes the previous one by that name, so a restart cannot accumulate keys. + const credential = await mintSessionCredential(owner.id, 'opencode-serve'); + if (credential) { + Object.assign(env, credential); + console.log('[opencode] minted an agent credential for the serve'); + } + } + } catch (err) { + console.error('[opencode] could not mint an agent credential', err); + } + + const skillRoots: string[] = []; + const instructions: string[] = []; + + try { + // Only roots that exist: OpenCode is handed these verbatim and a missing directory is noise in its + // log for something the platform already knows is not there. + for (const root of await capabilityRoots('skills')) if (existsSync(root)) skillRoots.push(root); + } catch (err) { + console.error('[opencode] could not resolve the skill roots', err); + } + + try { + const affordances = await affordanceBlock(); + if (affordances) { + mkdirSync(serveCwd, { recursive: true }); + writeFileSync(affordancesPath(serveCwd), affordances); + instructions.push(affordancesPath(serveCwd)); + } + } catch (err) { + console.error('[opencode] could not write the affordances file', err); + } + + env.OPENCODE_CONFIG_CONTENT = serveConfigContent(skillRoots, instructions); + console.log(`[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s)`); + return env; +}