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; +}