1111 * then delete the session so it does not pollute the user's TUI session
1212 * list.
1313 *
14+ * Internal capture sessions are least-privilege (issue #189): ordinary
15+ * agent tools are denied, only StructuredOutput is allowed, a dedicated
16+ * agent caps steps, and a hard timeout fails closed.
17+ *
1418 * The primary transport is the authenticated v2 SDK client initialized from
1519 * the plugin host's client configuration. A raw fetch fallback remains for
1620 * older SDK builds that do not expose the v2 session methods.
@@ -34,6 +38,38 @@ import {
3438} from "./internal-capture-sessions.js" ;
3539import { createLazyV2Client , type HostTransport } from "./opencode-sdk-client.js" ;
3640
41+ /** Dedicated agent registered via the plugin config hook (step-capped). */
42+ export const STRUCTURED_OUTPUT_AGENT = "opencode-mem-structured" ;
43+
44+ /** Hard ceiling for a single internal structured-output prompt. */
45+ export const STRUCTURED_OUTPUT_TIMEOUT_MS = 90_000 ;
46+
47+ let _structuredOutputTimeoutMs = STRUCTURED_OUTPUT_TIMEOUT_MS ;
48+
49+ /** Test helper: override the structured-output prompt timeout. Pass undefined to reset. */
50+ export function setStructuredOutputTimeoutMsForTests ( ms : number | undefined ) : void {
51+ _structuredOutputTimeoutMs = ms ?? STRUCTURED_OUTPUT_TIMEOUT_MS ;
52+ }
53+
54+ export const STRUCTURED_OUTPUT_PERMISSIONS = [
55+ { permission : "*" , pattern : "*" , action : "deny" as const } ,
56+ { permission : "StructuredOutput" , pattern : "*" , action : "allow" as const } ,
57+ ] ;
58+
59+ export const STRUCTURED_OUTPUT_TOOLS : Record < string , boolean > = {
60+ "*" : false ,
61+ StructuredOutput : true ,
62+ } ;
63+
64+ export const STRUCTURED_OUTPUT_METADATA = {
65+ "opencode-mem" : {
66+ internal : true ,
67+ purpose : "structured-output" ,
68+ } ,
69+ } ;
70+
71+ const _internalSessions = new Set < string > ( ) ;
72+
3773let _connectedProviders : Set < string > = new Set ( ) ;
3874let _v2Client : OpencodeClient | undefined ;
3975let _v2BaseUrl : string | undefined ;
@@ -72,6 +108,57 @@ export function createV2Client(serverUrl: URL | string, transport?: HostTranspor
72108 return createLazyV2Client ( baseUrl , activeTransport ) ;
73109}
74110
111+ /** True while an internal structured-output session is live (create → delete). */
112+ export function isInternalStructuredSession ( sessionID : string ) : boolean {
113+ return _internalSessions . has ( sessionID ) ;
114+ }
115+
116+ /** Test helper: clear tracked internal session IDs. */
117+ export function resetInternalStructuredSessions ( ) : void {
118+ _internalSessions . clear ( ) ;
119+ }
120+
121+ function markInternalSession ( sessionID : string ) : void {
122+ _internalSessions . add ( sessionID ) ;
123+ }
124+
125+ function unmarkInternalSession ( sessionID : string ) : void {
126+ _internalSessions . delete ( sessionID ) ;
127+ }
128+
129+ function sessionCreateBody ( ) : Record < string , unknown > {
130+ return {
131+ title : INTERNAL_CAPTURE_SESSION_TITLE ,
132+ permission : STRUCTURED_OUTPUT_PERMISSIONS ,
133+ metadata : STRUCTURED_OUTPUT_METADATA ,
134+ } ;
135+ }
136+
137+ function sessionPromptFields ( args : {
138+ providerID : string ;
139+ modelID : string ;
140+ systemPrompt : string ;
141+ userPrompt : string ;
142+ jsonSchema : Record < string , unknown > ;
143+ retryCount ?: number ;
144+ } ) : Record < string , unknown > {
145+ return {
146+ model : { providerID : args . providerID , modelID : args . modelID } ,
147+ agent : STRUCTURED_OUTPUT_AGENT ,
148+ system : args . systemPrompt ,
149+ parts : [ { type : "text" , text : args . userPrompt } ] ,
150+ tools : STRUCTURED_OUTPUT_TOOLS ,
151+ // `noReply` suppresses assistant generation in current OpenCode builds,
152+ // which also suppresses `info.structured_output`; structured capture needs
153+ // the assistant run even though the temporary session is deleted afterward.
154+ format : {
155+ type : "json_schema" ,
156+ schema : args . jsonSchema ,
157+ ...( args . retryCount !== undefined ? { retryCount : args . retryCount } : { } ) ,
158+ } ,
159+ } ;
160+ }
161+
75162export interface StructuredOutputOptions < T > {
76163 client : OpencodeClient ;
77164 providerID : string ;
@@ -138,7 +225,7 @@ function readRecentOpencodeModel(
138225 * Generate one structured-output completion via opencode's HTTP API.
139226 * Throws on: session.create failure, prompt failure, AssistantMessage.error
140227 * (StructuredOutputError / ApiError / ...), missing `info.structured`,
141- * or final Zod validation failure.
228+ * timeout, or final Zod validation failure.
142229 */
143230export async function generateStructuredOutput < T > ( opts : StructuredOutputOptions < T > ) : Promise < T > {
144231 const resolved = resolveOpencodeModelRef ( {
@@ -177,17 +264,22 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
177264 const base = stripTrailingSlash ( baseUrl ) ;
178265
179266 const sessionID = await createSession ( base , directory ) ;
267+ markInternalSession ( sessionID ) ;
180268 try {
181- const info = await promptSession ( base , {
182- sessionID,
183- directory,
184- providerID,
185- modelID,
186- systemPrompt,
187- userPrompt,
188- jsonSchema,
189- retryCount,
190- } ) ;
269+ const info = await withStructuredOutputTimeout (
270+ ( ) =>
271+ promptSession ( base , {
272+ sessionID,
273+ directory,
274+ providerID,
275+ modelID,
276+ systemPrompt,
277+ userPrompt,
278+ jsonSchema,
279+ retryCount,
280+ } ) ,
281+ ( ) => abortSession ( base , sessionID , directory )
282+ ) ;
191283
192284 if ( info . error ) {
193285 throw new Error (
@@ -204,6 +296,7 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
204296
205297 return schema . parse ( structuredOutput ) ;
206298 } finally {
299+ unmarkInternalSession ( sessionID ) ;
207300 // Best-effort: leaving a transient session behind is cosmetic, not
208301 // worth failing a successful capture if cleanup itself errors.
209302 try {
@@ -221,6 +314,7 @@ type V2SessionClient = {
221314 create ( parameters ?: Record < string , unknown > ) : Promise < unknown > ;
222315 prompt ( parameters : Record < string , unknown > ) : Promise < unknown > ;
223316 delete ( parameters : Record < string , unknown > ) : Promise < unknown > ;
317+ abort ?( parameters : Record < string , unknown > ) : Promise < unknown > ;
224318 } ;
225319} ;
226320
@@ -251,7 +345,7 @@ async function generateViaSdkClient<T>(
251345 args : SdkStructuredOutputArgs < T >
252346) : Promise < T > {
253347 const createdResponse = await client . session . create ( {
254- title : INTERNAL_CAPTURE_SESSION_TITLE ,
348+ ... sessionCreateBody ( ) ,
255349 ...( args . directory ? { directory : args . directory } : { } ) ,
256350 } ) ;
257351 const created = readSdkData < { id ?: string } > ( createdResponse , "POST /session" ) ;
@@ -263,19 +357,21 @@ async function generateViaSdkClient<T>(
263357
264358 const sessionID = created . id ;
265359 trackInternalCaptureSession ( sessionID ) ;
360+ markInternalSession ( sessionID ) ;
266361 try {
267- const promptResponse = await client . session . prompt ( {
268- sessionID,
269- ...( args . directory ? { directory : args . directory } : { } ) ,
270- model : { providerID : args . providerID , modelID : args . modelID } ,
271- system : args . systemPrompt ,
272- parts : [ { type : "text" , text : args . userPrompt } ] ,
273- format : {
274- type : "json_schema" ,
275- schema : args . jsonSchema ,
276- ...( args . retryCount !== undefined ? { retryCount : args . retryCount } : { } ) ,
277- } ,
278- } ) ;
362+ const promptResponse = await withStructuredOutputTimeout (
363+ ( ) =>
364+ client . session . prompt ( {
365+ sessionID,
366+ ...( args . directory ? { directory : args . directory } : { } ) ,
367+ ...sessionPromptFields ( args ) ,
368+ } ) ,
369+ ( ) =>
370+ client . session . abort ?.( {
371+ sessionID,
372+ ...( args . directory ? { directory : args . directory } : { } ) ,
373+ } )
374+ ) ;
279375 const data = readSdkData < MessageV2WithParts > ( promptResponse , "POST /session/{id}/message" ) ;
280376 if ( ! data . info ) {
281377 throw new Error ( "opencode-mem: prompt response missing `info`" ) ;
@@ -294,6 +390,7 @@ async function generateViaSdkClient<T>(
294390 }
295391 return args . schema . parse ( structuredOutput ) ;
296392 } finally {
393+ unmarkInternalSession ( sessionID ) ;
297394 try {
298395 await client . session . delete ( {
299396 sessionID,
@@ -307,6 +404,34 @@ async function generateViaSdkClient<T>(
307404 }
308405}
309406
407+ async function withStructuredOutputTimeout < T > (
408+ run : ( ) => Promise < T > ,
409+ onTimeout : ( ) => unknown
410+ ) : Promise < T > {
411+ let timer : ReturnType < typeof setTimeout > | undefined ;
412+ const timeoutMs = _structuredOutputTimeoutMs ;
413+ const timeoutPromise = new Promise < never > ( ( _ , reject ) => {
414+ timer = setTimeout ( ( ) => {
415+ reject ( new Error ( `opencode-mem: structured-output timed out after ${ timeoutMs } ms` ) ) ;
416+ } , timeoutMs ) ;
417+ } ) ;
418+
419+ try {
420+ return await Promise . race ( [ run ( ) , timeoutPromise ] ) ;
421+ } catch ( error ) {
422+ if ( error instanceof Error && error . message . includes ( "structured-output timed out after" ) ) {
423+ try {
424+ await onTimeout ( ) ;
425+ } catch {
426+ // best-effort abort
427+ }
428+ }
429+ throw error ;
430+ } finally {
431+ if ( timer !== undefined ) clearTimeout ( timer ) ;
432+ }
433+ }
434+
310435function readSdkData < T > ( response : unknown , label : string ) : T {
311436 const result = response as
312437 { data ?: T ; error ?: unknown ; request ?: Request ; response ?: Response } | undefined ;
@@ -354,7 +479,7 @@ async function createSession(base: string, directory?: string): Promise<string>
354479 {
355480 method : "POST" ,
356481 headers : { "Content-Type" : "application/json" } ,
357- body : JSON . stringify ( { title : INTERNAL_CAPTURE_SESSION_TITLE } ) ,
482+ body : JSON . stringify ( sessionCreateBody ( ) ) ,
358483 }
359484 ) ;
360485 if ( ! body . id ) {
@@ -415,19 +540,7 @@ interface MessageV2WithParts {
415540
416541async function promptSession ( base : string , args : PromptSessionArgs ) : Promise < AssistantInfo > {
417542 const url = `${ base } /session/${ encodeURIComponent ( args . sessionID ) } /message${ buildQuery ( args . directory ) } ` ;
418- const body : Record < string , unknown > = {
419- model : { providerID : args . providerID , modelID : args . modelID } ,
420- system : args . systemPrompt ,
421- parts : [ { type : "text" , text : args . userPrompt } ] ,
422- // `noReply` suppresses assistant generation in current OpenCode builds,
423- // which also suppresses `info.structured_output`; structured capture needs
424- // the assistant run even though the temporary session is deleted afterward.
425- format : {
426- type : "json_schema" ,
427- schema : args . jsonSchema ,
428- ...( args . retryCount !== undefined ? { retryCount : args . retryCount } : { } ) ,
429- } ,
430- } ;
543+ const body = sessionPromptFields ( args ) ;
431544 const data = await fetchJson < MessageV2WithParts > (
432545 { label : "POST /session/{id}/message" , url } ,
433546 {
@@ -442,6 +555,15 @@ async function promptSession(base: string, args: PromptSessionArgs): Promise<Ass
442555 return data . info ;
443556}
444557
558+ async function abortSession ( base : string , sessionID : string , directory ?: string ) : Promise < void > {
559+ const url = `${ base } /session/${ encodeURIComponent ( sessionID ) } /abort${ buildQuery ( directory ) } ` ;
560+ try {
561+ await activeFetch ( ) ( new Request ( url , { method : "POST" } ) ) ;
562+ } catch {
563+ // best-effort
564+ }
565+ }
566+
445567async function deleteSession ( base : string , sessionID : string , directory ?: string ) : Promise < void > {
446568 const url = `${ base } /session/${ encodeURIComponent ( sessionID ) } ${ buildQuery ( directory ) } ` ;
447569 let res : Response ;
0 commit comments