mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
fix(opencode): submit admitted native startup briefs without overwriting input (#24762)
* fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup * Retry interrupted OpenCode startup prompt claims --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy <ahmednagy25t@gmail.com> Co-authored-by: Orca startup hydration review <agents@stably.ai> Co-authored-by: Orca <dev@stably.ai>
This commit is contained in:
co-authored by
Brennan Benson
Ahmed Nagy
Orca startup hydration review
Orca
parent
58bd15fa3f
commit
e8310d5a4f
@@ -2,6 +2,7 @@ import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
@@ -10,22 +11,23 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
|
||||
import { isSafeDescendCandidate, mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
|
||||
import {
|
||||
getOpenCode2PluginSource,
|
||||
getOpenCodeFamilyPluginSource,
|
||||
getOpenCodePluginSource
|
||||
} from './status-plugin-module-source'
|
||||
import {
|
||||
readOpenCodeOverlayManifest,
|
||||
OPENCODE_OVERLAY_MANIFEST_FILE,
|
||||
type OpenCodeOverlayManifest
|
||||
} from './opencode-overlay-manifest'
|
||||
import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory'
|
||||
import {
|
||||
getOpenCodeLegacySharedConfigDir,
|
||||
OPENCODE2_LEGACY_HOOKS_DIR,
|
||||
OPENCODE_LEGACY_HOOKS_DIR
|
||||
} from './legacy-shared-config-dir'
|
||||
import {
|
||||
isInstalledOpenCodePluginCurrent,
|
||||
isOverlayOpenCodePluginCurrent
|
||||
} from '../../shared/opencode-installed-plugin'
|
||||
import {
|
||||
openCodeTuiPluginDirName,
|
||||
writeOpenCodeTuiPlugin
|
||||
@@ -34,19 +36,11 @@ import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry
|
||||
|
||||
export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource }
|
||||
|
||||
import {
|
||||
writeCanonicalOpenCodePluginAtomically,
|
||||
writeOverlayOpenCodePluginAtomically
|
||||
} from '../../shared/opencode-plugin-atomic-write'
|
||||
import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write'
|
||||
import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer'
|
||||
|
||||
const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
|
||||
const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays'
|
||||
const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json'
|
||||
|
||||
type OpenCodeOverlayManifest = {
|
||||
topLevelEntries: string[]
|
||||
pluginEntries: string[]
|
||||
}
|
||||
|
||||
type OpenCodeHookVariant = {
|
||||
pluginFileName: string
|
||||
@@ -55,6 +49,7 @@ type OpenCodeHookVariant = {
|
||||
pluginSource: () => string
|
||||
/** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */
|
||||
installsTuiPlugin?: boolean
|
||||
tuiOnlyDirectory?: string
|
||||
}
|
||||
|
||||
// Why: session IDs may contain path separators and are hashed downstream; cap pathological input.
|
||||
@@ -74,6 +69,7 @@ export class OpenCodeHookService {
|
||||
private readonly legacyHooksDir: string
|
||||
private readonly overlayDir: string
|
||||
private readonly installsTuiPlugin: boolean
|
||||
private readonly tuiOnlyDirectory: string | undefined
|
||||
|
||||
constructor(variant?: OpenCodeHookVariant | (() => string)) {
|
||||
const config: OpenCodeHookVariant =
|
||||
@@ -93,6 +89,7 @@ export class OpenCodeHookService {
|
||||
})
|
||||
this.pluginSource = config.pluginSource
|
||||
this.installsTuiPlugin = config.installsTuiPlugin === true
|
||||
this.tuiOnlyDirectory = config.tuiOnlyDirectory
|
||||
this.pluginFileName = config.pluginFileName
|
||||
this.legacyHooksDir = config.legacyHooksDir
|
||||
this.overlayDir = config.overlayDir
|
||||
@@ -102,6 +99,27 @@ export class OpenCodeHookService {
|
||||
// Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows.
|
||||
}
|
||||
|
||||
installIntoSourceOverlay(
|
||||
directory: string,
|
||||
sourceConfigDir: string,
|
||||
owner: OpenCodeHookService
|
||||
): 'unmatched' | 'installed' | 'failed' {
|
||||
if (directory !== owner.getSourceOverlayDir(sourceConfigDir)) {
|
||||
return 'unmatched'
|
||||
}
|
||||
try {
|
||||
for (const path of [owner.getOverlayRoot(), directory, join(directory, 'plugins')]) {
|
||||
if (!isSafeDescendCandidate(lstatSync(path))) {
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
this.writePluginIntoOverlay(directory)
|
||||
return 'installed'
|
||||
} catch {
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record<string, string> {
|
||||
if (!isUsableId(ptyId)) {
|
||||
// Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited.
|
||||
@@ -118,13 +136,15 @@ export class OpenCodeHookService {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
if (!existsSync(existingConfigDir)) {
|
||||
if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) {
|
||||
return { OPENCODE_CONFIG_DIR: existingConfigDir }
|
||||
}
|
||||
const overlayDir = this.getSourceOverlayDir(existingConfigDir)
|
||||
try {
|
||||
mkdirSync(overlayDir, { recursive: true })
|
||||
this.mirrorUserConfig(existingConfigDir, overlayDir)
|
||||
if (existsSync(existingConfigDir)) {
|
||||
this.mirrorUserConfig(existingConfigDir, overlayDir)
|
||||
}
|
||||
this.writePluginIntoOverlay(overlayDir)
|
||||
return { OPENCODE_CONFIG_DIR: overlayDir }
|
||||
} catch {
|
||||
@@ -135,6 +155,9 @@ export class OpenCodeHookService {
|
||||
// Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps
|
||||
// processes that load it later; a running OpenCode 2 service keeps its cached module until restarted.
|
||||
refreshLegacySharedPlugin(): void {
|
||||
if (this.tuiOnlyDirectory) {
|
||||
return
|
||||
}
|
||||
const pluginsDir = join(this.getSharedConfigDir(), 'plugins')
|
||||
const pluginPath = join(pluginsDir, this.pluginFileName)
|
||||
try {
|
||||
@@ -198,20 +221,6 @@ export class OpenCodeHookService {
|
||||
)
|
||||
}
|
||||
|
||||
private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8')
|
||||
) as Partial<OpenCodeOverlayManifest>
|
||||
return {
|
||||
topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [],
|
||||
pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : []
|
||||
}
|
||||
} catch {
|
||||
return { topLevelEntries: [], pluginEntries: [] }
|
||||
}
|
||||
}
|
||||
|
||||
private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void {
|
||||
writeFileSync(
|
||||
join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE),
|
||||
@@ -235,7 +244,7 @@ export class OpenCodeHookService {
|
||||
|
||||
// Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file.
|
||||
private mirrorUserConfig(sourceDir: string, overlayDir: string): void {
|
||||
const previousManifest = this.readOverlayManifest(overlayDir)
|
||||
const previousManifest = readOpenCodeOverlayManifest(overlayDir)
|
||||
// Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive.
|
||||
this.clearManifestEntries(overlayDir, previousManifest)
|
||||
|
||||
@@ -266,6 +275,7 @@ export class OpenCodeHookService {
|
||||
// Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file.
|
||||
if (
|
||||
pluginEntry.name === this.pluginFileName ||
|
||||
pluginEntry.name === this.tuiOnlyDirectory ||
|
||||
(this.installsTuiPlugin &&
|
||||
pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName))
|
||||
) {
|
||||
@@ -288,27 +298,23 @@ export class OpenCodeHookService {
|
||||
this.writeOverlayManifest(overlayDir, nextManifest)
|
||||
}
|
||||
|
||||
// Atomic replacement detaches mirrored links without touching user plugins.
|
||||
private writePluginIntoOverlay(overlayDir: string): void {
|
||||
const pluginsDir = join(overlayDir, 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
const pluginPath = join(pluginsDir, this.pluginFileName)
|
||||
const source = this.pluginSource()
|
||||
this.writeTuiPlugin(pluginsDir, source, 'overlay')
|
||||
if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) {
|
||||
writeOverlayOpenCodePluginAtomically(pluginPath, source)
|
||||
}
|
||||
this.writePluginToDirectory(overlayDir, 'overlay')
|
||||
}
|
||||
|
||||
private writePluginToConfigDir(configDir: string): void {
|
||||
const pluginsDir = join(configDir, 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
const pluginPath = join(pluginsDir, this.pluginFileName)
|
||||
const source = this.pluginSource()
|
||||
this.writeTuiPlugin(pluginsDir, source)
|
||||
if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) {
|
||||
writeCanonicalOpenCodePluginAtomically(pluginPath, source)
|
||||
}
|
||||
this.writePluginToDirectory(configDir, 'canonical')
|
||||
}
|
||||
|
||||
private writePluginToDirectory(configDir: string, ownership: 'canonical' | 'overlay'): void {
|
||||
writeOpenCodePluginConfig({
|
||||
configDir,
|
||||
ownership,
|
||||
pluginFileName: this.pluginFileName,
|
||||
getSource: () => this.pluginSource(),
|
||||
installsTuiPlugin: this.installsTuiPlugin,
|
||||
tuiOnlyDirectory: this.tuiOnlyDirectory
|
||||
})
|
||||
}
|
||||
|
||||
private writeTuiPlugin(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json'
|
||||
export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] }
|
||||
|
||||
export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest {
|
||||
const empty = { topLevelEntries: [], pluginEntries: [] }
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8')
|
||||
)
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return empty
|
||||
}
|
||||
return {
|
||||
topLevelEntries:
|
||||
'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries)
|
||||
? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [],
|
||||
pluginEntries:
|
||||
'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries)
|
||||
? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string')
|
||||
: []
|
||||
}
|
||||
} catch {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
isInstalledOpenCodePluginCurrent,
|
||||
isOverlayOpenCodePluginCurrent
|
||||
} from '../../shared/opencode-installed-plugin'
|
||||
import {
|
||||
writeCanonicalOpenCodePluginAtomically,
|
||||
writeOverlayOpenCodePluginAtomically
|
||||
} from '../../shared/opencode-plugin-atomic-write'
|
||||
import {
|
||||
writeOpenCodeTuiPlugin,
|
||||
writeOpenCodeTuiPluginDirectory
|
||||
} from '../../shared/opencode-tui-plugin-install'
|
||||
|
||||
export function writeOpenCodePluginConfig(options: {
|
||||
configDir: string
|
||||
pluginFileName: string
|
||||
getSource: () => string
|
||||
installsTuiPlugin: boolean
|
||||
tuiOnlyDirectory: string | undefined
|
||||
ownership: 'canonical' | 'overlay'
|
||||
}): void {
|
||||
const pluginsDir = join(options.configDir, 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
const pluginPath = join(pluginsDir, options.pluginFileName)
|
||||
const source = options.getSource()
|
||||
if (options.tuiOnlyDirectory) {
|
||||
writeOpenCodeTuiPluginDirectory(pluginsDir, options.tuiOnlyDirectory, source, options.ownership)
|
||||
return
|
||||
}
|
||||
if (options.installsTuiPlugin) {
|
||||
writeOpenCodeTuiPlugin(pluginsDir, options.pluginFileName, source, options.ownership)
|
||||
}
|
||||
const overlay = options.ownership === 'overlay'
|
||||
const current = overlay
|
||||
? isOverlayOpenCodePluginCurrent(pluginPath, source)
|
||||
: isInstalledOpenCodePluginCurrent(pluginPath, source)
|
||||
if (!current) {
|
||||
const write = overlay
|
||||
? writeOverlayOpenCodePluginAtomically
|
||||
: writeCanonicalOpenCodePluginAtomically
|
||||
write(pluginPath, source)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version'
|
||||
import { prepareOpenCodePtyLaunch } from './opencode-pty-launch'
|
||||
import {
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_BODY_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_SHELL_ENV
|
||||
} from '../../shared/opencode-startup-prompt'
|
||||
import {
|
||||
buildLocalPtySpawnEnvironment,
|
||||
enforceLocalPtySpawnEnvironmentOverrides
|
||||
@@ -28,20 +34,195 @@ const plan: LocalPtyLaunchPlan = {
|
||||
launchWslDistro: null
|
||||
}
|
||||
|
||||
const hookServer = vi.hoisted(() => {
|
||||
const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' }
|
||||
return server
|
||||
})
|
||||
vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer }))
|
||||
const reserve = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve }))
|
||||
|
||||
async function prepare(options: Parameters<typeof prepareOpenCodePtyLaunch>[0]) {
|
||||
return (await prepareOpenCodePtyLaunch(options)).env
|
||||
}
|
||||
|
||||
const probe = vi.hoisted(() => vi.fn())
|
||||
const install = vi.hoisted(() => vi.fn())
|
||||
vi.mock('./opencode-startup-prompt-installer', () => ({
|
||||
installOpenCodeStartupPromptForLaunch: install
|
||||
}))
|
||||
vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe }))
|
||||
|
||||
beforeEach(() => probe.mockReset())
|
||||
beforeEach(() => {
|
||||
probe.mockReset()
|
||||
reserve.mockReset().mockReturnValue(true)
|
||||
install.mockReset()
|
||||
hookServer.endpointFilePath = '/private/endpoint.env'
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('execution-host OpenCode launch preparation', () => {
|
||||
it('retains fresh owned source provenance through the final provider deletion pass', async () => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
install.mockImplementation((env) => {
|
||||
env.OPENCODE_CONFIG_DIR = '/private/owned-overlay'
|
||||
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/private/real-source'
|
||||
return true
|
||||
})
|
||||
const envToDelete = ['OPENCODE_CONFIG_DIR', 'ORCA_OPENCODE_SOURCE_CONFIG_DIR']
|
||||
const env = await prepare({
|
||||
command: 'opencode --prompt task',
|
||||
isFreshLaunch: true,
|
||||
envToDelete,
|
||||
env: {
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_SHA256: createHash('sha256').update('task').digest('hex'),
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'task',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_SHELL: 'posix'
|
||||
}
|
||||
})
|
||||
const finalEnv = await buildLocalPtySpawnEnvironment({
|
||||
id: 'source-proof',
|
||||
spawn: { cols: 80, rows: 24, env, envToDelete },
|
||||
getOptions: () => ({}),
|
||||
plan
|
||||
})
|
||||
enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv)
|
||||
expect(finalEnv.OPENCODE_CONFIG_DIR).toBe('/private/owned-overlay')
|
||||
expect(finalEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/private/real-source')
|
||||
})
|
||||
it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
const env = {
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
|
||||
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'),
|
||||
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
|
||||
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix'
|
||||
}
|
||||
const options = { env, envToDelete: [], isFreshLaunch: true }
|
||||
expect(
|
||||
(
|
||||
await prepareOpenCodePtyLaunch({
|
||||
...options,
|
||||
command: "opencode --standalone --prompt 'task'"
|
||||
})
|
||||
).command
|
||||
).toBe('opencode --standalone')
|
||||
expect(
|
||||
(await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" }))
|
||||
.command
|
||||
).toBe("opencode run --prompt 'task'")
|
||||
expect(
|
||||
(await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" }))
|
||||
.command
|
||||
).toBe("opencode --prompt 'task'")
|
||||
})
|
||||
it.each(['inherited', 'explicit', 'deleted'] as const)(
|
||||
'passes the %s config environment used by the execution-host version probe to the prompt installer',
|
||||
async (selection) => {
|
||||
vi.stubEnv('XDG_CONFIG_HOME', '/ambient/config')
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
await prepareOpenCodePtyLaunch({
|
||||
command: 'opencode --prompt task',
|
||||
envToDelete: selection === 'deleted' ? ['XDG_CONFIG_HOME'] : [],
|
||||
isFreshLaunch: true,
|
||||
env: {
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
|
||||
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'),
|
||||
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
|
||||
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix',
|
||||
...(selection === 'explicit' ? { XDG_CONFIG_HOME: '/selected/config' } : {})
|
||||
}
|
||||
})
|
||||
expect(install).toHaveBeenCalledTimes(1)
|
||||
const resolved = install.mock.calls[0][2]
|
||||
expect(resolved).toEqual(probe.mock.calls[0][0].env)
|
||||
expect(resolved.XDG_CONFIG_HOME).toBe(
|
||||
selection === 'deleted'
|
||||
? undefined
|
||||
: selection === 'explicit'
|
||||
? '/selected/config'
|
||||
: '/ambient/config'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['endpoint', 'installer', 'capacity', 'identity'])(
|
||||
'keeps the editable brief when automatic preparation lacks %s',
|
||||
async (failure) => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
if (failure === 'endpoint') {
|
||||
hookServer.endpointFilePath = null
|
||||
}
|
||||
if (failure === 'installer') {
|
||||
install.mockImplementation((env) => {
|
||||
delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE
|
||||
})
|
||||
}
|
||||
if (failure === 'capacity') {
|
||||
reserve.mockReturnValue(false)
|
||||
}
|
||||
const original = "opencode --standalone --prompt 'task'"
|
||||
const result = await prepareOpenCodePtyLaunch({
|
||||
command: original,
|
||||
envToDelete: [],
|
||||
isFreshLaunch: true,
|
||||
env: {
|
||||
...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }),
|
||||
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'),
|
||||
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
|
||||
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix'
|
||||
}
|
||||
})
|
||||
expect(result.command).toBe(original)
|
||||
expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE')
|
||||
}
|
||||
)
|
||||
it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])(
|
||||
'gates native intent against the executing %s capability',
|
||||
async (version) => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities(version))
|
||||
const envToDelete: string[] = []
|
||||
const fingerprint = createHash('sha256').update('task').digest('hex')
|
||||
const env = await prepare({
|
||||
command: 'opencode --prompt task',
|
||||
env: {
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch',
|
||||
[OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint,
|
||||
[OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task',
|
||||
[OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix'
|
||||
},
|
||||
envToDelete,
|
||||
isFreshLaunch: true
|
||||
})
|
||||
expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe(
|
||||
version === '2.0.16' ? fingerprint : undefined
|
||||
)
|
||||
expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16')
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses remote automatic intent without execution-owned driving input', async () => {
|
||||
const fingerprint = 'b'.repeat(64)
|
||||
const envToDelete: string[] = []
|
||||
const env = await prepare({
|
||||
command: 'opencode --prompt task',
|
||||
connectionId: 'remote',
|
||||
isFreshLaunch: true,
|
||||
env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint },
|
||||
envToDelete
|
||||
})
|
||||
expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined()
|
||||
expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV)
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps deleted credentials and config absent from the probe and final provider environment', async () => {
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key')
|
||||
vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config')
|
||||
vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1')
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities(null))
|
||||
const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR']
|
||||
const env = await prepareOpenCodePtyLaunch({
|
||||
const env = await prepare({
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
envToDelete,
|
||||
@@ -67,7 +248,7 @@ describe('execution-host OpenCode launch preparation', () => {
|
||||
vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1')
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API']
|
||||
const env = await prepareOpenCodePtyLaunch({
|
||||
const env = await prepare({
|
||||
command: 'opencode',
|
||||
env: {},
|
||||
envToDelete,
|
||||
@@ -94,7 +275,7 @@ describe('execution-host OpenCode launch preparation', () => {
|
||||
KEEP: '1',
|
||||
ORCA_OPENCODE_PLUGIN_API: 'stale'
|
||||
}
|
||||
const result = await prepareOpenCodePtyLaunch({
|
||||
const result = await prepare({
|
||||
command: 'opencode --prompt test',
|
||||
agent: 'opencode',
|
||||
env,
|
||||
@@ -117,7 +298,7 @@ describe('execution-host OpenCode launch preparation', () => {
|
||||
it('creates a launch environment for a known binary without caller env', async () => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16'))
|
||||
expect(
|
||||
await prepareOpenCodePtyLaunch({
|
||||
await prepare({
|
||||
command: 'opencode',
|
||||
env: undefined,
|
||||
envToDelete: [],
|
||||
@@ -129,7 +310,7 @@ describe('execution-host OpenCode launch preparation', () => {
|
||||
it('forwards WSL plugin selection through WSLENV after a guest probe', async () => {
|
||||
probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23'))
|
||||
const env = { KEEP: '1' }
|
||||
const result = await prepareOpenCodePtyLaunch({
|
||||
const result = await prepare({
|
||||
command: 'opencode',
|
||||
agent: 'opencode',
|
||||
env,
|
||||
@@ -148,9 +329,7 @@ describe('execution-host OpenCode launch preparation', () => {
|
||||
'never probes the client for an attach or SSH launch',
|
||||
async (route) => {
|
||||
const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' }
|
||||
expect(
|
||||
await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route })
|
||||
).toEqual({})
|
||||
expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({})
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { addWslEnvKeys } from '../../shared/wsl-env'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell'
|
||||
import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command'
|
||||
import {
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_BODY_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_SHELL_ENV
|
||||
} from '../../shared/opencode-startup-prompt'
|
||||
|
||||
const intentKeys = [
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_BODY_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_SHELL_ENV
|
||||
]
|
||||
|
||||
import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path'
|
||||
import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities'
|
||||
import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner'
|
||||
import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer'
|
||||
|
||||
export async function prepareOpenCodePtyLaunch(options: {
|
||||
command: string | undefined
|
||||
@@ -12,17 +34,29 @@ export async function prepareOpenCodePtyLaunch(options: {
|
||||
connectionId?: string | null
|
||||
isFreshLaunch: boolean
|
||||
wsl?: { distro?: string }
|
||||
}): Promise<Record<string, string> | undefined> {
|
||||
}): Promise<{ env: Record<string, string> | undefined; command: string | undefined }> {
|
||||
let command = options.command
|
||||
const env = options.env ? { ...options.env } : undefined
|
||||
const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]
|
||||
const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV]
|
||||
const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV]
|
||||
if (env) {
|
||||
delete env.ORCA_OPENCODE_PLUGIN_API
|
||||
for (const key of intentKeys) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
// Providers merge their own ambient environment after this preparation.
|
||||
if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) {
|
||||
options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API')
|
||||
}
|
||||
for (const key of intentKeys) {
|
||||
if (!options.envToDelete.includes(key)) {
|
||||
options.envToDelete.push(key)
|
||||
}
|
||||
}
|
||||
if (options.connectionId || !options.isFreshLaunch) {
|
||||
return env
|
||||
return { env, command }
|
||||
}
|
||||
const probeEnv: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries({ ...process.env, ...env })) {
|
||||
@@ -36,12 +70,70 @@ export async function prepareOpenCodePtyLaunch(options: {
|
||||
env: probeEnv
|
||||
})
|
||||
if (!capabilities || capabilities.pluginApi === 'unknown') {
|
||||
return env
|
||||
return { env, command }
|
||||
}
|
||||
const launchEnv: Record<string, string> = {
|
||||
...env,
|
||||
ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi
|
||||
}
|
||||
const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }
|
||||
options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1)
|
||||
if (
|
||||
capabilities.promptMode === 'prefill' &&
|
||||
!options.wsl &&
|
||||
launchEnv.ORCA_AGENT_LAUNCH_TOKEN &&
|
||||
requestedPrompt &&
|
||||
body &&
|
||||
command &&
|
||||
(shell === 'posix' || shell === 'powershell' || shell === 'cmd') &&
|
||||
createHash('sha256').update(body).digest('hex') === requestedPrompt
|
||||
) {
|
||||
const parsed = tokenizeStartupCommand(command, shell)
|
||||
const last = parsed.ok ? parsed.tokens.length - 1 : -1
|
||||
if (
|
||||
parsed.ok &&
|
||||
!isOpenCodeRunCommand(parsed.tokens, shell) &&
|
||||
parsed.tokens.filter((token) => token === '--prompt').length === 1 &&
|
||||
parsed.tokens[last - 1] === '--prompt' &&
|
||||
parsed.tokens[last] === body &&
|
||||
!parsed.spans[last].divergesFromShell &&
|
||||
!parsed.spans[last - 1].divergesFromShell
|
||||
) {
|
||||
const endpoint = agentHookServer.endpointFilePath
|
||||
if (endpoint) {
|
||||
launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt
|
||||
launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body
|
||||
launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID()
|
||||
launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint
|
||||
if (installOpenCodeStartupPromptForLaunch(launchEnv, false, probeEnv)) {
|
||||
for (const key of [
|
||||
'OPENCODE_CONFIG_DIR',
|
||||
'ORCA_OPENCODE_CONFIG_DIR',
|
||||
'ORCA_OPENCODE_SOURCE_CONFIG_DIR'
|
||||
]) {
|
||||
const deletion = options.envToDelete.indexOf(key)
|
||||
if (launchEnv[key] && deletion !== -1) {
|
||||
options.envToDelete.splice(deletion, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
|
||||
if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) {
|
||||
command = command.slice(0, parsed.spans[last - 1].start).trimEnd()
|
||||
} else {
|
||||
for (const key of intentKeys) {
|
||||
delete launchEnv[key]
|
||||
}
|
||||
}
|
||||
for (const key of intentKeys) {
|
||||
if (launchEnv[key]) {
|
||||
options.envToDelete.splice(options.envToDelete.indexOf(key), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.wsl) {
|
||||
addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API'])
|
||||
}
|
||||
return launchEnv
|
||||
return { env: launchEnv, command }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalRunFacts } from '../runtime/terminal-run-facts'
|
||||
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
|
||||
|
||||
describe('execution-owned startup prompt claims', () => {
|
||||
it('consumes each nonce once and checks owner facts at claim time', () => {
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null }
|
||||
claims.register('first', 'hash', () => facts)
|
||||
facts.firstUserInputAt = 1
|
||||
expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false)
|
||||
facts.firstUserInputAt = null
|
||||
expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false)
|
||||
claims.register('second', 'hash', () => facts)
|
||||
expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true)
|
||||
expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false)
|
||||
claims.register('missing', 'hash', () => facts)
|
||||
facts = null
|
||||
expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => {
|
||||
let now = 0
|
||||
const claims = new OpenCodeStartupPromptClaims(() => now)
|
||||
claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null }))
|
||||
expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false)
|
||||
claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
|
||||
expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false)
|
||||
expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false)
|
||||
claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
|
||||
now = 20000
|
||||
expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false)
|
||||
for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) {
|
||||
expect(claims.claim(body)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps pending admission bounded and never recreates canceled or consumed claims', () => {
|
||||
let now = 0
|
||||
const claims = new OpenCodeStartupPromptClaims(() => now)
|
||||
claims.register('waiting', 'hash', () => 'pending')
|
||||
expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending')
|
||||
expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending')
|
||||
expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true)
|
||||
expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true)
|
||||
expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(
|
||||
false
|
||||
)
|
||||
claims.register('canceled', 'hash', () => 'pending')
|
||||
claims.cancel('canceled')
|
||||
expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(
|
||||
false
|
||||
)
|
||||
claims.register('expired', 'hash', () => 'pending')
|
||||
now = 20000
|
||||
expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false)
|
||||
expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(
|
||||
false
|
||||
)
|
||||
claims.clear()
|
||||
})
|
||||
|
||||
it('bounds pending claims and reclaims expired capacity without timers', () => {
|
||||
let now = 0
|
||||
const claims = new OpenCodeStartupPromptClaims(() => now)
|
||||
for (let index = 0; index < 129; index++) {
|
||||
claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
|
||||
}
|
||||
expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false)
|
||||
now = 20000
|
||||
claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
|
||||
expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true)
|
||||
})
|
||||
it('starts a fresh bounded claim window after delayed spawn admission', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
claims.register('slow-spawn', 'hash', () => 'pending')
|
||||
vi.advanceTimersByTime(18000)
|
||||
const cleanup = vi.fn()
|
||||
expect(
|
||||
claims.admit('slow-spawn', () => ({ freshSpawn: true, firstUserInputAt: null }), cleanup)
|
||||
).toBe(true)
|
||||
vi.advanceTimersByTime(8000)
|
||||
expect(claims.claim({ nonce: 'slow-spawn', digest: 'hash' })).toBe(true)
|
||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||
claims.clear()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays only the same operation while execution facts remain authorized', () => {
|
||||
let now = 0
|
||||
const claims = new OpenCodeStartupPromptClaims(() => now)
|
||||
let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null }
|
||||
const cleanup = vi.fn()
|
||||
claims.register('retry', 'hash', () => facts, cleanup)
|
||||
const body = { nonce: 'retry', digest: 'hash', requestId: 'stable-operation' }
|
||||
expect(claims.claim(body)).toBe(true)
|
||||
expect(claims.claim(body)).toBe(true)
|
||||
expect(claims.claim({ ...body, requestId: 'another-operation' })).toBe(false)
|
||||
expect(claims.claim({ nonce: 'retry', digest: 'hash' })).toBe(false)
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
expect(claims.claim(body)).toBe(true)
|
||||
facts.firstUserInputAt = 1
|
||||
expect(claims.claim(body)).toBe(false)
|
||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||
facts = { freshSpawn: true, firstUserInputAt: null }
|
||||
expect(claims.claim(body)).toBe(false)
|
||||
claims.register('expires', 'hash', () => facts, cleanup)
|
||||
expect(claims.claim({ ...body, nonce: 'expires' })).toBe(true)
|
||||
now = 20000
|
||||
expect(claims.claim({ ...body, nonce: 'expires' })).toBe(false)
|
||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not renew admission, retain unbounded IDs or replay retired owners', () => {
|
||||
let now = 0
|
||||
const claims = new OpenCodeStartupPromptClaims(() => now)
|
||||
let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null }
|
||||
claims.register('bound', 'hash', () => 'pending')
|
||||
now = 18000
|
||||
expect(claims.admit('bound', () => facts)).toBe(true)
|
||||
now = 37000
|
||||
expect(claims.admit('bound', () => facts)).toBe(false)
|
||||
expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'x'.repeat(129) })).toBe(false)
|
||||
expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(true)
|
||||
facts = null
|
||||
expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(false)
|
||||
claims.register('clear', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
|
||||
expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(true)
|
||||
claims.clear()
|
||||
expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(false)
|
||||
})
|
||||
|
||||
it.each([null, 1, '', 'with spaces', 'x'.repeat(129)])(
|
||||
'rejects malformed operation ID %j without consuming valid authorization',
|
||||
(requestId) => {
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
claims.register('valid', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null }))
|
||||
expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId })).toBe(false)
|
||||
expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId: 'valid-operation' })).toBe(
|
||||
true
|
||||
)
|
||||
claims.clear()
|
||||
}
|
||||
)
|
||||
|
||||
it('expires the renewed window without permitting repeated admission to extend it', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
claims.register('bounded', 'hash', () => 'pending')
|
||||
vi.advanceTimersByTime(18000)
|
||||
const owner = () => ({ freshSpawn: true, firstUserInputAt: null })
|
||||
const cleanup = vi.fn()
|
||||
expect(claims.admit('bounded', owner, cleanup)).toBe(true)
|
||||
expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true)
|
||||
vi.advanceTimersByTime(19999)
|
||||
expect(claims.admit('bounded', owner)).toBe(false)
|
||||
expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||
expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(false)
|
||||
claims.clear()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { TerminalRunFacts } from '../runtime/terminal-run-facts'
|
||||
|
||||
type PromptClaim = {
|
||||
digest: string
|
||||
expiresAt: number
|
||||
admitted: boolean
|
||||
grantedRequestId?: string
|
||||
readOwner: () => TerminalRunFacts | 'pending' | null
|
||||
cleanup: () => void
|
||||
expiry: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/** Authorizes one startup operation against current execution-owner facts. */
|
||||
export class OpenCodeStartupPromptClaims {
|
||||
private readonly pending = new Map<string, PromptClaim>()
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
register(
|
||||
nonce: string,
|
||||
digest: string,
|
||||
readOwner: PromptClaim['readOwner'],
|
||||
cleanup = () => {}
|
||||
): boolean {
|
||||
const now = this.now()
|
||||
for (const [key, claim] of this.pending) {
|
||||
if (claim.expiresAt <= now) {
|
||||
this.cancel(key)
|
||||
}
|
||||
}
|
||||
if (this.pending.size >= 128 || this.pending.has(nonce)) {
|
||||
return false
|
||||
}
|
||||
const expiry = setTimeout(() => this.cancel(nonce), 20000)
|
||||
expiry.unref?.()
|
||||
this.pending.set(nonce, {
|
||||
digest,
|
||||
readOwner,
|
||||
expiresAt: now + 20000,
|
||||
admitted: false,
|
||||
cleanup,
|
||||
expiry
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean {
|
||||
const pending = this.pending.get(nonce)
|
||||
if (!pending || pending.expiresAt <= this.now()) {
|
||||
this.cancel(nonce)
|
||||
return false
|
||||
}
|
||||
if (pending.admitted || pending.grantedRequestId !== undefined) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(pending.expiry)
|
||||
pending.expiresAt = this.now() + 20000
|
||||
pending.expiry = setTimeout(() => this.cancel(nonce), 20000)
|
||||
pending.expiry.unref?.()
|
||||
pending.admitted = true
|
||||
pending.readOwner = readOwner
|
||||
pending.cleanup = cleanup
|
||||
return true
|
||||
}
|
||||
|
||||
cancel(nonce: string): void {
|
||||
const pending = this.pending.get(nonce)
|
||||
this.pending.delete(nonce)
|
||||
if (pending) {
|
||||
clearTimeout(pending.expiry)
|
||||
}
|
||||
pending?.cleanup()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const nonce of this.pending.keys()) {
|
||||
this.cancel(nonce)
|
||||
}
|
||||
}
|
||||
|
||||
claim(body: unknown): boolean | 'pending' {
|
||||
if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') {
|
||||
return false
|
||||
}
|
||||
const pending = this.pending.get(body.nonce)
|
||||
if (
|
||||
!pending ||
|
||||
pending.expiresAt <= this.now() ||
|
||||
!('digest' in body) ||
|
||||
body.digest !== pending.digest
|
||||
) {
|
||||
this.cancel(body.nonce)
|
||||
return false
|
||||
}
|
||||
const requestId = 'requestId' in body ? body.requestId : undefined
|
||||
if (
|
||||
requestId !== undefined &&
|
||||
(typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(requestId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (pending.grantedRequestId !== undefined && pending.grantedRequestId !== requestId) {
|
||||
return false
|
||||
}
|
||||
const owner = pending.readOwner()
|
||||
if (owner === 'pending') {
|
||||
return 'pending'
|
||||
}
|
||||
if (owner?.freshSpawn !== true || owner.firstUserInputAt !== null) {
|
||||
this.cancel(body.nonce)
|
||||
return false
|
||||
}
|
||||
if (requestId === undefined) {
|
||||
this.cancel(body.nonce)
|
||||
} else {
|
||||
pending.grantedRequestId = requestId
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setAppEnvironment } from '../../shared/app-environment'
|
||||
import {
|
||||
createOpenCodeStartupPromptInstaller,
|
||||
installOpenCodeStartupPromptForLaunch
|
||||
} from './opencode-startup-prompt-installer'
|
||||
import {
|
||||
captureOpenCodeSourceConfig,
|
||||
applyOpenCodeStatusPluginEnv
|
||||
} from '../ipc/pty/host-env/opencode-config'
|
||||
|
||||
let root: string
|
||||
let originalXdg: string | undefined
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-'))
|
||||
originalXdg = process.env.XDG_CONFIG_HOME
|
||||
process.env.XDG_CONFIG_HOME = join(root, 'config')
|
||||
setAppEnvironment({
|
||||
getPath: () => join(root, 'profile'),
|
||||
getAppPath: () => root,
|
||||
getVersion: () => 'test',
|
||||
isPackaged: () => false,
|
||||
onWillQuit: () => {},
|
||||
exit: () => {},
|
||||
getAppMetrics: () => []
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
if (originalXdg === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = originalXdg
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
describe('OpenCode startup prompt installer', () => {
|
||||
it('refuses a replaced status overlay instead of writing through its symlink or losing status hooks', () => {
|
||||
const source = join(root, 'safe-source')
|
||||
const foreign = join(root, 'foreign')
|
||||
mkdirSync(source)
|
||||
mkdirSync(foreign)
|
||||
writeFileSync(join(foreign, 'untouched.txt'), 'foreign bytes')
|
||||
const env: Record<string, string> = {
|
||||
OPENCODE_CONFIG_DIR: source,
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v2',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'replaced-overlay'
|
||||
}
|
||||
const config = captureOpenCodeSourceConfig(env, join(root, 'profile'))
|
||||
applyOpenCodeStatusPluginEnv(
|
||||
'pane',
|
||||
env,
|
||||
config,
|
||||
{
|
||||
launchAgent: 'opencode',
|
||||
agentStatusHooksEnabled: true
|
||||
},
|
||||
'opencode'
|
||||
)
|
||||
rmSync(env.OPENCODE_CONFIG_DIR, { recursive: true })
|
||||
symlinkSync(foreign, env.OPENCODE_CONFIG_DIR, 'junction')
|
||||
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(false)
|
||||
expect(env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE).toBeUndefined()
|
||||
expect(readFileSync(join(foreign, 'untouched.txt'), 'utf8')).toBe('foreign bytes')
|
||||
expect(existsSync(join(foreign, 'plugins'))).toBe(false)
|
||||
})
|
||||
it.each(['opencode', 'opencode2'] as const)(
|
||||
'keeps real source provenance and composes the %s status and prompt in one final overlay',
|
||||
(agent) => {
|
||||
const source = join(root, 'real-source')
|
||||
mkdirSync(join(source, 'plugins'), { recursive: true })
|
||||
writeFileSync(join(source, 'plugins', 'user.js'), 'user bytes')
|
||||
writeFileSync(join(source, 'opencode.json'), '{"model":"selected/model"}')
|
||||
const env: Record<string, string> = {
|
||||
OPENCODE_CONFIG_DIR: source,
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v2',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'source-provenance'
|
||||
}
|
||||
expect(installOpenCodeStartupPromptForLaunch(env, false)).toBe(true)
|
||||
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source)
|
||||
const captured = captureOpenCodeSourceConfig(env, join(root, 'profile'))
|
||||
expect(captured.directory).toBe(source)
|
||||
applyOpenCodeStatusPluginEnv(
|
||||
'pane',
|
||||
env,
|
||||
captured,
|
||||
{
|
||||
launchAgent: agent,
|
||||
agentStatusHooksEnabled: true
|
||||
},
|
||||
`${agent} --standalone`
|
||||
)
|
||||
const statusOverlay = env.OPENCODE_CONFIG_DIR
|
||||
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true)
|
||||
expect(env.OPENCODE_CONFIG_DIR).toBe(statusOverlay)
|
||||
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source)
|
||||
expect(
|
||||
readFileSync(join(statusOverlay, 'plugins', `orca-${agent}-status.js`), 'utf8')
|
||||
).toContain('ORCA_STATUS_AGENT')
|
||||
expect(
|
||||
existsSync(join(statusOverlay, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'))
|
||||
).toBe(true)
|
||||
expect(readFileSync(join(statusOverlay, 'plugins', 'user.js'), 'utf8')).toBe('user bytes')
|
||||
const nested: Record<string, string> = {
|
||||
...env,
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nested-source-provenance'
|
||||
}
|
||||
expect(installOpenCodeStartupPromptForLaunch(nested)).toBe(true)
|
||||
expect(nested.OPENCODE_CONFIG_DIR).toBe(statusOverlay)
|
||||
expect(nested.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source)
|
||||
expect(readFileSync(join(source, 'plugins', 'user.js'), 'utf8')).toBe('user bytes')
|
||||
expect(existsSync(join(source, 'plugins', `orca-${agent}-status.js`))).toBe(false)
|
||||
}
|
||||
)
|
||||
it.each(['inherited', 'explicit'] as const)(
|
||||
'preserves status and user plugins from the %s XDG config when launch env is sparse',
|
||||
(selection) => {
|
||||
const configHome = join(root, selection === 'explicit' ? 'selected-config' : 'config')
|
||||
const config = join(configHome, 'opencode')
|
||||
mkdirSync(join(config, 'plugins'), { recursive: true })
|
||||
writeFileSync(join(config, 'plugins', 'orca-opencode-status.js'), 'status entry')
|
||||
writeFileSync(join(config, 'plugins', 'user.js'), 'user entry')
|
||||
writeFileSync(join(config, 'opencode.json'), '{"model":"selected/model"}')
|
||||
const env: Record<string, string> = {
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v2',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'sparse-launch',
|
||||
...(selection === 'explicit' ? { XDG_CONFIG_HOME: configHome } : {})
|
||||
}
|
||||
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true)
|
||||
expect(
|
||||
readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'), 'utf8')
|
||||
).toBe('status entry')
|
||||
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe(
|
||||
'user entry'
|
||||
)
|
||||
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toContain(
|
||||
'selected/model'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it("uses the execution owner's resolved environment instead of reintroducing a deleted ambient XDG home", () => {
|
||||
const selected = join(root, 'resolved-config')
|
||||
mkdirSync(join(selected, 'opencode', 'plugins'), { recursive: true })
|
||||
writeFileSync(join(selected, 'opencode', 'plugins', 'user.js'), 'resolved entry')
|
||||
const env: Record<string, string> = {
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v2',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'resolved-launch'
|
||||
}
|
||||
expect(installOpenCodeStartupPromptForLaunch(env, false, { XDG_CONFIG_HOME: selected })).toBe(
|
||||
true
|
||||
)
|
||||
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe(
|
||||
'resolved entry'
|
||||
)
|
||||
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['inherited', 'explicit'] as const)(
|
||||
'preserves the %s OPENCODE_CONFIG_DIR ahead of the XDG default',
|
||||
(selection) => {
|
||||
const ambient = join(root, 'ambient-source')
|
||||
const selected = join(root, 'selected-source')
|
||||
for (const config of [ambient, selected]) {
|
||||
mkdirSync(join(config, 'plugins'), { recursive: true })
|
||||
writeFileSync(join(config, 'plugins', 'user.js'), config)
|
||||
}
|
||||
vi.stubEnv('OPENCODE_CONFIG_DIR', ambient)
|
||||
const env: Record<string, string> = {
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v2',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'custom-config-launch',
|
||||
...(selection === 'explicit' ? { OPENCODE_CONFIG_DIR: selected } : {})
|
||||
}
|
||||
expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true)
|
||||
expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe(
|
||||
selection === 'explicit' ? selected : ambient
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a missing user config untouched and installs the launch into an owned overlay', () => {
|
||||
const source = join(root, 'missing-user-config')
|
||||
const env: Record<string, string> = {
|
||||
ORCA_OPENCODE_PLUGIN_API: 'v2',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch',
|
||||
OPENCODE_CONFIG_DIR: source,
|
||||
OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}'
|
||||
}
|
||||
installOpenCodeStartupPromptForLaunch(env)
|
||||
expect(existsSync(source)).toBe(false)
|
||||
expect(env.OPENCODE_CONFIG_DIR).toContain(
|
||||
join(root, 'profile', 'opencode-startup-prompt-overlays')
|
||||
)
|
||||
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR)
|
||||
expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}')
|
||||
expect(
|
||||
existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'))
|
||||
).toBe(true)
|
||||
expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
it('installs only a TUI entry without installing status hooks or a v1/server entry', () => {
|
||||
const service = createOpenCodeStartupPromptInstaller(() => 'prompt source')
|
||||
expect(service.buildPtyEnv('pane')).toEqual({})
|
||||
const plugins = join(root, 'config', 'opencode', 'plugins')
|
||||
expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe(
|
||||
'prompt source'
|
||||
)
|
||||
expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false)
|
||||
expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false)
|
||||
expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false)
|
||||
})
|
||||
it('preserves user config and plugins across source-scoped overlay refreshes', () => {
|
||||
const config = join(root, 'custom')
|
||||
mkdirSync(join(config, 'plugins'), { recursive: true })
|
||||
writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}')
|
||||
writeFileSync(join(config, 'plugins', 'user.js'), 'user source')
|
||||
mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'))
|
||||
writeFileSync(
|
||||
join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'),
|
||||
'user collision'
|
||||
)
|
||||
let source = 'first prompt source'
|
||||
const service = createOpenCodeStartupPromptInstaller(() => source)
|
||||
const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR
|
||||
expect(first).toBeDefined()
|
||||
source = 'next prompt source'
|
||||
expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first)
|
||||
if (!first) {
|
||||
throw new Error('Missing overlay')
|
||||
}
|
||||
expect(
|
||||
readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')
|
||||
).toBe(source)
|
||||
expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model')
|
||||
expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source')
|
||||
expect(
|
||||
readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')
|
||||
).toBe('user collision')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { OpenCodeHookService, openCodeHookService, openCode2HookService } from './hook-service'
|
||||
import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install'
|
||||
import { join } from 'node:path'
|
||||
import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory'
|
||||
import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin'
|
||||
import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source'
|
||||
import { resolveOpenCodeSourceConfigDir } from '../ipc/pty/host-env/pi-agent'
|
||||
import {
|
||||
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_BODY_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV
|
||||
} from '../../shared/opencode-startup-prompt'
|
||||
|
||||
export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService {
|
||||
return new OpenCodeHookService({
|
||||
pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`,
|
||||
legacyHooksDir: 'opencode-startup-prompt-hooks',
|
||||
overlayDir: 'opencode-startup-prompt-overlays',
|
||||
pluginSource: source,
|
||||
tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY
|
||||
})
|
||||
}
|
||||
|
||||
const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource)
|
||||
|
||||
export function installOpenCodeStartupPromptForLaunch(
|
||||
env: Record<string, string>,
|
||||
restoreAfterShellStartup = true,
|
||||
sourceEnvironment: NodeJS.ProcessEnv = { ...process.env, ...env }
|
||||
): boolean {
|
||||
const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
|
||||
if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') {
|
||||
return false
|
||||
}
|
||||
const source =
|
||||
resolveOpenCodeSourceConfigDir(env, sourceEnvironment) ||
|
||||
resolveOpenCodeConfigDirectory(sourceEnvironment)
|
||||
const statusOwner =
|
||||
env.ORCA_OPENCODE_AGENT === 'opencode2' ? openCode2HookService : openCodeHookService
|
||||
const existing =
|
||||
env.OPENCODE_CONFIG_DIR && env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR
|
||||
? installer.installIntoSourceOverlay(env.OPENCODE_CONFIG_DIR, source, statusOwner)
|
||||
: 'unmatched'
|
||||
const overlay =
|
||||
existing === 'installed'
|
||||
? env.OPENCODE_CONFIG_DIR
|
||||
: existing === 'failed'
|
||||
? undefined
|
||||
: installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR
|
||||
if (
|
||||
!overlay ||
|
||||
!isOverlayOpenCodePluginCurrent(
|
||||
join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'),
|
||||
getOpenCodeStartupPromptSource()
|
||||
)
|
||||
) {
|
||||
for (const key of [
|
||||
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_BODY_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV
|
||||
]) {
|
||||
delete env[key]
|
||||
}
|
||||
return false
|
||||
}
|
||||
env.OPENCODE_CONFIG_DIR = overlay
|
||||
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = source
|
||||
if (restoreAfterShellStartup || existing === 'installed') {
|
||||
env.ORCA_OPENCODE_CONFIG_DIR = overlay
|
||||
} else {
|
||||
delete env.ORCA_OPENCODE_CONFIG_DIR
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function ensureOpenCodeStartupPromptForLaunch(env: Record<string, string>): void {
|
||||
if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) {
|
||||
throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts'
|
||||
import {
|
||||
reserveOpenCodeStartupPrompt,
|
||||
commitPtyWithOpenCodePromptIntent
|
||||
} from './opencode-startup-prompt-owner'
|
||||
|
||||
const control = vi.hoisted(() => ({
|
||||
claim: (_body: unknown): boolean | 'pending' => false,
|
||||
clear: () => {}
|
||||
}))
|
||||
const ownership = vi.hoisted(() => ({
|
||||
ptyOwnership: new Map<string, null>(),
|
||||
ptyIncarnationById: new Map<string, string>()
|
||||
}))
|
||||
vi.mock('../agent-hooks/server', () => ({
|
||||
agentHookServer: {
|
||||
setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => {
|
||||
control.claim = claim
|
||||
control.clear = clear
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../ipc/pty/provider/ownership-state', () => ownership)
|
||||
beforeEach(() => {
|
||||
control.clear()
|
||||
ownership.ptyOwnership.clear()
|
||||
ownership.ptyIncarnationById.clear()
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const facts = new TerminalRunFactsRegister()
|
||||
const result = { id: 'owned', incarnationId: 'incarnation' }
|
||||
let identityReady = false
|
||||
let release = () => {}
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const runtime = {
|
||||
terminalRunFacts: facts,
|
||||
readOpenCodeStartupPromptOwner: () =>
|
||||
identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const),
|
||||
isPtyStopRequested: () => false,
|
||||
subscribeToPtyExit: (_ptyId: string, _listener: () => void) => () => {}
|
||||
}
|
||||
ownership.ptyOwnership.set(result.id, null)
|
||||
ownership.ptyIncarnationById.set(result.id, result.incarnationId)
|
||||
const context = {
|
||||
env: {
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce',
|
||||
ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch'
|
||||
},
|
||||
deps: { runtime },
|
||||
result,
|
||||
provider: { hasPty: () => true },
|
||||
args: {}
|
||||
}
|
||||
reserveOpenCodeStartupPrompt('nonce', 'digest')
|
||||
const body = { nonce: 'nonce', digest: 'digest' }
|
||||
return {
|
||||
facts,
|
||||
result,
|
||||
context,
|
||||
waiting,
|
||||
release,
|
||||
body,
|
||||
admit: () => {
|
||||
identityReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('native prompt admission after spawn commit', () => {
|
||||
it('waits through delayed persistence and later runtime identity admission', async () => {
|
||||
const f = fixture()
|
||||
const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => {
|
||||
await f.waiting
|
||||
f.facts.recordSpawnCommit(f.result)
|
||||
return f.result
|
||||
})
|
||||
expect(control.claim(f.body)).toBe('pending')
|
||||
f.release()
|
||||
await committed
|
||||
expect(control.claim(f.body)).toBe('pending')
|
||||
f.admit()
|
||||
expect(control.claim(f.body)).toBe(true)
|
||||
expect(control.claim(f.body)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps pre-commit driving input sticky even if the draft is erased', async () => {
|
||||
const f = fixture()
|
||||
const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => {
|
||||
await f.waiting
|
||||
f.facts.recordSpawnCommit(f.result)
|
||||
return f.result
|
||||
})
|
||||
f.facts.recordInput(f.result.id, 'driving', 'x')
|
||||
f.facts.recordInput(f.result.id, 'driving', '\u007f')
|
||||
expect(control.claim(f.body)).toBe('pending')
|
||||
f.release()
|
||||
await committed
|
||||
f.admit()
|
||||
expect(control.claim(f.body)).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a failed commit without allowing later admission', async () => {
|
||||
const f = fixture()
|
||||
await expect(
|
||||
commitPtyWithOpenCodePromptIntent(f.context, async () => {
|
||||
throw new Error('persistence rejected')
|
||||
})
|
||||
).rejects.toThrow('persistence rejected')
|
||||
f.admit()
|
||||
expect(control.claim(f.body)).toBe(false)
|
||||
})
|
||||
it.each(['incarnation', 'provider', 'stop', 'exit', 'clear'])(
|
||||
'invalidates granted replay after %s',
|
||||
async (reason) => {
|
||||
const f = fixture()
|
||||
let exited = () => {}
|
||||
const unsubscribe = vi.fn()
|
||||
vi.spyOn(f.context.deps.runtime, 'subscribeToPtyExit').mockImplementation(
|
||||
(_id, listener: () => void) => {
|
||||
exited = listener
|
||||
return unsubscribe
|
||||
}
|
||||
)
|
||||
await commitPtyWithOpenCodePromptIntent(f.context, async () => {
|
||||
f.facts.recordSpawnCommit(f.result)
|
||||
return f.result
|
||||
})
|
||||
f.admit()
|
||||
const body = { ...f.body, requestId: 'stable-operation' }
|
||||
expect(control.claim(body)).toBe(true)
|
||||
expect(control.claim(body)).toBe(true)
|
||||
if (reason === 'incarnation') {
|
||||
ownership.ptyIncarnationById.set(f.result.id, 'replacement')
|
||||
}
|
||||
if (reason === 'provider') {
|
||||
vi.spyOn(f.context.provider, 'hasPty').mockReturnValue(false)
|
||||
}
|
||||
if (reason === 'stop') {
|
||||
vi.spyOn(f.context.deps.runtime, 'isPtyStopRequested').mockReturnValue(true)
|
||||
}
|
||||
if (reason === 'exit') {
|
||||
exited()
|
||||
}
|
||||
if (reason === 'clear') {
|
||||
control.clear()
|
||||
}
|
||||
expect(control.claim(body)).toBe(false)
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
f.facts.recordSpawnCommit(f.result)
|
||||
expect(control.claim(body)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('retains sticky input cancellation after a lost grant response', async () => {
|
||||
const f = fixture()
|
||||
await commitPtyWithOpenCodePromptIntent(f.context, async () => {
|
||||
f.facts.recordSpawnCommit(f.result)
|
||||
return f.result
|
||||
})
|
||||
f.admit()
|
||||
const body = { ...f.body, requestId: 'stable-operation' }
|
||||
expect(control.claim(body)).toBe(true)
|
||||
f.facts.recordInput(f.result.id, 'driving', 'x')
|
||||
f.facts.recordInput(f.result.id, 'driving', '\u007f')
|
||||
expect(control.claim(body)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { agentHookServer } from '../agent-hooks/server'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { IPtyProvider, PtySpawnResult } from '../providers/types'
|
||||
import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state'
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import {
|
||||
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV
|
||||
} from '../../shared/opencode-startup-prompt'
|
||||
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
|
||||
|
||||
type OpenCodePromptRuntime = Pick<
|
||||
OrcaRuntimeService,
|
||||
| 'terminalRunFacts'
|
||||
| 'readOpenCodeStartupPromptOwner'
|
||||
| 'isPtyStopRequested'
|
||||
| 'subscribeToPtyExit'
|
||||
>
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
|
||||
export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean {
|
||||
agentHookServer.setStartupPromptClaimListener(
|
||||
(body) => claims.claim(body),
|
||||
() => claims.clear()
|
||||
)
|
||||
return claims.register(nonce, digest, () => 'pending')
|
||||
}
|
||||
|
||||
export async function commitPtyWithOpenCodePromptIntent<Result extends PtySpawnResult>(
|
||||
context: {
|
||||
env?: Record<string, string>
|
||||
spawnEnv?: Record<string, string>
|
||||
deps: { runtime?: OpenCodePromptRuntime }
|
||||
result: PtySpawnResult
|
||||
provider: Pick<IPtyProvider, 'hasPty'>
|
||||
args: { connectionId?: string | null }
|
||||
},
|
||||
commit: () => Promise<Result>
|
||||
): Promise<Result> {
|
||||
const options = {
|
||||
env: context.spawnEnv ?? context.env,
|
||||
runtime: context.deps.runtime,
|
||||
result: context.result,
|
||||
provider: context.provider,
|
||||
connectionId: context.args.connectionId
|
||||
}
|
||||
const facts = options.runtime?.terminalRunFacts
|
||||
facts?.reserveSpawnCommit(options.result)
|
||||
try {
|
||||
const result = await commit()
|
||||
bindOpenCodeStartupPromptOwner({ ...options, result })
|
||||
return result
|
||||
} catch (error) {
|
||||
const nonce = options.env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
|
||||
if (nonce) {
|
||||
claims.cancel(nonce)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
facts?.discardSpawnCommit(options.result)
|
||||
}
|
||||
}
|
||||
|
||||
export function bindOpenCodeStartupPromptOwner(options: {
|
||||
env: Record<string, string> | undefined
|
||||
result: PtySpawnResult
|
||||
runtime: OpenCodePromptRuntime | undefined
|
||||
provider: Pick<IPtyProvider, 'hasPty'>
|
||||
connectionId?: string | null
|
||||
}): void {
|
||||
const { env, result, runtime, provider } = options
|
||||
const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV]
|
||||
const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]
|
||||
const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN
|
||||
const incarnation = result.incarnationId
|
||||
if (
|
||||
options.connectionId ||
|
||||
!runtime ||
|
||||
result.isReattach ||
|
||||
result.agentSessionEnsure?.disposition === 'adopted' ||
|
||||
!isPtyIncarnationId(incarnation) ||
|
||||
!nonce ||
|
||||
!digest ||
|
||||
!launchToken
|
||||
) {
|
||||
if (nonce) {
|
||||
claims.cancel(nonce)
|
||||
}
|
||||
return
|
||||
}
|
||||
let unsubscribe = () => {}
|
||||
if (
|
||||
claims.admit(
|
||||
nonce,
|
||||
() => {
|
||||
if (
|
||||
ptyOwnership.get(result.id) !== null ||
|
||||
ptyIncarnationById.get(result.id) !== incarnation ||
|
||||
provider.hasPty?.(result.id) !== true ||
|
||||
runtime.isPtyStopRequested(result.id)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken)
|
||||
},
|
||||
() => unsubscribe()
|
||||
)
|
||||
) {
|
||||
unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
|
||||
import {
|
||||
createTranscriptPane,
|
||||
TRANSCRIPT_PANE_PTY_ID
|
||||
} from '../runtime/agent-transcript-pane-test-harness'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp') }
|
||||
}))
|
||||
|
||||
async function fixture(launchAgent?: 'opencode' | 'opencode2' | 'zcode') {
|
||||
const { runtime } = await createTranscriptPane({
|
||||
paneTitle: 'Terminal',
|
||||
foregroundProcess: null,
|
||||
data: '',
|
||||
...(launchAgent ? { launchAgent } : {})
|
||||
})
|
||||
runtime.terminalRunFacts.recordSpawnCommit({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' })
|
||||
const read = (
|
||||
ptyId = TRANSCRIPT_PANE_PTY_ID,
|
||||
incarnation = 'inc-1',
|
||||
token = 'transcript-launch'
|
||||
) => runtime.readOpenCodeStartupPromptOwner(ptyId, incarnation, token)
|
||||
return { runtime, read }
|
||||
}
|
||||
|
||||
describe('runtime-owned startup prompt identity', () => {
|
||||
it('keeps launch admission pending before runtime identity is assigned', async () => {
|
||||
const f = await fixture()
|
||||
expect(f.read()).toBe('pending')
|
||||
expect(f.read('missing')).toBeNull()
|
||||
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['opencode', 'opencode2'] as const)(
|
||||
'reads only the admitted %s launch',
|
||||
async (agent) => {
|
||||
const f = await fixture(agent)
|
||||
expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: null })
|
||||
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-1', 'another-launch')).toBeNull()
|
||||
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull()
|
||||
f.runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', 'x', 123)
|
||||
expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: 123 })
|
||||
}
|
||||
)
|
||||
|
||||
it('denies another agent even with the exact incarnation and launch token', async () => {
|
||||
expect((await fixture('zcode')).read()).toBeNull()
|
||||
})
|
||||
it('refuses same-ID replay after the execution owner or launch token changes', async () => {
|
||||
const f = await fixture('opencode2')
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
claims.register('owned-operation', 'digest', () => f.read())
|
||||
const body = { nonce: 'owned-operation', digest: 'digest', requestId: 'stable-operation' }
|
||||
expect(claims.claim(body)).toBe(true)
|
||||
expect(claims.claim(body)).toBe(true)
|
||||
await f.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0, 'inc-1')
|
||||
f.runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, 'wt-1', null, {
|
||||
tabId: 'tab-1',
|
||||
leafId: '11111111-1111-4111-8111-111111111111',
|
||||
incarnationId: 'inc-2',
|
||||
agentLaunchAuthority: { launchToken: 'replacement-launch', launchAgent: 'opencode2' }
|
||||
})
|
||||
f.runtime.terminalRunFacts.recordSpawnCommit({
|
||||
id: TRANSCRIPT_PANE_PTY_ID,
|
||||
incarnationId: 'inc-2'
|
||||
})
|
||||
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'replacement-launch')).toEqual({
|
||||
freshSpawn: true,
|
||||
firstUserInputAt: null
|
||||
})
|
||||
expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'transcript-launch')).toBeNull()
|
||||
expect(claims.claim(body)).toBe(false)
|
||||
claims.clear()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,761 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer } from 'node:http'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_BODY_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV
|
||||
} from '../../shared/opencode-startup-prompt'
|
||||
import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source'
|
||||
import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims'
|
||||
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
|
||||
|
||||
type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise<void>> } }
|
||||
const prompt = 'exact startup brief\nwith unicode é'
|
||||
const digest = createHash('sha256').update(prompt).digest('hex')
|
||||
let dir: string
|
||||
let setup: PluginModule['default']['setup']
|
||||
let claim: ReturnType<typeof vi.fn>
|
||||
|
||||
class Editor extends EventEmitter {
|
||||
traits: { owner: string; role: string; capture: string[]; status?: string } = {
|
||||
owner: 'opencode',
|
||||
role: 'prompt',
|
||||
capture: ['tab']
|
||||
}
|
||||
plainText = ''
|
||||
focused = true
|
||||
insertText(text: string) {
|
||||
this.replace(this.plainText + text)
|
||||
}
|
||||
replace(text: string) {
|
||||
this.plainText = text
|
||||
this.emit('line-info-change')
|
||||
}
|
||||
}
|
||||
|
||||
type FixtureLocation = { directory: string; workspaceID?: string }
|
||||
|
||||
function fixture() {
|
||||
const editor = new Editor()
|
||||
const input = new EventEmitter()
|
||||
const memory = { settled: false, expiresAt: Date.now() + 20000 }
|
||||
const route = { type: 'home' }
|
||||
const agent = vi.fn((): unknown[] | undefined => [{}])
|
||||
const model = vi.fn((): unknown[] | undefined => [{}])
|
||||
const sync = vi.fn(async (_location: FixtureLocation) => {})
|
||||
const dispatch = vi.fn(() => editor.replace(''))
|
||||
const ctx = {
|
||||
app: { version: '2.0.16' },
|
||||
renderer: { keyInput: input, currentFocusedEditor: editor },
|
||||
storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] },
|
||||
keymap: { dispatch },
|
||||
ui: { router: { current: () => route } },
|
||||
location: { directory: '/private' },
|
||||
data: { location: { sync, agent: { list: agent }, model: { list: model } } }
|
||||
}
|
||||
return { ctx, editor, input, memory, route, agent, model, sync, dispatch }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-'))
|
||||
const path = join(dir, 'prompt.mjs')
|
||||
writeFileSync(path, getOpenCodeStartupPromptSource())
|
||||
const module: PluginModule = await import(pathToFileURL(path).href)
|
||||
setup = module.default.setup
|
||||
vi.useFakeTimers()
|
||||
vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest)
|
||||
vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt)
|
||||
vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce')
|
||||
const endpoint = join(dir, 'endpoint.cmd')
|
||||
writeFileSync(
|
||||
endpoint,
|
||||
'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n'
|
||||
)
|
||||
vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint)
|
||||
claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) }))
|
||||
vi.stubGlobal('fetch', claim)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('installed-version native prompt intent plugin', () => {
|
||||
it('waits for the current home location after the startup directory changes', async () => {
|
||||
const f = fixture()
|
||||
let location: FixtureLocation = { directory: '/private/home/private-folder' }
|
||||
Object.defineProperty(f.ctx, 'location', { get: () => location })
|
||||
let releaseStartup = () => {}
|
||||
let releaseHome = () => {}
|
||||
let selectedModel = 'opencode/mimo-v2.6-flash-free'
|
||||
const selections: string[] = []
|
||||
f.sync.mockImplementation(
|
||||
(target) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (target.directory.endsWith('/private-folder')) {
|
||||
releaseStartup = resolve
|
||||
} else {
|
||||
releaseHome = () => {
|
||||
selectedModel = 'private-proof/model-a'
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
f.dispatch.mockImplementation(() => {
|
||||
selections.push(selectedModel)
|
||||
f.editor.replace('')
|
||||
})
|
||||
const dispose = await setup(f.ctx)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
location = { directory: '/private/home' }
|
||||
releaseStartup()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(selections).toEqual([])
|
||||
expect(f.sync).toHaveBeenCalledTimes(2)
|
||||
expect(f.sync).toHaveBeenLastCalledWith(location)
|
||||
releaseHome()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(selections).toEqual(['private-proof/model-a'])
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for a concrete location before starting authoritative hydration', async () => {
|
||||
const f = fixture()
|
||||
let location: FixtureLocation | undefined
|
||||
Object.defineProperty(f.ctx, 'location', { get: () => location })
|
||||
const dispose = await setup(f.ctx)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(f.sync).not.toHaveBeenCalled()
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
location = { directory: '/private/home' }
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(f.sync).toHaveBeenCalledExactlyOnceWith(location)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps readiness scoped to the workspace as well as the directory', async () => {
|
||||
const f = fixture()
|
||||
let location: FixtureLocation = { directory: '/private', workspaceID: 'first' }
|
||||
Object.defineProperty(f.ctx, 'location', { get: () => location })
|
||||
let release = () => {}
|
||||
f.sync.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const dispose = await setup(f.ctx)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
location = { directory: '/private', workspaceID: 'second' }
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(f.sync).toHaveBeenCalledTimes(2)
|
||||
expect(f.sync).toHaveBeenLastCalledWith(location)
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a granted claim after the composer location changes', async () => {
|
||||
const f = fixture()
|
||||
let location: FixtureLocation = { directory: '/private' }
|
||||
Object.defineProperty(f.ctx, 'location', { get: () => location })
|
||||
let grant = () => {}
|
||||
claim.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) })
|
||||
})
|
||||
)
|
||||
const insert = vi.spyOn(f.editor, 'insertText')
|
||||
const dispose = await setup(f.ctx)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
|
||||
location = { directory: '/private/other' }
|
||||
grant()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.memory.settled).toBe(true)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for authoritative location config before claiming or selecting a model', async () => {
|
||||
const f = fixture()
|
||||
let configuredModel = 'unavailable-fallback'
|
||||
let release = () => {}
|
||||
f.sync.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
configuredModel = 'configured-model'
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
)
|
||||
const selections: string[] = []
|
||||
f.dispatch.mockImplementation(() => {
|
||||
selections.push(configuredModel)
|
||||
f.editor.replace('')
|
||||
})
|
||||
const insert = vi.spyOn(f.editor, 'insertText')
|
||||
const dispose = await setup(f.ctx)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(selections).toEqual([])
|
||||
expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location)
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit'))
|
||||
expect(selections).toEqual(['configured-model'])
|
||||
expect(insert).toHaveBeenCalledExactlyOnceWith(prompt)
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
expect(f.sync).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['keypress', 'paste', 'edit', 'route', 'expiry', 'dispose', 'editor', 'focus'])(
|
||||
'cancels delayed location hydration on %s before any claim',
|
||||
async (reason) => {
|
||||
const f = fixture()
|
||||
let release = () => {}
|
||||
f.sync.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const dispose = await setup(f.ctx)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
if (reason === 'keypress' || reason === 'paste') {
|
||||
f.input.emit(reason)
|
||||
}
|
||||
if (reason === 'edit') {
|
||||
f.editor.replace('typed')
|
||||
f.editor.replace('')
|
||||
}
|
||||
if (reason === 'route') {
|
||||
f.route.type = 'session'
|
||||
}
|
||||
if (reason === 'expiry') {
|
||||
f.memory.expiresAt = Date.now()
|
||||
}
|
||||
if (reason === 'dispose') {
|
||||
await dispose()
|
||||
}
|
||||
if (reason === 'editor') {
|
||||
f.ctx.renderer.currentFocusedEditor = new Editor()
|
||||
}
|
||||
if (reason === 'focus') {
|
||||
f.editor.focused = false
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.editor.plainText).toBe('')
|
||||
if (reason !== 'focus') {
|
||||
expect(f.memory.settled).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
expect(f.input.listenerCount('keypress')).toBe(0)
|
||||
expect(f.editor.listenerCount('line-info-change')).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('fails closed when authoritative location sync rejects', async () => {
|
||||
const f = fixture()
|
||||
f.sync.mockRejectedValue(new Error('location unavailable'))
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.memory.settled).toBe(true)
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.input.listenerCount('keypress')).toBe(0)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('fails closed when authoritative location sync is missing', async () => {
|
||||
const f = fixture()
|
||||
const { sync: _sync, ...location } = f.ctx.data.location
|
||||
const dispose = await setup({ ...f.ctx, data: { location } })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.input.listenerCount('keypress')).toBe(0)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => {
|
||||
const cancelled = vi.fn()
|
||||
const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled)
|
||||
if (reason === 'json-rejected') {
|
||||
vi.spyOn(response, 'json').mockRejectedValue(new Error('decoder rejected'))
|
||||
}
|
||||
claim.mockResolvedValue(response)
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(cancelled).toHaveBeenCalled())
|
||||
expect(f.memory.settled).toBe(false)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
expect(f.memory.settled).toBe(true)
|
||||
})
|
||||
|
||||
it('consumes an allowed claim body before dispatching the prompt', async () => {
|
||||
const response = Response.json({ allowed: true })
|
||||
claim.mockResolvedValue(response)
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit'))
|
||||
expect(response.bodyUsed).toBe(true)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('consumes malformed JSON and retries without delivering until expiry', async () => {
|
||||
const response = new Response('{invalid', { status: 200 })
|
||||
claim.mockResolvedValue(response)
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => {
|
||||
expect(response.bodyUsed).toBe(true)
|
||||
expect(claim.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
expect(f.memory.settled).toBe(false)
|
||||
f.memory.expiresAt = Date.now()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(response.bodyUsed).toBe(true)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.memory.settled).toBe(true)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => {
|
||||
const f = fixture()
|
||||
if (kind === 'dialog') {
|
||||
f.editor.traits.role = 'dialog'
|
||||
}
|
||||
if (kind === 'shell') {
|
||||
f.editor.traits.status = 'SHELL'
|
||||
}
|
||||
if (kind === 'autocomplete') {
|
||||
f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab']
|
||||
}
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.editor.plainText).toBe('')
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
it('retries pending admission, then submits once', async () => {
|
||||
claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) })
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(claim).toHaveBeenCalledTimes(2)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('cancels pending admission on input without retrying a consumed denial', async () => {
|
||||
claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) })
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
|
||||
f.input.emit('keypress', { name: 'x' })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('preserves typing that predates plugin setup without requesting owner permission', async () => {
|
||||
const f = fixture()
|
||||
f.editor.replace('early typing')
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.editor.plainText).toBe('early typing')
|
||||
expect(claim).not.toHaveBeenCalled()
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
it('waits for catalogs and settles before a single dispatch', async () => {
|
||||
const f = fixture()
|
||||
f.model.mockReturnValue(undefined)
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
f.model.mockReturnValue([{}])
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit'))
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
expect(f.memory.settled).toBe(true)
|
||||
f.editor.replace(prompt)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).toHaveBeenCalledTimes(1)
|
||||
await dispose()
|
||||
const reloadDispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).toHaveBeenCalledTimes(1)
|
||||
await reloadDispose()
|
||||
})
|
||||
|
||||
it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => {
|
||||
const f = fixture()
|
||||
f.agent.mockReturnValue(undefined)
|
||||
const dispose = await setup(f.ctx)
|
||||
f.input.emit(event)
|
||||
f.agent.mockReturnValue([{}])
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.memory.settled).toBe(true)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('cancels a changed draft even when it is restored before the next tick', async () => {
|
||||
const f = fixture()
|
||||
f.model.mockReturnValue(undefined)
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
f.editor.replace('edited')
|
||||
f.editor.replace('')
|
||||
f.model.mockReturnValue([{}])
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('cancels pending intent on route changes, expiration and disposal', async () => {
|
||||
for (const reason of ['route', 'expiration', 'dispose']) {
|
||||
const f = fixture()
|
||||
f.model.mockReturnValue(undefined)
|
||||
const dispose = await setup(f.ctx)
|
||||
if (reason === 'route') {
|
||||
f.route.type = 'session'
|
||||
}
|
||||
if (reason === 'expiration') {
|
||||
f.memory.expiresAt = Date.now()
|
||||
}
|
||||
if (reason === 'dispose') {
|
||||
await dispose()
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
f.model.mockReturnValue([{}])
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.memory.settled).toBe(true)
|
||||
await dispose()
|
||||
expect(f.input.listenerCount('keypress')).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves unverified versions and mismatched drafts unsubmitted', async () => {
|
||||
const f = fixture()
|
||||
f.ctx.app.version = '2.0.17'
|
||||
await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
f.ctx.app.version = '2.0.16'
|
||||
f.editor.replace('another brief')
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it.each(['canceled', 'unavailable'])(
|
||||
'fails closed when the execution owner is %s',
|
||||
async (reason) => {
|
||||
claim.mockImplementation(async () => {
|
||||
if (reason === 'unavailable') {
|
||||
throw new Error('contact lost')
|
||||
}
|
||||
return { ok: true, json: async () => ({ allowed: false }) }
|
||||
})
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
if (reason === 'unavailable') {
|
||||
await vi.waitFor(() => expect(claim).toHaveBeenCalled())
|
||||
expect(f.memory.settled).toBe(false)
|
||||
f.memory.expiresAt = Date.now()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
}
|
||||
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
|
||||
await dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('rechecks physical cancellation after the owner response', async () => {
|
||||
const f = fixture()
|
||||
claim.mockImplementation(async () => {
|
||||
f.input.emit('keypress')
|
||||
return { ok: true, json: async () => ({ allowed: true }) }
|
||||
})
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
it('settles before insertion and never repeats dispatch when the draft is retained', async () => {
|
||||
const f = fixture()
|
||||
f.dispatch.mockImplementation(() => {})
|
||||
const insert = vi.spyOn(f.editor, 'insertText')
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
|
||||
expect(insert).toHaveBeenCalledExactlyOnceWith(prompt)
|
||||
expect(f.memory.settled).toBe(true)
|
||||
expect(f.editor.plainText).toBe(prompt)
|
||||
await dispose()
|
||||
const reloadDispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).toHaveBeenCalledTimes(1)
|
||||
await reloadDispose()
|
||||
})
|
||||
|
||||
it.each(['before-grant', 'after-grant', 'timeout-after-grant'])(
|
||||
'recovers actual HTTP response loss %s exactly once',
|
||||
async (phase) => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
claims.register('single-use-nonce', digest, () => ({
|
||||
freshSpawn: true,
|
||||
firstUserInputAt: null
|
||||
}))
|
||||
let requests = 0
|
||||
const bodies: unknown[] = []
|
||||
const grants: (boolean | 'pending')[] = []
|
||||
let heldResponse: ReturnType<typeof setTimeout> | undefined
|
||||
const server = createServer(async (request, response) => {
|
||||
let text = ''
|
||||
for await (const chunk of request) {
|
||||
text += chunk.toString()
|
||||
}
|
||||
const body: unknown = JSON.parse(text)
|
||||
bodies.push(body)
|
||||
requests++
|
||||
if (requests === 1 && phase === 'before-grant') {
|
||||
response.writeHead(503).end('temporarily unavailable')
|
||||
return
|
||||
}
|
||||
const allowed = claims.claim(body)
|
||||
grants.push(allowed)
|
||||
if (requests === 1 && phase === 'after-grant') {
|
||||
response.destroy()
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
if (requests === 1 && phase === 'timeout-after-grant') {
|
||||
response.write('{"allowed":')
|
||||
heldResponse = setTimeout(() => response.end('true}'), 1300)
|
||||
return
|
||||
}
|
||||
response.end(JSON.stringify({ allowed: allowed === true, pending: allowed === 'pending' }))
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('missing loopback address')
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'endpoint.cmd'),
|
||||
`set ORCA_AGENT_HOOK_PORT=${address.port}\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n`
|
||||
)
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
try {
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(
|
||||
f.dispatch,
|
||||
JSON.stringify({ phase, requests, bodies, grants })
|
||||
).toHaveBeenCalledTimes(1),
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 300))
|
||||
expect(requests).toBe(2)
|
||||
expect(grants).toEqual(phase === 'before-grant' ? [true] : [true, true])
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
expect(bodies[0]).toHaveProperty('requestId', expect.any(String))
|
||||
expect(f.memory.settled).toBe(true)
|
||||
expect(f.editor.plainText).toBe('')
|
||||
} finally {
|
||||
await dispose()
|
||||
claims.clear()
|
||||
clearTimeout(heldResponse)
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([408, 429, 503])(
|
||||
'retries transient HTTP %s with one stable operation ID',
|
||||
async (status) => {
|
||||
claim.mockResolvedValueOnce({ ok: false, status })
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1))
|
||||
const firstBody = JSON.parse(claim.mock.calls[0][1].body)
|
||||
expect(firstBody.requestId).toMatch(/^[a-f0-9-]{36}$/)
|
||||
expect(JSON.parse(claim.mock.calls[1][1].body)).toEqual(firstBody)
|
||||
expect(claim).toHaveBeenCalledTimes(2)
|
||||
await dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([401, 403, 404])('settles HTTP %s denial without retrying', async (status) => {
|
||||
claim.mockResolvedValue({ ok: false, status })
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
|
||||
expect(claim).toHaveBeenCalledTimes(1)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it.each(['input', 'route', 'dispose', 'expiry', 'editor'])(
|
||||
'rejects late grants after %s changes',
|
||||
async (reason) => {
|
||||
const f = fixture()
|
||||
let release = (_response: unknown) => {}
|
||||
claim.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
|
||||
if (reason === 'input') {
|
||||
f.input.emit('paste')
|
||||
}
|
||||
if (reason === 'route') {
|
||||
f.route.type = 'session'
|
||||
}
|
||||
if (reason === 'dispose') {
|
||||
await dispose()
|
||||
}
|
||||
if (reason === 'expiry') {
|
||||
f.memory.expiresAt = Date.now()
|
||||
}
|
||||
if (reason === 'editor') {
|
||||
f.ctx.renderer.currentFocusedEditor = new Editor()
|
||||
}
|
||||
release({ ok: true, json: async () => ({ allowed: true }) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.editor.plainText).toBe('')
|
||||
await dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['input', 'route', 'dispose'])(
|
||||
'ends delivery when %s changes during insertion',
|
||||
async (reason) => {
|
||||
const f = fixture()
|
||||
let dispose = async () => {}
|
||||
const insert = f.editor.insertText.bind(f.editor)
|
||||
vi.spyOn(f.editor, 'insertText').mockImplementation((text) => {
|
||||
expect(f.memory.settled).toBe(true)
|
||||
insert(text)
|
||||
if (reason === 'input') {
|
||||
f.input.emit('keypress')
|
||||
}
|
||||
if (reason === 'route') {
|
||||
f.route.type = 'session'
|
||||
}
|
||||
if (reason === 'dispose') {
|
||||
void dispose()
|
||||
}
|
||||
})
|
||||
dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1))
|
||||
expect(f.dispatch).not.toHaveBeenCalled()
|
||||
expect(f.memory.settled).toBe(true)
|
||||
await dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['allow', 'lost-response'])(
|
||||
'degrades safely against a legacy host with %s',
|
||||
async (reason) => {
|
||||
const claims = new OpenCodeStartupPromptClaims()
|
||||
claims.register('single-use-nonce', digest, () => ({
|
||||
freshSpawn: true,
|
||||
firstUserInputAt: null
|
||||
}))
|
||||
let lost = false
|
||||
claim.mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse(init.body)
|
||||
const allowed = claims.claim({ nonce: body.nonce, digest: body.digest })
|
||||
if (reason === 'lost-response' && !lost) {
|
||||
lost = true
|
||||
throw new Error('legacy grant response lost')
|
||||
}
|
||||
return { ok: true, json: async () => ({ allowed }) }
|
||||
})
|
||||
const f = fixture()
|
||||
const dispose = await setup(f.ctx)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.waitFor(() => expect(f.memory.settled).toBe(true))
|
||||
expect(f.dispatch).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 0)
|
||||
expect(claim).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 2)
|
||||
expect(f.editor.plainText).toBe('')
|
||||
claims.clear()
|
||||
await dispose()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
|
||||
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
|
||||
import {
|
||||
OPENCODE_STARTUP_PROMPT_SHA256_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_NONCE_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV,
|
||||
OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
|
||||
OPENCODE_STARTUP_PROMPT_BODY_ENV
|
||||
} from '../../shared/opencode-startup-prompt'
|
||||
|
||||
export function getOpenCodeStartupPromptSource(): string {
|
||||
return String.raw`
|
||||
const parseEndpoint = ${parseAgentHookEndpointFile.toString()};
|
||||
const cancelUnreadResponseBody = ${cancelUnreadResponseBody.toString()};
|
||||
async function claimStartupPrompt(nonce, digest, endpoint, requestId) {
|
||||
let response;
|
||||
try {
|
||||
const { readFile, stat } = await import("node:fs/promises");
|
||||
if ((await stat(endpoint)).size > 4096) return false;
|
||||
const coords = parseEndpoint(await readFile(endpoint, "utf8"));
|
||||
const port = Number(coords.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
|
||||
response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", {
|
||||
method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token },
|
||||
body: JSON.stringify({ nonce, digest, requestId }), signal: AbortSignal.timeout(1000)
|
||||
});
|
||||
if (!response.ok) return response.status === 408 || response.status === 429 || response.status >= 500 ? "pending" : false;
|
||||
const result = await response.json();
|
||||
return result.allowed === true ? true : result.pending === true ? "pending" : false;
|
||||
} catch {
|
||||
// A lost grant response can be replayed with the same operation ID until expiry.
|
||||
return "pending";
|
||||
} finally {
|
||||
if (response) await cancelUnreadResponseBody(response);
|
||||
}
|
||||
}
|
||||
async function submitStartupPrompt(ctx) {
|
||||
const noop = async () => {};
|
||||
const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV};
|
||||
const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV};
|
||||
const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV};
|
||||
const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV};
|
||||
if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop;
|
||||
const input = ctx.renderer?.keyInput;
|
||||
if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" ||
|
||||
typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" ||
|
||||
typeof ctx.ui?.router?.current !== "function" ||
|
||||
typeof ctx.data?.location?.sync !== "function" ||
|
||||
typeof ctx.data?.location?.agent?.list !== "function" ||
|
||||
typeof ctx.data?.location?.model?.list !== "function") return noop;
|
||||
const [memory, setMemory] = ctx.storage.memory("startup-prompt", {
|
||||
initial: { settled: false, expiresAt: Date.now() + 20000 }
|
||||
});
|
||||
if (memory.settled) return noop;
|
||||
let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation;
|
||||
// Home can change location after plugin setup.
|
||||
const locationKey = (location) => typeof location?.directory === "string" && location.directory ?
|
||||
JSON.stringify([location.directory, location.workspaceID]) : undefined;
|
||||
const isComposer = (candidate) => candidate?.traits?.owner === "opencode" &&
|
||||
candidate.traits.role === "prompt" && !candidate.traits.status &&
|
||||
candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab";
|
||||
const matches = (candidate) => typeof candidate?.plainText === "string" &&
|
||||
createHash("sha256").update(candidate.plainText).digest("hex") === digest;
|
||||
const cleanup = () => {
|
||||
clearInterval(timer);
|
||||
input.off("keypress", cancel);
|
||||
input.off("paste", cancel);
|
||||
editor?.off("line-info-change", changed);
|
||||
};
|
||||
const settle = () => {
|
||||
setMemory((draft) => { draft.settled = true; });
|
||||
cleanup();
|
||||
};
|
||||
const cancel = () => { canceled = true; settle(); };
|
||||
// Any edit before delivery ends this startup operation.
|
||||
const changed = () => { if (seen && editor.plainText !== "") settle(); };
|
||||
input.on("keypress", cancel);
|
||||
input.on("paste", cancel);
|
||||
const dispose = async () => { disposed = true; settle(); };
|
||||
try {
|
||||
const crypto = await import("node:crypto");
|
||||
createHash = crypto.createHash;
|
||||
setMemory((draft) => { draft.requestId ??= crypto.randomUUID(); });
|
||||
requestId = memory.requestId;
|
||||
if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; }
|
||||
if (memory.settled) return dispose;
|
||||
timer = setInterval(async () => {
|
||||
if (disposed || memory.settled) return;
|
||||
try {
|
||||
if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle();
|
||||
const current = ctx.renderer.currentFocusedEditor;
|
||||
if (!isComposer(current)) { if (seen) settle(); return; }
|
||||
if (editor !== current) {
|
||||
if (seen) return settle();
|
||||
editor?.off("line-info-change", changed);
|
||||
editor = current;
|
||||
editor?.on("line-info-change", changed);
|
||||
}
|
||||
if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return;
|
||||
if (editor.plainText !== "") return settle();
|
||||
seen = true;
|
||||
const location = ctx.location;
|
||||
const key = locationKey(location);
|
||||
if (!key) return;
|
||||
if (readyLocation !== key) {
|
||||
readyLocation = undefined;
|
||||
if (!hydrating) {
|
||||
hydrating = true;
|
||||
const ref = { directory: location.directory, workspaceID: location.workspaceID };
|
||||
void ctx.data.location.sync(ref).then(() => {
|
||||
hydrating = false;
|
||||
if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key;
|
||||
}, settle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const agents = ctx.data.location.agent.list(location);
|
||||
const models = ctx.data.location.model.list(location);
|
||||
if (!editor.focused || !agents?.length || !models?.length) return;
|
||||
if (claiming) return;
|
||||
claiming = true;
|
||||
const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId);
|
||||
claiming = false;
|
||||
if (allowed === "pending") return;
|
||||
if (!allowed) return settle();
|
||||
if (disposed || memory.settled || Date.now() >= memory.expiresAt ||
|
||||
locationKey(ctx.location) !== key ||
|
||||
ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor ||
|
||||
!editor.focused || !isComposer(editor) || editor.plainText !== "") return settle();
|
||||
setMemory((draft) => { draft.settled = true; });
|
||||
clearInterval(timer);
|
||||
editor.off("line-info-change", changed);
|
||||
try {
|
||||
editor.insertText(prompt);
|
||||
if (disposed || canceled || Date.now() >= memory.expiresAt ||
|
||||
locationKey(ctx.location) !== key ||
|
||||
ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor ||
|
||||
!editor.focused || !isComposer(editor) || !matches(editor)) return;
|
||||
ctx.keymap.dispatch("prompt.submit");
|
||||
} finally { cleanup(); }
|
||||
} catch { settle(); }
|
||||
}, 100);
|
||||
timer.unref?.();
|
||||
return dispose;
|
||||
} catch {
|
||||
settle();
|
||||
return noop;
|
||||
}
|
||||
}
|
||||
export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt };
|
||||
`.trimStart()
|
||||
}
|
||||
Reference in New Issue
Block a user