diff --git a/src/index.ts b/src/index.ts index 97ad9ee..6d41256 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,11 @@ import { getLanguageName } from "./services/language-detector.js"; import type { MemoryScope } from "./services/client.js"; import { getHostClientConfig } from "./services/ai/opencode-host-config.js"; import { loadOpencodeProvider } from "./services/ai/opencode-provider-loader.js"; +import { + isInternalStructuredSession, + STRUCTURED_OUTPUT_AGENT, + STRUCTURED_OUTPUT_TOOLS, +} from "./services/ai/opencode-provider.js"; import { INTERNAL_CAPTURE_SESSION_TITLE, @@ -89,6 +94,25 @@ async function isInternalCaptureSession(client: unknown, sessionID: string): Pro return false; } +/** Least-privilege agent used only by internal structured-output sessions (issue #189). */ +export function applyStructuredOutputAgentConfig(cfg: { agent?: Record }): void { + cfg.agent = { + ...cfg.agent, + [STRUCTURED_OUTPUT_AGENT]: { + description: "Internal least-privilege agent for opencode-mem structured output", + mode: "subagent", + // OpenCode reads `steps` at runtime; SDK AgentConfig also documents maxSteps. + steps: 2, + maxSteps: 2, + tools: STRUCTURED_OUTPUT_TOOLS, + permission: { + "*": "deny", + StructuredOutput: "allow", + }, + }, + }; +} + export async function configureOpencodeHostTransport(ctx: { readonly client: unknown; readonly serverUrl?: string | URL; @@ -313,6 +337,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { }); return { + config: async (cfg) => { + applyStructuredOutputAgentConfig(cfg); + }, + "chat.message": async (input, output) => { if (!isConfigured() || !CONFIG.chatMessage.enabled) return; @@ -325,7 +353,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const userMessage = textParts.map((p) => p.text).join("\n"); if (!userMessage.trim()) return; - if (isStructuredSummaryPromptMessage(userMessage)) { + if ( + isStructuredSummaryPromptMessage(userMessage) || + isInternalStructuredSession(input.sessionID) + ) { return; } diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 8c278b0..6df8098 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -11,6 +11,10 @@ * then delete the session so it does not pollute the user's TUI session * list. * + * Internal capture sessions are least-privilege (issue #189): ordinary + * agent tools are denied, only StructuredOutput is allowed, a dedicated + * agent caps steps, and a hard timeout fails closed. + * * The primary transport is the authenticated v2 SDK client initialized from * the plugin host's client configuration. A raw fetch fallback remains for * older SDK builds that do not expose the v2 session methods. @@ -34,6 +38,38 @@ import { } from "./internal-capture-sessions.js"; import { createLazyV2Client, type HostTransport } from "./opencode-sdk-client.js"; +/** Dedicated agent registered via the plugin config hook (step-capped). */ +export const STRUCTURED_OUTPUT_AGENT = "opencode-mem-structured"; + +/** Hard ceiling for a single internal structured-output prompt. */ +export const STRUCTURED_OUTPUT_TIMEOUT_MS = 90_000; + +let _structuredOutputTimeoutMs = STRUCTURED_OUTPUT_TIMEOUT_MS; + +/** Test helper: override the structured-output prompt timeout. Pass undefined to reset. */ +export function setStructuredOutputTimeoutMsForTests(ms: number | undefined): void { + _structuredOutputTimeoutMs = ms ?? STRUCTURED_OUTPUT_TIMEOUT_MS; +} + +export const STRUCTURED_OUTPUT_PERMISSIONS = [ + { permission: "*", pattern: "*", action: "deny" as const }, + { permission: "StructuredOutput", pattern: "*", action: "allow" as const }, +]; + +export const STRUCTURED_OUTPUT_TOOLS: Record = { + "*": false, + StructuredOutput: true, +}; + +export const STRUCTURED_OUTPUT_METADATA = { + "opencode-mem": { + internal: true, + purpose: "structured-output", + }, +}; + +const _internalSessions = new Set(); + let _connectedProviders: Set = new Set(); let _v2Client: OpencodeClient | undefined; let _v2BaseUrl: string | undefined; @@ -72,6 +108,57 @@ export function createV2Client(serverUrl: URL | string, transport?: HostTranspor return createLazyV2Client(baseUrl, activeTransport); } +/** True while an internal structured-output session is live (create → delete). */ +export function isInternalStructuredSession(sessionID: string): boolean { + return _internalSessions.has(sessionID); +} + +/** Test helper: clear tracked internal session IDs. */ +export function resetInternalStructuredSessions(): void { + _internalSessions.clear(); +} + +function markInternalSession(sessionID: string): void { + _internalSessions.add(sessionID); +} + +function unmarkInternalSession(sessionID: string): void { + _internalSessions.delete(sessionID); +} + +function sessionCreateBody(): Record { + return { + title: INTERNAL_CAPTURE_SESSION_TITLE, + permission: STRUCTURED_OUTPUT_PERMISSIONS, + metadata: STRUCTURED_OUTPUT_METADATA, + }; +} + +function sessionPromptFields(args: { + providerID: string; + modelID: string; + systemPrompt: string; + userPrompt: string; + jsonSchema: Record; + retryCount?: number; +}): Record { + return { + model: { providerID: args.providerID, modelID: args.modelID }, + agent: STRUCTURED_OUTPUT_AGENT, + system: args.systemPrompt, + parts: [{ type: "text", text: args.userPrompt }], + tools: STRUCTURED_OUTPUT_TOOLS, + // `noReply` suppresses assistant generation in current OpenCode builds, + // which also suppresses `info.structured_output`; structured capture needs + // the assistant run even though the temporary session is deleted afterward. + format: { + type: "json_schema", + schema: args.jsonSchema, + ...(args.retryCount !== undefined ? { retryCount: args.retryCount } : {}), + }, + }; +} + export interface StructuredOutputOptions { client: OpencodeClient; providerID: string; @@ -138,7 +225,7 @@ function readRecentOpencodeModel( * Generate one structured-output completion via opencode's HTTP API. * Throws on: session.create failure, prompt failure, AssistantMessage.error * (StructuredOutputError / ApiError / ...), missing `info.structured`, - * or final Zod validation failure. + * timeout, or final Zod validation failure. */ export async function generateStructuredOutput(opts: StructuredOutputOptions): Promise { const resolved = resolveOpencodeModelRef({ @@ -177,17 +264,22 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< const base = stripTrailingSlash(baseUrl); const sessionID = await createSession(base, directory); + markInternalSession(sessionID); try { - const info = await promptSession(base, { - sessionID, - directory, - providerID, - modelID, - systemPrompt, - userPrompt, - jsonSchema, - retryCount, - }); + const info = await withStructuredOutputTimeout( + () => + promptSession(base, { + sessionID, + directory, + providerID, + modelID, + systemPrompt, + userPrompt, + jsonSchema, + retryCount, + }), + () => abortSession(base, sessionID, directory) + ); if (info.error) { throw new Error( @@ -204,6 +296,7 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< return schema.parse(structuredOutput); } finally { + unmarkInternalSession(sessionID); // Best-effort: leaving a transient session behind is cosmetic, not // worth failing a successful capture if cleanup itself errors. try { @@ -221,6 +314,7 @@ type V2SessionClient = { create(parameters?: Record): Promise; prompt(parameters: Record): Promise; delete(parameters: Record): Promise; + abort?(parameters: Record): Promise; }; }; @@ -251,7 +345,7 @@ async function generateViaSdkClient( args: SdkStructuredOutputArgs ): Promise { const createdResponse = await client.session.create({ - title: INTERNAL_CAPTURE_SESSION_TITLE, + ...sessionCreateBody(), ...(args.directory ? { directory: args.directory } : {}), }); const created = readSdkData<{ id?: string }>(createdResponse, "POST /session"); @@ -263,19 +357,21 @@ async function generateViaSdkClient( const sessionID = created.id; trackInternalCaptureSession(sessionID); + markInternalSession(sessionID); try { - const promptResponse = await client.session.prompt({ - sessionID, - ...(args.directory ? { directory: args.directory } : {}), - model: { providerID: args.providerID, modelID: args.modelID }, - system: args.systemPrompt, - parts: [{ type: "text", text: args.userPrompt }], - format: { - type: "json_schema", - schema: args.jsonSchema, - ...(args.retryCount !== undefined ? { retryCount: args.retryCount } : {}), - }, - }); + const promptResponse = await withStructuredOutputTimeout( + () => + client.session.prompt({ + sessionID, + ...(args.directory ? { directory: args.directory } : {}), + ...sessionPromptFields(args), + }), + () => + client.session.abort?.({ + sessionID, + ...(args.directory ? { directory: args.directory } : {}), + }) + ); const data = readSdkData(promptResponse, "POST /session/{id}/message"); if (!data.info) { throw new Error("opencode-mem: prompt response missing `info`"); @@ -294,6 +390,7 @@ async function generateViaSdkClient( } return args.schema.parse(structuredOutput); } finally { + unmarkInternalSession(sessionID); try { await client.session.delete({ sessionID, @@ -307,6 +404,34 @@ async function generateViaSdkClient( } } +async function withStructuredOutputTimeout( + run: () => Promise, + onTimeout: () => unknown +): Promise { + let timer: ReturnType | undefined; + const timeoutMs = _structuredOutputTimeoutMs; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`opencode-mem: structured-output timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + return await Promise.race([run(), timeoutPromise]); + } catch (error) { + if (error instanceof Error && error.message.includes("structured-output timed out after")) { + try { + await onTimeout(); + } catch { + // best-effort abort + } + } + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + function readSdkData(response: unknown, label: string): T { const result = response as { data?: T; error?: unknown; request?: Request; response?: Response } | undefined; @@ -354,7 +479,7 @@ async function createSession(base: string, directory?: string): Promise { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: INTERNAL_CAPTURE_SESSION_TITLE }), + body: JSON.stringify(sessionCreateBody()), } ); if (!body.id) { @@ -415,19 +540,7 @@ interface MessageV2WithParts { async function promptSession(base: string, args: PromptSessionArgs): Promise { const url = `${base}/session/${encodeURIComponent(args.sessionID)}/message${buildQuery(args.directory)}`; - const body: Record = { - model: { providerID: args.providerID, modelID: args.modelID }, - system: args.systemPrompt, - parts: [{ type: "text", text: args.userPrompt }], - // `noReply` suppresses assistant generation in current OpenCode builds, - // which also suppresses `info.structured_output`; structured capture needs - // the assistant run even though the temporary session is deleted afterward. - format: { - type: "json_schema", - schema: args.jsonSchema, - ...(args.retryCount !== undefined ? { retryCount: args.retryCount } : {}), - }, - }; + const body = sessionPromptFields(args); const data = await fetchJson( { label: "POST /session/{id}/message", url }, { @@ -442,6 +555,15 @@ async function promptSession(base: string, args: PromptSessionArgs): Promise { + const url = `${base}/session/${encodeURIComponent(sessionID)}/abort${buildQuery(directory)}`; + try { + await activeFetch()(new Request(url, { method: "POST" })); + } catch { + // best-effort + } +} + async function deleteSession(base: string, sessionID: string, directory?: string): Promise { const url = `${base}/session/${encodeURIComponent(sessionID)}${buildQuery(directory)}`; let res: Response; diff --git a/src/services/ai/opencode-sdk-client.ts b/src/services/ai/opencode-sdk-client.ts index 45446bc..dc0e688 100644 --- a/src/services/ai/opencode-sdk-client.ts +++ b/src/services/ai/opencode-sdk-client.ts @@ -50,6 +50,10 @@ export function createLazyV2Client(baseUrl: string, transport?: HostTransport): const client = await getSdkClient(); return client.session.delete(...args); }, + abort: async (...args: Parameters) => { + const client = await getSdkClient(); + return client.session.abort(...args); + }, }, } as OpencodeClient; } diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index 6a3f6b0..dc1fd8b 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -4,11 +4,18 @@ import { createV2Client, generateStructuredOutput, getV2Client, + isInternalStructuredSession, isProviderConnected, resetHostFetch, + resetInternalStructuredSessions, setConnectedProviders, setHostFetch, + setStructuredOutputTimeoutMsForTests, setV2Client, + STRUCTURED_OUTPUT_AGENT, + STRUCTURED_OUTPUT_METADATA, + STRUCTURED_OUTPUT_PERMISSIONS, + STRUCTURED_OUTPUT_TOOLS, } from "../src/services/ai/opencode-provider.js"; const schema = z.object({ @@ -104,11 +111,15 @@ describe("generateStructuredOutput", () => { beforeEach(() => { mock = undefined; resetHostFetch(); + resetInternalStructuredSessions(); + setStructuredOutputTimeoutMsForTests(undefined); }); afterEach(() => { mock?.restore(); resetHostFetch(); + resetInternalStructuredSessions(); + setStructuredOutputTimeoutMsForTests(undefined); }); it("posts schema without noReply and returns parsed structured output", async () => { @@ -142,6 +153,12 @@ describe("generateStructuredOutput", () => { expect(result).toEqual({ topic: "auth", count: 3 }); + const createCall = mock.calls.find((c) => c.method === "POST" && c.url.endsWith("/session")); + expect(createCall).toBeDefined(); + const createBody = createCall!.body as Record; + expect(createBody.permission).toEqual(STRUCTURED_OUTPUT_PERMISSIONS); + expect(createBody.metadata).toEqual(STRUCTURED_OUTPUT_METADATA); + const promptCall = mock.calls.find((c) => c.url.includes("/session/ses_test_1/message")); expect(promptCall).toBeDefined(); const promptBody = promptCall!.body as Record; @@ -150,6 +167,8 @@ describe("generateStructuredOutput", () => { modelID: "gpt-4o-mini", }); expect(promptBody.system).toBe("system"); + expect(promptBody.agent).toBe(STRUCTURED_OUTPUT_AGENT); + expect(promptBody.tools).toEqual(STRUCTURED_OUTPUT_TOOLS); expect(promptBody).not.toHaveProperty("noReply"); const format = promptBody.format as Record; expect(format.type).toBe("json_schema"); @@ -158,6 +177,7 @@ describe("generateStructuredOutput", () => { const deleteCall = mock.calls.find((c) => c.method === "DELETE"); expect(deleteCall).toBeDefined(); expect(deleteCall!.url.endsWith("/session/ses_test_1")).toBe(true); + expect(isInternalStructuredSession("ses_test_1")).toBe(false); }); it("rejects with full info.error details when opencode reports an assistant error", async () => { @@ -367,8 +387,7 @@ describe("generateStructuredOutput", () => { const promptCall = mock.calls.find((c) => c.url.includes("/session/ses_retry/message")); const format = (promptCall!.body as Record).format as - | Record - | undefined; + Record | undefined; expect(format?.retryCount).toBe(2); }); }); @@ -908,3 +927,162 @@ describe("resolveOpencodeModelRef / inherit", () => { } }); }); + +describe("generateStructuredOutput tool isolation (issue #189)", () => { + let mock: ReturnType | undefined; + + beforeEach(() => { + mock = undefined; + resetHostFetch(); + resetInternalStructuredSessions(); + setStructuredOutputTimeoutMsForTests(undefined); + }); + + afterEach(() => { + mock?.restore(); + resetHostFetch(); + resetInternalStructuredSessions(); + setStructuredOutputTimeoutMsForTests(undefined); + }); + + it("fails closed when structured output is missing and still deletes the session", async () => { + mock = installFetchMock((call) => { + if (call.method === "POST" && call.url.endsWith("/session")) { + return { body: { id: "ses_no_so" } }; + } + if (call.method === "POST" && call.url.includes("/session/ses_no_so/message")) { + // Simulates a turn that used ordinary tools / no StructuredOutput result. + return { + body: { + info: {}, + parts: [{ type: "tool", tool: "bash" }], + }, + }; + } + if (call.method === "DELETE" && call.url.endsWith("/session/ses_no_so")) { + return { body: true }; + } + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); + }); + + const client = createV2Client("http://127.0.0.1:9999"); + await expect( + generateStructuredOutput({ + client, + providerID: "github-copilot", + modelID: "gpt-4o-mini", + systemPrompt: "s", + userPrompt: "u", + schema, + }) + ).rejects.toThrow(/no structured output/); + + expect(mock.calls.some((c) => c.method === "DELETE")).toBe(true); + expect(isInternalStructuredSession("ses_no_so")).toBe(false); + }); + + it("times out, aborts, and deletes when the prompt never returns", async () => { + setStructuredOutputTimeoutMsForTests(50); + + const original = globalThis.fetch; + const calls: FetchCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const req = + input instanceof Request + ? input + : new Request(typeof input === "string" ? input : input.toString(), init); + const url = req.url; + const method = req.method.toUpperCase(); + let body: unknown = undefined; + if (method !== "GET" && method !== "HEAD") { + try { + const text = await req.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + } + calls.push({ url, method, body }); + if (method === "POST" && url.endsWith("/session")) { + return new Response(JSON.stringify({ id: "ses_hang" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (method === "POST" && url.includes("/session/ses_hang/message")) { + // Never resolves — simulates an unbounded agent/tool loop. + return await new Promise(() => {}); + } + if (method === "POST" && url.includes("/session/ses_hang/abort")) { + return new Response(JSON.stringify(true), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (method === "DELETE" && url.includes("/session/ses_hang")) { + return new Response(JSON.stringify(true), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${method} ${url}`); + }) as typeof fetch; + mock = { + calls, + restore: () => { + globalThis.fetch = original; + }, + }; + + const client = createV2Client("http://127.0.0.1:9999"); + await expect( + generateStructuredOutput({ + client, + providerID: "github-copilot", + modelID: "gpt-4o-mini", + systemPrompt: "s", + userPrompt: "u", + schema, + }) + ).rejects.toThrow(/structured-output timed out after 50ms/); + + expect(calls.some((c) => c.method === "POST" && c.url.includes("/abort"))).toBe(true); + expect(calls.some((c) => c.method === "DELETE")).toBe(true); + expect(isInternalStructuredSession("ses_hang")).toBe(false); + }); + + it("tracks internal session IDs only while the prompt is in flight", async () => { + let sawDuringPrompt = false; + mock = installFetchMock((call) => { + if (call.method === "POST" && call.url.endsWith("/session")) { + return { body: { id: "ses_track" } }; + } + if (call.method === "POST" && call.url.includes("/session/ses_track/message")) { + sawDuringPrompt = isInternalStructuredSession("ses_track"); + return { + body: { + info: { structured_output: { topic: "x", count: 1 } }, + parts: [], + }, + }; + } + if (call.method === "DELETE") { + return { body: true }; + } + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); + }); + + const client = createV2Client("http://127.0.0.1:9999"); + await generateStructuredOutput({ + client, + providerID: "github-copilot", + modelID: "gpt-4o-mini", + systemPrompt: "s", + userPrompt: "u", + schema, + }); + + expect(sawDuringPrompt).toBe(true); + expect(isInternalStructuredSession("ses_track")).toBe(false); + }); +}); diff --git a/tests/plugin-host-config.test.ts b/tests/plugin-host-config.test.ts index 672d5dd..b8d354e 100644 --- a/tests/plugin-host-config.test.ts +++ b/tests/plugin-host-config.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + applyStructuredOutputAgentConfig, configureOpencodeHostTransport, INTERNAL_CAPTURE_SESSION_TITLE, isInternalCaptureSessionTitle, @@ -14,6 +15,8 @@ import { generateStructuredOutput, resetHostFetch, setHostFetch, + STRUCTURED_OUTPUT_AGENT, + STRUCTURED_OUTPUT_TOOLS, } from "../src/services/ai/opencode-provider.js"; import { z } from "zod"; @@ -166,3 +169,25 @@ describe("internal capture session title", () => { expect(isInternalCaptureSessionTitle(null)).toBe(false); }); }); + +describe("structured-output agent config (issue #189)", () => { + it("registers a step-capped least-privilege agent", () => { + const cfg: { agent?: Record } = { + agent: { build: { mode: "primary" } }, + }; + applyStructuredOutputAgentConfig(cfg); + + expect(cfg.agent?.build).toEqual({ mode: "primary" }); + expect(cfg.agent?.[STRUCTURED_OUTPUT_AGENT]).toEqual({ + description: "Internal least-privilege agent for opencode-mem structured output", + mode: "subagent", + steps: 2, + maxSteps: 2, + tools: STRUCTURED_OUTPUT_TOOLS, + permission: { + "*": "deny", + StructuredOutput: "allow", + }, + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 888d707..a051c0c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,18 +12,15 @@ "verbatimModuleSyntax": true, "esModuleInterop": true, "resolveJsonModule": true, - "outDir": "./dist", "rootDir": "./src", "declaration": true, "declarationMap": true, - "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false