diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 664bbaf1aa32..2be65fd1c73a 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -22,6 +22,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/gitea/client.ts', 1], // Generated OpenCode claim source consumes JSON or cancels its body in finally. ['main/opencode/opencode-startup-prompt-source.ts', 1], + // Authenticated loopback preflight consumes bounded JSON and cancels every body in finally. + ['main/opencode/opencode-launch-model-context.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], diff --git a/src/main/opencode/opencode-launch-model-config.test.ts b/src/main/opencode/opencode-launch-model-config.test.ts new file mode 100644 index 000000000000..65d183e156ff --- /dev/null +++ b/src/main/opencode/opencode-launch-model-config.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' + +describe('per-launch OpenCode model config', () => { + it('retains credentials, permissions and other agents while overriding the resolved primary model', () => { + const original = { + model: 'provider/old', + default_agent: 'reviewer', + providers: { + custom: { apiKey: 'placeholder-key', models: { preferred: { name: 'Preferred' } } } + }, + permissions: [{ tool: 'shell', action: 'ask' }], + agents: { + reviewer: { model: 'provider/agent-old', system: 'Review only', permissions: [] }, + build: { model: 'provider/build', steps: 5 }, + nested: { mode: 'subagent', model: 'provider/nested' } + } + } + const configContent = JSON.stringify(original) + const merged = resolveOpenCodeLaunchModelConfig({ + configContent, + primaryAgent: 'reviewer', + model: 'provider/preferred' + }) + expect(merged).not.toBeNull() + expect(JSON.parse(merged ?? 'null')).toEqual({ + ...original, + model: 'provider/preferred', + agents: { + ...original.agents, + reviewer: { ...original.agents.reviewer, model: 'provider/preferred' } + } + }) + expect(configContent).toBe(JSON.stringify(original)) + }) + + it('adds an override for the externally resolved built-in primary agent without guessing its name', () => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: undefined, + primaryAgent: 'plan', + model: 'provider/preferred' + }) + ).toBe('{"model":"provider/preferred","agents":{"plan":{"model":"provider/preferred"}}}') + }) + + it.each(['{', 'null', '[]', '{"agents":[]}', '{"agents":{"build":null}}'])( + 'refuses malformed inline config %s', + (configContent) => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent, + primaryAgent: 'build', + model: 'provider/a' + }) + ).toBeNull() + } + ) + + it('refuses oversized config and absent resolved preferences', () => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: ' '.repeat(1_048_577), + primaryAgent: 'build', + model: 'provider/a' + }) + ).toBeNull() + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: '{}', + primaryAgent: '', + model: 'provider/a' + }) + ).toBeNull() + }) +}) diff --git a/src/main/opencode/opencode-launch-model-config.ts b/src/main/opencode/opencode-launch-model-config.ts new file mode 100644 index 000000000000..c8a1d75f5ae6 --- /dev/null +++ b/src/main/opencode/opencode-launch-model-config.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' + +const launchConfig = z + .object({ agents: z.record(z.string(), z.record(z.string(), z.unknown())).optional() }) + .passthrough() + +export function resolveOpenCodeLaunchModelConfig(options: { + configContent: string | undefined + primaryAgent: string + model: string +}): string | null { + if (!options.primaryAgent || !options.model || (options.configContent?.length ?? 0) > 1_048_576) { + return null + } + try { + const raw: unknown = options.configContent?.trim() ? JSON.parse(options.configContent) : {} + const parsed = launchConfig.safeParse(raw) + if (!parsed.success) { + return null + } + const config = parsed.data + return JSON.stringify({ + ...config, + model: options.model, + agents: { + ...config.agents, + [options.primaryAgent]: { ...config.agents?.[options.primaryAgent], model: options.model } + } + }) + } catch { + return null + } +} diff --git a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts new file mode 100644 index 000000000000..4acf1ba72de4 --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts @@ -0,0 +1,137 @@ +import { z } from 'zod' +import { ChildProcess } from 'node:child_process' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + forceTerminateProcessTree, + signalProcessTree +} from '../../shared/child-process/process-tree-termination' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' + +vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: vi.fn() })) +vi.mock('../../shared/child-process/process-tree-termination', () => ({ + signalProcessTree: vi.fn(), + forceTerminateProcessTree: vi.fn() +})) + +vi.mock('../../shared/fetch-response-body', () => ({ readFetchResponseJsonWithinLimit: vi.fn() })) + +class ProbeChild extends ChildProcess { + override stdin = new PassThrough() + override stdout = new PassThrough() + override stderr = new PassThrough() + override pid = 123456 + override exitCode: number | null = null + override stdio: [PassThrough, PassThrough, PassThrough, undefined, undefined] = [ + this.stdin, + this.stdout, + this.stderr, + undefined, + undefined + ] +} + +const directory = '/private/project' +const model = { id: 'model-b', providerID: 'private-proof', enabled: true } +const snapshot = { + model: { location: { directory }, data: [model] }, + agent: { + location: { directory }, + data: [{ id: 'build', mode: 'primary', hidden: false, model }] + }, + 'model/default': { location: { directory }, data: model }, + config: [{ type: 'document', info: { default_agent: 'build' } }] +} +const platform = Object.getOwnPropertyDescriptor(process, 'platform') +let child: ProbeChild +let closeDuringFetch = false +function close() { + child.exitCode = 0 + child.emit('exit', 0, null) + child.emit('close', 0, null) +} +const options = { executable: '/private/opencode', cwd: directory, env: {} } + +describe('OpenCode model probe termination evidence', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(readFetchResponseJsonWithinLimit).mockImplementation((response) => response.json()) + child = new ProbeChild() + closeDuringFetch = false + vi.mocked(spawnProcess).mockImplementation(() => { + queueMicrotask(() => child.stdout.write('server listening on http://127.0.0.1:45678\n')) + return child + }) + vi.stubGlobal( + 'fetch', + vi.fn(async (input: URL) => { + const endpoint = z + .enum(['model', 'agent', 'model/default', 'config']) + .parse(input.pathname.slice('/api/'.length)) + const response = snapshot[endpoint] + if (closeDuringFetch && endpoint === 'config') { + queueMicrotask(close) + } + return new Response(JSON.stringify(response), { status: 200 }) + }) + ) + vi.mocked(signalProcessTree).mockImplementation(async () => { + close() + return true + }) + vi.mocked(forceTerminateProcessTree).mockResolvedValue(false) + }) + + afterEach(() => { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + vi.unstubAllGlobals() + }) + + it('cancels all unread responses if the bounded body reader fails', async () => { + const cancel = vi.fn() + vi.mocked(readFetchResponseJsonWithinLimit).mockRejectedValue(new Error('body_reader_failed')) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(new ReadableStream({ cancel }), { status: 200 })) + ) + expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() + expect(cancel).toHaveBeenCalledTimes(4) + expect(signalProcessTree).toHaveBeenCalledOnce() + }) + + it('accepts an already-closed Windows probe without signaling its former pid', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + closeDuringFetch = true + vi.mocked(signalProcessTree).mockResolvedValue(false) + expect(await probeOpenCodeLaunchModelContext(options)).toMatchObject({ primaryAgent: 'build' }) + expect(signalProcessTree).not.toHaveBeenCalled() + expect(forceTerminateProcessTree).not.toHaveBeenCalled() + }) + + it('does not force a POSIX group after the graceful close', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + expect(await probeOpenCodeLaunchModelContext(options)).toMatchObject({ primaryAgent: 'build' }) + expect(signalProcessTree).toHaveBeenCalledOnce() + expect(forceTerminateProcessTree).not.toHaveBeenCalled() + }) + + it('keeps a root exit with inherited pipes unverified and avoids its former pid', async () => { + vi.mocked(signalProcessTree).mockImplementation(async () => { + child.exitCode = 0 + child.emit('exit', 0, null) + return false + }) + expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() + expect(forceTerminateProcessTree).not.toHaveBeenCalled() + }) + + it('requires verified termination when a still-live probe does not close', async () => { + vi.mocked(signalProcessTree).mockResolvedValue(true) + expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() + expect(forceTerminateProcessTree).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/opencode/opencode-launch-model-context.test.ts b/src/main/opencode/opencode-launch-model-context.test.ts new file mode 100644 index 000000000000..faf19bfdedf7 --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + parseOpenCodeLaunchModelContext, + probeOpenCodeLaunchModelContext +} from './opencode-launch-model-context' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' + +const directory = '/private/project' +const snapshot = { + directory, + models: { + location: { directory }, + data: [ + { id: 'one', providerID: 'provider', enabled: true }, + { id: 'disabled', providerID: 'provider', enabled: false } + ] + }, + agents: { + location: { directory }, + data: [ + { + id: 'reviewer', + mode: 'primary', + hidden: false, + model: { id: 'one', providerID: 'provider' } + }, + { id: 'build', mode: 'primary', hidden: false }, + { id: 'hidden', mode: 'primary', hidden: true }, + { id: 'child', mode: 'subagent', hidden: false } + ] + }, + config: [{ type: 'document', info: { default_agent: 'reviewer' } }], + defaultModel: { + location: { directory }, + data: { id: 'one', providerID: 'provider', enabled: true } + } +} + +describe('OpenCode execution-host model context', () => { + it('uses the configured visible primary and exact enabled provider/model pairs', () => { + expect(parseOpenCodeLaunchModelContext(snapshot)).toEqual({ + primaryAgent: 'reviewer', + availableModels: ['provider/one'], + primaryModel: 'provider/one' + }) + }) + + it('uses actual returned primary ordering and skips hidden agents and children', () => { + const agents = { + location: { directory }, + data: [...snapshot.agents.data.slice(2), ...snapshot.agents.data.slice(0, 2)] + } + expect(parseOpenCodeLaunchModelContext({ ...snapshot, agents })?.primaryAgent).toBe('reviewer') + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + defaultModel: { location: { directory }, data: null } + }) + ).toBeNull() + }) + + it('refuses an uninitialized, malformed or foreign-location response', () => { + for (const models of [ + null, + { location: { directory }, data: [] }, + { location: { directory: '/other' }, data: snapshot.models.data } + ]) { + expect(parseOpenCodeLaunchModelContext({ ...snapshot, models })).toBeNull() + } + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + agents: { location: { directory }, data: [] } + }) + ).toBeNull() + }) +}) + +describe.skipIf(!process.env.ORCA_REAL_OPENCODE_CLI_TEST)( + 'real current OpenCode model preflight', + () => { + it('waits for initialization and verifies a custom primary before and after a private overlay', async () => { + const executable = process.env.ORCA_REAL_OPENCODE_CLI_TEST + if (!executable) { + throw new Error('Real OpenCode executable required') + } + const home = await mkdtemp(join(tmpdir(), 'orca-opencode-model-context-')) + try { + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!/API_KEY|TOKEN|SECRET|AUTH|CREDENTIAL|OPENCODE_|ORCA_/.test(key)) { + env[key] = value + } + } + for (const [key, child] of [ + ['HOME', 'home'], + ['XDG_CONFIG_HOME', 'config'], + ['XDG_DATA_HOME', 'data'], + ['XDG_STATE_HOME', 'state'], + ['XDG_CACHE_HOME', 'cache'] + ]) { + const path = join(home, child) + await mkdir(path, { mode: 0o700 }) + env[key] = path + } + env.ORCA_BACKGROUND_LAUNCH = '1' + env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + default_agent: 'reviewer', + model: 'opencode/big-pickle', + agents: { + reviewer: { mode: 'primary', system: 'Review only', model: 'opencode/big-pickle' } + } + }) + const options = { executable, cwd: process.cwd(), env } + const before = await probeOpenCodeLaunchModelContext(options) + expect(before?.primaryAgent).toBe('reviewer') + expect(before?.primaryModel).toBe('opencode/big-pickle') + expect(before?.availableModels).toContain('opencode/fledge-alpha-free') + const config = resolveOpenCodeLaunchModelConfig({ + configContent: env.OPENCODE_CONFIG_CONTENT, + primaryAgent: before?.primaryAgent ?? '', + model: 'opencode/fledge-alpha-free' + }) + expect(config).not.toBeNull() + const after = await probeOpenCodeLaunchModelContext({ + ...options, + env: { ...env, OPENCODE_CONFIG_CONTENT: config ?? '' }, + expectedPrimaryAgent: 'reviewer', + expectedPrimaryModel: 'opencode/fledge-alpha-free' + }) + expect(after?.primaryAgent).toBe('reviewer') + expect(after?.primaryModel).toBe('opencode/fledge-alpha-free') + expect(env.OPENCODE_CONFIG_CONTENT).toContain('opencode/big-pickle') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, 30_000) + } +) diff --git a/src/main/opencode/opencode-launch-model-context.ts b/src/main/opencode/opencode-launch-model-context.ts new file mode 100644 index 000000000000..ce1098d8acfb --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context.ts @@ -0,0 +1,206 @@ +import { randomBytes } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + forceTerminateProcessTree, + signalProcessTree +} from '../../shared/child-process/process-tree-termination' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import { createOutputSink } from '../../shared/child-process/bounded-output-sink' +import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' + +const model = z.object({ id: z.string(), providerID: z.string() }) +const availableModel = model.extend({ enabled: z.boolean() }) +const agent = z.object({ + id: z.string(), + mode: z.enum(['primary', 'subagent', 'all']), + hidden: z.boolean(), + model: model.optional() +}) +const location = z.object({ directory: z.string() }) +const modelResponse = z.object({ location, data: availableModel.array() }) +const agentResponse = z.object({ location, data: agent.array() }) +const defaultModelResponse = z.object({ location, data: availableModel.nullable() }) +const configResponse = z.array( + z.object({ + type: z.enum(['directory', 'document']), + info: z.object({ default_agent: z.string().optional() }).optional() + }) +) + +export type OpenCodeLaunchModelContext = { + primaryAgent: string + availableModels: string[] + primaryModel: string | null +} + +export function parseOpenCodeLaunchModelContext(options: { + directory: string + models: unknown + agents: unknown + defaultModel: unknown + config: unknown +}): OpenCodeLaunchModelContext | null { + const models = modelResponse.safeParse(options.models) + const agents = agentResponse.safeParse(options.agents) + const defaultModel = defaultModelResponse.safeParse(options.defaultModel) + const config = configResponse.safeParse(options.config) + if ( + !models.success || + !agents.success || + !defaultModel.success || + !config.success || + models.data.location.directory !== options.directory || + agents.data.location.directory !== options.directory || + defaultModel.data.location.directory !== options.directory || + !defaultModel.data.data?.enabled || + agents.data.data.length === 0 + ) { + return null + } + // Installed 2.0.16 returns the selected default first; newer source has a different order. + const primary = agents.data.data.find((item) => item.mode !== 'subagent' && !item.hidden) + const configured = config.data.reduce( + (value, entry) => entry.info?.default_agent ?? value, + undefined + ) + const available = models.data.data.filter((item) => item.enabled) + if ( + !primary || + (configured !== undefined && primary.id !== configured) || + !available.some( + (item) => + item.id === defaultModel.data.data?.id && + item.providerID === defaultModel.data.data?.providerID + ) + ) { + return null + } + return { + primaryAgent: primary.id, + availableModels: available.map((item) => `${item.providerID}/${item.id}`), + primaryModel: primary.model ? `${primary.model.providerID}/${primary.model.id}` : null + } +} + +export async function probeOpenCodeLaunchModelContext(options: { + executable: string + cwd: string + env: NodeJS.ProcessEnv + signal?: AbortSignal + expectedPrimaryAgent?: string + expectedPrimaryModel?: string +}): Promise { + const password = randomBytes(32).toString('base64url') + const child = spawnProcess({ + program: options.executable, + args: ['serve', '--hostname', '127.0.0.1', '--port', '0'], + cwd: options.cwd, + env: { + ...options.env, + OPENCODE_SERVER_USERNAME: 'opencode', + OPENCODE_SERVER_PASSWORD: password + }, + detached: true + }) + const output = createOutputSink(4096) + let childClosed = false + let rootExited = false + const closed = new Promise((resolve) => { + child.once('close', () => { + childClosed = true + resolve(true) + }) + }) + let failed = false + child.on('error', () => { + failed = true + }) + child.once('exit', () => { + rootExited = true + failed = true + }) + child.stdout.on('data', output.write) + child.stdout.on('error', () => { + failed = true + }) + child.stderr.on('error', () => { + failed = true + }) + child.stderr.resume() + child.stdin.on('error', () => { + failed = true + }) + child.stdin.end() + const signal = AbortSignal.any([ + AbortSignal.timeout(10_000), + ...(options.signal ? [options.signal] : []) + ]) + let context: OpenCodeLaunchModelContext | null = null + let stopped = false + try { + while (!failed && !signal.aborted && !output.truncated()) { + const address = output + .text() + .match(/^server listening on (http:\/\/127\.0\.0\.1:\d+)\s*$/m)?.[1] + if (address) { + const read = async (endpoint: string): Promise => { + const url = new URL(`/api/${endpoint}`, address) + url.searchParams.set('location[directory]', options.cwd) + const response = await fetch(url, { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` + }, + redirect: 'error', + signal + }) + try { + if (!response.ok) { + throw new Error('OpenCode model preflight refused') + } + return await readFetchResponseJsonWithinLimit(response, 1_048_576) + } finally { + await cancelUnreadResponseBody(response) + } + } + const [models, agents, defaultModel, config] = await Promise.all([ + read('model'), + read('agent'), + read('model/default'), + read('config') + ]) + context = parseOpenCodeLaunchModelContext({ + directory: options.cwd, + models, + agents, + defaultModel, + config + }) + if ( + context && + (!options.expectedPrimaryAgent || + context.primaryAgent === options.expectedPrimaryAgent) && + (!options.expectedPrimaryModel || context.primaryModel === options.expectedPrimaryModel) + ) { + break + } + context = null + } + await delay(100, undefined, { signal }) + } + } catch { + context = null + } finally { + if (!childClosed && !rootExited) { + await signalProcessTree(child, 'SIGTERM') + } + stopped = childClosed || (await withTimeout(closed, 1_500, false)) + // A reaped root's PID can be reused while descendants still hold its pipes. + if (!stopped && !rootExited) { + stopped = await forceTerminateProcessTree(child) + } + } + return stopped ? context : null +} diff --git a/src/main/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts index 0740bacfb0d9..46771e630bc8 100644 --- a/src/main/opencode/opencode-model-startup-plan.test.ts +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -1,10 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' import { probeOpenCodeModelAvailability, resolveOpenCodeDirectModelExecutable } from './opencode-model-availability' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' import { buildExecutionHostAgentStartupPlan } from './opencode-model-startup-plan' vi.mock('../managed-data-accounts/launch-environment', () => ({ @@ -12,18 +14,28 @@ vi.mock('../managed-data-accounts/launch-environment', () => ({ })) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) vi.mock('./opencode-model-availability', () => ({ - probeOpenCodeModelAvailability: vi.fn(), - resolveOpenCodeDirectModelExecutable: vi.fn() + resolveOpenCodeDirectModelExecutable: vi.fn(), + probeOpenCodeModelAvailability: vi.fn() })) +vi.mock('./opencode-launch-model-context', () => ({ probeOpenCodeLaunchModelContext: vi.fn() })) +const preferred = 'opencode/fledge-alpha-free' +const original = JSON.stringify({ + default_agent: 'reviewer', + providers: { custom: { apiKey: 'placeholder' } }, + agents: { reviewer: { system: 'Review only', model: 'opencode/big-pickle' } } +}) function scope() { return { inputs: resolveAgentStartupPlanInputs({ agent: 'opencode', - settings: { agentCmdOverrides: {} }, + settings: { + agentCmdOverrides: {}, + agentDefaultEnv: { opencode: { OPENCODE_CONFIG_CONTENT: original } } + }, platform: process.platform, isRemote: false, - sessionOptions: { model: 'private-proof/model-b' } + sessionOptions: { model: preferred } }), cwd: '/private/project', prompt: 'Read only', @@ -31,64 +43,129 @@ function scope() { } } -describe('verified legacy execution-host model startup', () => { +describe('execution-host OpenCode model startup', () => { beforeEach(() => { vi.resetAllMocks() - vi.mocked(resolveOpenCodeDirectModelExecutable).mockResolvedValue('/resolved/opencode') - vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) + vi.mocked(resolveOpenCodeDirectModelExecutable).mockResolvedValue('/private/opencode') vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ - version: '1.18.30', - pluginApi: 'v1', - promptMode: 'submit' + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) + vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockResolvedValueOnce({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: 'opencode/big-pickle' + }) + .mockResolvedValue({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: preferred + }) + }) + + it('refuses unsupported effort preferences before probing or dropping them', async () => { + const options = scope() + options.inputs.sessionOptions = { model: preferred, effort: 'high' } + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + + it('launches current v2 in isolation and keeps one-time model preferences out of resume state', async () => { + const options = scope() + const plan = await buildExecutionHostAgentStartupPlan(options) + expect(plan?.launchCommand).toContain('--standalone') + expect(plan?.launchCommand).not.toContain('--model') + expect(plan?.sessionOptions).toEqual({ model: preferred }) + expect(JSON.parse(plan?.env?.OPENCODE_CONFIG_CONTENT ?? 'null')).toEqual({ + default_agent: 'reviewer', + model: preferred, + providers: { custom: { apiKey: 'placeholder' } }, + agents: { reviewer: { system: 'Review only', model: preferred } } + }) + expect(plan?.launchConfig).toEqual({ + agentCommand: 'opencode', + agentArgs: '', + agentEnv: { OPENCODE_CONFIG_CONTENT: original } + }) + expect(options.inputs.agentEnv.OPENCODE_CONFIG_CONTENT).toBe(original) + }) + + it('uses the selected account environment in both authoritative probes', async () => { + vi.mocked(applyManagedDataAccountEnvironment).mockImplementation((env) => { + env.XDG_DATA_HOME = '/selected/data' + env.OPENCODE_DB = 'opencode.db' }) + await buildExecutionHostAgentStartupPlan(scope()) + for (const call of vi.mocked(probeOpenCodeLaunchModelContext).mock.calls) { + expect(call[0].env).toMatchObject({ + XDG_DATA_HOME: '/selected/data', + OPENCODE_DB: 'opencode.db' + }) + } }) it.each(['1.18.30', '1.18.32'])( - 'verifies the default executable and exact model on %s', + 'preserves the verified legacy launch route %s', async (version) => { vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ version, pluginApi: 'v1', promptMode: 'submit' }) - const plan = await buildExecutionHostAgentStartupPlan(scope()) - expect(plan?.launchCommand).toContain('--model') - expect(resolveOpenCodeDirectModelExecutable).toHaveBeenCalledWith( - expect.objectContaining({ command: 'opencode' }) - ) - expect(probeOpenCodeModelAvailability).toHaveBeenCalledWith( - expect.objectContaining({ - command: 'opencode', - model: 'private-proof/model-b', - cwd: '/private/project' - }) + expect((await buildExecutionHostAgentStartupPlan(scope()))?.launchCommand).toContain( + '--model' ) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() } ) - it('refuses a missing model even when the CLI could silently use its default', async () => { - vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(false) - await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ - code: 'capability_unsupported' - }) + it('preserves ordinary launches without any private server probe', async () => { + const options = scope() + options.inputs.sessionOptions = undefined + expect((await buildExecutionHostAgentStartupPlan(options))?.launchCommand).not.toContain( + '--standalone' + ) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() }) - it.each(['1.18.31', '2.0.16', '9.0.0'])( - 'refuses unverified legacy support for %s', - async (version) => { - vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ - version, - pluginApi: 'v1', - promptMode: 'submit' - }) - await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + it.each(['--agent build', '--continue', '--server http://localhost'])( + 'refuses current-v2 manual launch arguments %s', + async (agentArgs) => { + const options = scope() + options.inputs.agentArgs = agentArgs + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ code: 'capability_unsupported' }) - expect(probeOpenCodeModelAvailability).not.toHaveBeenCalled() + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() } ) - it('refuses remote, WSL, and extra preferences before a local probe', async () => { + it('refuses unavailable models and mismatched observed model preferences', async () => { + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValue({ primaryAgent: 'reviewer', availableModels: [], primaryModel: null }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValue({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: 'opencode/big-pickle' + }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + }) + + it('refuses remote and WSL execution before touching the local binary', async () => { const options = scope() options.inputs.isRemote = true await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ @@ -98,19 +175,42 @@ describe('verified legacy execution-host model startup', () => { await expect( buildExecutionHostAgentStartupPlan({ ...options, isWsl: true }) ).rejects.toMatchObject({ code: 'capability_unsupported' }) - options.inputs.sessionOptions = { model: 'private-proof/model-b', effort: 'high' } - await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ - code: 'capability_unsupported' - }) expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() }) - it('preserves an ordinary launch without probing or overriding its own model', async () => { - const options = scope() - options.inputs.sessionOptions = undefined - expect((await buildExecutionHostAgentStartupPlan(options))?.launchCommand).not.toContain( - '--model' + it.each(['1.18.31', '1.18.33', '2.0.17'])( + 'refuses unverified version %s before model probing', + async (version) => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version, + pluginApi: 'v2', + promptMode: 'prefill' + }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + expect(probeOpenCodeModelAvailability).not.toHaveBeenCalled() + } + ) + + it('resolves the default bare launch command through the execution host', async () => { + await buildExecutionHostAgentStartupPlan(scope()) + expect(resolveOpenCodeDirectModelExecutable).toHaveBeenCalledWith( + expect.objectContaining({ command: 'opencode' }) ) - expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() }) + + it.each(['blank-model', 'effort-only'])( + 'refuses invalid preferences before probing', + async (scenario) => { + const options = scope() + options.inputs.sessionOptions = + scenario === 'blank-model' ? { model: '' } : { effort: 'high' } + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() + } + ) }) diff --git a/src/main/opencode/opencode-model-startup-plan.ts b/src/main/opencode/opencode-model-startup-plan.ts index a520a6c5696b..f0878bd51772 100644 --- a/src/main/opencode/opencode-model-startup-plan.ts +++ b/src/main/opencode/opencode-model-startup-plan.ts @@ -1,7 +1,10 @@ import type { AgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { buildSleepingAgentLaunchConfig } from '../../shared/sleeping-agent-launch-config' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { TuiAgent } from '../../shared/tui-agent' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' import { isVerifiedOpenCodeLegacyModelVersion } from './opencode-model-version-policy' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' @@ -84,7 +87,54 @@ export async function prepareOpenCodeModelStartupInputs( } return { inputs } } - refuseModel() + if (capabilities?.version !== '2.0.16' || inputs.agentArgs?.trim()) { + refuseModel() + } + const before = await probeOpenCodeLaunchModelContext({ + executable, + cwd: options.cwd, + env, + signal: options.signal + }).catch(() => null) + if (!before?.availableModels.includes(model)) { + refuseModel() + } + const configContent = resolveOpenCodeLaunchModelConfig({ + configContent: env.OPENCODE_CONFIG_CONTENT, + primaryAgent: before.primaryAgent, + model + }) + if (configContent === null) { + refuseModel() + } + const after = await probeOpenCodeLaunchModelContext({ + executable, + cwd: options.cwd, + env: { ...env, OPENCODE_CONFIG_CONTENT: configContent }, + expectedPrimaryAgent: before.primaryAgent, + expectedPrimaryModel: model, + signal: options.signal + }).catch(() => null) + if ( + after?.primaryAgent !== before.primaryAgent || + after.primaryModel !== model || + !after.availableModels.includes(model) + ) { + refuseModel() + } + return { + inputs: { + ...inputs, + agentArgs: '--standalone', + agentEnv: { ...inputs.agentEnv, OPENCODE_CONFIG_CONTENT: configContent }, + sessionOptions: undefined + }, + launchConfig: buildSleepingAgentLaunchConfig({ + agentCommand: command, + agentArgs: inputs.agentArgs, + agentEnv: inputs.agentEnv + }) + } } export async function buildExecutionHostAgentStartupPlan(