From a44345ce496482c2d9d5bcaf7bb890faa3d4d52e Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 21:57:34 -0700 Subject: [PATCH] fix(opencode): keep wrapped run tasks positional Recognize supported environment prefixes and PowerShell call operators without mistaking prompt arguments for executables. Keep environment and run separators separate, preserve the task text and exclude run commands from native submission. Source-parent: 23fc08b4e939169f0f6bc80d6e2a92e88699258b Related-to: stablya/orca#17551 Credits: @coelho-doti (stablya/orca#13065) --- src/main/opencode/opencode-pty-launch.ts | 2 +- src/shared/flag-prompt-startup.ts | 14 ++-- src/shared/opencode-headless-command.test.ts | 30 +++++++ src/shared/opencode-headless-command.ts | 82 ++++++++++++++++++-- src/shared/opencode-startup-prompt.test.ts | 52 +++++++++++++ src/shared/opencode-startup-prompt.ts | 2 +- 6 files changed, 165 insertions(+), 17 deletions(-) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 3a547d35c0e..53c0e43ff88 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -91,7 +91,7 @@ export async function prepareOpenCodePtyLaunch(options: { const last = parsed.ok ? parsed.tokens.length - 1 : -1 if ( parsed.ok && - !isOpenCodeRunCommand(parsed.tokens) && + !isOpenCodeRunCommand(parsed.tokens, shell) && parsed.tokens.filter((token) => token === '--prompt').length === 1 && parsed.tokens[last - 1] === '--prompt' && parsed.tokens[last] === body && diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts index 28dd70cf0bd..50e1be306fa 100644 --- a/src/shared/flag-prompt-startup.ts +++ b/src/shared/flag-prompt-startup.ts @@ -4,7 +4,7 @@ import type { SessionOptionValue } from './native-chat-session-options' import type { TuiAgent } from './tui-agent' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' -import { isOpenCodeRunCommand } from './opencode-headless-command' +import { findOpenCodeRunCommand } from './opencode-headless-command' import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export function appliedSessionOptionProps(values: Record) { @@ -22,13 +22,13 @@ export function buildFlagPromptStartupPlan(args: { agentEnv: Record | null | undefined }): AgentStartupPlan { const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) - const isOpenCodeRun = - (args.agent === 'opencode' || args.agent === 'opencode2') && - parsed.ok && - isOpenCodeRunCommand(parsed.tokens) + const openCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && parsed.ok + ? findOpenCodeRunCommand(parsed.tokens, args.shell) + : null // OpenCode run takes a positional message; --prompt belongs to its TUI. - const promptSuffix = isOpenCodeRun - ? parsed.tokens.includes('--') + const promptSuffix = openCodeRun + ? openCodeRun.messageSeparatorIndex !== null ? ` ${args.quotedPrompt}` : ` -- ${args.quotedPrompt}` : ` --prompt ${args.quotedPrompt}` diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 408895a1dbb..cc240be7f9c 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' import { tokenizeCommandLine } from './agent-command-line-entrypoint' import { isOpenCodeRunCommand } from './opencode-headless-command' @@ -24,3 +25,32 @@ describe('isOpenCodeRunCommand', () => { expect(matches('opencode --log-level run')).toBe(false) }) }) + +describe('wrapped OpenCode run command position', () => { + it.each([ + 'CUSTOM_CONFIG=private opencode --log-level debug run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone' + ])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it('recognizes a PowerShell call operator before a quoted executable', () => { + const parsed = tokenizeStartupCommand( + '& "C:\\Program Files\\opencode\\opencode.exe" run --standalone', + 'powershell' + ) + expect(parsed.ok).toBe(true) + if (!parsed.ok) throw new Error(parsed.error) + expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) + expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) + }) + + it('does not find executable names inside other commands or prompt arguments', () => { + expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) + expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) + expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false) + expect(matches('env -- opencode serve --title run')).toBe(false) + expect(matches('opencode attach http://host/run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 5328f7931fc..858fb965125 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -1,16 +1,82 @@ -// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. -// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, -// whose in-process plugin reports it. Only `--log-level` takes a separate value before the -// subcommand; any other valued option makes the value the first positional, which fails safe. -export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { +import { extractLeadingEnvAssignments } from './command-environment' +import { getCommandTokenPathBasename } from './command-token-scanner' +import type { AgentStartupShell } from './tui-agent-startup-shell' + +const RUN_VALUE_FLAGS = new Set([ + '--log-level', + '--completions', + '--server', + '--session', + '-s', + '--model', + '-m', + '--agent', + '--format', + '--file', + '-f', + '--title' +]) + +function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { + if (shell === 'powershell' && tokens[0] === '&') { + return 1 + } + if (shell !== 'posix') { + return 0 + } + let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length + if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { + index += 1 + if (tokens[index] === '--') { + index += 1 + } + index = tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length + } + return index +} + +export function findOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): { runIndex: number; messageSeparatorIndex: number | null } | null { + const commandPosition = openCodeCommandPosition(tokens, shell) + if (commandPosition > 0) { + const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '') + .toLowerCase() + .replace(/\.(?:exe|cmd)$/, '') + if (binary !== 'opencode' && binary !== 'opencode2') { + return null + } + } + for (let index = commandPosition + 1; index < tokens.length; index += 1) { const token = tokens[index] if (!token.startsWith('-')) { - return token === 'run' + if (token !== 'run') { + return null + } + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) { + const argument = tokens[argumentIndex] + if (argument === '--') { + return { runIndex: index, messageSeparatorIndex: argumentIndex } + } + if (RUN_VALUE_FLAGS.has(argument)) { + argumentIndex += 1 + } + } + return { runIndex: index, messageSeparatorIndex: null } } + // Other valued global options fail closed at their first positional value. if (token === '--log-level') { index += 1 } } - return false + return null +} + +// OpenCode run's process lifetime is its turn; v1 still reports through its plugin. +export function isOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): boolean { + return findOpenCodeRunCommand(tokens, shell) !== null } diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index d438028ba96..e82b46f8e77 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -105,3 +105,55 @@ describe('native OpenCode startup submission intent', () => { ).toBeUndefined() }) }) + +describe('wrapped OpenCode run startup', () => { + it.each(['opencode', 'opencode2'] as const)( + 'keeps %s run flags and positional task behind POSIX prefixes', + (agent) => { + for (const command of [ + 'CUSTOM_CONFIG=private opencode run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone --', + 'env CUSTOM_CONFIG=private opencode run --title "--"' + ]) { + const plan = buildAgentStartupPlan({ + agent, + prompt: '--literal task', + cmdOverrides: { [agent]: command }, + platform: 'linux', + shell: 'posix', + isRemote: true, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe( + command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` + ) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'preserves %s PowerShell call syntax and its run separator', + (agent) => { + for (const separator of ['', ' --']) { + const command = + '& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone' + + separator + const plan = buildAgentStartupPlan({ + agent, + prompt: "--task's é", + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell: 'powershell', + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe(command + (separator ? ' ' : ' -- ') + "'--task''s é'") + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts index 1dbe9c2719d..e3b1c462ffa 100644 --- a/src/shared/opencode-startup-prompt.ts +++ b/src/shared/opencode-startup-prompt.ts @@ -21,7 +21,7 @@ export function openCodeStartupPromptEnv( if ( (agent !== 'opencode' && agent !== 'opencode2') || !parsed.ok || - isOpenCodeRunCommand(parsed.tokens) + isOpenCodeRunCommand(parsed.tokens, shell) ) { return env ? { env: { ...env } } : {} }