Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions

This commit is contained in:
Brennan Benson
2026-10-04 14:24:13 -07:00
708 changed files with 37385 additions and 4451 deletions
+16 -2
View File
@@ -63,7 +63,17 @@ async function runSearch(
): Promise<{ call: ReturnType<typeof vi.fn>; output: string }> {
const call = options.error
? vi.fn().mockRejectedValue(options.error)
: vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse))
: vi
.fn()
.mockImplementation((method: string) =>
Promise.resolve(
envelope(
method === 'aiVault.searchStatus'
? statusResponse
: (options.result ?? resultsResponse)
)
)
)
const lines: string[] = []
vi.spyOn(console, 'log').mockImplementation((value: unknown) => {
lines.push(String(value))
@@ -161,9 +171,13 @@ describe('orca search over the runtime RPC', () => {
]
]
it.each(flagCases)('sends %s', async (_name, flags, params) => {
it.each(flagCases)('sends %s', async (name, flags, params) => {
const { call } = await runSearch(flags)
expect(call).toHaveBeenCalledTimes(name === 'filters' ? 2 : 1)
if (name === 'filters') {
expect(call).toHaveBeenNthCalledWith(1, 'aiVault.searchStatus', {})
}
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', {
...params,
supportedAgents: [...AI_VAULT_AGENTS],
@@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() {
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
# which prints a warning above every command under warn_create_global.
builtin typeset -g __orca_in_command=1
}
__orca_deferred_line_init() {
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) || return 0
local __orca_direct_line_init=0
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
__orca_deferred_init
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
zle zle-line-init "$@"
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
local __orca_prev_line_init_fn=""
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
builtin unset __orca_deferred_sched_armed
builtin unfunction __orca_deferred_sched_init
return $__orca_prompt_status
}
__orca_arm_deferred_line_init() {
builtin emulate -L zsh
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
if (( ${+widgets[zle-line-init]} )); then
zle -A zle-line-init __orca_saved_line_init
fi
zle -N zle-line-init __orca_deferred_line_init
fi
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -54,12 +88,27 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
if (( ${+widgets[__orca_saved_line_init]} )); then
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -A __orca_saved_line_init zle-line-init
fi
zle -D __orca_saved_line_init
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -D zle-line-init
fi
if __orca_has_feature markers; then
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
else
precmd_functions=(${precmd_functions:#__orca_deferred_init})
fi
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if __orca_has_feature overlay; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
__orca_restore_agent_teams_path() {
@@ -202,13 +251,25 @@ __orca_deferred_init() {
# the permanent hook has not run yet and the first prompt would lose its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
builtin unfunction __orca_deferred_init __orca_has_feature
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
local __orca_widget __orca_line_init_bound=0
for __orca_widget in "${(v)widgets[@]}"; do
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
__orca_line_init_bound=1
break
fi
done
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
if (( ! $+_orca_deferred_init_done )); then
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
__orca_arm_deferred_line_init
fi
}
@@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() {
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then
if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then
export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}"
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
# which prints a warning above every command under warn_create_global.
builtin typeset -g __orca_in_command=1
}
__orca_deferred_line_init() {
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) || return 0
local __orca_direct_line_init=0
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
__orca_deferred_init
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
zle zle-line-init "$@"
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
local __orca_prev_line_init_fn=""
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
builtin unset __orca_deferred_sched_armed
builtin unfunction __orca_deferred_sched_init
return $__orca_prompt_status
}
__orca_arm_deferred_line_init() {
builtin emulate -L zsh
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
if (( ${+widgets[zle-line-init]} )); then
zle -A zle-line-init __orca_saved_line_init
fi
zle -N zle-line-init __orca_deferred_line_init
fi
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -54,8 +88,17 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
if (( ${+widgets[__orca_saved_line_init]} )); then
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -A __orca_saved_line_init zle-line-init
fi
zle -D __orca_saved_line_init
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -D zle-line-init
fi
if __orca_has_feature markers; then
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
else
precmd_functions=(${precmd_functions:#__orca_deferred_init})
@@ -67,6 +110,12 @@ __orca_deferred_init() {
printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2
fi
fi
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if __orca_has_feature overlay; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
__orca_restore_agent_teams_path() {
@@ -219,13 +268,25 @@ __orca_deferred_init() {
# the permanent hook has not run yet and the first prompt would lose its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
builtin unfunction __orca_deferred_init __orca_has_feature
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
local __orca_widget __orca_line_init_bound=0
for __orca_widget in "${(v)widgets[@]}"; do
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
__orca_line_init_bound=1
break
fi
done
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
if (( ! $+_orca_deferred_init_done )); then
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
__orca_arm_deferred_line_init
fi
}
@@ -26,6 +26,12 @@ fi
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as `omp config` keep
# their normal argv shape.
@@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
builtin unset ORCA_HISTFILE
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
__orca_deferred_line_init() {
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) || return 0
local __orca_direct_line_init=0
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
__orca_deferred_init
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
zle zle-line-init "$@"
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
local __orca_prev_line_init_fn=""
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
builtin unset __orca_deferred_sched_armed
builtin unfunction __orca_deferred_sched_init
return $__orca_prompt_status
}
__orca_arm_deferred_line_init() {
builtin emulate -L zsh
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
if (( ${+widgets[zle-line-init]} )); then
zle -A zle-line-init __orca_saved_line_init
fi
zle -N zle-line-init __orca_deferred_line_init
fi
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -40,7 +74,21 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
if (( ${+widgets[__orca_saved_line_init]} )); then
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -A __orca_saved_line_init zle-line-init
fi
zle -D __orca_saved_line_init
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -D zle-line-init
fi
precmd_functions=(${precmd_functions:#__orca_deferred_init})
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if __orca_has_feature overlay; then
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
@@ -170,13 +218,25 @@ __orca_deferred_init() {
zle -N zle-line-init __orca_prompt_mark
fi
builtin unset _orca_shell_features _orca_histfile
builtin unfunction __orca_deferred_init __orca_has_feature
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
local __orca_widget __orca_line_init_bound=0
for __orca_widget in "${(v)widgets[@]}"; do
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
__orca_line_init_bound=1
break
fi
done
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
if (( ! $+_orca_deferred_init_done )); then
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
__orca_arm_deferred_line_init
fi
}
@@ -147,8 +147,10 @@ describe('managed hook script refresh', () => {
homedirMock.mockReturnValue(home)
const previousGrokHome = process.env.GROK_HOME
const previousKimiHome = process.env.KIMI_CODE_HOME
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
delete process.env.GROK_HOME
delete process.env.KIMI_CODE_HOME
delete process.env.XDG_CONFIG_HOME
try {
await withPlatform('win32', () => {
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
@@ -187,6 +189,11 @@ describe('managed hook script refresh', () => {
} else {
process.env.KIMI_CODE_HOME = previousKimiHome
}
if (previousXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
}
rmSync(home, { recursive: true, force: true })
}
})
@@ -0,0 +1,146 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
import { PANE } from './server.test-fixtures'
describe('startup prompt control with status hooks disabled', () => {
it.each([false, true])(
'persists a pending terminal status at shutdown after starting with hooks %s',
async (statusHooksEnabled) => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-'))
const server = new AgentHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled })
server.setStatusHooksEnabled(false)
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'folder-1',
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
})
expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done')
const statusPath = server.lastStatusPath
if (!statusPath) {
throw new Error('missing status persistence path')
}
server.stop()
expect(existsSync(statusPath)).toBe(true)
expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({
paneKey: PANE,
worktreeId: 'folder-1',
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
})
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
}
)
it('enables and disables status without restarting the control listener', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-'))
class IsolatedHookServer extends AgentHookServer {
constructor() {
super()
this._setOpenCodeBinderDepsForTests({
dbPath: () => join(dir, 'no-user-db'),
listSessions: async () => [],
listPanes: () => [],
sweep: async () => []
})
}
}
const server = new IsolatedHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled: false })
const endpoint = server.endpointFilePath
if (!endpoint) {
throw new Error('missing control endpoint')
}
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
const post = (path: string) =>
fetch(`http://127.0.0.1:${coords.port}${path}`, {
method: 'POST',
headers: { 'x-orca-agent-hook-token': coords.token },
body: '{}'
})
expect((await post('/hook/opencode')).status).toBe(404)
await server.start({ statusHooksEnabled: true })
expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port)
expect((await post('/hook/opencode')).status).toBe(204)
server.setStatusHooksEnabled(false)
expect(server.buildPtyEnv()).toEqual({})
expect((await post('/hook/opencode')).status).toBe(404)
await server.start()
expect((await post('/hook/opencode')).status).toBe(404)
server.setStartupPromptClaimListener(
() => 'pending',
() => {}
)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({
allowed: false,
pending: true
})
server.setStatusHooksEnabled(true)
expect((await post('/hook/opencode')).status).toBe(204)
expect(server.endpointFilePath).toBe(endpoint)
expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords)
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-'))
const server = new AgentHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled: false })
expect(server.buildPtyEnv()).toEqual({})
const statusPath = server.lastStatusPath
if (!statusPath) {
throw new Error('missing status persistence path')
}
writeFileSync(statusPath, 'existing status must survive control-only shutdown')
const endpoint = server.endpointFilePath
if (!endpoint) {
throw new Error('missing control endpoint')
}
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
const post = (path: string, body: string, token = coords.token) =>
fetch(`http://127.0.0.1:${coords.port}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token },
body
})
expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
allowed: false
})
const clear = vi.fn()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, clear)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
allowed: true
})
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({
allowed: false
})
expect(claim).toHaveBeenCalledTimes(1)
expect((await post('/hook/opencode', '{}')).status).toBe(404)
expect((await post('/statusline/claude', '{}')).status).toBe(404)
server.stop()
expect(clear).toHaveBeenCalledTimes(1)
expect(readFileSync(statusPath, 'utf8')).toBe(
'existing status must survive control-only shutdown'
)
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
})
@@ -2,9 +2,11 @@
// short of its own Content-Length. The listener fails open on every request error, so the only
// way this stays diagnosable is if the truncation is classified before it is swallowed.
import { connect } from 'node:net'
import { ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
import { AgentHookServer } from './server'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
async function postTruncatedHook(
port: number,
@@ -40,11 +42,15 @@ async function postTruncatedHook(
}
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
async function postStalledHook(port: number, token: string): Promise<void> {
async function postStalledHook(
port: number,
token: string,
pathname = '/hook/claude'
): Promise<void> {
const socket = connect({ port, host: '127.0.0.1' })
await new Promise<void>((resolve) => socket.on('connect', () => resolve()))
socket.write(
`POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
`POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
)
await new Promise<void>((resolve) => {
socket.on('close', () => resolve())
@@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => {
expect(reports).toHaveLength(1)
}, 20_000)
it('classifies an interrupted startup claim as retryable without consuming it', async () => {
const { server, port, token, reports } = await startServer()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, () => {})
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
try {
await postTruncatedHook(port, token, {
pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
sentBytes: '{"nonce":',
announcedLength: 1000
})
// The reset peer cannot receive this response; observe the real handler's classification.
expect(writeHead.mock.calls).toEqual([[503]])
expect(claim).not.toHaveBeenCalled()
expect(reports).toEqual([])
} finally {
writeHead.mockRestore()
}
})
it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => {
const { server, port, token, reports } = await startServer()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, () => {})
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
try {
await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH)
expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]])
expect(claim).not.toHaveBeenCalled()
expect(reports).toEqual([])
} finally {
writeHead.mockRestore()
}
}, 30_000)
it('never reports for POSTs that deliver their whole body', async () => {
const { port, token, reports } = await startServer()
+46 -30
View File
@@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
import { trackEmptyPaneKeyHook } from './server-transport-rules'
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt'
export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle {
/** Start the loopback listener after hydration and spool replay have settled. */
async start(options?: {
env?: string
userDataPath?: string
endpointNamespace?: string
statusHooksEnabled?: boolean
}): Promise<void> {
if (this.server) {
if (options?.statusHooksEnabled !== undefined) {
this.setStatusHooksEnabled(options.statusHooksEnabled)
}
return
}
this.statusHooksEnabled = options?.statusHooksEnabled !== false
if (options?.env) {
this.env = options.env
@@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
if (!this.ownerStateInitialized) {
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
const replayedPaneKeys = new Set<string>()
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => {
this.ingestSpoolRecord(record)
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
}
})
// Why: the owner may have died while Orca was down; check each replayed pane once.
for (const paneKey of replayedPaneKeys) {
void this.checkAgentPresence(paneKey)
}
}
this.ownerStateInitialized = true
if (this.statusHooksEnabled) {
this.initializeStatusHookOwner()
}
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'POST') {
@@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
try {
const body = await readRequestBody(req)
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
res.writeHead(200, { 'content-type': 'application/json' })
const claim = this.onStartupPromptClaim?.(body)
res.end(
JSON.stringify({
allowed: claim === true,
...(claim === 'pending' ? { pending: true } : {})
})
)
return
}
if (!this.statusHooksEnabled) {
res.writeHead(404)
res.end()
return
}
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
const statusLineEvent = parseClaudeStatusLineBody(body)
if (statusLineEvent) {
@@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
res.writeHead(204)
res.end()
} catch (error) {
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
res.writeHead(503)
res.end()
return
}
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"allowed":false}')
return
}
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
// this is the one failure mode that silently stops status for every runtime at once.
@@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.rollbackTransportStart()
throw error
}
this.startOpenCodeBinderLoop()
if (this.statusHooksEnabled) {
this.startOpenCodeBinderLoop()
}
}
private rollbackTransportStart(): void {
@@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
}
stop(): void {
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
this.flushStatusPersistSync()
// Terminal status may still have a pending write while hook ingress is disabled.
if (this.statusHooksEnabled || this.statusPersistTimer) {
this.flushStatusPersistSync()
}
this.stopOpenCodeBinderLoop()
this.stopTmuxStatus()
this.rollbackTransportStart()
this.env = 'production'
this.onAgentStatus = null
this.onClaudeStatusLine = null
this.clearStartupPromptClaims?.()
this.clearStartupPromptClaims = null
this.onStartupPromptClaim = null
this.onPaneStatusCleared = null
this.onTransportInterference = null
this.transportInterference.reset()
@@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
export abstract class AgentHookServerListeners extends AgentHookServerState {
setStartupPromptClaimListener(
listener: (body: unknown) => boolean | 'pending',
clear: () => void
): void {
this.onStartupPromptClaim = listener
this.clearStartupPromptClaims = clear
}
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
this.onAgentStatus?.(enriched)
for (const listener of this.enrichedStatusListeners) {
@@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote'
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
if (!this.statusHooksEnabled || this.port <= 0 || !this.token) {
return {}
}
const env: Record<string, string> = {
@@ -89,6 +89,9 @@ export abstract class AgentHookServerState {
protected env = 'production'
protected onAgentStatus: ServerAgentStatusListener = null
protected onClaudeStatusLine: ServerStatusLineListener = null
protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null
protected clearStartupPromptClaims: (() => void) | null = null
protected statusHooksEnabled = true
protected onPaneStatusCleared: PaneStatusClearListener | null = null
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
protected statusDropListeners = new Set<StatusDropListener>()
@@ -0,0 +1,52 @@
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv {
setStatusHooksEnabled(enabled: boolean): void {
if (enabled === this.statusHooksEnabled) {
return
}
if (!enabled) {
this.flushStatusPersistSync()
this.stopOpenCodeBinderLoop()
for (const timer of this.assistantMessageRetryTimers.values()) {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
this.clearAllTranscriptPolls()
}
this.statusHooksEnabled = enabled
if (enabled && this.server) {
this.initializeStatusHookOwner()
this.startOpenCodeBinderLoop()
}
}
protected initializeStatusHookOwner(): void {
if (!this.ownerStateInitialized) {
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
const replayedPaneKeys = new Set<string>()
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => {
this.ingestSpoolRecord(record)
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
}
})
// Why: the owner may have died while Orca was down; check each replayed pane once.
for (const paneKey of replayedPaneKeys) {
void this.checkAgentPresence(paneKey)
}
}
this.ownerStateInitialized = true
}
}
}
+39
View File
@@ -6,6 +6,7 @@ import {
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
@@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => {
}
})
it('keeps a token-bearing config.json owner-only', () => {
if (process.platform === 'win32') {
return
}
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
// Why: a restrictive runner umask would mask a dropped mode.
const originalUmask = process.umask(0o022)
try {
mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), {
mode: 0o600
})
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
expect(statSync(configPath).mode & 0o777).toBe(0o600)
expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' })
} finally {
process.umask(originalUmask)
rmSync(workspace, { recursive: true, force: true })
}
})
it('creates a missing config.json owner-only', () => {
if (process.platform === 'win32') {
return
}
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const originalUmask = process.umask(0o022)
try {
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
expect(statSync(configPath).mode & 0o777).toBe(0o600)
} finally {
process.umask(originalUmask)
rmSync(workspace, { recursive: true, force: true })
}
})
it('preserves existing config keys and dedups already-trusted folders', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const realpath = realpathSync(workspace)
+2 -1
View File
@@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
// Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts).
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
}
/**
@@ -53,9 +53,12 @@ test.each(['runtime', 'relay'] as const)(
},
transport
)
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
expect(service.search).not.toHaveBeenCalled()
expect(service.reconcile).not.toHaveBeenCalled()
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
expect(service.search).toHaveBeenCalledExactlyOnceWith(
{ query: 'proof', limit: 20, freshness: 'wait-until-current' },
{ kind: 'resolved', paths: [''] }
)
expect(service.reconcile).toHaveBeenCalledTimes(1)
}
)
@@ -84,7 +87,7 @@ test.each(['qoder', 'jcode'] as const)(
)
test.each(['runtime', 'relay'] as const)(
'does not query or reconcile an unsupported-only filter on %s',
'keeps an unsupported-only filter narrow through normal service checks on %s',
async (transport) => {
const request = {
query: 'proof',
@@ -105,14 +108,25 @@ test.each(['runtime', 'relay'] as const)(
{ ...request, filters: { agents: [...request.filters.agents] } },
transport
)
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
expect(service.search).not.toHaveBeenCalled()
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
expect(service.search).toHaveBeenCalledExactlyOnceWith(
{
query: 'proof',
limit: 20,
filters: { agents: ['jcode'] },
freshness: 'wait-until-current'
},
{ kind: 'resolved', paths: [''] }
)
expect(service.status).not.toHaveBeenCalled()
expect(service.reconcile).not.toHaveBeenCalled()
expect(service.reconcile).toHaveBeenCalledTimes(1)
for (const reason of ['disabled', 'not-ready'] as const) {
service.search.mockResolvedValue({ kind: 'unavailable', reason })
expect(
await searchSessionService({ query: 'proof', filters: { agents: ['codex'] } }, transport)
await searchSessionService(
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
transport
)
).toEqual({ kind: 'unavailable', reason })
}
}
@@ -86,8 +86,8 @@ describe('session search service registry', () => {
{ query: 'proof', supportedAgents: ['claude'], filters: { agents: [agent] } },
transport
)
).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' })
expect(service.search).toHaveBeenCalledTimes(1)
).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } })
expect(service.search).toHaveBeenCalledTimes(2)
expect(service.reconcile).not.toHaveBeenCalled()
const client = createSessionSearchClient(
(method, request) =>
@@ -114,6 +114,24 @@ describe('session search service registry', () => {
)
expect((await sessionSearchServiceStatus({}, 'ipc')).supportedAgents).toEqual(AI_VAULT_AGENTS)
})
it.each(['disabled', 'not-ready'] as const)(
'preserves %s precedence for empty negotiated host intersections',
async (reason) => {
const service = fakeSearchService()
service.search.mockResolvedValue({ kind: 'unavailable', reason })
setSessionSearchService(service)
expect(
await searchSessionService(
{ query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } },
'runtime'
)
).toEqual({ kind: 'unavailable', reason })
expect(service.search).toHaveBeenCalledExactlyOnceWith(
{ query: 'proof', limit: 20, filters: { agents: ['jcode'] } },
{ kind: 'resolved', paths: [''] }
)
}
)
it('waits for reconcile before search, and clears its timeout', async () => {
vi.useFakeTimers()
const service = fakeSearchService()
@@ -52,11 +52,8 @@ export async function searchSessionService(
supportsJcodeHistory
}
)
if (compatibleAgents.length === 0) {
return { kind: 'unavailable', reason: 'unsupported-agent' }
}
const compatibleRequest =
compatibleAgents.length === agents.length
compatibleAgents.length === 0 || compatibleAgents.length === agents.length
? request
: {
...request,
@@ -68,12 +65,16 @@ export async function searchSessionService(
const hostScope = within
? resolveSessionSearchScope(within, sessionSearchScopeCatalog())
: undefined
const retrievalScope =
compatibleAgents.length === 0 && hostScope?.kind !== 'unknown'
? { kind: 'resolved' as const, paths: [''] }
: hostScope
const freshness =
request.freshness === 'wait-until-current'
? await reconcileWithin(current, freshnessTimeoutMs)
: false
const result = AiVaultSearchResponseSchema.parse(
await current.search(compatibleRequest, hostScope)
await current.search(compatibleRequest, retrievalScope)
)
if (result.kind !== 'results') {
return result
@@ -81,6 +82,7 @@ export async function searchSessionService(
const { debug, ...fields } = result
return {
...fields,
...(compatibleAgents.length === 0 ? { page: { cursor: null, hasMore: false } } : {}),
hits: result.hits
.filter((hit) => compatibleAgents.includes(hit.agent))
.map((hit) => redactForTransport(hit, transport)),
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import { readStreamedSessionDocument } from './session-document-stream'
async function* bytes(content: string, chunkSize = 7): AsyncGenerator<Buffer> {
const buffer = Buffer.from(content)
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
yield buffer.subarray(offset, offset + chunkSize)
}
}
function read(
content: string,
overrides: Partial<Parameters<typeof readStreamedSessionDocument<unknown[]>>[0]> = {}
) {
return readStreamedSessionDocument({
bytes: bytes(content),
arrayKey: 'messages',
fields: ['id'],
objectFields: { agent: ['model'] },
create: (): unknown[] => [],
consume: (state, value) => {
state.push(value)
},
...overrides
})
}
describe('streamed session document contracts', () => {
it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])(
'a later messages value %s clears an earlier fold',
async (last) => {
expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] })
}
)
it('replaces a failed earlier array and continues with its successor', async () => {
const consume = (state: unknown[], value: unknown): void => {
if (value === 'bad') {
throw new Error('discarded failure')
}
state.push(value)
}
expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({
record: {},
state: [2, 3]
})
expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({
record: {},
state: []
})
})
it('keeps the first consumer failure unless a later array replaces it', async () => {
const failure = new Error('consumer failed')
let calls = 0
await expect(
read('{"messages":[1,2]}', {
consume: () => {
calls++
throw failure
}
})
).rejects.toBe(failure)
expect(calls).toBe(1)
})
it('gives malformed trailing JSON precedence over a consumer failure', async () => {
await expect(
read('{"messages":[1]} trailing', {
consume: () => {
throw new Error('consumer')
}
})
).rejects.toBeInstanceOf(SyntaxError)
})
it('keeps projected object resets and full-field overlap precedence', async () => {
expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({
record: { agent: {} },
state: []
})
expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({
record: { agent: { model: 'm', extra: [1, 2] } },
state: []
})
expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({
record: { messages: {} },
state: [1, 2]
})
expect(
await read('{"messages":{"model":"m","ignored":1}}', {
objectFields: { messages: ['model'] }
})
).toEqual({ record: { messages: { model: 'm' } }, state: [] })
})
it('preserves selected prototype keys as own properties', async () => {
const content =
'{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}'
const parsed = await read(content)
expect(parsed?.record).toEqual(JSON.parse(content))
expect(Object.getPrototypeOf(parsed?.record)).toBeNull()
expect(Object.prototype).not.toHaveProperty('polluted')
})
it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])(
'rejects malformed input %j',
async (content) => {
await expect(read(content)).rejects.toBeInstanceOf(SyntaxError)
}
)
it.each(['null', '[]', '123', '"text"'])(
'does not publish a nonobject document %s',
async (content) => {
expect(await read(content)).toBeNull()
}
)
it('validates discarded subtrees and closes their source on malformed input', async () => {
let closed = false
async function* malformed() {
try {
yield Buffer.from('{"ignored":[{"deep":1},]}')
throw new Error('must not request another chunk')
} finally {
closed = true
}
}
await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError)
expect(closed).toBe(true)
})
it('preserves source failure and closes the source even after a consumer failure', async () => {
const failure = new Error('disk failure')
let closed = false
async function* failing() {
try {
yield Buffer.from('{"messages":[1]')
throw failure
} finally {
closed = true
}
}
await expect(
read('', {
bytes: failing(),
consume: () => {
throw new Error('consumer')
}
})
).rejects.toBe(failure)
expect(closed).toBe(true)
})
it('preserves escaped astral text across large and byte-sized chunks', async () => {
const value = `${'x'.repeat(65530)}日本語😀\\literal`
const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] })
for (const size of [1, 65536, content.length * 4]) {
expect(await read(content, { bytes: bytes(content, size) })).toEqual({
record: {},
state: [value, '\ud800', '\udc00']
})
}
})
})
+101 -104
View File
@@ -1,6 +1,7 @@
import { StringDecoder } from 'node:string_decoder'
import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json'
import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { createJsonTokenReader } from '../../shared/json-token-reader'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
/** Fold one root array while retaining only the root fields the agent parser uses. */
@@ -13,92 +14,108 @@ export async function readStreamedSessionDocument<T>(args: {
consume: (state: T, value: unknown) => void
signal?: AbortSignal
}): Promise<{ record: Record<string, unknown>; state: T } | null> {
const parser = new JSONParser({
paths: [
...args.fields.map((field) => `$.${field}`),
...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) =>
fields.map((field) => `$.${root}.${field}`)
),
...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : [])
],
keepStack: false,
stringBufferSize: 64 * 1024
})
const record: Record<string, unknown> = Object.create(null)
const fields = new Set(args.fields)
let depth = 0
let expectingRootKey = false
parser.onToken = ({ token, value }) => {
if (depth === 1 && expectingRootKey && token === TokenType.STRING) {
if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) {
record[value] = Object.create(null)
}
expectingRootKey = false
}
if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) {
if (depth === 0 && token === TokenType.LEFT_BRACE) {
expectingRootKey = true
}
depth++
} else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) {
depth--
} else if (token === TokenType.COMMA && depth === 1) {
expectingRootKey = true
}
}
const objectFields = new Map(
Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)])
)
const foldedArray = Symbol('folded session array')
let state = args.create()
let currentArray: unknown = null
let consumeFailure: { error: unknown } | undefined
let inFoldedArray = false
const reset = (): void => {
state = args.create()
consumeFailure = undefined
}
const retain = (path: (string | number)[]): boolean => {
const [root, child] = path
return (
typeof root === 'string' &&
((root !== args.arrayKey && fields.has(root)) ||
(inFoldedArray && root === args.arrayKey && path.length >= 2) ||
(typeof child === 'string' && objectFields.get(root)?.has(child) === true))
)
}
// Every discarded container needs a rule; dropping only its parent still builds large children.
const discard = {
filter: (path: (string | number)[]) => !retain(path),
create: () => null,
add: () => {}
}
const assembler = new FlexAssembler({
maxDepth: Infinity,
objectRules: [
objectRule<Record<string, unknown>>({
filter: (path) => path.length === 0,
create: () => record,
add: (target, key, value) => {
if (key === args.arrayKey) {
if (value !== foldedArray) {
reset()
}
} else if (fields.has(key)) {
target[key] = value
}
}
}),
objectRule<Record<string, unknown>>({
filter: (path) =>
path.length === 1 &&
typeof path[0] === 'string' &&
objectFields.has(path[0]) &&
(!fields.has(path[0]) || path[0] === args.arrayKey),
create: (path) => {
const projected: Record<string, unknown> = Object.create(null)
record[String(path[0])] = projected
return projected
},
add: (target, key, value) => {
const root = assembler.path[0]
if (typeof root === 'string' && objectFields.get(root)?.has(key)) {
target[key] = value
}
}
}),
discard
],
arrayRules: [
arrayRule<null>({
filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey,
create: () => {
reset()
inFoldedArray = true
return null
},
add: (_target, value) => {
if (!consumeFailure) {
try {
args.consume(state, value)
} catch (error) {
consumeFailure = { error }
}
}
},
finalize: () => {
inFoldedArray = false
return foldedArray
}
}),
discard
]
})
const parser = createJsonTokenReader((token) => {
if (
token.name === 'keyValue' &&
assembler.depth === 1 &&
!assembler.isArray &&
objectFields.has(token.value)
) {
record[token.value] = Object.create(null)
}
assembler.consume(token)
})
const decoder = new StringDecoder('utf8')
let objectRoot: boolean | undefined
parser.onValue = ({ key, value, parent, stack }) => {
if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) {
if (parent !== currentArray) {
state = args.create()
consumeFailure = undefined
currentArray = parent
}
if (!consumeFailure) {
try {
args.consume(state, value)
} catch (error) {
consumeFailure = { error }
}
}
// The parser's array cursor is independent of retained array slots.
parent.pop()
} else if (
stack.length === 2 &&
typeof stack[1].key === 'string' &&
typeof key === 'string' &&
parent &&
!Array.isArray(parent)
) {
const root = stack[1].key
const projected = record[root]
if (
Object.hasOwn(args.objectFields ?? {}, root) &&
args.objectFields?.[root]?.includes(key) &&
projected &&
typeof projected === 'object'
) {
Reflect.set(projected, key, value)
}
} else if (stack.length === 1 && typeof key === 'string') {
if (key === args.arrayKey) {
if (value !== currentArray || !Array.isArray(value)) {
state = args.create()
consumeFailure = undefined
}
currentArray = null
} else if (fields.has(key)) {
record[key] = value
}
if (parent && typeof parent === 'object') {
Reflect.deleteProperty(parent, key)
}
}
}
for await (const chunk of args.bytes) {
throwIfAiVaultScanCancelled(args.signal)
if (objectRoot === undefined) {
@@ -107,37 +124,17 @@ export async function readStreamedSessionDocument<T>(args: {
objectRoot = first === 123
}
}
parseJson(() => parser.write(decoder.write(chunk)))
parser.write(decoder.write(chunk))
await yieldToEventLoop()
}
const tail = decoder.end()
if (tail) {
parseJson(() => parser.write(tail))
}
if (objectRoot === undefined) {
throw new SyntaxError('Unexpected end of JSON input')
}
if (!parser.isEnded) {
parseJson(() => parser.end(), true)
parser.write(tail)
}
parser.end()
throwIfAiVaultScanCancelled(args.signal)
if (consumeFailure) {
throw consumeFailure.error
}
return objectRoot ? { record, state } : null
}
function parseJson(run: () => void, ending = false): void {
try {
run()
} catch (error) {
if (
error instanceof TokenizerError ||
error instanceof TokenParserError ||
(ending && error instanceof Error)
) {
throw new SyntaxError(error.message)
}
throw error
}
}
@@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => {
throw new Error('Expected the bounded native page')
}
expect(page.items.map((item) => item.rowid)).toEqual([2, 3])
expect(page.items[0]?.message.role).toBe('system')
expect(page.items[0]?.message.blocks).toEqual([
{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
{
type: 'text',
text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'],
presentation: 'history-item-too-large'
}
])
const repeat = await client.readNativeChat(args)
expect(repeat).toEqual(page)
@@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => {
const service = new AntigravityAccountService(store, h.backend)
h.setNative(paddedCredential('new-account', 60000))
await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved')
expect(readFileSync(path)).toEqual(before)
expect(readFileSync(path).equals(before)).toBe(true)
expect(store.read().accounts).toHaveLength(52)
h.setNative(paddedCredential('large-51', 65000))
await expect(service.listAccounts()).rejects.toThrow('could not be saved')
expect(readFileSync(path)).toEqual(before)
expect(readFileSync(path).equals(before)).toBe(true)
expect(store.read().accounts).toHaveLength(52)
})
@@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => {
'Protected secret storage'
)
expect(() => store.read()).toThrow('Protected secret storage')
expect(readFileSync(path)).toEqual(before)
expect(readFileSync(path).equals(before)).toBe(true)
}
)
@@ -51,6 +51,28 @@ const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
token: 'annotationviewporttoken'
}
const MARKER_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
...BRIDGE_OPTIONS,
markers: [
{
id: 'historical-document-marker',
index: 2,
isFixed: false,
rectPage: { x: 80, y: 120, width: 100, height: 40 },
rectViewport: { x: 80, y: 120, width: 100, height: 40 }
}
]
}
function deferGuestInjection(guest: Record<string, unknown>): () => void {
let release = (): void => {}
const gate = new Promise<void>((resolve) => {
release = resolve
})
guest.executeJavaScriptInIsolatedWorld = vi.fn(() => gate)
return release
}
function registerPage(pageId: string, guest: Record<string, unknown>): void {
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
@@ -141,6 +163,161 @@ describe('browserManager.setAnnotationViewportBridge', () => {
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
})
it.each([false, true])(
'skips retired queued markers after invalidation (replacement guest: %s)',
async (replaceGuest) => {
const { guest: firstGuest } = makeGuest(5201)
const release = deferGuestInjection(firstGuest)
registerPage('tab-retired-markers', firstGuest)
const resolveGuest = resolveFromRegistry('tab-retired-markers')
const inFlight = browserManager.setAnnotationViewportBridge(
'tab-retired-markers',
BRIDGE_OPTIONS,
resolveGuest
)
await flushViewportOps()
const retired = browserManager.setAnnotationViewportBridge(
'tab-retired-markers',
MARKER_OPTIONS,
resolveGuest
)
const { guest: replacementGuest } = makeGuest(5202)
if (replaceGuest) {
registerPage('tab-retired-markers', replacementGuest)
}
const cleared = browserManager.setAnnotationViewportBridge(
'tab-retired-markers',
{ ...BRIDGE_OPTIONS, enabled: false },
resolveGuest
)
release()
await expect(inFlight).resolves.toBe(true)
await expect(retired).resolves.toBe(false)
await expect(cleared).resolves.toBe(true)
const currentGuest = replaceGuest ? replacementGuest : firstGuest
expect(currentGuest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalledWith(
expect.any(Number),
[expect.objectContaining({ code: expect.stringContaining('historical-document-marker') })],
false
)
expect(currentGuest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
expect.any(Number),
[expect.objectContaining({ code: expect.stringContaining('const enabled = false;') })],
false
)
}
)
it('keeps an in-flight injection and applies the latest fresh markers and token', async () => {
const { guest } = makeGuest(5203)
const release = deferGuestInjection(guest)
const resolveGuest = resolveFromRegistry('tab-fresh-markers')
registerPage('tab-fresh-markers', guest)
const inFlight = browserManager.setAnnotationViewportBridge(
'tab-fresh-markers',
MARKER_OPTIONS,
resolveGuest
)
await flushViewportOps()
const cleared = browserManager.setAnnotationViewportBridge(
'tab-fresh-markers',
{ ...BRIDGE_OPTIONS, enabled: false },
resolveGuest
)
const fresh = browserManager.setAnnotationViewportBridge(
'tab-fresh-markers',
{
...MARKER_OPTIONS,
token: 'remintedpreviewtoken',
markers: MARKER_OPTIONS.markers.map((marker) => ({ ...marker, id: 'fresh-preview-marker' }))
},
resolveGuest
)
release()
await expect(inFlight).resolves.toBe(true)
await expect(cleared).resolves.toBe(false)
await expect(fresh).resolves.toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('fresh-preview-marker')
})
],
false
)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith(
expect.any(Number),
[expect.objectContaining({ code: expect.stringContaining('remintedpreviewtoken') })],
false
)
})
it('continues the queue after an in-flight injection fails', async () => {
const { guest } = makeGuest(5204)
let rejectInjection = (): void => {}
const gate = new Promise<void>((_resolve, reject) => {
rejectInjection = () => reject(new Error('retired document'))
})
guest.executeJavaScriptInIsolatedWorld = vi
.fn()
.mockReturnValueOnce(gate)
.mockResolvedValue(true)
registerPage('tab-failed-injection', guest)
const resolveGuest = resolveFromRegistry('tab-failed-injection')
const inFlight = browserManager.setAnnotationViewportBridge(
'tab-failed-injection',
MARKER_OPTIONS,
resolveGuest
)
await flushViewportOps()
const cleared = browserManager.setAnnotationViewportBridge(
'tab-failed-injection',
{ ...BRIDGE_OPTIONS, enabled: false },
resolveGuest
)
rejectInjection()
await expect(inFlight).resolves.toBe(false)
await expect(cleared).resolves.toBe(true)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-failed-injection',
BRIDGE_OPTIONS,
resolveGuest
)
).resolves.toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(3)
})
it('does not run a queued marker request after page teardown removes its chain', async () => {
const { guest } = makeGuest(5205)
const release = deferGuestInjection(guest)
registerPage('tab-closed-queue', guest)
const resolveGuest = resolveFromRegistry('tab-closed-queue')
const inFlight = browserManager.setAnnotationViewportBridge(
'tab-closed-queue',
BRIDGE_OPTIONS,
resolveGuest
)
await flushViewportOps()
const retired = browserManager.setAnnotationViewportBridge(
'tab-closed-queue',
MARKER_OPTIONS,
resolveGuest
)
browserManager.unregisterGuest('tab-closed-queue')
release()
await expect(inFlight).resolves.toBe(true)
await expect(retired).resolves.toBe(false)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
expect(browserManager.getGuestWebContentsId('tab-closed-queue')).toBeNull()
})
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
// is the one that can see the guest is gone. The bridge only reports the refusal.
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
+7 -1
View File
@@ -77,7 +77,13 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
.then(() => {
// A newer document invalidation retires geometry still waiting in the queue.
if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) !== next) {
return false
}
return this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)
})
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
@@ -8,6 +8,7 @@ import { deriveToolInputPreview } from '../../shared/agent-hook-listener/tool-in
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeChildWorkDecoder } from './claude-child-work-decoder'
import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence'
import { ClaudePromptRegistry } from './claude-prompt-registry'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import {
claudeToolResults,
@@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => {
translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 })
const join = vi.spyOn(Array.prototype, 'join')
const evidence = drainClaudeChildWork(
{ childWork: new ClaudeChildWorkDecoder(), translator },
{
childWork: new ClaudeChildWorkDecoder(),
prompts: new ClaudePromptRegistry(),
translator
},
message,
1_000
)
@@ -46,6 +46,25 @@ describe('resolveRealClaudeCliGate', () => {
expect(runClaude.mock.calls).toEqual([[['--version']], [['auth', 'status', '--json']]])
})
it('reads the final account report after the CLI configuration warning', () => {
const warning = [
'Claude configuration file not found at: /home/dev/.claude/.claude.json',
'A backup file exists at: /home/dev/.claude/backups/.claude.json.backup.123',
'You can manually restore it by running: cp /home/dev/.claude/backups/.claude.json.backup.123 /home/dev/.claude/.claude.json',
''
].join('\n')
const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult =>
args[0] === '--version'
? { status: 0, stdout: '2.1.0\n' }
: { status: 0, stdout: warning + warning + JSON.stringify({ loggedIn: true }) }
)
expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({
skipReason: null,
authStatus: { loggedIn: true }
})
})
it('still skips when opted in but no claude binary answers', () => {
const runClaude = vi.fn((): ClaudeCliProbeResult => ({ status: null, stdout: '' }))
@@ -57,7 +76,14 @@ describe('resolveRealClaudeCliGate', () => {
it.each([
['a failed auth probe', { status: 1, stdout: '' }],
['unparseable auth output', { status: 0, stdout: 'not json' }]
['unparseable auth output', { status: 0, stdout: 'not json' }],
['malformed JSON', { status: 0, stdout: '{"loggedIn":' }],
['trailing output', { status: 0, stdout: '{"loggedIn":true}\nnot json' }],
['two JSON objects', { status: 0, stdout: '{"loggedIn":false}\n{"loggedIn":true}' }],
['another JSON value before an object', { status: 0, stdout: 'true\n{"loggedIn":true}' }],
['an array before an object', { status: 0, stdout: '[]\n{"loggedIn":true}' }],
['a JSON array', { status: 0, stdout: '[{"loggedIn":true}]' }],
['an unrelated JSON value', { status: 0, stdout: 'true' }]
])('runs with no account report after %s', (_label, authResult) => {
const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult =>
args[0] === '--version' ? { status: 0, stdout: '2.1.0\n' } : authResult
+15 -2
View File
@@ -13,13 +13,26 @@ export type RealClaudeCliGate = {
}
function parseAuthStatus(stdout: string): RealClaudeAuthStatus | null {
const start = stdout.search(/[[{]/)
if (start === -1) {
return null
}
// CLI warnings may precede the report, but another JSON value makes it ambiguous.
for (const line of stdout.slice(0, start).split('\n')) {
try {
JSON.parse(line)
return null
} catch {
continue
}
}
let parsed: unknown
try {
parsed = JSON.parse(stdout)
parsed = JSON.parse(stdout.slice(start))
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const status: RealClaudeAuthStatus = {}
@@ -93,7 +93,8 @@ export class ClaudeJournalPrompts {
const append = (identity: AgentJournalItemIdentity, body: ClaudeJournalPrompt['body']) => {
const options = { ...producer, turnScope }
if (this.deps.sink.tryAppendItem) {
admitted &&= this.deps.sink.tryAppendItem(identity, body, options).accepted
const admission = this.deps.sink.tryAppendItem(identity, body, options)
admitted &&= admission.accepted
} else {
this.deps.sink.appendItem(identity, body, options)
}
@@ -255,89 +255,129 @@ describe('Claude live prompt ownership', () => {
).toBe(false)
})
it('rejects a grouped prompt batch without partially revising its first row', () => {
const tombstones: string[] = []
const appendTombstone = vi.fn(
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
tombstones.push(agentJournalItemKey(identity))
}
)
let rowAdmission = 0
const tryAppendTombstone = vi.fn(
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
rowAdmission += 1
if (rowAdmission === 2) {
it.each([0, 1, null] as const)(
'rejects a grouped prompt batch without partial revision after row %s admission refusal',
async (refusedAt) => {
const tombstones: string[] = []
const appendTombstone = vi.fn(
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
tombstones.push(agentJournalItemKey(identity))
}
)
let rowAdmission = 0
const tryAppendTombstone = vi.fn(
(identity: Parameters<StructuredAgentSessionEventSink['appendTombstone']>[0]) => {
rowAdmission += 1
if (rowAdmission === 2) {
return { accepted: false as const, reason: 'backpressure' as const }
}
appendTombstone(identity)
return { accepted: true as const }
}
)
const tryAppendLifecycleBatch = vi.fn(
(
_settlementId: string,
mutations: Parameters<
NonNullable<StructuredAgentSessionEventSink['tryAppendLifecycleBatch']>
>[1]
) => {
expect(mutations[1]).toMatchObject({
kind: 'item',
body: { resolution: { state: 'cancelled' } }
})
return { accepted: false as const, reason: 'backpressure' as const }
}
appendTombstone(identity)
return { accepted: true as const }
)
const admittedRows = new Map<string, AgentJournalItemBody>()
let admissionAttempts = 0
const tryAppendItem = vi.fn(
(
identity: Parameters<StructuredAgentSessionEventSink['appendItem']>[0],
body: AgentJournalItemBody
) => {
if (admissionAttempts++ === refusedAt) {
return { accepted: false as const, reason: 'backpressure' as const }
}
admittedRows.set(agentJournalItemKey(identity), body)
return { accepted: true as const }
}
)
const bindPromptItemId = vi.fn()
const written = vi.fn(async () => ({ ok: true as const }))
const prompts = new ClaudeJournalPrompts({
sink: {
appendItem: () => {},
tryAppendItem,
appendTombstone,
tryAppendTombstone,
tryAppendLifecycleBatch,
publish: () => {},
written
},
bindPromptItemId,
producerOf: () => ({ agentId: 'agent-grouped', producerKind: 'agent' }),
questionItems: (input) => {
const item = claudeQuestionItems(input)[0]
return item
? [
{
...item,
identity: { provider: 'orca', clientMessageId: 'group:first' }
},
{
...item,
identity: { provider: 'orca', clientMessageId: 'group:second' }
}
]
: []
},
turnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})
const prompt: ClaudePendingPrompt = {
requestId: 'grouped-request',
promptKey: 'grouped-request',
toolUseId: 'tool-grouped',
toolName: 'AskUserQuestion',
kind: 'question',
input: {
questions: [
{ question: 'First?', options: [{ label: 'Yes' }] },
{ question: 'Second?', options: [{ label: 'No' }] }
]
},
suggestions: [],
questionIds: ['First?', 'Second?'],
settle: vi.fn()
}
)
const tryAppendLifecycleBatch = vi.fn(
(
_settlementId: string,
mutations: Parameters<
NonNullable<StructuredAgentSessionEventSink['tryAppendLifecycleBatch']>
>[1]
) => {
expect(mutations[1]).toMatchObject({
kind: 'item',
body: { resolution: { state: 'cancelled' } }
})
return { accepted: false as const, reason: 'backpressure' as const }
}
)
const prompts = new ClaudeJournalPrompts({
sink: {
appendItem: () => {},
appendTombstone,
tryAppendTombstone,
tryAppendLifecycleBatch,
publish: () => {}
},
questionItems: (input) => {
const item = claudeQuestionItems(input)[0]
return item
? [
{
...item,
identity: { provider: 'orca', clientMessageId: 'group:first' }
},
{
...item,
identity: { provider: 'orca', clientMessageId: 'group:second' }
}
]
: []
},
turnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})
const prompt: ClaudePendingPrompt = {
requestId: 'grouped-request',
promptKey: 'grouped-request',
toolUseId: 'tool-grouped',
toolName: 'AskUserQuestion',
kind: 'question',
input: {
questions: [
{ question: 'First?', options: [{ label: 'Yes' }] },
{ question: 'Second?', options: [{ label: 'No' }] }
]
},
suggestions: [],
questionIds: ['First?', 'Second?'],
settle: vi.fn()
}
prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt })
prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt })
await prompts.whenWritten(prompt.promptKey)
expect(prompts.cancel(prompt.promptKey)).toEqual({
accepted: false,
reason: 'backpressure'
})
expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce()
expect(tryAppendTombstone).not.toHaveBeenCalled()
expect(tombstones).toEqual([])
})
expect(tryAppendItem.mock.calls.map(([identity]) => agentJournalItemKey(identity))).toEqual([
'orca:group%3Afirst',
'orca:group%3Asecond'
])
expect([...admittedRows.keys()]).toEqual(
['orca:group%3Afirst', 'orca:group%3Asecond'].filter((_, index) => index !== refusedAt)
)
expect(bindPromptItemId.mock.calls).toEqual([
['orca:group%3Afirst', prompt.promptKey],
['orca:group%3Asecond', prompt.promptKey]
])
expect(written).toHaveBeenCalledTimes(refusedAt === null ? 1 : 0)
expect([...prompts.openCards()]).toEqual(
refusedAt === null ? [{ promptKey: prompt.promptKey, asker: 'agent-grouped' }] : []
)
expect(prompts.cancel(prompt.promptKey)).toEqual({
accepted: false,
reason: 'backpressure'
})
expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce()
expect(tryAppendTombstone).not.toHaveBeenCalled()
expect(tombstones).toEqual([])
}
)
it('keeps every backpressured prompt cancellation retry in its owned entry', () => {
let backpressured = true
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { buildClaudeChildProcessEnv } from './claude-child-process-environment'
import * as claudeConnection from './claude-stream-json-connection'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
@@ -11,7 +13,8 @@ import {
realClaudeAuthStatus,
realClaudeAvailable,
realClaudeCliGate,
realClaudeCommand
realClaudeCommand,
realClaudeLaunchHome
} from './claude-real-cli-availability-test-support'
import {
ClaudeStructuredSessionAdapter,
@@ -27,13 +30,15 @@ function realAdapter(
claudeConfigDir: string,
events: ClaudeStructuredSessionEvent[] = [],
cwd = process.cwd(),
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate']
onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'],
env = realClaudeLaunchHome().env
): ClaudeStructuredSessionAdapter {
const adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: command,
options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId },
cwd,
env,
claudeConfigDir,
providerSessionId,
resumeLeafUuid: null,
@@ -81,6 +86,53 @@ async function waitForResolvedTranscript(
}
}
describe('real CLI fixture authentication isolation', () => {
it.each(['authenticated', 'signed-out'] as const)(
'keeps the %s launch environment independent of inherited credentials',
async (mode) => {
const authKeys = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'] as const
for (const key of authKeys) {
vi.stubEnv(key, 'unusable-fixture-auth-value')
}
let childEnv: Record<string, string> | undefined
const open = vi
.spyOn(claudeConnection, 'openClaudeStreamJsonConnection')
.mockImplementation(async (launch) => {
childEnv = buildClaudeChildProcessEnv(launch.env)
throw new Error('fixture stopped before spawning a CLI')
})
const providerSessionId = randomUUID()
const adapter = realAdapter(
providerSessionId,
join(tmpdir(), 'orca-synthetic-signed-out'),
[],
process.cwd(),
undefined,
mode === 'signed-out' ? {} : undefined
)
try {
await expect(
adapter.acquire({
identity: identity(providerSessionId),
fence: 1,
spawnToken: 'fixture'
})
).rejects.toThrow('fixture stopped before spawning a CLI')
expect(open).toHaveBeenCalledOnce()
for (const key of authKeys) {
expect(childEnv?.[key]).toBe(
mode === 'authenticated' ? 'unusable-fixture-auth-value' : undefined
)
}
} finally {
await adapter.closeAll()
open.mockRestore()
vi.unstubAllEnvs()
}
}
)
})
describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
it.skipIf(!realClaudeAuthenticated)(
'proves a pre-minted session before the first user message',
@@ -442,7 +494,14 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-'))
const providerSessionId = randomUUID()
const events: ClaudeStructuredSessionEvent[] = []
const adapter = realAdapter(providerSessionId, claudeConfigDir, events)
const adapter = realAdapter(
providerSessionId,
claudeConfigDir,
events,
process.cwd(),
undefined,
{}
)
try {
await adapter.acquire({
+14 -2
View File
@@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => {
platform: 'linux'
})
expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`)
expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR)
expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({
userDataPath: USER_DATA
})
@@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => {
it('leaves packaged Linux PATH untouched when no shim could be written', () => {
shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null)
const env: Record<string, string> = { PATH: '/usr/bin' }
const env: Record<string, string> = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' }
prependOrcaCliDirToChildPath(env, {
isPackaged: true,
userDataPath: USER_DATA,
platform: 'linux'
})
expect(env.PATH).toBe('/usr/bin')
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
})
it('leads packaged macOS PATH with the bundled CLI dir', () => {
@@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => {
platform: 'darwin'
})
expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`)
expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin'))
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
})
it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => {
const env: Record<string, string> = { Path: 'C:\\Windows\\System32' }
const env: Record<string, string> = {
Path: 'C:\\Windows\\System32',
ORCA_CLI_BIN_DIR: '/parent-host/cli',
ORCA_WSL_CLI_DIR: '/guest/orca/bin'
}
prependOrcaCliDirToChildPath(env, {
isPackaged: true,
userDataPath: USER_DATA,
@@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => {
})
expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`)
expect(env.PATH).toBeUndefined()
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin')
})
it('leaves a packaged darwin/win32 PATH alone with no resources root', () => {
@@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => {
platform
})
expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`)
expect(env.ORCA_CLI_BIN_DIR).toBe(
platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin')
)
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
})
+8
View File
@@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath(
opts: OrcaCliChildPathOptions
): string | null {
const platform = opts.platform ?? process.platform
delete env.ORCA_CLI_BIN_DIR
// Why: matches node:path's `delimiter` for the running platform, but stays correct when a test
// drives a foreign platform through the seam.
const pathDelimiter = platform === 'win32' ? ';' : delimiter
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
if (platform !== 'win32') {
env.ORCA_CLI_BIN_DIR = devCliBin
}
const inheritedPath = readInheritedPath(env, platform)
// Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
env[resolvePathEnvKey(env, platform)] = inheritedPath
@@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath(
// Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
if (shimDir) {
env.ORCA_CLI_BIN_DIR = shimDir
const inheritedEntries = readInheritedPath(env, platform)
.split(pathDelimiter)
.filter((entry) => entry.length > 0 && entry !== shimDir)
@@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath(
} else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) {
// Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
const bundledCliBin = join(opts.resourcesPath, 'bin')
if (platform === 'darwin') {
env.ORCA_CLI_BIN_DIR = bundledCliBin
}
const inheritedPath = readInheritedPath(env, platform)
env[resolvePathEnvKey(env, platform)] = inheritedPath
? `${bundledCliBin}${pathDelimiter}${inheritedPath}`
@@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => {
expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth)
})
it('keeps an Orca-added MCP server while settings and trust still refresh', () => {
const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json')
const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] })
writeFileSync(baselinePath, baseline)
writeFileSync(
join(systemCodexHome, 'config.toml'),
['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join(
'\n'
)
)
writeFileSync(
join(sharedRuntimeHome, 'config.toml'),
[
'model = "stale"',
'',
'[projects."/trusted-in-orca"]',
'trust_level = "trusted"',
'',
'[projects."/revoked"]',
'trust_level = "trusted"',
'',
'[hooks.state."orca:stop:0:0"]',
'enabled = true',
'',
'[mcp_servers.demo-mcp]',
'command = "demo"',
''
].join('\n')
)
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')
expect(sharedConfig).toContain('model = "canonical"')
expect(sharedConfig).not.toContain('model = "stale"')
expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"')
expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"')
expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"')
expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]')
expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"')
expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline)
})
it('does not delete config when the canonical source is transiently missing', () => {
const staleConfig = 'model_provider = "stale-provider"\n'
writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8')
@@ -21,6 +21,20 @@ beforeEach(() => {
afterEach(() => rmSync(root, { recursive: true, force: true }))
const BASELINE_FILE = '.orca-config-settings-baseline.json'
type StoredBaselineFixture = {
version: 1 | 3
settings: Record<string, string | null>
mcpServers?: string[]
}
function writeBaseline(baseline: StoredBaselineFixture): string {
const serialized = JSON.stringify(baseline)
writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized)
return serialized
}
describe('canonical MCP ownership during config mirroring', () => {
it('does not duplicate a server defined inline in the canonical MCP table', () => {
writeFileSync(
@@ -130,6 +144,60 @@ describe('MCP ownership migration', () => {
)
})
it('keeps the retained shared home canonical under a pre-ownership baseline', () => {
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
writeBaseline({ version: 1, settings: {} })
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
'[mcp_servers.'
)
})
it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => {
writeFileSync(
join(runtimeHomePath, 'config.toml'),
[
'[mcp_servers.demo-mcp]',
'command = "orca-only"',
'[mcp_servers.removed]',
'command = "mirrored"',
'[mcp_servers.kept]',
'command = "stale"',
''
].join('\n')
)
writeFileSync(
join(systemHomePath, 'config.toml'),
'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n'
)
const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] })
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"')
expect(runtimeConfig).not.toContain('[mcp_servers.removed]')
expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"')
expect(runtimeConfig).not.toContain('"stale"')
expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline)
})
it('leaves the retained shared home untouched when its baseline cannot be read', () => {
const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n'
writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig)
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
mkdirSync(join(runtimeHomePath, BASELINE_FILE))
expect(() =>
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
).toThrow()
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig)
})
it('tracks commented CRLF names so their later removal remains authoritative', () => {
writeFileSync(
join(runtimeHomePath, 'config.toml'),
+20 -4
View File
@@ -16,7 +16,7 @@ import {
type CodexSettingsPromotionHomes,
type CodexSettingsPromotionPlan
} from './config-settings-promotion'
import { readCodexSettingsBaseline } from './config-settings-baseline'
import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline'
import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall'
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard'
@@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? ''
if (rawSystemConfig.trim() !== '') {
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath)
// The retired home has no ownership baseline; its entire MCP root stays canonical.
mirroredRuntimeConfig =
runtimeConfigBeforeMirror !== null
? mergeSystemCodexConfigIntoRuntime(
runtimeConfigBeforeMirror,
prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir),
sourceConfigDir,
new Set(),
true
...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath)
)
: prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
}
@@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig)
}
/**
* Why: MCP servers added from an Orca terminal exist only in the retired home,
* so its last mirror's baseline decides which ones ~/.codex owns. With no
* baseline the whole root stays canonical, as before; an unreadable one throws
* rather than guess. Read-only: this one-way refresh never advances it.
*/
function readLegacySharedHomeMcpOwnership(
runtimeHomePath: string
): [mirroredMcpServerNames: ReadonlySet<string>, mirroredMcpServerRoot: boolean] {
const observation = observeCodexSettingsBaseline(runtimeHomePath)
if (observation.kind === 'indeterminate') {
throw new Error('Codex settings baseline could not be read')
}
return observation.kind === 'present'
? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot]
: [new Set(), true]
}
type CodexConfigMirrorResult =
| { status: 'skipped-missing-source' }
| { status: 'refused-indeterminate'; error: unknown }
@@ -10,6 +10,7 @@ import {
} from '../runtime/structured-worker-identity'
const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/
const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/
// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global.
const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/
@@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => {
cwd: '/worktree',
codexHome: '/pinned/home',
resumeThreadId: null,
env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }
env: {
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/shell/home',
ORCA_CLI_BIN_DIR: '/inherited/unowned-cli'
}
},
'spawn-token',
'session-not-a-worker'
@@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
ORCA_AGENT_SESSION_ID: 'session-not-a-worker',
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
...(process.platform !== 'win32'
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
: {}),
ORCA_USER_DATA_PATH: expect.any(String),
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
@@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
ORCA_AGENT_SESSION_ID: sessionId,
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
...(process.platform !== 'win32'
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
: {}),
ORCA_USER_DATA_PATH: expect.any(String),
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
})
@@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw`
result: {
sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null,
cliCommand: process.env.ORCA_CLI_COMMAND ?? null,
cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null,
path: process.env.PATH ?? process.env.Path ?? null
}
})
@@ -135,6 +147,7 @@ describe('the spawned Codex child', () => {
await expect(connection.request('test/env')).resolves.toEqual({
sessionId,
cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER),
cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR),
path: expect.stringMatching(DEV_CLI_BIN_FIRST)
})
} finally {
@@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
ORCA_AGENT_SESSION_ID: 'session-1',
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/),
...(process.platform !== 'win32'
? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) }
: {}),
ORCA_USER_DATA_PATH: expect.any(String),
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/)
@@ -1,4 +1,5 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path'
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell'
import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
@@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() {
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
${ORCA_CLI_POSIX_PATH_RESTORE}
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@@ -0,0 +1,323 @@
import './mock-descendant-sweep'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type * as CryptoModule from 'node:crypto'
import { DaemonServer } from './daemon-server'
import { DaemonClient } from './client'
import { DaemonClientConnections } from './daemon-client-connections'
import { TerminalHost } from './terminal-host'
import { Session } from './session'
import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import { getDaemonSocketPath } from './daemon-spawner'
import type { SubprocessHandle } from './session-subprocess-handle'
type MockSubprocess = SubprocessHandle & { exit: () => void }
const seed = vi.hoisted(() => ({ uuid: 0 }))
vi.mock('node:crypto', async (importOriginal) => {
const original = await importOriginal<typeof CryptoModule>()
return {
...original,
randomUUID: () => `00000000-0000-4000-8000-${String(++seed.uuid).padStart(12, '0')}`
}
})
const clients: DaemonClient[] = []
const processes: MockSubprocess[] = []
let dir = ''
let server: DaemonServer | undefined
async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = performance.now() + 2_000
while (!predicate()) {
if (performance.now() >= deadline) {
throw new Error('Daemon event did not settle')
}
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
function spawn(): MockSubprocess {
let exited: ((code: number) => void) | undefined
const proc: MockSubprocess = {
pid: 90_001,
getForegroundProcess: () => null,
write: vi.fn(),
resize: vi.fn(),
kill: () => exited?.(0),
forceKill: () => exited?.(137),
terminateOwnedTree: () => 'unavailable',
signal: vi.fn(),
onData: vi.fn(),
onExit: (callback) => {
exited = callback
},
dispose: vi.fn(),
exit: () => exited?.(0)
}
processes.push(proc)
return proc
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-idle-inventory-'))
clients.length = 0
processes.length = 0
seed.uuid = 0
})
afterEach(async () => {
for (const process of processes) {
process.exit()
}
for (const client of clients) {
client.disconnect()
}
await server?.shutdown()
vi.restoreAllMocks()
rmSync(dir, { recursive: true, force: true })
})
it('keeps live sessions and exact inventories while visitors repeatedly disconnect', async () => {
const socketPath = getDaemonSocketPath(dir)
const tokenPath = join(dir, 'daemon.token')
const idle = vi.fn()
const getClient = vi.spyOn(DaemonClientConnections.prototype, 'get')
const create = vi.spyOn(TerminalHost.prototype, 'createOrAttach')
server = new DaemonServer({ socketPath, tokenPath, spawnSubprocess: spawn, onIdleShutdown: idle })
await server.start()
const owner = new DaemonClient({ socketPath, tokenPath })
clients.push(owner)
await owner.ensureConnected()
for (let index = 0; index < 16; index++) {
await owner.request('createOrAttach', {
sessionId: `session-${index}`,
cols: 80,
rows: 24,
...(index < 8
? {
agentSessionEnsure: {
claim: {
digestVersion: 1,
keyId: `key${index}`,
identityDigest: 'a'.repeat(43),
worktreeScopeDigest: 'b'.repeat(43),
agent: 'codex'
},
surface: {
worktreeId: `folder${index}`,
tabId: '11111111-1111-4111-8111-111111111111',
leafId: '22222222-2222-4222-8222-222222222222',
terminalHandle: `term_idle${index}`
}
}
}
: {})
})
}
const connections = getClient.mock.contexts[0]
if (!(connections instanceof DaemonClientConnections)) {
throw new Error('Actual server connections were not used')
}
const before = await owner.request('listSessions', undefined)
const expected = structuredClone(before)
if (
typeof before !== 'object' ||
before === null ||
!('sessions' in before) ||
!Array.isArray(before.sessions)
) {
throw new Error('Initial inventory was malformed')
}
const first = before.sessions[0]
if (
typeof first !== 'object' ||
first === null ||
!('agentSessionOwners' in first) ||
!Array.isArray(first.agentSessionOwners) ||
!first.agentSessionOwners.every(isAgentSessionOwnerBinding)
) {
throw new Error('Initial owners were malformed')
}
const priorOwner = first.agentSessionOwners[0]
priorOwner.claim.keyId = 'mutated-client-copy'
priorOwner.surface.terminalHandle = 'term_mutated'
const inventory = vi.spyOn(TerminalHost.prototype, 'listSessions')
const owners = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty')
const sizes = vi.spyOn(Session.prototype, 'getAppliedSize')
const cwds = vi.spyOn(Session.prototype, 'getCwd')
const originalAlive = Object.getOwnPropertyDescriptor(Session.prototype, 'isAlive')?.get
if (!originalAlive) {
throw new Error('Session alive getter is missing')
}
const alive = vi.spyOn(Session.prototype, 'isAlive', 'get')
for (let iteration = 0; iteration < 10; iteration++) {
const visitor = new DaemonClient({ socketPath, tokenPath })
clients.push(visitor)
await visitor.ensureConnected()
expect(await visitor.request('ping', undefined)).toEqual({ pong: true })
visitor.disconnect()
await waitFor(() => connections.size === 1 && connections.transportCount === 2)
expect(idle).not.toHaveBeenCalled()
}
const calls = inventory.mock.calls.length
const sizeCalls = sizes.mock.calls.length
const cwdCalls = cwds.mock.calls.length
const readSessionIds = (): string[] =>
alive.mock.contexts.map((session) => {
if (!(session instanceof Session)) {
throw new Error('Actual Session was not read')
}
return session.sessionId
})
const aliveReads = readSessionIds()
const ownerCalls = owners.mock.calls.length
const copiedOwners = owners.mock.results.reduce((total, result) => {
const value: unknown = result.value
if (!Array.isArray(value)) {
throw new Error('Owner result was not an array')
}
return total + value.length
}, 0)
const after = await owner.request('listSessions', undefined)
expect(after).toEqual(expected)
expect(JSON.stringify(after)).toBe(JSON.stringify(expected))
expect(after).not.toBe(before)
if (
typeof after !== 'object' ||
after === null ||
!('sessions' in after) ||
!Array.isArray(after.sessions)
) {
throw new Error('Fresh inventory was malformed')
}
const fresh = after.sessions[0]
if (
typeof fresh !== 'object' ||
fresh === null ||
!('agentSessionOwners' in fresh) ||
!Array.isArray(fresh.agentSessionOwners) ||
!fresh.agentSessionOwners.every(isAgentSessionOwnerBinding)
) {
throw new Error('Fresh owners were malformed')
}
expect(fresh.agentSessionOwners).not.toBe(first.agentSessionOwners)
expect(fresh.agentSessionOwners[0]).not.toBe(priorOwner)
expect(fresh.agentSessionOwners[0].claim).not.toBe(priorOwner.claim)
expect(fresh.agentSessionOwners[0].surface).not.toBe(priorOwner.surface)
expect(idle).not.toHaveBeenCalled()
const host = create.mock.contexts[0]
if (!(host instanceof TerminalHost)) {
throw new Error('Actual host was not used')
}
const hasLive = (): boolean => {
const candidate: unknown = host
if (
typeof candidate === 'object' &&
candidate !== null &&
'hasLiveSessions' in candidate &&
typeof candidate.hasLiveSessions === 'function'
) {
const result: unknown = candidate.hasLiveSessions()
if (typeof result !== 'boolean') {
throw new Error('Live result was not boolean')
}
return result
}
return host.listSessions().length > 0
}
const readAlive = (session: Session): boolean => {
const result: unknown = originalAlive.call(session)
if (typeof result !== 'boolean') {
throw new Error('Original alive getter was malformed')
}
return result
}
const controlReads: string[][] = []
const failure = new Error('later-session-alive-failure')
alive.mockImplementation(function (this: Session) {
if (this.sessionId === 'session-1') {
throw failure
}
return readAlive(this)
})
let caught: unknown
try {
hasLive()
} catch (error) {
caught = error
}
expect(caught).toBe(failure)
const sessionIds = Array.from({ length: 16 }, (_value, index) => `session-${index}`)
const deadIds = new Set(sessionIds)
alive.mockClear().mockImplementation(function (this: Session) {
return !deadIds.has(this.sessionId) && readAlive(this)
})
expect(hasLive()).toBe(false)
controlReads.push(readSessionIds())
deadIds.delete('session-3')
alive.mockClear()
expect(hasLive()).toBe(true)
controlReads.push(readSessionIds())
deadIds.add('session-3')
alive.mockClear()
expect(hasLive()).toBe(false)
controlReads.push(readSessionIds())
let entered = false
let nested: boolean | undefined
alive.mockClear().mockImplementation(function (this: Session) {
if (!entered) {
entered = true
processes[1].exit()
nested = hasLive()
}
return readAlive(this)
})
expect(hasLive()).toBe(true)
expect(nested).toBe(true)
controlReads.push(readSessionIds())
alive.mockRestore()
if (
typeof expected !== 'object' ||
expected === null ||
!('sessions' in expected) ||
!Array.isArray(expected.sessions)
) {
throw new Error('Inventory was malformed')
}
const remaining = {
...expected,
sessions: expected.sessions.filter(
(row: unknown) =>
typeof row === 'object' &&
row !== null &&
'sessionId' in row &&
row.sessionId !== 'session-1'
)
}
const afterExit = await owner.request('listSessions', undefined)
expect(afterExit).toEqual(remaining)
expect(JSON.stringify(afterExit)).toBe(JSON.stringify(remaining))
expect(aliveReads).toEqual(Array.from({ length: 30 }, () => sessionIds).flat())
const remainingIds = sessionIds.filter((id) => id !== 'session-1')
expect(controlReads).toEqual([
sessionIds,
sessionIds,
sessionIds,
[
sessionIds[0],
...sessionIds,
sessionIds[1],
...remainingIds,
...remainingIds,
...sessionIds.slice(2)
]
])
expect(calls).toBe(0)
expect(ownerCalls).toBe(0)
expect(copiedOwners).toBe(0)
expect(sizeCalls).toBe(0)
expect(cwdCalls).toBe(0)
})
+1 -1
View File
@@ -233,7 +233,7 @@ export class DaemonServer {
}
private isIdle(): boolean {
if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) {
if (this.admission.inFlight > 0 || this.host.hasLiveSessions()) {
return false
}
if (this.endpoint.lost) {
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { Terminal } from '@xterm/headless'
import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors'
import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors'
import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip'
// Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor.
@@ -78,48 +78,6 @@ function allocatingBufferRows(
}
describe('serialize oracle cell reuse', () => {
it.each([false, true])(
'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)',
(conpty) => {
const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
try {
const writes = [
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n',
'scroll1\r\nscroll2\r\nscroll3\r\n',
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
'\x1b[?1049l\x1b[2J\x1b[Hclear'
]
for (const data of writes) {
writeTerminal(terminal, data)
for (const buffer of [
terminal.buffer.normal,
terminal.buffer.alternate,
terminal.buffer.active
]) {
expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual(
allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)
)
}
terminal.resize(terminal.cols === 14 ? 9 : 14, 4)
expect(
bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols)
).toEqual(
allocatingBufferRows(
terminal.buffer.active,
0,
terminal.buffer.active.length,
terminal.cols
)
)
}
} finally {
terminal.dispose()
}
}
)
it('preserves the order of every text flag combination while reloading a plain cell', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
try {
@@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => {
const scratch = terminal.buffer.active.getNullCell()
expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED')
expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4))
expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual(
allocatingBufferRows(terminal.buffer.active, 0, 1, 4)
)
} finally {
terminal.dispose()
}
})
it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
try {
writeTerminal(terminal, '\x1b[1mA\x1b[0m B界')
const buffer = terminal.buffer.active
const scratch = buffer.getNullCell()
const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
const getLine = buffer.getLine.bind(buffer)
const loaded: unknown[] = []
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
const line = getLine(y)
if (line) {
const getCell = line.getCell.bind(line)
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
loaded.push(cell)
return getCell(x, cell)
})
}
return line
})
const expected = allocatingBufferRows(buffer, 0, 2, 4)
loaded.length = 0
const actual = bufferRows(buffer, 0, 2, 4)
expect(actual).toEqual(expected)
expect(allocate).toHaveBeenCalledTimes(1)
expect(loaded).toHaveLength(8)
expect(loaded.every((cell) => cell === scratch)).toBe(true)
writeTerminal(terminal, '\x1b[2J\x1b[Hnew')
bufferRows(buffer, 0, 2, 4)
expect(actual).toEqual(expected)
} finally {
terminal.dispose()
vi.restoreAllMocks()
}
})
it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
try {
@@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => {
}
})
})
function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) {
for (let y = 0; y < Math.max(expected.length, actual.length); y++) {
const expectedRow = expected[y]
const actualRow = actual[y]
if (
!expectedRow ||
!actualRow ||
expectedRow.length !== actualRow.length ||
!expectedRow.every(
(cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯'))
)
) {
return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') }
}
}
return null
}
function expectComparisonParity(
expected: Buffer,
actual: Buffer,
cols: number,
expectedStart = 0,
expectedEnd = expected.length,
actualStart = 0,
actualEnd = actual.length
): ReturnType<typeof compareBufferRows> {
const frozen = allocatingRowDiff(
'parity',
allocatingBufferRows(expected, expectedStart, expectedEnd, cols),
allocatingBufferRows(actual, actualStart, actualEnd, cols)
)
expect(
compareBufferRows(
'parity',
expected,
expectedStart,
expectedEnd,
actual,
actualStart,
actualEnd,
cols
)
).toEqual(frozen)
return frozen
}
describe('serialize oracle streaming comparison', () => {
it('reuses one cell per buffer and stops before rows after the first difference', () => {
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
try {
writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast')
writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast')
for (const buffer of [source.buffer.active, replay.buffer.active]) {
const scratch = buffer.getNullCell()
vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
const getLine = buffer.getLine.bind(buffer)
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
const line = getLine(y)
if (line) {
const getCell = line.getCell.bind(line)
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
expect(cell).toBe(scratch)
return getCell(x, cell)
})
}
return line
})
}
const diff = compareBufferRows(
'visible-grid',
source.buffer.active,
0,
4,
replay.buffer.active,
0,
4,
8
)
expect(diff?.row).toBe(0)
for (const buffer of [source.buffer.active, replay.buffer.active]) {
expect(buffer.getNullCell).toHaveBeenCalledTimes(1)
expect(buffer.getLine).toHaveBeenCalledTimes(2)
expect(buffer.getLine).toHaveBeenCalledWith(0)
expect(buffer.getLine).toHaveBeenCalledWith(3)
}
writeTerminal(source, '\x1b[2J\x1b[Hchanged')
expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000')
} finally {
source.dispose()
replay.dispose()
vi.restoreAllMocks()
}
})
it.each([false, true])(
'preserves first-row diagnostics through buffer changes (ConPTY=%s)',
(conpty) => {
const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
try {
for (const [index, data] of [
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n',
'scroll1\r\nscroll2\r\nscroll3\r\n',
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
'\x1b[?1049l\x1b[2J\x1b[Hclear'
].entries()) {
writeTerminal(source, data)
writeTerminal(replay, data)
for (const [expected, actual] of [
[source.buffer.active, replay.buffer.active],
[source.buffer.normal, replay.buffer.normal],
[source.buffer.alternate, replay.buffer.alternate]
]) {
expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull()
expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1)
}
const corruption = `\x1b[H\x1b[0mFAULT${index}`
writeTerminal(replay, corruption)
expect(
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
).not.toBeNull()
writeTerminal(source, corruption)
source.resize(source.cols === 14 ? 9 : 14, 4)
replay.resize(source.cols, 4)
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
}
} finally {
source.dispose()
replay.dispose()
}
}
)
it('detects every text-flag loss and compares all combinations against the allocating oracle', () => {
const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
try {
const sgr = [1, 2, 3, 4, 7, 8, 9]
const writeFlags = (terminal: Terminal, mask: number): void => {
const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0)
writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`)
}
for (let mask = 0; mask < 128; mask++) {
writeFlags(source, mask)
writeFlags(replay, mask)
expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull()
for (let bit = 0; bit < sgr.length; bit++) {
if ((mask & (1 << bit)) !== 0) {
writeFlags(replay, mask & ~(1 << bit))
expect(
expectComparisonParity(source.buffer.active, replay.buffer.active, 4)
).not.toBeNull()
}
}
}
} finally {
source.dispose()
replay.dispose()
}
})
it.each([
['abc界', 'abc ', 4, false],
['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false],
['abc ', 'abc界', 4, true],
['é', 'è', 8, true],
['\x1b[32mA', '\x1b[38;5;2mA', 8, false],
['\x1b[42m ', '\x1b[48;5;2m ', 8, false],
['\x1b[7;31m ', '\x1b[7;32m ', 8, true],
['\x1b[4m\x1b[2J', '\x1b[2J', 8, false],
['\x1b[4m ', ' ', 8, true],
['\x1b[4m \x1b[0mB', ' B', 8, true],
['text\r\n', 'text', 8, false],
['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true]
] as const)(
'preserves blank, clipped and color policy for %j / %j',
(expected, actual, cols, differs) => {
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
try {
writeTerminal(source, expected)
writeTerminal(replay, actual)
expect(
Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols))
).toBe(differs)
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5)
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4)
} finally {
source.dispose()
replay.dispose()
}
}
)
})
@@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual:
type BufferLine = NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>
type Buffer = Terminal['buffer']['active']
type Cell = ReturnType<Buffer['getNullCell']>
const COLOR_MODE_P16 = 16777216
const COLOR_MODE_P256 = 33554432
@@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean {
return expected === actual || (expected === CLIPPED && actual.startsWith('▯'))
}
function rowsMatch(expected: string[], actual: string[]): boolean {
return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!))
function sameColor(
expectedMode: number,
expectedColor: number,
actualMode: number,
actualColor: number
): boolean {
return (
expectedColor === actualColor &&
canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor)
)
}
export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] {
const rows: string[][] = []
const reusableCell = buffer.getNullCell()
for (let y = start; y < end; y++) {
rows.push(rowCells(buffer.getLine(y), cols, reusableCell))
// Equal public fields imply identical descriptors; differing fields still use the frozen policy.
function sameCellFields(expected: Cell, actual: Cell): boolean {
if (
expected.getWidth() !== actual.getWidth() ||
!sameColor(
expected.getBgColorMode(),
expected.getBgColor(),
actual.getBgColorMode(),
actual.getBgColor()
)
) {
return false
}
while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) {
rows.pop()
const expectedChars = expected.getChars()
const actualChars = actual.getChars()
const expectedBlank = expectedChars === '' || expectedChars === ' '
const actualBlank = actualChars === '' || actualChars === ' '
if (expectedBlank || actualBlank) {
return (
expectedBlank &&
actualBlank &&
(expectedChars === ' ' && expected.isUnderline() !== 0) ===
(actualChars === ' ' && actual.isUnderline() !== 0) &&
(expectedChars === ' ' && expected.isStrikethrough() !== 0) ===
(actualChars === ' ' && actual.isStrikethrough() !== 0) &&
(expectedChars === ' ' && expected.isOverline() !== 0) ===
(actualChars === ' ' && actual.isOverline() !== 0) &&
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
(expected.isInverse() === 0 ||
sameColor(
expected.getFgColorMode(),
expected.getFgColor(),
actual.getFgColorMode(),
actual.getFgColor()
))
)
}
return rows
return (
expectedChars === actualChars &&
sameColor(
expected.getFgColorMode(),
expected.getFgColor(),
actual.getFgColorMode(),
actual.getFgColor()
) &&
(expected.isBold() !== 0) === (actual.isBold() !== 0) &&
(expected.isDim() !== 0) === (actual.isDim() !== 0) &&
(expected.isItalic() !== 0) === (actual.isItalic() !== 0) &&
(expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) &&
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
(expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) &&
(expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0)
)
}
export function compareRowSets(
function trimmedEnd(
buffer: Buffer,
start: number,
end: number,
cols: number,
scratch: Cell
): number {
while (end > start) {
const line = buffer.getLine(end - 1)
let blank = true
for (let x = 0; x < cols; x++) {
if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) {
blank = false
break
}
}
if (!blank) {
break
}
end--
}
return end
}
function lineCellsMatch(
expected: BufferLine | undefined,
actual: BufferLine | undefined,
cols: number,
expectedScratch: Cell,
actualScratch: Cell
): boolean {
for (let x = 0; x < cols; x++) {
const expectedCell =
expected && x < expected.length ? expected.getCell(x, expectedScratch) : null
const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null
const clipped =
x === cols - 1 &&
((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) ||
(actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1))
if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) {
continue
}
if (
!cellsMatch(
cellDescriptor(expected, x, cols, expectedScratch),
cellDescriptor(actual, x, cols, actualScratch)
)
) {
return false
}
}
return true
}
/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */
export function compareBufferRows(
stage: string,
expected: string[][],
actual: string[][]
expected: Buffer,
expectedStart: number,
expectedEnd: number,
actual: Buffer,
actualStart: number,
actualEnd: number,
cols: number
): GridDiff | null {
const length = Math.max(expected.length, actual.length)
for (let y = 0; y < length; y++) {
const e = expected[y]
const a = actual[y]
if (!e || !a || !rowsMatch(e, a)) {
return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') }
const expectedScratch = expected.getNullCell()
const actualScratch = actual.getNullCell()
const expectedLength =
trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart
const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart
for (let y = 0; y < Math.max(expectedLength, actualLength); y++) {
const expectedLine = expected.getLine(expectedStart + y)
const actualLine = actual.getLine(actualStart + y)
if (
y >= expectedLength ||
y >= actualLength ||
!lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch)
) {
return {
stage,
row: y,
expected:
y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined,
actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined
}
}
}
return null
+17 -12
View File
@@ -18,12 +18,7 @@ import {
variantTrailingBackgroundRows
} from './serialize-grid-variant-scope'
import type { GridDiff } from './serialize-grid-cell-descriptors'
import {
bufferRows,
cellDescriptor,
CLIPPED,
compareRowSets
} from './serialize-grid-cell-descriptors'
import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors'
export type NamedSerializer = { name: string; create: () => SerializeAddon }
@@ -156,15 +151,25 @@ async function compareReplay(
(src.type === dst.type
? null
: { stage: 'active-buffer', expected: src.type, actual: dst.type }) ??
compareRowSets(
compareBufferRows(
'visible-grid',
bufferRows(src, src.baseY, src.baseY + rows, cols),
bufferRows(dst, dst.baseY, dst.baseY + rows, cols)
src,
src.baseY,
src.baseY + rows,
dst,
dst.baseY,
dst.baseY + rows,
cols
) ??
compareRowSets(
compareBufferRows(
'normal-buffer',
bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols),
bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols)
source.buffer.normal,
normalStart,
source.buffer.normal.length,
replay.buffer.normal,
0,
replay.buffer.normal.length,
cols
) ??
(src.cursorX === dst.cursorX && src.cursorY === dst.cursorY
? null
@@ -253,7 +253,13 @@ describe('serialize round trip over captured PTY transcripts', () => {
'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63'
])
}
if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
// The pre-#22586 serializer has these same row-1 bold diffs on the untouched capture.
if (SEEDS === 2 && transcript.name === 'dsb-6-9-0-folder') {
expect(failureSignatures).toEqual([
'jitter/false/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27',
'jitter/true/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27'
])
} else if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? [])
} else if (!OLD_ADDON_PATH && SEEDS === 2) {
expect(counts['new-fail'] ?? 0).toBe(KNOWN_PREEXISTING_I2_FAILURES[transcript.name] ?? 0)
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile'
import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile'
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path'
import {
OVERLAY_ONLY_FEATURES,
STARTUP_COMMAND_FEATURES
@@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => {
vi.restoreAllMocks()
})
itWithBash.each([
['daemon', getDaemonBashShellReadyRcfileContent],
['local', getBashShellReadyRcfileContent]
] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => {
const cliBin = join(userDataPath, 'cli', 'bin')
const ambientBin = join(userDataPath, 'ambient-bin')
mkdirSync(cliBin, { recursive: true })
mkdirSync(ambientBin)
for (const bin of [cliBin, ambientBin]) {
const launcher = join(bin, 'orca-dev')
writeFileSync(launcher, '#!/bin/sh\nexit 0\n')
chmodSync(launcher, 0o755)
}
const env: Record<string, string> = {
HOME: userDataPath,
USERPROFILE: userDataPath,
PATH: `${ambientBin}:/usr/bin:/bin`,
ORCA_BACKGROUND_LAUNCH: '1'
}
const expectedLauncher = prependOrcaCliDirToChildPath(env, {
isPackaged: false,
userDataPath
})
writeFileSync(
join(userDataPath, '.bash_profile'),
'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n'
)
const rcfile = join(userDataPath, 'cli-path-rcfile')
writeFileSync(rcfile, content())
const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], {
env,
encoding: 'utf8',
timeout: 5000
})
expect(result.error).toBeUndefined()
expect(result.status).toBe(0)
expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher)
})
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
@@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void {
core.writeSync(data)
}
type Cell = ReturnType<Terminal['buffer']['active']['getNullCell']>
function observeCellReads(terminal: Terminal) {
const allocated: Cell[] = []
const targets: (Cell | undefined)[] = []
const active = terminal.buffer.active
const source = {
rows: terminal.rows,
modes: terminal.modes,
buffer: {
active: {
baseY: active.baseY,
cursorX: active.cursorX,
cursorY: active.cursorY,
viewportY: active.viewportY,
getNullCell: () => {
const cell = active.getNullCell()
allocated.push(cell)
return cell
},
getLine: (row: number) => {
const line = active.getLine(row)
if (!line) {
return undefined
}
return {
isWrapped: line.isWrapped,
length: line.length,
translateToString: line.translateToString.bind(line),
getCell: (column: number, reusableCell?: Cell) => {
targets.push(reusableCell)
return line.getCell(column, reusableCell)
}
}
}
}
}
}
return { source, allocated, targets }
}
describe('readTerminalCursorLineContext', () => {
it('reuses one cell across every scan without retaining it between reads', () => {
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
try {
writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8')
const rig = observeCellReads(terminal)
const first = readTerminalCursorLineContext(rig.source, terminal.rows)
expect(rig.allocated).toHaveLength(1)
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true)
rig.targets.length = 0
expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first)
expect(rig.allocated).toHaveLength(2)
expect(rig.allocated[1]).not.toBe(rig.allocated[0])
expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true)
} finally {
terminal.dispose()
}
})
it('preserves full context for an adapter without reusable cells', () => {
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
try {
writeSync(
terminal,
'\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8'
)
const rig = observeCellReads(terminal)
const source = {
...rig.source,
buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } }
}
const context = readTerminalCursorLineContext(source, terminal.rows)
expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows))
expect(context?.typedRows).toEqual(['❯ typed'])
expect(context?.typedRowsBelow[0]).toBe('界e\u0301')
expect(context?.beforeCursor).toBe('❯ typed')
expect(context?.afterCursor).toBe('')
expect(context?.rawAfterCursor).toBe(' hint')
expect(context?.promptGlyphBoldRows).toEqual([true])
expect(context?.rowsBelowCustomForeground?.[0]).toBe(true)
expect(rig.allocated).toEqual([])
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
expect(rig.targets.every((cell) => cell === undefined)).toBe(true)
} finally {
terminal.dispose()
}
})
it.each([
{ cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' },
{ cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' }
+15 -14
View File
@@ -1,20 +1,14 @@
import type { Session } from './session'
import {
SessionNotFoundError,
type SessionInfo,
type TakePendingOutputResult,
type TerminalSnapshot
} from './types'
import { SessionNotFoundError } from './types'
import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types'
import type { CreateOrAttachResult } from './terminal-host-create-contract'
import type { TerminalHostOptions } from './terminal-host-options'
import { disposeTerminalHostSessions } from './terminal-host-disposal'
import { getAliveTerminalHostSession } from './terminal-host-session-access'
import { TerminalSessionTeardown } from './terminal-session-teardown'
import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
import {
createOrAttachClaimedAgentSession,
type InternalCreateOrAttachOptions
} from './terminal-host-agent-session-claim'
import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim'
import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim'
import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations'
import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
import { TerminalHostTombstones } from './terminal-host-tombstones'
@@ -24,10 +18,8 @@ import { TerminalAttachCanceledError } from './daemon-errors'
import { waitForTerminalAttachOperation } from './terminal-attach-cancellation'
import { randomUUID } from 'node:crypto'
import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations'
import {
inspectTerminalHostProcess,
type TerminalHostProcessInspection
} from './terminal-host-process-inspection'
import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
import type { TerminalHostProcessInspection } from './terminal-host-process-inspection'
import {
confirmTerminalHostForegroundProcess,
confirmTerminalHostShellForeground,
@@ -320,6 +312,15 @@ export class TerminalHost {
return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners)
}
hasLiveSessions(): boolean {
let hasLive = false
// Read every Session, even after a live one, to preserve the inventory's error order.
for (const session of this.sessions.values()) {
hasLive = session.isAlive || hasLive
}
return hasLive
}
dispose(): Promise<void> {
this.creationFenced = true
if (this.disposePromise) {
@@ -2,6 +2,7 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_pr
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write'
import { runProcess } from '../../../shared/child-process/run-process'
import { resolveSelectedLocalCommand } from '../../ipc/command-path-resolver'
import type { WslProcessGroupTermination } from '../wsl-process-group-termination'
import { createAbortError } from './abort-error'
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
@@ -30,7 +31,10 @@ export async function execFileCaptureToTermination(
// Spawn cost is reported by spawnProcess's observer, which runProcess goes
// through; recording it again here would double-count every capture.
const pending = runProcess({
program: command,
program: resolveSelectedLocalCommand(command, {
env: options.env,
cwd: typeof options.cwd === 'string' ? options.cwd : undefined
}),
args,
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
env: options.env,
@@ -1,250 +1,183 @@
/**
* POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
* Timing distributions are reported for field comparison; CI assertions stay structural.
*/
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { existsSync, watch } from 'node:fs'
import { chmod, mkdir, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { runProcess } from '../../../shared/child-process/run-process'
import { gitExecFileAsync } from './git-exec-file'
import {
GIT_ADMISSION_AGING_MS,
GENERAL_CAP,
GENERAL_HEADROOM,
GitAdmissionScheduler,
MAX_GIT_CHILDREN,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests,
type GitAdmissionEvent
} from './git-subprocess-admission'
import {
assertAdmissionLedger,
createStormRoot,
formatMeasurementTable,
measureStorm,
type InteractiveQueueSnapshot
} from './git-admission-storm-test-fixture'
type StormMeasurement = {
mode: 'disabled' | 'enabled'
maxConcurrentChildren: number
eventLoopMaxDriftMs: number
eventLoopP99DriftMs: number
interactiveP50Ms: number
interactiveP95Ms: number
totalWallMs: number
admissionEvents: GitAdmissionEvent[]
interactiveQueueSnapshots: InteractiveQueueSnapshot[]
function waitForLiveChild(stateDir: string, id: string, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const watcher = watch(stateDir)
const finish = (error?: Error): void => {
clearTimeout(deadline)
watcher.close()
signal.removeEventListener('abort', aborted)
if (error) {
reject(error)
} else {
resolve()
}
}
const aborted = (): void => finish(new Error(`Aborted waiting for ${id}`))
const deadline = setTimeout(() => finish(new Error(`Child ${id} did not start`)), 5_000)
const check = (): void => {
if (existsSync(path.join(stateDir, `${id}.live`))) {
finish()
}
}
watcher.on('change', check)
watcher.once('error', finish)
signal.addEventListener('abort', aborted, { once: true })
if (signal.aborted) {
aborted()
} else {
check()
}
})
}
type InteractiveQueueSnapshot = {
commandLabel: string
backgroundWaiterIds: number[]
}
const tempRoots: string[] = []
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
afterEach(async () => {
if (originalAdmissionDisabled === undefined) {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
} else {
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
}
_resetGitAdmissionForTests()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function percentile(values: readonly number[], percentileValue: number): number {
if (values.length === 0) {
return 0
}
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
}
async function liveChildCount(stateDir: string): Promise<number> {
return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
}
async function createStubGit(root: string): Promise<string> {
async function verifySpawnContention(): Promise<void> {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
const admissionEvents: GitAdmissionEvent[] = []
_resetGitAdmissionForTests(
new GitAdmissionScheduler({
now: () => 0,
onAdmissionEvent: (event) => admissionEvents.push(event)
})
)
const root = await createStormRoot('contention')
const stateDir = path.join(root, 'state')
const gateDir = path.join(root, 'gates')
const binDir = path.join(root, 'bin')
await mkdir(binDir)
const stubPath = path.join(binDir, 'git')
await Promise.all([stateDir, gateDir, binDir].map((directory) => mkdir(directory)))
const backgroundIds = Array.from({ length: GENERAL_CAP + 4 }, (_, index) => `background-${index}`)
const headroomIds = Array.from({ length: GENERAL_HEADROOM }, (_, index) => `interactive-${index}`)
const queuedIds = ['interactive-queued-0', 'interactive-queued-1']
const ids = [...backgroundIds, ...headroomIds, ...queuedIds]
const gates = await runProcess({
program: 'mkfifo',
args: ids.map((id) => path.join(gateDir, id))
})
expect(gates.code, gates.stderr).toBe(0)
const stub = path.join(binDir, 'git')
await writeFile(
stubPath,
stub,
`#!/bin/sh
set -eu
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
: > "$live"
trap 'rm -f "$live"' EXIT HUP INT TERM
sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
IFS= read -r release < "$ORCA_STUB_GATE_DIR/$ORCA_STUB_ID"
printf 'stub:%s\\n' "$*"
`
)
await chmod(stubPath, 0o755)
return binDir
}
function setAdmissionMode(mode: StormMeasurement['mode']): void {
if (mode === 'disabled') {
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
} else {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
}
}
async function measureStorm(mode: StormMeasurement['mode']): Promise<StormMeasurement> {
setAdmissionMode(mode)
const admissionEvents: GitAdmissionEvent[] = []
const admissionClockStartedAt = performance.now()
_resetGitAdmissionForTests(
new GitAdmissionScheduler({
now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
onAdmissionEvent: (event) => admissionEvents.push(event)
})
)
const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`))
tempRoots.push(root)
const stateDir = path.join(root, 'state')
await mkdir(stateDir)
const binDir = await createStubGit(root)
const repoDirs = await Promise.all(
Array.from({ length: 6 }, async (_, index) => {
const repoDir = path.join(root, `repo-${index}`)
await mkdir(repoDir)
return repoDir
})
)
const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
const startedAt = performance.now()
const drifts: number[] = []
let nextJankSample = performance.now() + 50
const jankTimer = setInterval(() => {
const now = performance.now()
drifts.push(Math.max(0, now - nextJankSample))
nextJankSample = now + 50
}, 50)
let maxConcurrentChildren = 0
const censusTimer = setInterval(() => {
void liveChildCount(stateDir).then((count) => {
maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
})
}, 5)
const background = Array.from({ length: 60 }, (_, index) =>
gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
cwd: repoDirs[index % repoDirs.length],
env: {
...baseEnv,
ORCA_STUB_ID: `background-${index}`,
ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
ORCA_STUB_STATE_DIR: stateDir
},
admissionTier: 'background'
})
)
await chmod(stub, 0o755)
const controller = new AbortController()
const commands = new Map<string, ReturnType<typeof gitExecFileAsync>>()
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
const interactiveLatencies = await Promise.all(
Array.from(
{ length: 10 },
(_, index) =>
new Promise<number>((resolve, reject) => {
setTimeout(
() => {
void (async () => {
const concurrentAtInjection = await liveChildCount(stateDir)
const commandStartedAt = performance.now()
const commandLabel = `interactive-${index}`
interactiveQueueSnapshots.push({
commandLabel,
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
.map((waiter) => waiter.id)
})
await gitExecFileAsync(['rev-parse', commandLabel], {
cwd: repoDirs[index % repoDirs.length],
env: {
...baseEnv,
ORCA_STUB_ID: `interactive-${index}`,
ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
ORCA_STUB_STATE_DIR: stateDir
},
admissionTier: 'interactive'
})
resolve(performance.now() - commandStartedAt)
})().catch(reject)
},
50 * (index + 1)
)
})
)
)
await Promise.all(background)
clearInterval(censusTimer)
clearInterval(jankTimer)
maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
const totalWallMs = performance.now() - startedAt
return {
mode,
maxConcurrentChildren,
eventLoopMaxDriftMs: Math.max(0, ...drifts),
eventLoopP99DriftMs: percentile(drifts, 0.99),
interactiveP50Ms: percentile(interactiveLatencies, 0.5),
interactiveP95Ms: percentile(interactiveLatencies, 0.95),
totalWallMs,
admissionEvents,
interactiveQueueSnapshots
}
}
function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
const rounded = rows.map((row) => ({
mode: row.mode,
maxConcurrentChildren: row.maxConcurrentChildren,
eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
totalWallMs: row.totalWallMs.toFixed(1)
}))
return JSON.stringify(rounded)
}
function assertAdmissionLedger(measurement: StormMeasurement): void {
const activeWaiters = new Set<number>()
const grantSequenceByWaiter = new Map<number, number>()
const grantByLabel = new Map<string, GitAdmissionEvent>()
measurement.admissionEvents.forEach((event, index) => {
expect(event.sequence).toBe(index)
if (event.phase === 'grant') {
activeWaiters.add(event.waiterId)
grantSequenceByWaiter.set(event.waiterId, event.sequence)
const label = event.args.find((arg) => arg.startsWith('interactive-'))
if (label) {
grantByLabel.set(label, event)
const start = (id: string, tier: 'background' | 'interactive'): void => {
if (tier === 'interactive') {
interactiveQueueSnapshots.push({
commandLabel: id,
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
.map((waiter) => waiter.id)
})
}
const command = gitExecFileAsync([tier === 'background' ? 'status' : 'rev-parse', id], {
cwd: root,
admissionTier: tier,
signal: controller.signal,
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
ORCA_STUB_ID: id,
ORCA_STUB_STATE_DIR: stateDir,
ORCA_STUB_GATE_DIR: gateDir
}
} else {
expect(activeWaiters.delete(event.waiterId)).toBe(true)
})
void command.catch(() => {})
commands.set(id, command)
}
const release = async (id: string): Promise<void> => {
await waitForLiveChild(stateDir, id, controller.signal)
await writeFile(path.join(gateDir, id), 'release\n')
await expect(commands.get(id)).resolves.toEqual({
stdout: `stub:${id.startsWith('background-') ? 'status' : 'rev-parse'} ${id}\n`,
stderr: ''
})
}
try {
backgroundIds.forEach((id) => start(id, 'background'))
expect(_gitAdmissionSnapshotForTests().queued).toBe(4)
headroomIds.forEach((id) => start(id, 'interactive'))
queuedIds.forEach((id) => start(id, 'interactive'))
expect(_gitAdmissionSnapshotForTests()).toMatchObject({
queued: 6,
budgets: { general: { baseUsed: GENERAL_CAP, headroomUsed: GENERAL_HEADROOM } }
})
await Promise.all(
[...backgroundIds.slice(0, GENERAL_CAP), ...headroomIds].map((id) =>
waitForLiveChild(stateDir, id, controller.signal)
)
)
expect(await readdir(stateDir)).toHaveLength(GENERAL_CAP + GENERAL_HEADROOM)
for (const [index, id] of queuedIds.entries()) {
await release(backgroundIds[index])
await waitForLiveChild(stateDir, id, controller.signal)
}
expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
for (const budget of event.budgets) {
expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
expect(_gitAdmissionSnapshotForTests().queuedWaiters.map((waiter) => waiter.tier)).toEqual([
'background',
'background',
'background',
'background'
])
for (const id of [...headroomIds, ...queuedIds, ...backgroundIds.slice(queuedIds.length)]) {
await release(id)
}
})
expect(activeWaiters.size).toBe(0)
for (const snapshot of measurement.interactiveQueueSnapshots) {
const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
expect(interactiveGrant).toBeDefined()
const preceded = snapshot.backgroundWaiterIds.filter(
(waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
).length
expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
expect(admissionEvents.filter((event) => event.phase === 'grant')).toHaveLength(ids.length)
expect(admissionEvents.filter((event) => event.phase === 'release')).toHaveLength(ids.length)
assertAdmissionLedger({ admissionEvents, interactiveQueueSnapshots })
expect(_gitAdmissionSnapshotForTests()).toMatchObject({
queued: 0,
budgets: { general: { baseUsed: 0, headroomUsed: 0 } }
})
expect(await readdir(stateDir)).toEqual([])
} finally {
controller.abort()
await Promise.allSettled(commands.values())
}
}
describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => {
it('reports bounded-concurrency before and after measurements', async () => {
const disabled = await measureStorm('disabled')
const enabled = await measureStorm('enabled')
console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
assertAdmissionLedger(enabled)
})
it(
'bounds real children and drains interactive work before the background backlog',
verifySpawnContention
)
// Output parity lives in git-admission-output-parity.test.ts: it needs real git,
// not the PATH stub, so it runs on win32 too and cannot share this file's gate.
it.runIf(process.env.ORCA_GIT_ADMISSION_STORM_MEASUREMENT === '1')(
'reports bounded-concurrency before and after measurements',
async () => {
const disabled = await measureStorm('disabled')
const enabled = await measureStorm('enabled')
console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`)
assertAdmissionLedger(enabled)
}
)
// Real-git output parity remains in git-admission-output-parity.test.ts, including Windows.
})
@@ -0,0 +1,245 @@
/**
* POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path.
* Timing distributions are reported for field comparison; CI assertions stay structural.
*/
import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, expect } from 'vitest'
import { gitExecFileAsync } from './git-exec-file'
import {
GIT_ADMISSION_AGING_MS,
GitAdmissionScheduler,
MAX_GIT_CHILDREN,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests,
type GitAdmissionEvent
} from './git-subprocess-admission'
type StormMeasurement = {
mode: 'disabled' | 'enabled'
maxConcurrentChildren: number
eventLoopMaxDriftMs: number
eventLoopP99DriftMs: number
interactiveP50Ms: number
interactiveP95Ms: number
totalWallMs: number
admissionEvents: GitAdmissionEvent[]
interactiveQueueSnapshots: InteractiveQueueSnapshot[]
}
export type InteractiveQueueSnapshot = {
commandLabel: string
backgroundWaiterIds: number[]
}
const tempRoots: string[] = []
const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED
afterEach(async () => {
if (originalAdmissionDisabled === undefined) {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
} else {
process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled
}
_resetGitAdmissionForTests()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
export async function createStormRoot(label: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${label}-`))
tempRoots.push(root)
return root
}
function percentile(values: readonly number[], percentileValue: number): number {
if (values.length === 0) {
return 0
}
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
}
async function liveChildCount(stateDir: string): Promise<number> {
return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length
}
async function createStubGit(root: string): Promise<string> {
const binDir = path.join(root, 'bin')
await mkdir(binDir)
const stubPath = path.join(binDir, 'git')
await writeFile(
stubPath,
`#!/bin/sh
set -eu
live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live"
: > "$live"
trap 'rm -f "$live"' EXIT HUP INT TERM
sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")"
printf 'stub:%s\\n' "$*"
`
)
await chmod(stubPath, 0o755)
return binDir
}
function setAdmissionMode(mode: StormMeasurement['mode']): void {
if (mode === 'disabled') {
process.env.ORCA_GIT_ADMISSION_DISABLED = '1'
} else {
delete process.env.ORCA_GIT_ADMISSION_DISABLED
}
}
export async function measureStorm(mode: StormMeasurement['mode']): Promise<StormMeasurement> {
setAdmissionMode(mode)
const admissionEvents: GitAdmissionEvent[] = []
const admissionClockStartedAt = performance.now()
_resetGitAdmissionForTests(
new GitAdmissionScheduler({
now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1),
onAdmissionEvent: (event) => admissionEvents.push(event)
})
)
const root = await createStormRoot(mode)
const stateDir = path.join(root, 'state')
await mkdir(stateDir)
const binDir = await createStubGit(root)
const repoDirs = await Promise.all(
Array.from({ length: 6 }, async (_, index) => {
const repoDir = path.join(root, `repo-${index}`)
await mkdir(repoDir)
return repoDir
})
)
const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }
const startedAt = performance.now()
const drifts: number[] = []
let nextJankSample = performance.now() + 50
const jankTimer = setInterval(() => {
const now = performance.now()
drifts.push(Math.max(0, now - nextJankSample))
nextJankSample = now + 50
}, 50)
let maxConcurrentChildren = 0
const censusTimer = setInterval(() => {
void liveChildCount(stateDir).then((count) => {
maxConcurrentChildren = Math.max(maxConcurrentChildren, count)
})
}, 5)
const background = Array.from({ length: 60 }, (_, index) =>
gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], {
cwd: repoDirs[index % repoDirs.length],
env: {
...baseEnv,
ORCA_STUB_ID: `background-${index}`,
ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200',
ORCA_STUB_STATE_DIR: stateDir
},
admissionTier: 'background'
})
)
const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = []
const interactiveLatencies = await Promise.all(
Array.from(
{ length: 10 },
(_, index) =>
new Promise<number>((resolve, reject) => {
setTimeout(
() => {
void (async () => {
const concurrentAtInjection = await liveChildCount(stateDir)
const commandStartedAt = performance.now()
const commandLabel = `interactive-${index}`
interactiveQueueSnapshots.push({
commandLabel,
backgroundWaiterIds: _gitAdmissionSnapshotForTests()
.queuedWaiters.filter((waiter) => waiter.tier === 'background')
.map((waiter) => waiter.id)
})
await gitExecFileAsync(['rev-parse', commandLabel], {
cwd: repoDirs[index % repoDirs.length],
env: {
...baseEnv,
ORCA_STUB_ID: `interactive-${index}`,
ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)),
ORCA_STUB_STATE_DIR: stateDir
},
admissionTier: 'interactive'
})
resolve(performance.now() - commandStartedAt)
})().catch(reject)
},
50 * (index + 1)
)
})
)
)
await Promise.all(background)
clearInterval(censusTimer)
clearInterval(jankTimer)
maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir))
const totalWallMs = performance.now() - startedAt
return {
mode,
maxConcurrentChildren,
eventLoopMaxDriftMs: Math.max(0, ...drifts),
eventLoopP99DriftMs: percentile(drifts, 0.99),
interactiveP50Ms: percentile(interactiveLatencies, 0.5),
interactiveP95Ms: percentile(interactiveLatencies, 0.95),
totalWallMs,
admissionEvents,
interactiveQueueSnapshots
}
}
export function formatMeasurementTable(rows: readonly StormMeasurement[]): string {
const rounded = rows.map((row) => ({
mode: row.mode,
maxConcurrentChildren: row.maxConcurrentChildren,
eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1),
eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1),
interactiveP50Ms: row.interactiveP50Ms.toFixed(1),
interactiveP95Ms: row.interactiveP95Ms.toFixed(1),
totalWallMs: row.totalWallMs.toFixed(1)
}))
return JSON.stringify(rounded)
}
export function assertAdmissionLedger(
measurement: Pick<StormMeasurement, 'admissionEvents' | 'interactiveQueueSnapshots'>
): void {
const activeWaiters = new Set<number>()
const grantSequenceByWaiter = new Map<number, number>()
const grantByLabel = new Map<string, GitAdmissionEvent>()
measurement.admissionEvents.forEach((event, index) => {
expect(event.sequence).toBe(index)
if (event.phase === 'grant') {
activeWaiters.add(event.waiterId)
grantSequenceByWaiter.set(event.waiterId, event.sequence)
const label = event.args.find((arg) => arg.startsWith('interactive-'))
if (label) {
grantByLabel.set(label, event)
}
} else {
expect(activeWaiters.delete(event.waiterId)).toBe(true)
}
expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN)
for (const budget of event.budgets) {
expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity)
expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity)
}
})
expect(activeWaiters.size).toBe(0)
for (const snapshot of measurement.interactiveQueueSnapshots) {
const interactiveGrant = grantByLabel.get(snapshot.commandLabel)
expect(interactiveGrant).toBeDefined()
const preceded = snapshot.backgroundWaiterIds.filter(
(waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity)
).length
expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9))
}
}
+5 -1
View File
@@ -23,7 +23,11 @@ export {
type PullRequestPushTarget
} from './client/lookup/pull-request-push-target'
export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream'
export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item'
export {
getWorkItem,
getWorkItemByOwnerRepo,
getWorkItemWithRepository
} from './client/fetch/get-work-item'
export { listWorkItems } from './client/list/list-work-items'
export { mergePR } from './client/merge/merge-pr'
export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers'
+51 -18
View File
@@ -21,6 +21,30 @@ export async function getWorkItem(
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<MainWorkItem | null> {
const { item } = await getWorkItemWithRepository(
repoPath,
number,
type,
connectionId,
localGitOptions,
preference
)
return item
}
export type WorkItemRepositoryLookup = {
item: MainWorkItem | null
repository: GitHubApiRepository | null
}
export async function getWorkItemWithRepository(
repoPath: string,
number: number,
type?: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<WorkItemRepositoryLookup> {
await acquire()
try {
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
@@ -35,18 +59,24 @@ export async function getWorkItem(
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
// (same fail-closed rule as origin-pinned PR candidate resolution).
if (!source && preference === 'origin') {
return null
return { item: null, repository: null }
}
return {
item: await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions),
repository: source
}
return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
}
if (type === 'pr') {
return await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
)
return {
item: await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
),
repository: null
}
}
try {
@@ -65,7 +95,7 @@ export async function getWorkItem(
localGitOptions
)
if (issue) {
return issue
return { item: issue, repository: source }
}
}
} catch (err) {
@@ -75,15 +105,18 @@ export async function getWorkItem(
throw err
}
}
return await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
)
return {
item: await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
),
repository: null
}
} catch {
return null
return { item: null, repository: null }
} finally {
release()
}
@@ -22,4 +22,25 @@ describe('isValidGitHubApiRepository', () => {
expect(isValidGitHubApiRepository({ owner: 'acme', repo: '..' })).toBe(false)
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'a/b' })).toBe(false)
})
it.each([42, false, null, {}])('rejects a non-string repository host: %j', (host) => {
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(false)
})
it.each([undefined, 'github.com', 'github.enterprise.test:8443'])(
'accepts supported legacy or explicit repository host types: %j',
(host) => {
expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(true)
}
)
it.each([
null,
'acme/orca',
{ owner: 42, repo: 'orca' },
{ owner: 'acme', repo: 42 },
{ owner: 'acme' }
])('rejects malformed runtime repository identities: %j', (repository) => {
expect(isValidGitHubApiRepository(repository)).toBe(false)
})
})
@@ -11,8 +11,21 @@ export type GitHubApiRepositoryResolution =
const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE
const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/
export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean {
export function isValidGitHubApiRepository(repository: unknown): repository is GitHubOwnerRepo {
if (
!repository ||
typeof repository !== 'object' ||
!('owner' in repository) ||
!('repo' in repository)
) {
return false
}
return (
typeof repository.owner === 'string' &&
typeof repository.repo === 'string' &&
(!('host' in repository) ||
repository.host === undefined ||
typeof repository.host === 'string') &&
OWNER_SLUG_RE.test(repository.owner) &&
REPOSITORY_SLUG_RE.test(repository.repo) &&
repository.repo !== '.' &&
+17 -15
View File
@@ -1,32 +1,34 @@
import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
import {
resolveGitHubRepoExecution,
resolveIssueGitHubApiRepositorySource
} from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
/**
* Add a comment to an existing GitHub issue.
*
* Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`):
* a comment is posted against an issue number already bound to a worktree or
* surfaced from a prior read. Routing through the live per-repo preference
* would let a user read upstream#N, toggle the selector to origin, and have
* their reply silently post on origin#N — a different issue entirely. That
* is the same silent-source-switch class of wrongness #1186 / the parent
* design doc guard against. List and create paths honor preference;
* mutations stay on the heuristic `getIssueOwnerRepo`.
*/
// An explicit target keeps replies bound to the conversation; otherwise use the selected source.
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string,
connectionId?: string | null,
ownerRepoOverride?: OwnerRepo | null,
localGitOptions: LocalGitExecOptions = {}
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<GitHubCommentResult> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
ownerRepoOverride ??
(() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)),
(async () =>
(
await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
).source),
connectionId,
localGitOptions
)
+25 -21
View File
@@ -1,4 +1,4 @@
import type { GitHubAssignableUser } from '../../shared/github/pull-request-types'
import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
import {
@@ -11,19 +11,21 @@ export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
localGitOptions: LocalGitExecOptions = {},
repositoryOverride?: GitHubOwnerRepo | null
): Promise<string[]> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
async () =>
(
await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
).source,
repositoryOverride ??
(async () =>
(
await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
).source),
connectionId,
localGitOptions
)
@@ -57,19 +59,21 @@ export async function listAssignableUsers(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
localGitOptions: LocalGitExecOptions = {},
repositoryOverride?: GitHubOwnerRepo | null
): Promise<GitHubAssignableUser[]> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
async () =>
(
await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
).source,
repositoryOverride ??
(async () =>
(
await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
).source),
connectionId,
localGitOptions
)
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GithubApiRepositoryModule from './github-api-repository'
import type * as GhUtils from './gh-utils'
const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted(
() => ({
ghExecFileAsyncMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
})
)
vi.mock('./gh-utils', async () => {
const actual = await vi.importActual<typeof GhUtils>('./gh-utils')
return {
...actual,
ghExecFileAsync: ghExecFileAsyncMock,
acquire: acquireMock,
release: releaseMock
}
})
vi.mock('./github-api-repository', async (importOriginal) => ({
...(await importOriginal<typeof GithubApiRepositoryModule>()),
resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock
}))
import { addIssueComment } from './issue-comment'
import { updateIssue } from './issue-update'
const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' }
describe('issue mutations follow the issue source preference', () => {
beforeEach(() => {
ghExecFileAsyncMock.mockReset()
acquireMock.mockReset()
acquireMock.mockResolvedValue(undefined)
releaseMock.mockReset()
resolveIssueSourceMock.mockReset()
resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({
source: preference === 'origin' ? ORIGIN : UPSTREAM,
fellBack: false
}))
})
it('posts issue comments to origin when origin is selected', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
})
await expect(
addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin')
).resolves.toMatchObject({ ok: true })
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
'repos/fork-owner/widgets/issues/5/comments'
)
})
it('keeps an explicit owner/repo override ahead of the preference', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' })
})
await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin')
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain(
'repos/upstream-owner/widgets/issues/7/comments'
)
})
it('edits issues on origin when origin is selected', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await expect(
updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin')
).resolves.toEqual({ ok: true })
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5')
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
'issue',
'edit',
'5',
'--repo',
'fork-owner/widgets',
'--add-assignee',
'octo'
])
})
it('keeps the upstream-first default when no preference is set', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await updateIssue('/repo-root', 5, { body: 'Updated' })
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {})
expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5')
})
it('keeps edits on the opened issue when the saved preference changes', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await updateIssue(
'/repo-root',
5,
{ body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] },
null,
{},
'upstream',
ORIGIN
)
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([
['issue', 'close', '5', '--repo', 'fork-owner/widgets'],
[
'api',
'-X',
'PATCH',
'repos/fork-owner/widgets/issues/5',
'--raw-field',
'body=Origin edit'
],
[
'issue',
'edit',
'5',
'--repo',
'fork-owner/widgets',
'--add-label',
'bug',
'--add-assignee',
'octo'
]
])
})
it('preserves local WSL execution with an explicit issue repository', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await updateIssue(
'/home/fixture/widgets',
5,
{ body: 'Origin edit' },
null,
{ wslDistro: 'Ubuntu' },
'upstream',
ORIGIN
)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['repos/fork-owner/widgets/issues/5']),
expect.objectContaining({
cwd: '/home/fixture/widgets',
wslDistro: 'Ubuntu',
host: 'github.com'
})
)
})
it('refuses unresolved Origin mutations instead of using ambient gh', async () => {
resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false })
await expect(
updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin')
).resolves.toMatchObject({ ok: false })
await expect(
addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin')
).resolves.toMatchObject({ ok: false })
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
})
+20 -16
View File
@@ -1,31 +1,35 @@
import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
import {
resolveGitHubRepoExecution,
resolveIssueGitHubApiRepositorySource,
type GitHubApiRepository
} from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
/**
* Update an existing GitHub issue. Fans out to separate gh commands for
* state changes vs field edits since `gh issue edit` does not support state.
*
* Why this path doesn't take a preference (mirrors `getIssue`): mutations
* target an issue number already bound to a worktree / linked elsewhere in
* the UI. Routing an update through the live per-repo preference would let
* a user open upstream#N, toggle the selector to origin, save, and silently
* write to origin#N — a different issue (or 404). That is the exact
* silent-source-switch class of wrongness #1186 / the parent design doc
* guard against. List and create paths honor preference; mutations stay on
* the heuristic `getIssueOwnerRepo`.
*/
// Use the selected source for issue-number edits.
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitHubIssueUpdate,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference,
ownerRepoOverride?: GitHubApiRepository | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
ownerRepoOverride ??
(async () =>
(
await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
).source),
connectionId,
localGitOptions
)
@@ -1,3 +1,4 @@
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
@@ -27,8 +28,11 @@ vi.mock('./gh-utils', () => ({
})
}))
vi.mock('./client', () => ({
vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
getWorkItemWithRepository: (
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
).makeWorkItemDetailsLookupMock(getWorkItemMock),
getPRComments: getPRCommentsMock,
getPRChecks: getPRChecksMock
}))
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type * as NodeOs from 'node:os'
import type * as GitExecError from '../git/exec-error'
import type * as WorkItemLookup from './client/fetch/get-work-item'
import { runProcess } from '../../shared/child-process/run-process'
const { ghMock, fixture } = vi.hoisted(() => {
const fixture: {
path: string
initialHost: string
replacementUrl: string
requests: { args: string[]; host?: string }[]
} = { path: '', initialHost: '', replacementUrl: '', requests: [] }
return { ghMock: vi.fn(), fixture }
})
vi.mock('node:os', async (original) => ({
...(await original<typeof NodeOs>()),
homedir: () => join(fixture.path, 'isolated-home')
}))
vi.mock('../git/runner', async () => {
const errors = await vi.importActual<typeof GitExecError>('../git/exec-error')
return {
...errors,
ghExecFileAsync: ghMock,
gitExecFileAsync: async (args: string[], options: { cwd?: string }) => {
const result = await runProcess({
program: 'git',
args,
cwd: options.cwd,
env: {
...process.env,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
}
})
if (result.code !== 0) {
throw new Error(result.stderr)
}
return { stdout: result.stdout, stderr: result.stderr }
}
}
})
vi.mock('./client', async () => ({
...(await vi.importActual<typeof WorkItemLookup>('./client/fetch/get-work-item')),
getPRChecks: vi.fn(),
getPRComments: vi.fn()
}))
vi.mock('./rate-limit', () => ({
repositoryRateLimitGuard: () => ({ blocked: false }),
noteRepositoryRateLimitSpend: vi.fn()
}))
import { getWorkItemDetails } from './work-item-details'
import { _resetOwnerRepoCache } from './github-repository-identity'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
import { _resetGitHubHostAuthCache } from './github-enterprise-repository'
async function fixtureGit(args: string[]): Promise<void> {
const result = await runProcess({
program: 'git',
args,
cwd: fixture.path,
env: {
...process.env,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig')
}
})
expect(result.code, result.stderr).toBe(0)
}
beforeEach(async () => {
fixture.path = await mkdtemp(join(tmpdir(), 'orca-issue-config-change-'))
await mkdir(join(fixture.path, 'isolated-home'))
await fixtureGit(['init', '--quiet'])
await fixtureGit(['remote', 'add', 'origin', 'https://github.com/fork-owner/widgets.git'])
_resetOwnerRepoCache()
_resetOriginGitHubApiRepositoryCache()
_resetGitHubHostAuthCache()
fixture.initialHost = 'github.com'
fixture.replacementUrl = 'https://github.com/replacement-owner/widgets.git'
fixture.requests = []
ghMock.mockReset()
ghMock.mockImplementation(async (args: string[], options: { host?: string }) => {
fixture.requests.push({ args, host: options.host })
if (args[0] === 'auth' && args[1] === 'status') {
return {
stdout: ['github.com', 'github.enterprise.test']
.map((host) => `${host}\n Logged in to ${host} account fixture (keyring)`)
.join('\n'),
stderr: ''
}
}
if (args.includes('repos/fork-owner/widgets/issues/5')) {
await fixtureGit(['remote', 'set-url', 'origin', fixture.replacementUrl])
return {
stdout: JSON.stringify({
number: 5,
title: 'ORIGIN title',
state: 'open',
html_url: `https://${fixture.initialHost}/fork-owner/widgets/issues/5`,
labels: [],
updated_at: '2026-10-02T00:00:00Z',
user: { login: 'fork-author' }
}),
stderr: ''
}
}
if (args.includes('graphql')) {
const original = args.includes('owner=fork-owner') && options.host === fixture.initialHost
const marker = original ? 'ORIGIN' : 'REPLACEMENT'
return {
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: `${marker} body`,
assignees: { nodes: [{ login: `${marker}-assignee` }] },
participants: { nodes: [] },
comments: {
nodes: [
{
databaseId: 1,
body: `${marker} comment`,
createdAt: '2026-10-02T00:00:00Z',
url: `https://${options.host}/fork-owner/widgets/issues/5#issuecomment-1`,
author: { login: `${marker}-author`, avatarUrl: '' }
}
]
}
}
}
}
}),
stderr: ''
}
}
if (args.some((arg) => arg.includes('/timeline?'))) {
return { stdout: '[]', stderr: '' }
}
throw new Error(`Unexpected fixture gh request: ${args.join(' ')}`)
})
})
afterEach(async () => {
await rm(fixture.path, { recursive: true, force: true })
})
it.each(['issue', undefined] as const)(
'keeps implicit %s conversation on the repository used for the item fetch',
async (type) => {
const details = await getWorkItemDetails(fixture.path, 5, type, null, {}, 'origin')
expect(details?.item.title).toBe('ORIGIN title')
expect(details?.body).toBe('ORIGIN body')
expect(details?.assignees).toEqual(['ORIGIN-assignee'])
expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
expect(fixture.requests[1].args).toContain('owner=fork-owner')
}
)
it('keeps an explicit local Tasks identity through the same real config change', async () => {
const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
owner: 'fork-owner',
repo: 'widgets',
host: 'github.com'
})
expect(details?.item.title).toBe('ORIGIN title')
expect(details?.body).toBe('ORIGIN body')
})
it.each([
{ from: 'github.com', to: 'github.enterprise.test', qualified: false },
{ from: 'github.enterprise.test', to: 'github.com', qualified: false },
{ from: 'github.com', to: 'github.enterprise.test', qualified: true },
{ from: 'github.enterprise.test', to: 'github.com', qualified: true }
])(
'keeps explicit host from $from to $to (qualified=$qualified)',
async ({ from, to, qualified }) => {
fixture.initialHost = from
fixture.replacementUrl = `https://${to}/fork-owner/widgets.git`
await fixtureGit(['remote', 'set-url', 'origin', `https://${from}/fork-owner/widgets.git`])
const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', {
owner: 'fork-owner',
repo: 'widgets',
...(qualified ? { host: from } : {})
})
expect(details?.item.url).toBe(`https://${from}/fork-owner/widgets/issues/5`)
expect(details?.body).toBe('ORIGIN body')
expect(details?.assignees).toEqual(['ORIGIN-assignee'])
expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment'])
const conversationRequests = fixture.requests.filter(
({ args }) => args.includes('graphql') || args.some((arg) => arg.includes('/timeline?'))
)
expect(conversationRequests.some(({ args }) => args.includes('graphql'))).toBe(true)
expect(
conversationRequests.some(({ args }) => args.some((arg) => arg.includes('/timeline?')))
).toBe(true)
expect(conversationRequests.map(({ host }) => host)).toEqual([from, from])
}
)
@@ -1,3 +1,4 @@
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -47,8 +48,11 @@ vi.mock('./gh-utils', () => ({
release: vi.fn()
}))
vi.mock('./client', () => ({
vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
getWorkItemWithRepository: (
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
@@ -1,3 +1,4 @@
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -59,8 +60,11 @@ vi.mock('./gh-utils', () => ({
release: releaseMock
}))
vi.mock('./client', () => ({
vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
getWorkItemWithRepository: (
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: vi.fn(),
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
@@ -0,0 +1,239 @@
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
ghExecFileAsyncMock,
getOwnerRepoMock,
getOwnerRepoForRemoteMock,
getWorkItemMock,
getWorkItemByOwnerRepoMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
getOwnerRepoForRemoteMock: vi.fn(),
getWorkItemMock: vi.fn(),
getWorkItemByOwnerRepoMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
ghExecFileAsync: ghExecFileAsyncMock,
getOwnerRepo: getOwnerRepoMock,
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })),
githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})),
acquire: acquireMock,
release: releaseMock
}))
vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
getWorkItemWithRepository: (
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
getPRChecks: vi.fn(),
getPRComments: vi.fn()
}))
vi.mock('./github-enterprise-repository', () => ({
getEnterpriseGitHubRepoSlug: vi.fn().mockResolvedValue(null),
getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null),
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
}))
vi.mock('./rate-limit', () => ({
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
noteRepositoryRateLimitSpend: vi.fn()
}))
vi.mock('../git/remote-name-listing', () => ({
shouldProbeGitRemote: vi.fn().mockResolvedValue(true)
}))
import { getWorkItemDetails } from './work-item-details'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
const REMOTES: Record<string, { owner: string; repo: string }> = {
origin: { owner: 'fork-owner', repo: 'widgets' },
upstream: { owner: 'upstream-owner', repo: 'widgets' }
}
function graphQLIssueResponse(assignee: string): { stdout: string } {
return {
stdout: JSON.stringify({
data: {
repository: {
issue: {
body: `${assignee} body`,
assignees: { nodes: [{ login: assignee }] },
participants: { nodes: [] },
comments: { nodes: [] }
}
}
}
})
}
}
function graphQLOwnerArg(): string | undefined {
const args: string[] = ghExecFileAsyncMock.mock.calls[0][0]
return args.find((arg) => arg.startsWith('owner='))
}
describe('getWorkItemDetails issue source', () => {
beforeEach(() => {
_resetOriginGitHubApiRepositoryCache()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
getOwnerRepoMock.mockResolvedValue(REMOTES.origin)
getOwnerRepoForRemoteMock.mockReset()
getOwnerRepoForRemoteMock.mockImplementation(
async (_repoPath: string, remoteName: string) => REMOTES[remoteName] ?? null
)
getWorkItemMock.mockReset()
getWorkItemByOwnerRepoMock.mockReset()
getWorkItemMock.mockResolvedValue({
id: 'issue:5',
type: 'issue',
number: 5,
title: 'Origin issue',
state: 'open',
url: 'https://github.com/fork-owner/widgets/issues/5',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'fork-author'
})
acquireMock.mockReset()
acquireMock.mockResolvedValue(undefined)
releaseMock.mockReset()
})
it('reads body, assignees and comments from origin when origin is selected', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
.mockResolvedValueOnce({ stdout: '' })
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin')
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 5, 'issue', null, {}, 'origin')
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
'repos/fork-owner/widgets/issues/5/timeline?per_page=100&page=1'
)
expect(details?.assignees).toEqual(['fork-assignee'])
})
it('keeps the upstream-first default when no preference is set', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
.mockResolvedValueOnce({ stdout: '' })
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {})
expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
expect(details?.assignees).toEqual(['upstream-assignee'])
})
it('keeps a Project row on upstream even when Origin is selected', async () => {
const upstream = { ...REMOTES.upstream, host: 'github.com' }
getWorkItemByOwnerRepoMock.mockResolvedValue({
id: 'issue:5',
type: 'issue',
number: 5,
title: 'Upstream project issue',
url: 'https://github.com/upstream-owner/widgets/issues/5',
state: 'open',
labels: [],
updatedAt: '',
author: 'upstream-author'
})
ghExecFileAsyncMock
.mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee'))
.mockResolvedValueOnce({ stdout: '' })
const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin', upstream)
expect(getWorkItemMock).not.toHaveBeenCalled()
expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
'/repo-root',
upstream,
5,
'issue',
null,
{}
)
expect(graphQLOwnerArg()).toBe('owner=upstream-owner')
expect(details?.item.title).toBe('Upstream project issue')
expect(details?.assignees).toEqual(['upstream-assignee'])
})
it('does not fall back when the selected issue repository is unavailable', async () => {
getWorkItemByOwnerRepoMock.mockResolvedValue(null)
await expect(
getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
...REMOTES.origin,
host: 'github.com'
})
).resolves.toBeNull()
expect(getWorkItemMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('rejects an invalid runtime issue host before calling the repository lookup', async () => {
await expect(
getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', {
...REMOTES.origin,
host: 42
})
).resolves.toBeNull()
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
expect(getWorkItemMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('ignores an issue repository override on an untyped lookup', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
.mockResolvedValueOnce({ stdout: '' })
await getWorkItemDetails('/repo-root', 5, undefined, null, {}, 'origin', {
owner: 'untrusted',
repo: 'other',
host: 'untrusted.example'
})
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
})
it('normalizes host-less issue targets before loading the conversation', async () => {
getWorkItemByOwnerRepoMock.mockResolvedValue({
type: 'issue',
number: 5,
title: 'Origin issue',
url: 'https://github.com/fork-owner/widgets/issues/5',
author: null
})
ghExecFileAsyncMock
.mockResolvedValueOnce(graphQLIssueResponse('fork-assignee'))
.mockResolvedValueOnce({ stdout: '' })
await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', REMOTES.origin)
expect(graphQLOwnerArg()).toBe('owner=fork-owner')
expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' })
expect(ghExecFileAsyncMock.mock.calls[1][1]).toMatchObject({ host: 'github.com' })
})
})
@@ -1,3 +1,4 @@
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -52,8 +53,11 @@ vi.mock('./gh-utils', () => ({
release: vi.fn()
}))
vi.mock('./client', () => ({
vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
getWorkItemWithRepository: (
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: vi.fn(),
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
@@ -0,0 +1,17 @@
import { resolveIssueGitHubApiRepositorySource } from './github-api-repository'
import type { getWorkItem } from './client'
import type { WorkItemRepositoryLookup } from './client/fetch/get-work-item'
// Why: detail tests replace item lookup; retain its selected-source metadata.
export function makeWorkItemDetailsLookupMock(lookup: typeof getWorkItem) {
return async (...args: Parameters<typeof getWorkItem>): Promise<WorkItemRepositoryLookup> => {
const item = await lookup(...args)
return {
item,
repository:
item?.type === 'issue'
? (await resolveIssueGitHubApiRepositorySource(args[0], args[5], args[3], args[4])).source
: null
}
}
}
+5 -1
View File
@@ -1,3 +1,4 @@
import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
@@ -62,8 +63,11 @@ vi.mock('./gh-utils', () => ({
release: releaseMock
}))
vi.mock('./client', () => ({
vi.mock('./client', async () => ({
getWorkItem: getWorkItemMock,
getWorkItemWithRepository: (
await vi.importActual<typeof WorkItemDetailsTestLookup>('./work-item-details-test-lookup')
).makeWorkItemDetailsLookupMock(getWorkItemMock),
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
getPRChecks: getPRChecksMock,
getPRComments: getPRCommentsMock
+45 -17
View File
@@ -1,14 +1,16 @@
import type { PRCheckDetail } from '../../shared/github/check-types'
import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types'
import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types'
import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import { getPRChecks, getPRComments, getWorkItem } from './client'
import { acquire, release, type LocalGitExecOptions } from './gh-utils'
import {
getIssueGitHubApiRepository,
resolveGitHubRepoExecution,
type GitHubApiRepository
} from './github-api-repository'
getPRChecks,
getPRComments,
getWorkItemWithRepository,
getWorkItemByOwnerRepo
} from './client'
import { acquire, release, type LocalGitExecOptions } from './gh-utils'
import { resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository'
import { isValidGitHubApiRepository } from './github-api-repository-validation'
import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details'
import {
getPRFiles,
@@ -69,23 +71,49 @@ export async function getWorkItemDetails(
type?: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
repositoryOverride?: unknown
): Promise<GitHubWorkItemDetails | null> {
const item: Omit<GitHubWorkItem, 'repoId'> | null = await getWorkItem(
repoPath,
number,
type,
connectionId,
localGitOptions,
preference
)
let issueRepositoryOverride: GitHubApiRepository | null = null
if (type === 'issue' && repositoryOverride != null) {
if (!isValidGitHubApiRepository(repositoryOverride)) {
return null
}
issueRepositoryOverride = (
await resolveGitHubRepoExecution(repoPath, repositoryOverride, connectionId, localGitOptions)
).ownerRepo
if (!issueRepositoryOverride) {
return null
}
}
const { item, repository: selectedRepository } = issueRepositoryOverride
? {
item: await getWorkItemByOwnerRepo(
repoPath,
issueRepositoryOverride,
number,
'issue',
connectionId,
localGitOptions
),
repository: issueRepositoryOverride
}
: await getWorkItemWithRepository(
repoPath,
number,
type,
connectionId,
localGitOptions,
preference
)
if (!item) {
return null
}
// Keep the conversation in the same repository as the selected issue.
const resolvedRepository =
item.type === 'issue'
? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
? selectedRepository
: (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions))
.ownerRepo
@@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['main/bitbucket/client.ts', 1],
['main/bitbucket/user-request.ts', 1],
['main/gitea/client.ts', 1],
// Generated OpenCode claim source consumes JSON or cancels its body in finally.
['main/opencode/opencode-startup-prompt-source.ts', 1],
['main/orca-profiles/profile-cloud-client.ts', 1],
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
+127 -2
View File
@@ -1,8 +1,8 @@
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { access, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { isCommandOnLocalPath } from './command-path-resolver'
import { isCommandOnLocalPath, listLocalCommandPaths } from './command-path-resolver'
describe('isCommandOnLocalPath', () => {
it('returns false for an empty command', async () => {
@@ -112,4 +112,129 @@ describe('isCommandOnLocalPath', () => {
).resolves.toBe(true)
})
})
// Why the full list exists (#22975): a dead version-manager shim passes the
// same executable check as the binary it shadows, so a winner-only lookup can
// only ever hand the caller the shim. The copies behind it are the answer.
describe.skipIf(process.platform === 'win32')('listLocalCommandPaths', () => {
let front = ''
let middle = ''
let back = ''
async function executable(dir: string, name: string, body: string): Promise<void> {
await writeFile(path.join(dir, name), body)
await chmod(path.join(dir, name), 0o755)
}
beforeAll(async () => {
front = await mkdtemp(path.join(tmpdir(), 'cmd-list-front-'))
middle = await mkdtemp(path.join(tmpdir(), 'cmd-list-middle-'))
back = await mkdtemp(path.join(tmpdir(), 'cmd-list-back-'))
await executable(front, 'gh', '#!/usr/bin/env bash\nexec /nope/asdf exec "gh" "$@"\n')
await executable(back, 'gh', "#!/bin/sh\nprintf 'gh version 2.98.0\\n'\n")
// Both a non-executable file and a directory in the middle: neither is a
// match, and neither may end the scan.
await writeFile(path.join(middle, 'gh'), 'not executable\n')
await chmod(path.join(middle, 'gh'), 0o644)
await mkdir(path.join(middle, 'glab'))
await executable(back, 'glab', '#!/bin/sh\n')
})
afterAll(async () => {
await rm(front, { recursive: true, force: true })
await rm(middle, { recursive: true, force: true })
await rm(back, { recursive: true, force: true })
})
it('returns every match in PATH order, shim first', async () => {
await expect(
listLocalCommandPaths('gh', {
platform: 'linux',
env: { PATH: `${front}:${middle}:${back}` }
})
).resolves.toEqual([path.posix.join(front, 'gh'), path.posix.join(back, 'gh')])
})
it('follows PATH order rather than which copy runs', async () => {
await expect(
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: `${back}:${front}` } })
).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
})
it('skips a directory that matches the command name and keeps scanning', async () => {
await expect(
listLocalCommandPaths('glab', {
platform: 'linux',
env: { PATH: `${middle}:${front}:${back}` }
})
).resolves.toEqual([path.posix.join(back, 'glab')])
})
it('returns an empty list when PATH holds no absolute match', async () => {
await expect(
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: '' } })
).resolves.toEqual([])
})
it('applies the absolute-only gate to the whole list, not just the winner', async () => {
await expect(
listLocalCommandPaths('gh', {
platform: 'linux',
env: { PATH: `.${path.delimiter}${front}` }
})
).resolves.toEqual([path.posix.join(front, 'gh')])
})
it('drops a relative match the filesystem would otherwise find', async () => {
// Why a second relative case: `path.posix.join('.', 'gh')` collapses to
// `gh`, so the case above is unaffected by deleting the gate. `fs.access`
// resolves a relative candidate against the process cwd, so only a PATH
// entry that really reaches a fixture proves the filter — and the `access`
// proves that reach first, making this fail loudly instead of going
// vacuous if the suite is ever run from a directory that hides it.
const relative = path.relative(process.cwd(), back)
await expect(access(path.join(relative, 'gh'))).resolves.toBeUndefined()
await expect(
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: relative } })
).resolves.toEqual([])
})
it('lists a PATH entry that repeats only once', async () => {
// Why it matters: callers spawn each entry, and `/usr/local/bin` appearing
// twice in a real PATH is normal — a doomed shim must not be probed twice.
await expect(
listLocalCommandPaths('gh', {
platform: 'linux',
env: { PATH: `${back}:${front}:${back}` }
})
).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
})
it('returns an empty list for an empty command', async () => {
await expect(listLocalCommandPaths('')).resolves.toEqual([])
})
it('resolves an absolute command path directly', async () => {
await expect(
listLocalCommandPaths(path.join(front, 'gh'), { platform: 'linux', env: { PATH: '' } })
).resolves.toEqual([`${front}/gh`])
})
it('lists every PATHEXT permutation on win32, in resolver order', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'cmd-list-win32-'))
try {
await writeFile(path.join(dir, 'tool.CMD'), '@echo off\n')
await writeFile(path.join(dir, 'tool.EXE'), '')
await expect(
listLocalCommandPaths('tool', {
platform: 'win32',
env: { Path: dir, PATHEXT: '.CMD;.EXE' },
cwd: dir
})
).resolves.toEqual([`${dir}/tool.CMD`, `${dir}/tool.EXE`])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
})
+138 -5
View File
@@ -1,4 +1,6 @@
import { access, constants as fsConstants, stat } from 'node:fs/promises'
import { statSync, type Stats } from 'node:fs'
import { homedir } from 'node:os'
import path from 'node:path'
export type ResolveCommandOptions = {
@@ -8,6 +10,8 @@ export type ResolveCommandOptions = {
env?: NodeJS.ProcessEnv
/** CWD used only for the win32 "search current directory first" rule. */
cwd?: string
/** Stop after this many matches; defaults to the complete list. */
maxResults?: number
}
// Why: Windows env keys are case-insensitive (PATH is usually stored as `Path`,
@@ -38,6 +42,109 @@ function getWindowsExtensions(env: NodeJS.ProcessEnv, command: string): string[]
return extensions
}
type LocalCommandSelection = {
scope: string
selected?: { binary: string; stamp: string; cwd?: string }
}
const localCommandSelections = new Map<string, LocalCommandSelection>()
function selectionScope(options: ResolveCommandOptions): string {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
const isWin = platform === 'win32'
const pathValue = readEnvCaseInsensitive(env, 'PATH') ?? ''
const pathApi = isWin ? path.win32 : path.posix
const needsCwd = pathValue.split(isWin ? ';' : ':').some((dir) => !pathApi.isAbsolute(dir))
return JSON.stringify([
platform,
pathValue,
isWin ? readEnvCaseInsensitive(env, 'PATHEXT') : null,
env.HOME,
env.USERPROFILE,
homedir(),
needsCwd ? (options.cwd ?? process.cwd()) : null
])
}
function commandFileStamp(stats: Stats): string {
return [stats.dev, stats.ino, stats.size, stats.mtimeMs, stats.ctimeMs, stats.mode].join(':')
}
/** Publish only a successful version probe; a newer probe supersedes an older one. */
export function beginLocalCommandSelection(
command: string
): (binary: string | null) => Promise<void> {
if (command !== 'gh' && command !== 'glab') {
return async () => {}
}
const scope = selectionScope({})
const probeCwd = process.cwd()
const previous = localCommandSelections.get(command)
const selection: LocalCommandSelection = {
scope,
selected: previous?.scope === scope ? previous.selected : undefined
}
localCommandSelections.set(command, selection)
return async (binary) => {
if (localCommandSelections.get(command) !== selection) {
return
}
if (binary === null || !path.isAbsolute(binary)) {
delete selection.selected
return
}
try {
const stats = await stat(binary)
if (localCommandSelections.get(command) === selection) {
const cwd =
process.platform === 'win32' &&
path.win32.resolve(path.win32.dirname(binary)).toLowerCase() ===
path.win32.resolve(probeCwd).toLowerCase()
? probeCwd
: undefined
// Only a current-directory CLI needs to stay tied to the probe's folder.
selection.selected = stats.isFile()
? { binary, stamp: commandFileStamp(stats), cwd }
: undefined
}
} catch {
// A binary removed during the probe must not become the runtime selection.
if (localCommandSelections.get(command) === selection) {
delete selection.selected
}
}
}
}
/** Native execution reuses preflight's selection without probing or replaying the operation. */
export function resolveSelectedLocalCommand(
command: string,
options: ResolveCommandOptions = {}
): string {
const selection = localCommandSelections.get(command)
if (!selection?.selected || selection.scope !== selectionScope(options)) {
return command
}
if (
selection.selected.cwd &&
path.win32.resolve(options.cwd ?? process.cwd()).toLowerCase() !==
path.win32.resolve(selection.selected.cwd).toLowerCase()
) {
return command
}
try {
const stats = statSync(selection.selected.binary)
if (commandFileStamp(stats) === selection.selected.stamp) {
return selection.selected.binary
}
} catch {
// Missing or replaced binaries require a fresh version probe.
}
delete selection.selected
return command
}
async function isExecutableFile(candidate: string, isWin: boolean): Promise<boolean> {
try {
// Why: stat (not lstat) so symlinked CLIs resolve to their real target.
@@ -63,12 +170,15 @@ async function isExecutableFile(candidate: string, isWin: boolean): Promise<bool
* which(1)/where.exe lookup, including the current preflight quirk that only
* counts matches which resolve to an ABSOLUTE path (so relative PATH entries
* and relative command paths stay not-found, exactly as before).
*
* Stops at the first match; use {@link listLocalCommandPaths} when the rest of
* the PATH matters too.
*/
export async function isCommandOnLocalPath(
command: string,
options: ResolveCommandOptions = {}
): Promise<boolean> {
return (await resolveCommandOnLocalPath(command, options)) !== null
return (await findLocalCommandPaths(command, options, true)).length > 0
}
/** The absolute path `isCommandOnLocalPath` found, or null. */
@@ -76,8 +186,24 @@ export async function resolveCommandOnLocalPath(
command: string,
options: ResolveCommandOptions = {}
): Promise<string | null> {
return (await findLocalCommandPaths(command, options, true))[0] ?? null
}
/** Ordered, deduplicated candidates, including executable shims that may fail to run. */
export async function listLocalCommandPaths(
command: string,
options: ResolveCommandOptions = {}
): Promise<string[]> {
return findLocalCommandPaths(command, options, false)
}
async function findLocalCommandPaths(
command: string,
options: ResolveCommandOptions,
stopAtFirst: boolean
): Promise<string[]> {
if (!command) {
return null
return []
}
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
@@ -95,6 +221,8 @@ export async function resolveCommandOnLocalPath(
const searchDirs = hasPathSeparator ? [''] : isWin ? [cwd, ...pathDirs] : pathDirs
const extensions = isWin ? getWindowsExtensions(env, command) : ['']
const found: string[] = []
const seen = new Set<string>()
for (const dir of searchDirs) {
for (const ext of extensions) {
// Why: forward-slash joins so candidates are statable on every platform
@@ -102,13 +230,18 @@ export async function resolveCommandOnLocalPath(
const candidate = path.posix.join(dir, command) + ext
// Why: preserve the prior `.some(line => path.isAbsolute(line))` filter
// over where/which stdout — only absolute resolutions count.
if (!isAbsolute(candidate)) {
const candidateKey = isWin ? candidate.toLowerCase() : candidate
if (!isAbsolute(candidate) || seen.has(candidateKey)) {
continue
}
seen.add(candidateKey)
if (await isExecutableFile(candidate, isWin)) {
return candidate
found.push(candidate)
if (stopAtFirst || found.length >= (options.maxResults ?? Infinity)) {
return found
}
}
}
}
return null
return found
}
@@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => {
it('counts only matches against the ripgrep cap', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n'))
.mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep(''))
const files = await listQuickOpenFiles(
@@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => {
it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep('', null, -2))
await expect(
@@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => {
it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
await expect(
@@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => {
it('still rejects an ignored-pass failure for unfiltered listings', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('a.ts\n'))
.mockImplementationOnce(() => fakeRipgrep('a.ts\0'))
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
await expect(
+98 -106
View File
@@ -88,6 +88,33 @@ describe('filesystem-list-files', () => {
)
})
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
const child = createMockProcess()
spawnMock.mockReturnValue(child)
const store: Store = Object.create(null)
const promise = listQuickOpenFiles('/repo', store)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
if (kind === 'incomplete') {
child.emit('close', 0, null)
}
await expect(promise).rejects.toThrow('not valid UTF-8')
if (kind === 'invalid') {
expect(child.kill).toHaveBeenCalled()
}
})
it('counts NUL-delimited filenames containing newlines as one result each', async () => {
const child = createMockProcess()
spawnMock.mockReturnValue(child)
const store: Store = Object.create(null)
const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0')
await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r'])
expect(child.kill).toHaveBeenCalled()
})
it('rejects a synchronous launch failure before cleanup has been initialized', async () => {
spawnMock.mockImplementationOnce(() => {
throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' })
@@ -122,7 +149,7 @@ describe('filesystem-list-files', () => {
)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts')
p1.stdout?.emit('data', 'one.ts\0two.ts')
p1.emit('close', 0, null)
}, 0)
const result = await promise
@@ -136,38 +163,60 @@ describe('filesystem-list-files', () => {
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version')
})
it('merges normal files and ignored files and filters correctly', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
it('keeps source files first when only a serialized byte budget is provided', async () => {
const source = createMockProcess()
const broad = createMockProcess()
spawnMock.mockImplementation((_command, args: string[]) =>
isIgnoredRgPass(args) ? broad : source
)
const listing = listQuickOpenFiles(
'/mock/root',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization and workspace lookup are mocked above.
{} as Store,
undefined,
undefined,
undefined,
20
)
await flushMicrotasks()
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs')
source.stdout?.emit('data', 'source.ts\0')
source.emit('close', 0, null)
await flushMicrotasks()
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs')
broad.stdout?.emit('data', 'ignored-file.ts\0')
await expect(listing).resolves.toEqual(['source.ts'])
expect(broad.kill).toHaveBeenCalledOnce()
})
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
it('lists normal and ignored files with one broad scan and filters correctly', async () => {
const p1 = createMockProcess()
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
await flushMicrotasks()
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock.mock.calls[0]?.[1]).toContain('--no-ignore-vcs')
// Simulate stdout output for normal files
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n')
;(p1.stdout as unknown as EventEmitter).emit('data', 'node_modules/bad.js\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.git/config\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n')
;(p1.stdout as unknown as EventEmitter).emit('data', 'dir1/') // incomplete line
;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n')
p1.emit('close', 0, null)
p1.stdout?.emit('data', 'file1.ts\0')
p1.stdout?.emit('data', 'node_modules/bad.js\0')
p1.stdout?.emit('data', '.git/config\0')
p1.stdout?.emit('data', '.github/workflows/ci.yml\0')
p1.stdout?.emit('data', 'dir1/') // incomplete line
p1.stdout?.emit('data', 'file2.js\0')
// Simulate stdout output for ignored files
;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n')
;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n')
;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate
;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n')
p2.emit('close', 0, null)
// The broad pass includes ignored files too.
p1.stdout?.emit('data', '.env.local\0')
p1.stdout?.emit('data', 'dist/generated.js\0')
p1.stdout?.emit('data', 'file1.ts\0') // Duplicate
p1.stdout?.emit('data', 'node_modules/ignored.js\0')
p1.emit('close', 0, null)
}, 10)
const result = await promise
@@ -183,23 +232,16 @@ describe('filesystem-list-files', () => {
it('spawns the bundled Linux rg inside the registered WSL runtime for Windows-path worktrees', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('C:\\repo', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
p1.stdout?.emit('data', 'src/index.ts\0')
p1.emit('close', 0, null)
p2.emit('close', 0, null)
}, 10)
await expect(promise).resolves.toEqual(['src/index.ts'])
@@ -214,23 +256,16 @@ describe('filesystem-list-files', () => {
it('normalizes absolute WSL rg output for Windows-path worktrees', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('C:\\repo', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n')
p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0')
p1.emit('close', 0, null)
p2.emit('close', 0, null)
}, 10)
await expect(promise).resolves.toEqual(['src/index.ts'])
@@ -238,16 +273,13 @@ describe('filesystem-list-files', () => {
it('treats a WSL launcher exit 127 as the bundled rg failing to start', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
Object.defineProperty(p1, 'pid', { value: 1 })
Object.defineProperty(p2, 'pid', { value: 2 })
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
spawnMock.mockReturnValue(p1)
const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store)
setTimeout(() => {
p1.emit('close', 127, null)
p2.emit('close', 0, null)
}, 0)
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
@@ -256,48 +288,33 @@ describe('filesystem-list-files', () => {
it("rejects with the bundled-ripgrep error when a native launcher exits outside ripgrep's contract", async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
spawnMock.mockReturnValue(p1)
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
setTimeout(() => p1.emit('close', 127, null), 0)
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
expect(p2.kill).toHaveBeenCalled()
})
it('rejects rg failures instead of resolving a false-empty list', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
p1.emit('close', 2, null)
p2.emit('close', 0, null)
}, 10)
await expect(promise).rejects.toThrow('rg exited with code 2')
})
it('kills the sibling rg pass after one pass fails', async () => {
it('does not start another scan after the admitted pass fails', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
@@ -308,27 +325,20 @@ describe('filesystem-list-files', () => {
}, 10)
await expect(promise).rejects.toThrow('rg exited with code 2')
expect(p2.kill).toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('accepts rg code 2 when rg emitted parseable paths first', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
p1.stdout?.emit('data', 'src/index.ts\0')
p1.emit('close', 2, null)
p2.emit('close', 0, null)
}, 10)
await expect(promise).resolves.toEqual(['src/index.ts'])
@@ -339,14 +349,8 @@ describe('filesystem-list-files', () => {
try {
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
@@ -355,14 +359,13 @@ describe('filesystem-list-files', () => {
await Promise.resolve()
await Promise.resolve()
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial')
p1.stdout?.emit('data', 'src/index.ts\0partial')
const rejection = expect(promise).rejects.toThrow('rg list timed out')
await vi.advanceTimersByTimeAsync(10000)
await rejection
expect(p1.kill).toHaveBeenCalled()
expect(p2.kill).toHaveBeenCalled()
expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect(p1.listenerCount('error')).toBe(0)
@@ -374,8 +377,7 @@ describe('filesystem-list-files', () => {
it('kills local rg scans when a paired listing is cancelled', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
spawnMock.mockReturnValue(p1)
const controller = new AbortController()
const cancellation = new FileListingCancelledError('superseded')
const promise = listQuickOpenFiles(
@@ -390,34 +392,24 @@ describe('filesystem-list-files', () => {
await expect(promise).rejects.toBe(cancellation)
expect(p1.kill).toHaveBeenCalledOnce()
expect(p2.kill).toHaveBeenCalledOnce()
})
it('filters out .next, .cache, .stably, .vscode, .idea', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
spawnMock.mockImplementation((_cmd, args: string[]) => {
if (isIgnoredRgPass(args)) {
return p2
}
return p1
})
spawnMock.mockReturnValue(p1)
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n')
;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
p1.stdout?.emit('data', '.next/cache/1.js\0')
p1.stdout?.emit('data', '.cache/data.json\0')
p1.stdout?.emit('data', '.stably/config.json\0')
p1.stdout?.emit('data', '.vscode/settings.json\0')
p1.stdout?.emit('data', '.idea/workspace.xml\0')
p1.stdout?.emit('data', 'valid.ts\0')
p1.emit('close', 0, null)
// Empty ignored result
p2.emit('close', 0, null)
}, 10)
const result = await promise
@@ -451,7 +443,7 @@ describe('filesystem-list-files', () => {
})
describe('when the bundled rg cannot start', () => {
it('kills only the admitted pass when ignored rg fails before spawn', async () => {
it('does not kill a process that failed before receiving a pid', async () => {
const primary = createMockProcess()
const missingIgnored = createMockProcess()
Object.defineProperty(missingIgnored, 'pid', { value: undefined })
@@ -461,11 +453,11 @@ describe('filesystem-list-files', () => {
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
await flushMicrotasks()
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenCalledTimes(1)
missingIgnored.emit('close', -2, null)
await expect(promise).rejects.toThrow(BUNDLED_ERROR)
expect(primary.kill).toHaveBeenCalled()
expect(primary.kill).not.toHaveBeenCalled()
expect(missingIgnored.kill).not.toHaveBeenCalled()
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
expect(() => missingIgnored.emit('error', error)).not.toThrow()
+33 -47
View File
@@ -1,3 +1,5 @@
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder'
import { sep } from 'node:path'
import type { ChildProcess } from 'node:child_process'
import type { Store } from '../persistence'
@@ -8,7 +10,6 @@ import {
buildExcludePathPrefixes,
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
type RgOutputMode,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
@@ -79,6 +80,10 @@ export async function listQuickOpenFiles(
const runRg = (args: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
killSpawnedRipgrepProcess(child)
finish(error)
}, Boolean(wslDistroForOutput))
let buf = ''
let done = false
let parseablePathCount = 0
@@ -138,18 +143,22 @@ export async function listQuickOpenFiles(
return
}
let timer: ReturnType<typeof setTimeout>
const handleStdoutData = (chunk: string): void => {
buf += chunk
const handleStdoutData = (chunk: Buffer | string): void => {
const decoded = filenameDecoder.decode(chunk)
if (decoded === null) {
return
}
buf += decoded
let start = 0
let newlineIdx = buf.indexOf('\n', start)
while (newlineIdx !== -1) {
if (processLine(buf.substring(start, newlineIdx))) {
let delimiterIdx = buf.indexOf('\0', start)
while (delimiterIdx !== -1) {
if (processLine(buf.substring(start, delimiterIdx))) {
buf = ''
finishAtLimit()
return
}
start = newlineIdx + 1
newlineIdx = buf.indexOf('\n', start)
start = delimiterIdx + 1
delimiterIdx = buf.indexOf('\0', start)
}
buf = start < buf.length ? buf.substring(start) : ''
}
@@ -211,14 +220,15 @@ export async function listQuickOpenFiles(
finish(new Error(`rg killed by ${signal}`))
return
}
if (!filenameDecoder.finish()) {
return
}
if (buf && processLine(buf)) {
buf = ''
finishAtLimit()
return
}
if (code === 0 || code === 1) {
finish()
} else if (code === 2 && parseablePathCount > 0) {
if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) {
// rg can return 2 for unreadable subdirectories while still listing
// usable files from the rest of the root.
finish()
@@ -257,7 +267,6 @@ export async function listQuickOpenFiles(
children.push({ child, isDone: () => done, finish })
child.stdout?.setEncoding('utf-8')
child.stdout?.on('data', handleStdoutData)
child.stderr?.on('data', handleStderrData)
child.once('error', handleError)
@@ -277,9 +286,7 @@ export async function listQuickOpenFiles(
}
const killSurvivors = (): void => {
// Why: if one rg pass fails, Promise.all rejects immediately while the
// sibling scan can keep walking a huge tree until timeout. Stop it so
// repeated Quick Open attempts do not accumulate local rg processes.
// Failed listings must release any process still walking the tree.
for (const entry of children) {
if (entry.isDone()) {
continue
@@ -292,34 +299,29 @@ export async function listQuickOpenFiles(
}
function finishAtLimit(): void {
for (const entry of children) {
if (entry.isDone()) {
continue
}
entry.finish()
if (entry.child.exitCode === null && entry.child.signalCode === null) {
killSpawnedRipgrepProcess(entry.child)
}
}
killSurvivors()
}
try {
const primaryRun = runRg(primary)
if (maxResults === undefined && maxSerializedBytes === undefined) {
// Why: a pid-less primary proves launch failure; avoid doubling the failed spawn.
await (children[0]?.child.pid === undefined
? primaryRun
: Promise.all([primaryRun, runRg(ignoredPass)]))
// The broader pass already includes source files; an unbounded listing needs only one scan.
await runRg(ignoredPass)
} else {
// Why: ignored-file output can be much larger and faster than the primary pass; let source
// files claim every bounded autocomplete budget first, including the transport byte cap.
await primaryRun
await runRg(primary)
if (
(maxResults === undefined || files.size < maxResults) &&
(maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes)
) {
// Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches.
await runRg(ignoredPass).catch((err: unknown) => {
if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) {
if (
!pathFilter ||
signal?.aborted ||
err instanceof RipgrepUnavailableError ||
err instanceof RipgrepFilenameError
) {
throw err
}
})
@@ -334,19 +336,3 @@ export async function listQuickOpenFiles(
? result
: limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes)
}
function getQuickOpenRgOutputMode(
rawLine: string,
translatedLine: string,
rootPath: string
): RgOutputMode {
if (
translatedLine !== rawLine ||
rawLine.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
rawLine.startsWith('\\\\')
) {
return { kind: 'absolute', rootPath }
}
return { kind: 'cwd-relative' }
}
@@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => {
)
})
it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const controller = new AbortController()
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
query: 'file',
limit: 2,
signal: controller.signal
})
await flushMicrotasks()
child.stdout?.emit('data', Buffer.from([0xf0, 0x9f]))
controller.abort()
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
})
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 })
await flushMicrotasks()
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
if (kind === 'incomplete') {
child.emit('close', 0, null)
}
await expect(promise).rejects.toThrow('not valid UTF-8')
if (kind === 'invalid') {
expect(child.kill).toHaveBeenCalled()
}
})
it('preserves control characters within ranked paths', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
query: 'target',
limit: 2
})
await flushMicrotasks()
child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0')
child.emit('close', 0, null)
expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort())
})
it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
query: 'target',
limit: 2
})
await flushMicrotasks()
child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1))
await expect(promise).rejects.toThrow('file path exceeds the listing limit')
expect(child.kill).toHaveBeenCalledTimes(1)
child.stdout?.emit('data', 'target.ts\0')
child.emit('close', 0, null)
})
it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
@@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => {
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1)
expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg')
expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs')
;(child.stdout as unknown as EventEmitter).emit(
child.stdout?.emit(
'data',
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n`
)
;(child.stdout as unknown as EventEmitter).emit(
'data',
'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n'
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0`
)
child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0')
child.emit('close', 0, null)
await expect(promise).resolves.toEqual({
@@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => {
limit: 32
})
await flushMicrotasks()
;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
child.stdout?.emit('data', 'src/target.ts\0')
child.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
@@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => {
await flushMicrotasks()
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2)
;(succeeded.stdout as unknown as EventEmitter).emit(
'data',
'data/chunk-077568/sta-4354-gitignored-target.bin\n'
)
succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0')
succeeded.emit('close', 0, null)
await expect(promise).resolves.toEqual({
@@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => {
limit: 32
})
await flushMicrotasks()
;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
succeeded.stdout?.emit('data', 'src/target.ts\0')
succeeded.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
+22 -16
View File
@@ -1,3 +1,5 @@
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
import { sep } from 'node:path'
import type { Store } from '../persistence'
import { fileListingCancellationError } from '../../shared/file-listing-cancellation'
@@ -6,8 +8,7 @@ import {
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath,
type RgOutputMode
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search'
import {
@@ -110,7 +111,11 @@ function scanRipgrepPaths(args: {
return Promise.reject(fileListingCancellationError(args.signal))
}
return new Promise((resolve, reject) => {
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a)
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
killSpawnedRipgrepProcess(child)
finish(error)
}, Boolean(args.wslDistroForOutput))
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0)
let done = false
let parseablePathCount = 0
let processErrorObserved = false
@@ -141,7 +146,7 @@ function scanRipgrepPaths(args: {
: rawLine
const relPath = normalizeQuickOpenRgLine(
translated,
getOutputMode(rawLine, translated, args.authorizedRootPath)
getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath)
)
if (relPath === null) {
return
@@ -178,11 +183,19 @@ function scanRipgrepPaths(args: {
resolve()
}
}
const handleStdoutData = (chunk: string): void => {
pathAccumulator.push(chunk, (path) => {
const handleStdoutData = (chunk: Buffer | string): void => {
const decoded = filenameDecoder.decode(chunk)
if (decoded === null) {
return
}
const result = pathAccumulator.push(decoded, (path) => {
processLine(path)
return true
})
if (result === 'path-too-large') {
killSpawnedRipgrepProcess(child)
finish(new Error('Quick Open file path exceeds the listing limit'))
}
}
const handleStderrData = (): void => {
/* drain */
@@ -233,6 +246,9 @@ function scanRipgrepPaths(args: {
finish(new Error(`rg killed by ${signal}`))
return
}
if (!filenameDecoder.finish()) {
return
}
const trailingPath = pathAccumulator.finish()
if (trailingPath) {
processLine(trailingPath)
@@ -249,7 +265,6 @@ function scanRipgrepPaths(args: {
finish(fileListingCancellationError(args.signal))
}
child.stdout?.setEncoding('utf-8')
child.stdout?.on('data', handleStdoutData)
child.stderr?.on('data', handleStderrData)
child.once('error', handleError)
@@ -265,12 +280,3 @@ function scanRipgrepPaths(args: {
}
})
}
function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode {
return translatedLine !== rawLine ||
rawLine.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
rawLine.startsWith('\\\\')
? { kind: 'absolute', rootPath }
: { kind: 'cwd-relative' }
}
@@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => {
}
)
it('keeps post-spawn errors on the existing empty-result path', async () => {
it('rejects post-spawn errors instead of returning an empty result', async () => {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: 1 })
wslAwareSpawnMock.mockReturnValue(child)
@@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => {
await flushMicrotasks()
child.emit('error', new Error('post-spawn failure'))
await expect(promise).resolves.toMatchObject({ files: [] })
await expect(promise).rejects.toThrow('post-spawn failure')
})
// Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler
@@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => {
expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg')
})
it('marks WSL filenames that UNC cannot represent as incomplete', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler.
registerFilesystemHandlers({} as never)
const promise = handlers.get('fs:search')!(
{ sender: { id: 7 } },
{ rootPath: 'C:\\repo', query: 'hello' }
)
await flushMicrotasks()
child.stdout?.emit(
'data',
`${JSON.stringify({
type: 'match',
data: {
path: { text: './a\\b.txt' },
lines: { text: 'hello\n' },
line_number: 1,
submatches: [{ start: 0, end: 5 }]
}
})}\n`
)
child.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ files: [], truncated: true })
expect(toWindowsWslPathMock).not.toHaveBeenCalled()
})
it('translates WSL rg output for Windows-path project search results', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
@@ -1,3 +1,4 @@
import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics'
import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines'
import { ipcMain } from 'electron'
import type { ChildProcess } from 'node:child_process'
@@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
return new Promise<SearchResult>((resolvePromise, rejectPromise) => {
const rgArgs = buildRgArgs(args.query, rootPath, args)
const rgArgs = buildRgArgs(args.query, '.', args)
// Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on.
const previousChild = activeTextSearches.get(searchKey)
if (previousChild) {
@@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
}
const acc = createAccumulator()
const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER)
const lines = new SearchSubprocessLineAccumulator()
const diagnostics = new RipgrepSearchDiagnostics()
let resolved = false
let processErrorObserved = false
let unavailableExitObserved = false
@@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
let killTimeout: ReturnType<typeof setTimeout>
const transformAbsPath = wslDistroForOutput
? (path: string): string =>
path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path
? (path: string): string | null =>
path.includes('\\')
? null
: path.startsWith('/')
? toWindowsWslPath(path, wslDistroForOutput)
: path
: undefined
const finish = (result: SearchResult | PromiseLike<SearchResult>): void => {
@@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
}
resolvePromise(result)
}
const resolveOnce = (): void => finish(finalize(acc))
const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => {
const error = diagnostics.failure(code, signal, acc)
finish(error ? Promise.reject(error) : finalize(acc))
}
const rejectUnavailable = (): void =>
finish(Promise.reject(bundledRipgrepUnavailableError()))
const processLine = (line: string): void => {
@@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
activeTextSearches.set(searchKey, nextChild)
const handleStdoutData = (chunk: string): void => {
lines.push(chunk, processLine)
if (!lines.push(chunk, processLine)) {
acc.truncated = true
if (child) {
killSpawnedRipgrepProcess(child)
}
resolveOnce()
}
}
const handleStderrData = (): void => {
// Drain stderr so rg cannot block on a full pipe.
const handleStderrData = (chunk: Buffer): void => {
diagnostics.append(chunk)
}
const handleError = (error: NodeJS.ErrnoException): void => {
processErrorObserved = true
@@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
return
}
if (child && isRipgrepUnavailableExit(child, null, null)) {
// Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the
// binary for it tells the user to reinstall Orca over a workspace that simply moved.
// Why detach close first: a failed spawn emits error THEN close(code < 0), and
// close settles synchronously, so this probe would otherwise race it on a sub-ms
// margin -- two measurements disagreed on which wins. Detaching makes it deterministic.
// Distinguish a missing workspace from a missing binary before close can settle.
child.off('close', handleClose)
// Why catch: a failed probe must not strand the search; fall back to the prior verdict.
void isRipgrepSpawnCwdUsable(rootPath)
.catch(() => true)
.then((usable) => {
// Why re-check: finish() drops its argument once settled, so a rejected promise
// built after the close handler already won would go unhandled.
// A late rejected promise must not escape after close settles the search.
if (resolved) {
return
}
@@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
})
return
}
resolveOnce()
finish(Promise.reject(error))
if (child) {
killSpawnedRipgrepProcess(child)
}
}
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
// Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise
@@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
rejectUnavailable()
return
}
const tail = lines.finish()
const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null
if (tail !== null) {
processLine(tail)
}
resolveOnce()
resolveOnce(code ?? -1, signal)
}
nextChild.stdout?.setEncoding('utf-8')
+55 -21
View File
@@ -15,7 +15,14 @@ import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-eve
export function registerGitHubIssueMutationHandlers(store: Store): void {
ipcMain.handle(
'gh:updateIssue',
async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => {
async (
event,
args: GitHubRepoScopedArgs & {
number: number
updates: GitHubIssueUpdate
ownerRepo?: GitHubOwnerRepo
}
) => {
const repo = assertRegisteredGitHubRepo(args, store)
if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) {
return { ok: false, error: 'Invalid issue number' }
@@ -28,7 +35,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
args.number,
args.updates,
getGitHubRepoConnectionId(repo),
...getGitHubLocalGitOptionArgs(store, repo)
getGitHubLocalGitOptionArgs(store, repo)[0],
repo.issueSourcePreference,
...(args.ownerRepo ? [args.ownerRepo] : [])
)
if (result.ok) {
broadcastGitHubWorkItemMutation(
@@ -67,7 +76,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
args.body.trim(),
getGitHubRepoConnectionId(repo),
args.prRepo ?? null,
...getGitHubLocalGitOptionArgs(store, repo)
getGitHubLocalGitOptionArgs(store, repo)[0],
// Why: the issue source selector only scopes issues; PR comments keep their resolution.
args.type === 'pr' ? undefined : repo.issueSourcePreference
)
if (result.ok) {
broadcastGitHubWorkItemMutation(
@@ -84,23 +95,46 @@ export function registerGitHubIssueMutationHandlers(store: Store): void {
}
)
ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => {
const repo = assertRegisteredGitHubRepo(args, store)
return listLabels(
repo.path,
repo.issueSourcePreference,
getGitHubRepoConnectionId(repo),
...getGitHubLocalGitOptionArgs(store, repo)
)
})
ipcMain.handle(
'gh:listLabels',
(_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
const repo = assertRegisteredGitHubRepo(args, store)
const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
const connectionId = getGitHubRepoConnectionId(repo)
if (args.ownerRepo) {
return listLabels(
repo.path,
repo.issueSourcePreference,
connectionId,
localGitOptions[0],
args.ownerRepo
)
}
return listLabels(repo.path, repo.issueSourcePreference, connectionId, ...localGitOptions)
}
)
ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => {
const repo = assertRegisteredGitHubRepo(args, store)
return listAssignableUsers(
repo.path,
repo.issueSourcePreference,
getGitHubRepoConnectionId(repo),
...getGitHubLocalGitOptionArgs(store, repo)
)
})
ipcMain.handle(
'gh:listAssignableUsers',
(_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => {
const repo = assertRegisteredGitHubRepo(args, store)
const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)
const connectionId = getGitHubRepoConnectionId(repo)
if (args.ownerRepo) {
return listAssignableUsers(
repo.path,
repo.issueSourcePreference,
connectionId,
localGitOptions[0],
args.ownerRepo
)
}
return listAssignableUsers(
repo.path,
repo.issueSourcePreference,
connectionId,
...localGitOptions
)
}
)
}
@@ -143,4 +143,124 @@ describe('registerGitHubHandlers', () => {
'origin'
)
})
it('passes the displayed repository ahead of the current source preference', async () => {
harness.repos[0].issueSourcePreference = 'upstream'
const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
getWorkItemDetailsMock.mockResolvedValue(null)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:workItemDetails'](null, {
repoPath: '/workspace/repo',
number: 12,
type: 'issue',
ownerRepo
})
await handlers['gh:updateIssue'](null, {
repoPath: '/workspace/repo',
number: 12,
updates: { body: 'Origin edit' },
ownerRepo
})
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo', ownerRepo })
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo', ownerRepo })
expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
'/workspace/repo',
12,
'issue',
null,
undefined,
'upstream',
ownerRepo
)
expect(mocks.client.updateIssue).toHaveBeenCalledWith(
'/workspace/repo',
12,
{ body: 'Origin edit' },
null,
undefined,
'upstream',
ownerRepo
)
expect(mocks.client.listLabels).toHaveBeenCalledWith(
'/workspace/repo',
'upstream',
null,
undefined,
ownerRepo
)
expect(mocks.client.listAssignableUsers).toHaveBeenCalledWith(
'/workspace/repo',
'upstream',
null,
undefined,
ownerRepo
)
})
it('pins the repo origin source preference on issue update and comment IPC', async () => {
harness.repos = [
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
issueSourcePreference: 'origin'
}
]
mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' })
mocks.client.addIssueComment.mockResolvedValue({ ok: false, error: 'stub' })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered.
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:updateIssue'](null, {
repoPath: '/workspace/repo',
number: 5,
updates: { body: 'Updated' }
})
await handlers['gh:addIssueComment'](null, {
repoPath: '/workspace/repo',
number: 5,
body: 'Issue comment'
})
await handlers['gh:addIssueComment'](null, {
repoPath: '/workspace/repo',
number: 6,
body: 'PR comment',
type: 'pr'
})
expect(mocks.client.updateIssue).toHaveBeenCalledWith(
'/workspace/repo',
5,
{ body: 'Updated' },
null,
undefined,
'origin'
)
expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
1,
'/workspace/repo',
5,
'Issue comment',
null,
null,
undefined,
'origin'
)
expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith(
2,
'/workspace/repo',
6,
'PR comment',
null,
null,
undefined,
undefined
)
})
})
+14 -9
View File
@@ -146,15 +146,20 @@ export function registerGitHubWorkItemHandlers(store: Store): void {
}
)
ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => {
const repo = assertRegisteredGitHubRepo(args, store)
return dispatchWorkItem(
args,
repo,
getWorkItemDetails,
getGitHubLocalGitOptionArgs(store, repo)[0]
)
})
ipcMain.handle(
'gh:workItemDetails',
(_event, args: WorkItemArgs & { ownerRepo?: GitHubOwnerRepo }) => {
const repo = assertRegisteredGitHubRepo(args, store)
return dispatchWorkItem(
args,
repo,
args.ownerRepo
? (...params) => getWorkItemDetails(...params, args.ownerRepo)
: getWorkItemDetails,
getGitHubLocalGitOptionArgs(store, repo)[0]
)
}
)
ipcMain.handle(
'gh:notifyWorkItemMutated',
@@ -138,6 +138,15 @@ describe('registerGitHubHandlers', () => {
)
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' })
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' })
const metadataRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' }
await handlers['gh:listLabels'](null, {
repoPath: '/workspace/repo',
ownerRepo: metadataRepo
})
await handlers['gh:listAssignableUsers'](null, {
repoPath: '/workspace/repo',
ownerRepo: metadataRepo
})
expect(getPRForBranchMock).toHaveBeenCalledWith(
'/workspace/repo',
@@ -197,7 +206,8 @@ describe('registerGitHubHandlers', () => {
7,
{ body: 'Updated' },
null,
localGitOptions
localGitOptions,
undefined
)
expect(addIssueCommentMock).toHaveBeenCalledWith(
'/workspace/repo',
@@ -205,9 +215,24 @@ describe('registerGitHubHandlers', () => {
'Comment',
null,
null,
localGitOptions
localGitOptions,
undefined
)
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions)
expect(listLabelsMock).toHaveBeenLastCalledWith(
'/workspace/repo',
undefined,
null,
localGitOptions,
metadataRepo
)
expect(listAssignableUsersMock).toHaveBeenLastCalledWith(
'/workspace/repo',
undefined,
null,
localGitOptions,
metadataRepo
)
expect(listAssignableUsersMock).toHaveBeenCalledWith(
'/workspace/repo',
undefined,
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import type * as NodePath from 'node:path'
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof NodePath>()
return { ...actual, extname: actual.win32.extname }
})
import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents'
describe('remote Markdown filenames on a Windows client', () => {
it('keeps the local filename helper on Windows semantics', () => {
expect(isMarkdownDocumentName('notes\\.md')).toBe(false)
expect(isMarkdownDocumentName('notes.md\\')).toBe(true)
})
it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])(
'preserves POSIX filename %s and its extension',
(relativePath) => {
const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1)
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({
filePath: `/home/repo/${relativePath}`,
relativePath,
basename,
name: basename.slice(0, basename.lastIndexOf('.'))
})
}
)
it.each(['notes.md\\', 'nested/.md', '../outside.md'])(
'rejects non-Markdown or escaping POSIX filename %s',
(relativePath) => {
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull()
}
)
it('normalizes Windows remote separators before reading the basename', () => {
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({
filePath: 'C:\\repo/notes/README.MD',
relativePath: 'notes/README.MD',
basename: 'README.MD',
name: 'README'
})
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull()
expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull()
})
})
@@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => {
expect(child.listenerCount('close')).toBe(0)
})
it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => {
vi.useFakeTimers()
const result = listMarkdownDocuments(root)
const outcome = expect(result).rejects.toThrow('timed out')
child.stdout.write(Buffer.from([0xf0, 0x9f]))
await vi.advanceTimersByTimeAsync(15_000)
await outcome
})
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
const result = listMarkdownDocuments(root)
child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
if (kind === 'incomplete') {
child.emit('close', 0, null)
}
await expect(result).rejects.toThrow('not valid UTF-8')
})
it('accepts an empty listing', async () => {
const result = listMarkdownDocuments(root)
child.emit('close', 1, null)
+35 -1
View File
@@ -1,7 +1,41 @@
import { describe, expect, it } from 'vitest'
import { markdownDocumentFromFilePath } from './markdown-documents'
import {
markdownDocumentFromFilePath,
markdownDocumentFromRelativePath
} from './markdown-documents'
describe('markdownDocumentFromFilePath', () => {
it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => {
expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({
filePath: '/repo\\/a\\b.md',
relativePath: 'a\\b.md',
basename: 'a\\b.md'
})
})
it('preserves POSIX remote filenames and trailing root backslashes', () => {
for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) {
expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({
filePath: `/repo\\/${name}`,
relativePath: name,
basename: name.slice(name.lastIndexOf('/') + 1)
})
}
expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull()
})
it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])(
'preserves Windows separator handling under %s',
(root) => {
expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({
filePath: `${root.slice(0, -1)}/a/b.md`,
relativePath: 'a/b.md',
basename: 'b.md'
})
expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull()
}
)
it('keeps in-root path segments that merely start with parent traversal text', () => {
expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({
filePath: '/workspace/..notes/file.md',
+48 -27
View File
@@ -1,4 +1,15 @@
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { normalizeRelativePath } from '../../shared/text-search-paths'
import {
basename as pathBasename,
extname,
isAbsolute,
join,
posix,
relative,
resolve
} from 'node:path'
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
import { parseWslPath } from '../wsl'
@@ -7,36 +18,34 @@ import {
ripgrepMissingCwdError
} from '../../shared/ripgrep-process-availability'
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
export function isMarkdownDocumentName(name: string): boolean {
return isMarkdownExtension(extname(name))
}
export function isMarkdownDocumentName(name: string): boolean {
const extension = extname(name).toLowerCase()
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
function isMarkdownExtension(extension: string): boolean {
const normalized = extension.toLowerCase()
return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown'
}
function basenameFromRelativePath(relativePath: string): string {
const normalizedPath = relativePath.replaceAll('\\', '/')
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
return relativePath.slice(relativePath.lastIndexOf('/') + 1)
}
function isSafeRelativePath(relativePath: string): boolean {
return !relativePath.split('/').includes('..')
}
function hasParentTraversalSegment(relativePath: string): boolean {
return relativePath.split(/[\\/]+/).includes('..')
}
function rootRelativePath(rootPath: string, filePath: string): string | null {
const resolvedRoot = resolve(rootPath)
const resolvedFile = resolve(filePath)
const relativePath = relative(resolvedRoot, resolvedFile)
if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
if (
!isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) ||
isAbsolute(relativePath)
) {
return null
}
return normalizeRelativePath(relativePath)
return normalizeRelativePath(relativePath, rootPath)
}
export function fileDocumentFromFilePath(
@@ -50,7 +59,7 @@ export function fileDocumentFromFilePath(
rootRelativePath(rootPath, filePath) ??
(options.outsideRootRelativePath === 'basename'
? basename
: normalizeRelativePath(relative(rootPath, filePath)))
: normalizeRelativePath(relative(rootPath, filePath), rootPath))
return {
filePath,
relativePath,
@@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
): MarkdownDocument | null {
const normalizedRelativePath = normalizeRelativePath(relativePath)
const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath)
// Why: SSH providers should return root-relative paths; reject escape
// segments before building a synthetic absolute path for renderer use.
if (!isSafeRelativePath(normalizedRelativePath)) {
return null
}
const basename = basenameFromRelativePath(normalizedRelativePath)
if (!isMarkdownDocumentName(basename)) {
// Remote separators are already normalized; a POSIX backslash stays part of the name.
const extension = posix.extname(basename)
if (!isMarkdownExtension(extension)) {
return null
}
const extension = extname(basename)
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
const normalizedRoot = rootPath.replace(
isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/,
''
)
return {
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
relativePath: normalizedRelativePath,
@@ -111,9 +124,9 @@ export async function listMarkdownDocuments(
'--null',
'--path-separator',
'/',
// Directory-only globs preserve hidden Markdown files without traversing hidden folders.
// Keep case variants in --glob: --iglob is applied after exclusions and can reopen hidden folders.
'--glob',
'**',
'*.{[mM][dD],[mM][dD][xX],[mM][aA][rR][kK][dD][oO][wW][nN]}',
'--glob',
'!**/.*/',
'--glob',
@@ -131,6 +144,10 @@ export async function listMarkdownDocuments(
)
return new Promise((resolveListing, reject) => {
const filenameDecoder = new RipgrepFilenameDecoder(
(error) => finish(error),
Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro)
)
const documents: MarkdownDocument[] = []
let carry = ''
let stderr = ''
@@ -172,8 +189,12 @@ export async function listMarkdownDocuments(
const onStderr = (chunk: string): void => {
stderr = (stderr + chunk).slice(0, 4096)
}
const onData = (chunk: string): void => {
carry += chunk
const onData = (chunk: Buffer | string): void => {
const decoded = filenameDecoder.decode(chunk)
if (decoded === null) {
return
}
carry += decoded
let start = 0
let end: number
while ((end = carry.indexOf('\0', start)) !== -1) {
@@ -201,10 +222,11 @@ export async function listMarkdownDocuments(
finish(ripgrepMissingCwdError(rootPath))
} else if (signal || (code !== 0 && code !== 1)) {
finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`))
} else if (carry) {
finish(new Error('Incomplete path in Markdown document listing'))
} else {
finish()
if (!filenameDecoder.finish()) {
return
}
finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined)
}
}
const timer = setTimeout(
@@ -212,7 +234,6 @@ export async function listMarkdownDocuments(
MARKDOWN_LISTING_TIMEOUT_MS
)
timer.unref?.()
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', onData)
child.stderr?.on('data', onStderr)
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -113,6 +118,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
+8 -2
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -109,6 +114,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
+251 -3
View File
@@ -1,15 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'node:path'
import type * as LocalCommandResolver from './command-path-resolver'
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
const { runPreflightCommandInWslMock } = vi.hoisted(() => ({
runPreflightCommandInWslMock: vi.fn()
const {
runPreflightCommandInWslMock,
execFileAsyncMock,
listLocalCommandPathsMock,
runProcessMock
} = vi.hoisted(() => ({
runPreflightCommandInWslMock: vi.fn(),
execFileAsyncMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
runProcessMock: vi.fn()
}))
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
vi.mock('./preflight-local-env', () => ({ buildLocalPreflightEnv: () => undefined }))
vi.mock('./preflight-wsl-command', () => ({
runPreflightCommandInWsl: runPreflightCommandInWslMock
}))
import { isCommandOnPath } from './preflight-command-exec'
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: vi.fn(async () => false),
listLocalCommandPaths: listLocalCommandPathsMock
}))
// Why: `findRunnableLocalCommand` decides what to spawn next from the error
// `execFile` puts on a failed probe, so the shapes below have to be its own.
vi.mock('child_process', () => {
const execFileWithPromisify = Object.assign(vi.fn(), {
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
})
return { execFile: execFileWithPromisify, spawn: vi.fn() }
})
import { findRunnableLocalCommand, isCommandOnPath } from './preflight-command-exec'
describe('isCommandOnPath', () => {
const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__'
@@ -59,3 +88,222 @@ describe('isCommandOnPath', () => {
await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected)
})
})
describe('findRunnableLocalCommand', () => {
const shim = '/Users/tester/.asdf/shims/gh'
const second = '/Users/tester/.volta/bin/gh'
const third = '/usr/local/bin/gh'
const spawnedCommands = () => execFileAsyncMock.mock.calls.map(([command]) => command)
let pathBefore = ''
beforeEach(() => {
pathBefore = process.env.PATH ?? ''
execFileAsyncMock.mockReset()
runProcessMock.mockReset()
listLocalCommandPathsMock.mockReset()
listLocalCommandPathsMock.mockResolvedValue([shim, second, third])
})
afterEach(() => {
vi.restoreAllMocks()
process.env.PATH = pathBefore
})
it('returns the first copy that runs and leaves the rest alone', async () => {
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === shim) {
throw Object.assign(new Error('cannot execute'), { code: 126 })
}
return { stdout: 'gh version 2.98.0\n', stderr: '' }
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: second
})
expect(spawnedCommands()).toEqual([shim, second])
})
it('keeps looking when a copy exits non-zero', async () => {
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === third) {
return { stdout: 'gh version 2.98.0\n', stderr: '' }
}
throw Object.assign(new Error('Command failed'), { code: 1 })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: third
})
})
it('stops at a timed-out copy instead of paying the timeout once per copy', async () => {
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === second) {
throw Object.assign(new Error('Timed out'), { killed: true, code: null })
}
throw Object.assign(new Error('cannot execute'), { code: 126 })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: second
})
expect(spawnedCommands()).toEqual([shim, second])
})
it('stops on the ETIMEDOUT shape as well as the killed shape', async () => {
execFileAsyncMock.mockImplementation((command: string) =>
command === second
? Promise.reject(Object.assign(new Error('Timed out'), { code: 'ETIMEDOUT' }))
: Promise.reject(Object.assign(new Error('cannot execute'), { code: 126 }))
)
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: second
})
expect(spawnedCommands()).toEqual([shim, second])
})
it('reads a rejection that is not an object as an ordinary failure', async () => {
execFileAsyncMock.mockImplementation((command: string) =>
command === third
? Promise.resolve({ stdout: 'gh version 2.98.0\n', stderr: '' })
: Promise.reject('not an error object')
)
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: third
})
expect(spawnedCommands()).toEqual([shim, second, third])
})
it('probes the bare command name when fs finds no candidate', async () => {
listLocalCommandPathsMock.mockResolvedValue([])
execFileAsyncMock.mockResolvedValue({ stdout: 'gh version 2.98.0\n', stderr: '' })
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: 'gh'
})
expect(spawnedCommands()).toEqual(['gh'])
})
it('probes a relative PATH entry as the absolute directory cwd gives it', async () => {
const relativeDir = path.join('.', 'tools')
process.env.PATH = [shim.replace('/gh', ''), relativeDir].join(path.delimiter)
const absoluteDir = path.resolve(relativeDir)
const hidden = `${absoluteDir}/gh`
const absolutePath = [shim.replace('/gh', ''), absoluteDir].join(path.delimiter)
listLocalCommandPathsMock.mockImplementation(
async (_command: string, options?: { env?: NodeJS.ProcessEnv }) =>
options?.env?.PATH === absolutePath ? [shim, hidden] : []
)
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === hidden) {
return { stdout: 'gh version 2.98.0\n', stderr: '' }
}
throw Object.assign(new Error('cannot execute'), { code: 126 })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: hidden
})
expect(spawnedCommands()).toEqual([shim, hidden])
})
it('does not pay for the bare name when every PATH entry is absolute', async () => {
process.env.PATH = ['/Users/tester/.asdf/shims', '/usr/local/bin'].join(path.delimiter)
execFileAsyncMock.mockRejectedValue(Object.assign(new Error('cannot execute'), { code: 126 }))
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'exec_failed',
binary: third
})
expect(spawnedCommands()).not.toContain('gh')
})
it('runs an explicitly selected Windows cmd shim through the shared runner', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const command = path.win32.join('C:\\tools', 'gh.cmd')
runProcessMock.mockResolvedValue({
code: 0,
stdout: 'gh version fixture',
stderr: '',
timedOut: false
})
await expect(findRunnableLocalCommand(command)).resolves.toEqual({
status: 'available',
binary: command
})
expect(runProcessMock).toHaveBeenCalledWith({
program: command,
args: ['--version'],
env: undefined,
timeoutMs: expect.any(Number)
})
expect(execFileAsyncMock).not.toHaveBeenCalled()
expect(listLocalCommandPathsMock).not.toHaveBeenCalled()
})
it('keeps searching Windows PATH after a broken cmd shim', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const command = path.win32.join('C:\\tools', 'gh.cmd')
const working = path.win32.join('C:\\working', 'gh.exe')
listLocalCommandPathsMock.mockResolvedValue([command, working])
runProcessMock.mockResolvedValue({
code: 126,
stdout: '',
stderr: 'broken shim',
timedOut: false
})
execFileAsyncMock.mockResolvedValue({ stdout: 'gh version fixture', stderr: '' })
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: working
})
expect(runProcessMock).toHaveBeenCalledOnce()
expect(spawnedCommands()).toEqual([working])
})
it('stops on a shared runner timeout', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const command = path.win32.join('C:\\tools', 'gh.cmd')
listLocalCommandPathsMock.mockResolvedValue([command, third])
runProcessMock.mockResolvedValue({ code: null, stdout: '', stderr: '', timedOut: true })
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: command
})
expect(execFileAsyncMock).not.toHaveBeenCalled()
})
it('shares the five second timeout across failed candidates', async () => {
let now = 1000
vi.spyOn(Date, 'now').mockImplementation(() => now)
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === shim) {
now += 4000
throw Object.assign(new Error('cannot execute'), { code: 126 })
}
throw Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: second
})
expect(execFileAsyncMock.mock.calls.map(([, , options]) => options.timeout)).toEqual([
5000, 1000
])
})
})
+131 -11
View File
@@ -1,8 +1,15 @@
import { execFile } from 'node:child_process'
import path from 'node:path'
import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
import { isCommandOnLocalPath } from './command-path-resolver'
import { getSystemCliInstallDirectories } from '../../shared/system-cli-install-dirs'
import { runProcess } from '../../shared/child-process/run-process'
import {
beginLocalCommandSelection,
isCommandOnLocalPath,
listLocalCommandPaths
} from './command-path-resolver'
import { buildLocalPreflightEnv } from './preflight-local-env'
import { runPreflightCommandInWsl } from './preflight-wsl-command'
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
@@ -17,7 +24,11 @@ export function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
async function withPreflightTimeout<T>(command: string, commandPromise: Promise<T>): Promise<T> {
async function withPreflightTimeout<T>(
command: string,
commandPromise: Promise<T>,
timeoutMs = PREFLIGHT_COMMAND_TIMEOUT_MS
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
@@ -28,7 +39,7 @@ async function withPreflightTimeout<T>(command: string, commandPromise: Promise<
code: 'ETIMEDOUT'
})
reject(error)
}, PREFLIGHT_COMMAND_TIMEOUT_MS)
}, timeoutMs)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
@@ -47,19 +58,36 @@ async function withPreflightTimeout<T>(command: string, commandPromise: Promise<
* docs/reference/wsl-probe-failure-semantics.md before doing so. */
export async function execLocalPreflightCommandOrThrow(
command: string,
args: string[]
args: string[],
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}
): Promise<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const env = options.env ?? buildLocalPreflightEnv()
const timeoutMs = options.timeoutMs ?? PREFLIGHT_COMMAND_TIMEOUT_MS
// Node cannot execFile a batch shim; the shared runner handles its argv safely.
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command)) {
const result = await withPreflightTimeout(
command,
runProcess({ program: command, args, env, timeoutMs }),
timeoutMs
)
if (result.timedOut || result.code !== 0) {
throw Object.assign(new Error(`Failed running ${command}`), {
...result,
code: result.timedOut ? 'ETIMEDOUT' : result.code
})
}
return { stdout: result.stdout, stderr: result.stderr }
}
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
timeout: timeoutMs,
// Preflight probes console-subsystem binaries (git, gh, node); without this
// each one flashes a console and steals foreground on Windows (#10488).
windowsHide: true,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
})
return withPreflightTimeout(command, commandPromise)
return withPreflightTimeout(command, commandPromise, timeoutMs)
}
// Throws on any failure — a distro that is booting/unreachable throws the
@@ -76,14 +104,106 @@ export async function execCommandInWslOrThrow(
return withPreflightTimeout('wsl command', commandPromise)
}
const PREFLIGHT_LOCAL_PROBE_LIMIT = 4
export type LocalCommandProbe =
| { status: 'available'; binary: string }
| { status: 'absent' }
| { status: 'exec_failed' | 'timeout' | 'limit_reached'; binary: string }
function probeTimedOut(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false
}
return (
('killed' in error && error.killed === true) || ('code' in error && error.code === 'ETIMEDOUT')
)
}
async function localProbeCandidates(
command: string,
env: NodeJS.ProcessEnv | undefined
): Promise<string[]> {
const isWin = process.platform === 'win32'
const probeEnv = env ?? process.env
// Keep relative PATH entries in their original position, as execFile does.
const absoluteEnv = isWin
? probeEnv
: {
...probeEnv,
PATH: (probeEnv.PATH ?? '')
.split(path.delimiter)
.map((dir) => path.resolve(dir))
.join(path.delimiter)
}
const maxResults = PREFLIGHT_LOCAL_PROBE_LIMIT + 1
const paths = await listLocalCommandPaths(command, { env: absoluteEnv, maxResults })
const installPaths =
isWin || paths.length >= maxResults
? []
: await listLocalCommandPaths(command, {
env: {
PATH: getSystemCliInstallDirectories(process.platform, homedir()).join(path.delimiter)
},
maxResults
})
return [...new Set([...paths, ...installPaths])]
}
/** Try only version probes; authentication must stay on the selected binary. */
export async function findRunnableLocalCommand(command: string): Promise<LocalCommandProbe> {
const publishSelection = beginLocalCommandSelection(command)
const result = await probeRunnableLocalCommand(command)
await publishSelection(result.status === 'available' ? result.binary : null)
return result
}
async function probeRunnableLocalCommand(command: string): Promise<LocalCommandProbe> {
const env = buildLocalPreflightEnv()
const explicit = command.includes('/') || (process.platform === 'win32' && command.includes('\\'))
// An explicit path is the user's selection, even when it cannot run.
const candidates = explicit ? [] : await localProbeCandidates(command, env)
const probes = candidates.length ? candidates.slice(0, PREFLIGHT_LOCAL_PROBE_LIMIT) : [command]
const deadline = Date.now() + PREFLIGHT_COMMAND_TIMEOUT_MS
for (const binary of probes) {
const timeoutMs = deadline - Date.now()
if (timeoutMs <= 0) {
return { status: 'timeout', binary }
}
try {
await execLocalPreflightCommandOrThrow(binary, ['--version'], { env, timeoutMs })
return { status: 'available', binary }
} catch (error) {
if (probeTimedOut(error)) {
return { status: 'timeout', binary }
}
if (
candidates.length === 0 &&
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT' &&
!(await isCommandOnLocalPath(explicit ? path.resolve(command) : command, { env }))
) {
return { status: 'absent' }
}
}
}
return {
status: candidates.length > PREFLIGHT_LOCAL_PROBE_LIMIT ? 'limit_reached' : 'exec_failed',
binary: probes.at(-1) ?? command
}
}
export async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
if (!wslTarget) {
return (await findRunnableLocalCommand(command)).status === 'available'
}
try {
await (wslTarget
? execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
: execLocalPreflightCommandOrThrow(command, ['--version']))
await execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
return true
} catch {
return false
+110 -10
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -13,6 +14,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -28,6 +30,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -115,6 +120,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
@@ -162,17 +168,29 @@ describe('preflight', () => {
})
})
it('treats gh as unauthenticated when gh auth status fails without auth markers', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
it.each(['gh', 'glab'])('does not switch %s copies after authentication fails', async (cli) => {
const first = `/test/first/${cli}`
const second = `/test/second/${cli}`
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
command === cli ? [first, second] : []
)
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
if (command === first && args[0] === 'auth') {
throw Object.assign(new Error('not authenticated'), { code: 1, stderr: 'not logged in' })
}
return { stdout: 'fixture success', stderr: '' }
})
const status = await runPreflightCheck()
expect(status.gh).toEqual({ installed: true, authenticated: false })
expect(cli === 'gh' ? status.gh : status.glab).toEqual({
installed: true,
authenticated: false
})
expect(
execFileAsyncMock.mock.calls.filter(([command]) => command === first).map(([, args]) => args)
).toEqual([['--version'], ['auth', 'status']])
expect(execFileAsyncMock.mock.calls.some(([command]) => command === second)).toBe(false)
})
it('keeps older gh stderr success output from showing a false auth warning', async () => {
@@ -188,6 +206,88 @@ describe('preflight', () => {
expect(status.gh).toEqual({ installed: true, authenticated: true })
})
// Why (#22975): these two cases key the spawn mock on (command, args) instead
// of call order, because the fallback probes exactly which copy runs is the
// behaviour under test — and `gh auth status` runs in parallel with
// `glab auth status`, so a fixed sequence would not be the real one.
it('authenticates the gh copy that actually ran, not the shim PATH chose', async () => {
const shim = '/Users/octocat/.asdf/shims/gh'
const working = '/opt/homebrew/bin/gh'
const glabShim = '/Users/octocat/.asdf/shims/glab'
const glabWorking = '/usr/local/bin/glab'
const shims = new Set([shim, glabShim])
listLocalCommandPathsMock.mockImplementation(async (command: string) => {
if (command === 'gh') {
return [shim, working]
}
return command === 'glab' ? [glabShim, glabWorking] : []
})
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
if (shims.has(command)) {
throw Object.assign(new Error('spawn failed'), { code: 126 })
}
const auth = args[0] === 'auth'
if (command === working) {
return { stdout: auth ? 'github.com\n - Active account: true\n' : 'gh version 2.6.0\n' }
}
if (command === glabWorking) {
return { stdout: auth ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n' }
}
if (command === 'git') {
return { stdout: 'git version 2.0.0\n' }
}
throw new Error(`unexpected command ${command}`)
})
const status = await runPreflightCheck()
expect(status).toMatchObject({
gh: { installed: true, authenticated: true },
glab: { installed: true, authenticated: true }
})
for (const [cli, copy] of [
['gh', working],
['glab', glabWorking]
]) {
expect(execFileAsyncMock).toHaveBeenCalledWith(copy, ['auth', 'status'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true
})
expect(execFileAsyncMock).not.toHaveBeenCalledWith(cli, ['auth', 'status'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true
})
}
})
it('marks gh not installed when no copy PATH offers can run', async () => {
const doomed = ['/Users/octocat/.asdf/shims/gh', '/Users/octocat/.local/bin/gh']
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
command === 'gh' ? doomed : []
)
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
if (doomed.includes(command)) {
throw Object.assign(new Error('spawn failed'), { code: 126 })
}
if (command === 'git') {
return { stdout: 'git version 2.0.0\n' }
}
if (command === 'glab') {
return {
stdout: args[0] === 'auth' ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n'
}
}
throw new Error(`unexpected command ${command}`)
})
const status = await runPreflightCheck()
expect(status.gh).toEqual({ installed: false, authenticated: false })
expect(execFileAsyncMock).toHaveBeenCalledTimes(5)
})
it('marks glab as not installed when `glab --version` fails', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
@@ -0,0 +1,321 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import { ghExecFileAsync } from '../git/command-runner/gh-exec-file'
import { glabExecFileAsync } from '../git/command-runner/glab-exec-file'
import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture'
import { beginLocalCommandSelection, resolveSelectedLocalCommand } from './command-path-resolver'
import {
execLocalPreflightCommandOrThrow,
findRunnableLocalCommand,
shellQuote
} from './preflight-command-exec'
vi.mock('../../shared/system-cli-install-dirs', () => ({
getSystemCliInstallDirectories: (_platform: NodeJS.Platform, home: string) => [
path.join(home, '.nix-profile', 'bin')
]
}))
let root = ''
const CLIS = ['gh', 'glab'] as const
async function fixtureCli(
cli: string,
label: string,
body = `case "$1" in\n--version) echo fixture-version;;\nauth) echo 'Logged in fixture';;\napi) echo '${label}-api';;\nesac\n`
): Promise<string> {
const dir = path.join(root, label)
await mkdir(dir, { recursive: true })
const binary = path.join(dir, cli)
await writeFile(
binary,
`#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(`${binary}-calls`)}\n${body}`,
{ mode: 0o755 }
)
return binary
}
async function calls(binary: string): Promise<string[]> {
try {
return (await readFile(`${binary}-calls`, 'utf8')).trim().split('\n')
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
return []
}
throw error
}
}
function providerCommand(cli: 'gh' | 'glab', env?: NodeJS.ProcessEnv, cwd?: string) {
const execute = cli === 'gh' ? ghExecFileAsync : glabExecFileAsync
return execute(['api', 'user'], { timeout: 1000, idempotent: false, env, cwd })
}
describe.skipIf(process.platform === 'win32')(
'preflight selection in native provider commands',
() => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-'))
vi.stubEnv('HOME', root)
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it.each(CLIS)(
'uses the same runnable %s for version, auth and provider operations',
async (cli) => {
const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
const good = await fixtureCli(cli, 'good')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
const selected = await findRunnableLocalCommand(cli)
expect(selected).toEqual({ status: 'available', binary: good })
if (selected.status !== 'available') {
throw new Error('Expected the working fixture to be selected')
}
await expect(
execLocalPreflightCommandOrThrow(selected.binary, ['auth', 'status'])
).resolves.toMatchObject({ stdout: 'Logged in fixture\n' })
await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
stdout: 'good-api\n'
})
expect(await calls(shim)).toEqual(['--version'])
expect(await calls(good)).toEqual(['--version', 'auth status', 'api user'])
}
)
it.each(CLIS)(
'keeps the proven %s usable while its version selection refreshes',
async (cli) => {
const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
const good = await fixtureCli(cli, 'good')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand(cli)
const refreshing = findRunnableLocalCommand(cli)
await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'good-api\n' })
await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
expect(await calls(shim)).toEqual(['--version', '--version'])
}
)
it('clears the previous selection only after the newest version probe fails', async () => {
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
const good = await fixtureCli(
'gh',
'good',
'if [ "$1" = --version ] && [ "$ORCA_22975_VERSION_DISABLED" = 1 ]; then exit 126; fi\necho good-api\n'
)
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand('gh')
vi.stubEnv('ORCA_22975_VERSION_DISABLED', '1')
const refreshing = findRunnableLocalCommand('gh')
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
await expect(refreshing).resolves.toMatchObject({ status: 'exec_failed' })
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
})
it.each(CLIS)('also uses the recovered %s from a known install directory', async (cli) => {
const shim = await fixtureCli(cli, 'shim', 'exit 126\n')
const good = await fixtureCli(cli, path.join('.nix-profile', 'bin'))
vi.stubEnv('PATH', path.dirname(shim))
await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
status: 'available',
binary: good
})
await expect(providerCommand(cli)).resolves.toMatchObject({
stdout: `${path.join('.nix-profile', 'bin')}-api\n`
})
expect(await calls(shim)).toEqual(['--version'])
})
it.each(CLIS)('keeps %s selected when authentication fails', async (cli) => {
const first = await fixtureCli(
cli,
'first',
'if [ "$1" = auth ]; then echo unauthenticated >&2; exit 1; fi\necho first-result\n'
)
const other = await fixtureCli(cli, 'other')
vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
await findRunnableLocalCommand(cli)
await expect(
execLocalPreflightCommandOrThrow(first, ['auth', 'status'])
).rejects.toMatchObject({
code: 1
})
await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'first-result\n' })
expect(await calls(other)).toEqual([])
})
it.each(CLIS)('never retries a failed %s operation on another binary', async (cli) => {
const first = await fixtureCli(
cli,
'first',
'if [ "$1" = api ]; then echo operation-failed >&2; exit 126; fi\necho fixture-version\n'
)
const other = await fixtureCli(cli, 'other')
vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
await findRunnableLocalCommand(cli)
await expect(providerCommand(cli)).rejects.toMatchObject({
code: 126,
stderr: 'operation-failed\n'
})
expect(await calls(first)).toEqual(['--version', 'api user'])
expect(await calls(other)).toEqual([])
})
it('respects a caller PATH and explicit binary instead of the global selection', async () => {
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
const good = await fixtureCli('gh', 'good')
const custom = await fixtureCli('gh', 'custom')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand('gh')
const refreshing = findRunnableLocalCommand('gh')
await expect(
providerCommand('gh', { ...process.env, PATH: path.dirname(custom) })
).resolves.toMatchObject({ stdout: 'custom-api\n' })
await expect(
execFileCaptureToTermination(custom, ['api', 'user'], { encoding: 'utf8', timeout: 1000 })
).resolves.toMatchObject({ stdout: 'custom-api\n' })
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
})
it('bypasses a selection after PATH or relative-PATH cwd changes', async () => {
const good = await fixtureCli('gh', 'good')
const custom = await fixtureCli('gh', 'custom')
vi.stubEnv('PATH', path.dirname(good))
await findRunnableLocalCommand('gh')
const refreshing = findRunnableLocalCommand('gh')
vi.stubEnv('PATH', path.dirname(custom))
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
await refreshing
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
vi.stubEnv('PATH', path.relative(process.cwd(), path.dirname(good)))
await findRunnableLocalCommand('gh')
expect(resolveSelectedLocalCommand('gh', { cwd: root })).toBe('gh')
})
it('discards a replaced binary and clears a selection when the next probe fails', async () => {
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
const good = await fixtureCli('gh', 'good')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand('gh')
await writeFile(good, '#!/bin/sh\necho changed >&2\nexit 126\n')
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
await expect(findRunnableLocalCommand('gh')).resolves.toMatchObject({ status: 'exec_failed' })
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
await fixtureCli('gh', 'good')
await findRunnableLocalCommand('gh')
expect(resolveSelectedLocalCommand('gh')).toBe(good)
await rm(good)
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
})
it('keeps an older concurrent probe from replacing the newer selection', async () => {
const first = await fixtureCli('gh', 'first')
const second = await fixtureCli('gh', 'second')
const older = beginLocalCommandSelection('gh')
const newer = beginLocalCommandSelection('gh')
await newer(second)
await older(first)
await older(null)
expect(resolveSelectedLocalCommand('gh')).toBe(second)
})
}
)
describe('Windows selection across working folders', () => {
it('keeps an absolute PATH selection in a different provider cwd', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-cwd-'))
const binary = path.join(directory, 'gh.CMD')
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.stubEnv('PATH', directory)
const publish = beginLocalCommandSelection('gh')
await publish(binary)
expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe(
binary
)
} finally {
vi.unstubAllEnvs()
if (descriptor) {
Object.defineProperty(process, 'platform', descriptor)
}
await removeTree(directory)
}
})
it('does not carry a current-directory CLI into another folder', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-local-cwd-'))
const binary = path.join(directory, 'gh.CMD')
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(directory)
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.stubEnv('PATH', path.join(directory, 'other'))
const publish = beginLocalCommandSelection('gh')
await publish(binary)
expect(resolveSelectedLocalCommand('gh')).toBe(binary)
expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe('gh')
} finally {
cwd.mockRestore()
vi.unstubAllEnvs()
if (descriptor) {
Object.defineProperty(process, 'platform', descriptor)
}
await removeTree(directory)
}
})
})
describe.runIf(process.platform === 'win32')('native provider batch selection', () => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-cmd-'))
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it.each(CLIS)('passes the selected %s.cmd through the native runner', async (cli) => {
const shim = path.join(root, 'shim')
const good = path.join(root, 'good')
await mkdir(shim)
await mkdir(good)
await writeFile(path.join(shim, `${cli}.CMD`), '@echo off\r\nexit /b 126\r\n')
await writeFile(
path.join(good, `${cli}.CMD`),
'@echo off\r\nif "%~1"=="api" (echo good-api) else (echo fixture-version)\r\nexit /b 0\r\n'
)
vi.stubEnv('PATH', [shim, good].join(path.delimiter))
vi.stubEnv('Path', [shim, good].join(path.delimiter))
vi.stubEnv('PATHEXT', '.CMD')
await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
status: 'available',
binary: path.posix.join(good, `${cli}.CMD`)
})
await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
stdout: expect.stringContaining('good-api')
})
})
})
+8 -2
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -58,8 +61,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -106,6 +111,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
@@ -0,0 +1,249 @@
import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import {
execLocalPreflightCommandOrThrow,
findRunnableLocalCommand,
isCommandAvailable,
isCommandOnPath,
shellQuote
} from './preflight-command-exec'
const COMMAND = 'orca-22975-gh'
const BROKEN_SHIM = 'echo broken-shim >&2\nexit 126\n'
let root = ''
async function cliInDirectory(label: string, body = 'echo gh-version-fixture\n'): Promise<string> {
const dir = path.join(root, label)
await mkdir(dir, { recursive: true })
await writeFile(
path.join(dir, COMMAND),
`#!/bin/sh\nprintf '%s\\n' "$@" >> ${shellQuote(path.join(dir, 'argv.txt'))}\n${body}`,
{ mode: 0o755 }
)
return dir
}
async function probedArgs(dir: string): Promise<string[]> {
try {
return (await readFile(path.join(dir, 'argv.txt'), 'utf8')).split('\n').filter(Boolean)
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
return []
}
throw error
}
}
describe.skipIf(process.platform === 'win32')(
'local CLI version probes with real processes',
() => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-case-'))
vi.stubEnv('HOME', root)
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it('detects a runnable copy behind an executable shim that exits 126', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [shim, good].join(path.delimiter))
await expect(isCommandOnPath(COMMAND)).resolves.toBe(true)
await expect(
execLocalPreflightCommandOrThrow(path.join(shim, COMMAND), ['--version'])
).rejects.toMatchObject({ code: 126, stderr: 'broken-shim\n' })
await expect(
execLocalPreflightCommandOrThrow(path.join(good, COMMAND), ['--version'])
).resolves.toMatchObject({ stdout: 'gh-version-fixture\n' })
await expect(isCommandAvailable(COMMAND)).resolves.toBe(true)
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
})
it('keeps PATH order and leaves later copies unprobed when the first works', async () => {
const first = await cliInDirectory('first')
const second = await cliInDirectory('second')
vi.stubEnv('PATH', [first, second].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(first, COMMAND)
})
expect(await probedArgs(first)).toEqual(['--version'])
expect(await probedArgs(second)).toEqual([])
})
it('distinguishes exhausted failing copies from an absent command', async () => {
const first = await cliInDirectory('first', BROKEN_SHIM)
const second = await cliInDirectory('second', BROKEN_SHIM)
vi.stubEnv('PATH', [first, second].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'exec_failed',
binary: path.join(second, COMMAND)
})
await expect(findRunnableLocalCommand('orca-22975-absent-cli')).resolves.toEqual({
status: 'absent'
})
expect(await probedArgs(first)).toEqual(['--version'])
expect(await probedArgs(second)).toEqual(['--version'])
})
it('skips directories and non-executable files before a working copy', async () => {
const directory = path.join(root, 'directory')
await mkdir(path.join(directory, COMMAND), { recursive: true })
const plain = await cliInDirectory('plain')
await chmod(path.join(plain, COMMAND), 0o644)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [directory, plain, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
expect(await probedArgs(plain)).toEqual([])
})
it('never substitutes another binary for an explicit failing path', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', good)
const selected = path.join(shim, COMMAND)
await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
status: 'exec_failed',
binary: selected
})
expect(await probedArgs(good)).toEqual([])
})
it('executes relative explicit paths and reports an explicit missing path', async () => {
const good = await cliInDirectory('good')
const selected = path.relative(process.cwd(), path.join(good, COMMAND))
vi.stubEnv('PATH', '')
await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
status: 'available',
binary: selected
})
await expect(findRunnableLocalCommand(path.join(root, 'missing', COMMAND))).resolves.toEqual({
status: 'absent'
})
})
it.each([true, false])(
'preserves relative PATH order with shim first: %s',
async (shimFirst) => {
const good = await cliInDirectory('good')
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const relative = path.relative(process.cwd(), good)
vi.stubEnv('PATH', (shimFirst ? [shim, relative] : [relative, shim]).join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
expect(await probedArgs(good)).toEqual(['--version'])
expect(await probedArgs(shim)).toEqual(shimFirst ? ['--version'] : [])
}
)
it('deduplicates PATH entries before spending the probe limit', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [shim, shim, shim, shim, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
expect(await probedArgs(shim)).toEqual(['--version'])
})
it('caps execution at four distinct candidates and reports the limit', async () => {
const doomed: string[] = []
for (const index of [0, 1, 2, 3]) {
doomed.push(await cliInDirectory(`doomed-${index}`, BROKEN_SHIM))
}
const good = await cliInDirectory('past-cap')
vi.stubEnv('PATH', [...doomed, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'limit_reached',
binary: path.join(doomed[3], COMMAND)
})
for (const dir of doomed) {
expect(await probedArgs(dir)).toEqual(['--version'])
}
expect(await probedArgs(good)).toEqual([])
})
it('stops at a real timeout and leaves later copies unprobed', async () => {
const hung = await cliInDirectory('hung', 'exec /bin/sleep 30\n')
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [hung, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'timeout',
binary: path.join(hung, COMMAND)
})
expect(await probedArgs(good)).toEqual([])
}, 10_000)
it('uses the known Nix install directory after PATH copies fail', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const profile = await cliInDirectory(path.join('.nix-profile', 'bin'))
vi.stubEnv('PATH', shim)
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(profile, COMMAND)
})
})
}
)
describe.runIf(process.platform === 'win32')('Windows preflight batch shims', () => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-cmd-'))
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it('runs the next cmd shim after an earlier copy exits 126', async () => {
const first = path.join(root, 'first')
const second = path.join(root, 'second')
for (const dir of [first, second]) {
await mkdir(dir)
}
const name = `${COMMAND}.CMD`
await writeFile(path.join(first, name), '@echo off\r\nexit /b 126\r\n')
await writeFile(
path.join(second, name),
'@echo off\r\necho gh-version-fixture\r\nexit /b 0\r\n'
)
vi.stubEnv('PATH', [first, second].join(path.delimiter))
vi.stubEnv('Path', [first, second].join(path.delimiter))
const result = await findRunnableLocalCommand(COMMAND)
expect(result).toEqual({ status: 'available', binary: path.posix.join(second, name) })
if (result.status === 'available') {
await expect(
execLocalPreflightCommandOrThrow(result.binary, ['auth', 'status'])
).resolves.toMatchObject({ stdout: expect.stringContaining('gh-version-fixture') })
}
})
})
+7
View File
@@ -16,6 +16,7 @@ export type PreflightMocks = {
getGiteaAuthStatusMock: Mock
resolveCliCommandsMock: Mock
isCommandOnLocalPathMock: Mock
listLocalCommandPathsMock: Mock
mergePersistedWindowsPathAsyncMock: Mock
mergePersistedWindowsPathMock: Mock
}
@@ -52,6 +53,7 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = mocks
@@ -63,6 +65,11 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
mergePathSegmentsMock.mockReset()
getActiveMultiplexerMock.mockReset()
// Why empty by default: with no fs candidates the local probe keeps its
// historical bare-name spawn, so cases that stub only where/which and execFile
// still assert on the command names they were written against.
listLocalCommandPathsMock.mockReset()
listLocalCommandPathsMock.mockResolvedValue([])
getBitbucketAuthStatusMock.mockReset()
getAzureDevOpsAuthStatusMock.mockReset()
getGiteaAuthStatusMock.mockReset()
@@ -1,3 +1,4 @@
import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
@@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({
})
}))
vi.mock('../opencode/hook-service', () => ({
openCodeHookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
},
openCode2HookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
}
}))
vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock())
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
+3
View File
@@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record<string, unknown>) => ({
})
export const openCodeHookServiceModuleMock = () => ({
OpenCodeHookService: class {
buildPtyEnv = vi.fn(() => ({}))
},
openCodeHookService: {
buildPtyEnv: openCodeBuildPtyEnvMock,
refreshLegacySharedPlugin: vi.fn<() => void>(),
@@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir'
import { makePaneKey } from '../../shared/stable-pane-id'
import { selectShellStartupFeatures } from '../shell-startup-features'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => {
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
describe('spawn environment', () => {
it.each(['/bin/bash', '/bin/zsh'])(
'does not wrap a bare %s pane merely to expose this app CLI',
(shellPath) => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
try {
const env = buildPtyHostEnv(
'bare-cli-pane',
{},
{
isPackaged: false,
userDataPath: '/tmp/orca-user-data',
selectedCodexHomePath: null,
agentStatusHooksEnabled: false
}
)
expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin')
expect(
selectShellStartupFeatures({
shellPath,
env,
hasStartupCommand: false,
waitsForShellReady: false,
emitsStartupIdentity: false
})
).toEqual([])
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
}
)
it('does not install managed Pi extensions when Pi is disabled', () => {
piBuildPtyEnvMock.mockClear()
+11 -60
View File
@@ -1,15 +1,10 @@
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import {
detectExplicitPiAgentKindFromCommand,
isPiCompatibleAgentType
} from '../../../../shared/pi-agent-kind'
import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard'
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
import {
OPENCODE_CONFIG_DIR_ENV_KEYS,
isOpenCodeLegacySharedConfigDir
} from '../../../opencode/legacy-shared-config-dir'
import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer'
import { mimoCodeHookService } from '../../../mimo/hook-service'
import { agentHookServer } from '../../../agent-hooks/server'
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
@@ -28,13 +23,13 @@ import {
exposePiManagedExtensionEnv,
isMimoLaunchCommand,
resolveMimocodeSourceHome,
resolveOpenCodeSourceConfigDir,
resolvePiAgentSourceDir,
resolveScopedPiAgentSourceDir,
restoreOrStripOverlayEnv
} from './pi-agent'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys'
import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment'
import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config'
/**
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
@@ -50,32 +45,9 @@ export function buildPtyHostEnv(
mergePersistedWindowsPath(baseEnv)
Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings))
// Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config.
const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean =>
isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath)
const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {}
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
if (isLegacyOpenCodeHooksDir(baseEnv[key])) {
delete baseEnv[key]
}
if (!isLegacyOpenCodeHooksDir(process.env[key])) {
inheritedOpenCodeEnv[key] = process.env[key]
}
}
// A daemon or sibling shell can retain a retired path that main no longer sees.
openCodeHookService.refreshLegacySharedPlugin()
openCode2HookService.refreshLegacySharedPlugin()
const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv)
const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir)
? undefined
: resolvedOpenCodeConfigDir
const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint })
const openCodeAgent = selectOpenCodeHookAgent(
opts.launchAgent,
launchCommandHint,
(agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents)
)
const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent)
? opts.launchAgent
: opts.launchAgent === undefined
@@ -110,37 +82,13 @@ export function buildPtyHostEnv(
? resolvePiAgentSourceDir(baseEnv, 'prime-agent')
: resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent')
restoreOrStripOverlayEnv(
const openCodeAgent = applyOpenCodeStatusPluginEnv(
id,
baseEnv,
{
primary: 'OPENCODE_CONFIG_DIR',
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
preserveExplicitPrimary: true
},
inheritedOpenCodeEnv
openCodeConfig,
opts,
launchCommandHint
)
delete baseEnv.ORCA_OPENCODE_AGENT
if (openCodeAgent) {
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
const openCodeStatusService =
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
if (!opts.isWsl) {
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
}
if (baseEnv.OPENCODE_CONFIG_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
if (preexistingOpenCodeConfigDir) {
// Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays.
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
} else {
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
}
if (opts.agentStatusHooksEnabled) {
if (isMimoLaunchCommand(launchCommandHint)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
@@ -343,5 +291,8 @@ export function buildPtyHostEnv(
// process.env when baseEnv carries none, which is the daemon path's normal shape.
stripLegacyTerminalShimEnv(baseEnv, process.platform)
if (!opts.isWsl) {
ensureOpenCodeStartupPromptForLaunch(baseEnv)
}
return baseEnv
}

Some files were not shown because too many files have changed in this diff Show More