Merge origin/main (browser copy, TOC, mobile IME, i18n, Windows startup) + add version_suffix input to release-cut

rc kind can now append an extra prerelease identifier (rc.N.perf):
sorts above its base rc.N but below rc.N+1, so perf-branch RCs are
identifiable by clients without outranking the main RC series.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 20:05:49 -04:00
co-authored by Orca
239 changed files with 11988 additions and 2414 deletions
@@ -4,7 +4,7 @@ import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
const {
gitExecFileAsyncMock,
getGitUsernameMock,
resolveLocalGitUsernameMock,
getSshGitUsernameMock,
getSshGitProviderMock,
generateBranchNameMock,
@@ -14,7 +14,7 @@ const {
getConfiguredBranchPrefixMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getGitUsernameMock: vi.fn(() => 'you'),
resolveLocalGitUsernameMock: vi.fn(async () => 'you'),
getSshGitUsernameMock: vi.fn(async () => 'you'),
getSshGitProviderMock: vi.fn(() => undefined),
generateBranchNameMock: vi.fn(),
@@ -26,8 +26,10 @@ const {
}))
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock }))
vi.mock('../git/git-username', () => ({ getSshGitUsername: getSshGitUsernameMock }))
vi.mock('../git/git-username', () => ({
getSshGitUsername: getSshGitUsernameMock,
resolveLocalGitUsername: resolveLocalGitUsernameMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock }))
vi.mock('../text-generation/commit-message-text-generation', () => ({
generateBranchNameFromContext: generateBranchNameMock,
@@ -65,7 +67,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
beforeEach(() => {
resetFirstWorkBranchRenameState()
vi.clearAllMocks()
getGitUsernameMock.mockReturnValue('you')
resolveLocalGitUsernameMock.mockResolvedValue('you')
getSshGitUsernameMock.mockResolvedValue('you')
getSshGitProviderMock.mockReturnValue(undefined)
computeBranchNameMock.mockImplementation((leaf: string) => `you/${leaf}`)
@@ -15,8 +15,7 @@ import {
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
import { gitExecFileAsync } from '../git/runner'
import { getGitUsername } from '../git/repo'
import { getSshGitUsername } from '../git/git-username'
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
branchHasUpstream,
@@ -256,7 +255,7 @@ async function runAutoRename(
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: getGitUsername(repo.path) || null
: (await resolveLocalGitUsername(repo.path)) || null
// The model is told not to add a prefix, but sometimes echoes the configured
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
// leak into the display name.
@@ -96,6 +96,11 @@ describe('AntigravityHookService', () => {
expect(script).toContain('payload=$(cat)')
expect(script).toContain("payload='{}'")
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
}
expect(script).toContain('{"decision":""}')
})
+5 -2
View File
@@ -127,7 +127,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
" payload='{}'",
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -138,7 +141,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('../git/repo', () => ({
getGitUsername: vi.fn().mockReturnValue('testuser')
}))
vi.mock('./precheck-runner', () => ({
runAutomationPrecheck: runAutomationPrecheckMock
}))
-4
View File
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('../git/repo', () => ({
getGitUsername: vi.fn().mockReturnValue('testuser')
}))
async function createStore() {
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
+19
View File
@@ -105,11 +105,30 @@ describe('setupGuestContextMenu', () => {
screenY: 375,
pageUrl: 'https://test.dev/page',
linkUrl: 'https://test.dev/link',
selectionText: '',
canGoBack: true,
canGoForward: true
})
})
it('forwards the native selection text so the renderer can offer Copy', () => {
const guest = makeGuest()
const renderer = makeRenderer()
setupGuestContextMenu({
browserTabId,
guest,
resolveRenderer: () => renderer
})
triggerContextMenu(guest, { x: 10, y: 20, selectionText: 'copied selection' })
expect(rendererSendMock).toHaveBeenCalledWith(
'browser:context-menu-requested',
expect.objectContaining({ selectionText: 'copied selection' })
)
})
it('reads navigation state from navigationHistory', () => {
const deprecatedCanGoBack = vi.fn(() => false)
const deprecatedCanGoForward = vi.fn(() => false)
+5
View File
@@ -95,6 +95,11 @@ export function setupGuestContextMenu(args: {
screenY: cursor.y,
pageUrl,
linkUrl,
// Why: forward the native selection so the renderer can offer a Copy that
// writes it to the clipboard directly, bypassing pages that suppress copy
// via oncopy handlers (the reported bug — the selection is never re-read
// through a page-visible copy event).
selectionText: params.selectionText ?? '',
...navigationState
})
}
@@ -106,6 +106,17 @@ describe('BrowserSessionRegistry', () => {
expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION)
})
it('strictly resolves known profile partitions without downgrading unknown profiles', () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Strict Resolve')
expect(profile).not.toBeNull()
expect(browserSessionRegistry.resolveKnownPartition(null)).toBe(ORCA_BROWSER_PARTITION)
expect(browserSessionRegistry.resolveKnownPartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
expect(browserSessionRegistry.resolveKnownPartition('default')).toBe(ORCA_BROWSER_PARTITION)
expect(browserSessionRegistry.resolveKnownPartition(profile!.id)).toBe(profile!.partition)
expect(browserSessionRegistry.resolveKnownPartition('missing-profile')).toBeNull()
})
it('lists all profiles', () => {
const before = browserSessionRegistry.listProfiles().length
browserSessionRegistry.createProfile('isolated', 'List Test')
@@ -326,6 +326,13 @@ class BrowserSessionRegistry {
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
}
resolveKnownPartition(profileId: string | null | undefined): string | null {
if (!profileId) {
return ORCA_BROWSER_PARTITION
}
return this.profiles.get(profileId)?.partition ?? null
}
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
// Why: only the constructor may create the default profile. Allowing the
// renderer to pass scope:'default' would create a second profile sharing
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
const DEFAULT_MARGIN_INCHES = 1 / 2.54
const TTL_MS = 5 * 60 * 1000
describe('buildPrintToPdfOptions', () => {
it('returns empty options for empty params', () => {
expect(buildPrintToPdfOptions({})).toEqual({})
})
it('maps every well-formed field', () => {
expect(
buildPrintToPdfOptions({
landscape: true,
displayHeaderFooter: true,
printBackground: true,
preferCSSPageSize: true,
generateTaggedPDF: true,
generateDocumentOutline: false,
scale: 1.5,
paperWidth: 8.5,
paperHeight: 11,
marginTop: 0.25,
marginBottom: 0.5,
marginLeft: 0.75,
marginRight: 1,
pageRanges: '1-2',
headerTemplate: '<span></span>',
footerTemplate: '<span></span>'
})
).toEqual({
landscape: true,
displayHeaderFooter: true,
printBackground: true,
preferCSSPageSize: true,
generateTaggedPDF: true,
generateDocumentOutline: false,
scale: 1.5,
pageSize: { width: 8.5, height: 11 },
margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 },
pageRanges: '1-2',
headerTemplate: '<span></span>',
footerTemplate: '<span></span>'
})
})
it('defaults omitted margin sides to 1cm when any side is set', () => {
expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({
marginType: 'custom',
top: 0.25,
bottom: DEFAULT_MARGIN_INCHES,
left: DEFAULT_MARGIN_INCHES,
right: DEFAULT_MARGIN_INCHES
})
})
it('drops non-finite and out-of-range numeric options', () => {
const options = buildPrintToPdfOptions({
scale: 0,
paperWidth: Number.POSITIVE_INFINITY,
paperHeight: 11,
marginTop: Number.NaN
})
expect(options.scale).toBeUndefined()
expect(options.pageSize).toBeUndefined()
expect(options.margins).toBeUndefined()
})
it('requires both paper dimensions for pageSize', () => {
expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined()
})
it('ignores wrong-typed options', () => {
expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({})
})
})
describe('CdpPdfStreamStore', () => {
afterEach(() => {
vi.useRealTimers()
})
it('claims only its own handles', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('pdf'))
expect(store.ownsHandle({ handle })).toBe(true)
expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
expect(store.ownsHandle({})).toBe(false)
})
it('mints distinct handles per stream', () => {
const store = new CdpPdfStreamStore()
expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b')))
})
it('reads sequential chunks and reports eof', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
expect(store.read({ handle, size: 2 })).toEqual({
data: Buffer.from('ab').toString('base64'),
eof: false
})
expect(store.read({ handle })).toEqual({
data: Buffer.from('cdef').toString('base64'),
eof: true
})
})
it('honors an explicit read offset', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
expect(store.read({ handle, offset: 4 })).toEqual({
data: Buffer.from('ef').toString('base64'),
eof: true
})
})
it('returns an empty eof chunk when reading past the end', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
})
it('returns null for unknown handles', () => {
const store = new CdpPdfStreamStore()
expect(store.read({ handle: 'nope' })).toBeNull()
expect(store.read({})).toBeNull()
})
it('drops a stream on close', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
store.close({ handle })
expect(store.read({ handle })).toBeNull()
})
it('drops all streams on clear', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
store.clear()
expect(store.read({ handle })).toBeNull()
})
it('evicts an abandoned stream after the TTL', () => {
vi.useFakeTimers()
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
vi.advanceTimersByTime(TTL_MS + 1)
expect(store.read({ handle })).toBeNull()
})
it('refreshes the TTL on each read', () => {
vi.useFakeTimers()
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
vi.advanceTimersByTime(TTL_MS - 1)
expect(store.read({ handle, size: 1 })).not.toBeNull()
vi.advanceTimersByTime(TTL_MS - 1)
expect(store.read({ handle, size: 1 })).not.toBeNull()
})
})
+177
View File
@@ -0,0 +1,177 @@
import { randomUUID } from 'node:crypto'
import type { PrintToPDFOptions } from 'electron'
const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54
const PDF_STREAM_CHUNK_BYTES = 1024 * 1024
const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-'
const PDF_STREAM_TTL_MS = 5 * 60 * 1000
function finiteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/**
* Translate CDP `Page.printToPDF` params into Electron `printToPDF` options.
* Only well-formed values are forwarded so a malformed param can never smuggle
* NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm.
*/
export function buildPrintToPdfOptions(params: Record<string, unknown>): PrintToPDFOptions {
const options: PrintToPDFOptions = {}
if (typeof params.landscape === 'boolean') {
options.landscape = params.landscape
}
if (typeof params.displayHeaderFooter === 'boolean') {
options.displayHeaderFooter = params.displayHeaderFooter
}
if (typeof params.printBackground === 'boolean') {
options.printBackground = params.printBackground
}
if (typeof params.preferCSSPageSize === 'boolean') {
options.preferCSSPageSize = params.preferCSSPageSize
}
if (typeof params.generateTaggedPDF === 'boolean') {
options.generateTaggedPDF = params.generateTaggedPDF
}
if (typeof params.generateDocumentOutline === 'boolean') {
options.generateDocumentOutline = params.generateDocumentOutline
}
const scale = finiteNumber(params.scale)
if (scale !== null && scale > 0) {
options.scale = scale
}
const paperWidth = finiteNumber(params.paperWidth)
const paperHeight = finiteNumber(params.paperHeight)
if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) {
options.pageSize = { width: paperWidth, height: paperHeight }
}
const marginTop = finiteNumber(params.marginTop)
const marginBottom = finiteNumber(params.marginBottom)
const marginLeft = finiteNumber(params.marginLeft)
const marginRight = finiteNumber(params.marginRight)
if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) {
// CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm.
options.margins = {
marginType: 'custom',
top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES,
bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES,
left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES,
right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES
}
}
if (typeof params.pageRanges === 'string') {
options.pageRanges = params.pageRanges
}
if (typeof params.headerTemplate === 'string') {
options.headerTemplate = params.headerTemplate
}
if (typeof params.footerTemplate === 'string') {
options.footerTemplate = params.footerTemplate
}
return options
}
type PdfStream = {
data: Buffer
offset: number
cleanupTimer: ReturnType<typeof setTimeout>
}
export type PdfStreamChunk = {
data: string
eof: boolean
}
/**
* Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and
* serves them back through `IO.read` / `IO.close`, the path Playwright's
* `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client
* cannot forge one or collide with a real Chromium IO stream, and each buffer is
* evicted after a TTL so an abandoned stream can never leak memory.
*/
export class CdpPdfStreamStore {
private readonly streams = new Map<string, PdfStream>()
private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-`
private nextId = 0
/** True when `params.handle` names one of this store's streams. */
ownsHandle(params: Record<string, unknown>): boolean {
return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix)
}
create(data: Buffer): string {
const handle = `${this.handlePrefix}${++this.nextId}`
this.streams.set(handle, {
data,
offset: 0,
cleanupTimer: this.scheduleCleanup(handle)
})
return handle
}
/** Read the next chunk, or `null` if the handle is unknown/expired. */
read(params: Record<string, unknown>): PdfStreamChunk | null {
const handle = typeof params.handle === 'string' ? params.handle : ''
const stream = this.streams.get(handle)
if (!stream) {
return null
}
this.refreshCleanup(handle, stream)
const offset = finiteNumber(params.offset)
if (offset !== null) {
stream.offset = Math.max(0, Math.floor(offset))
}
const requestedSize = finiteNumber(params.size)
const size =
requestedSize !== null && requestedSize > 0
? Math.floor(requestedSize)
: PDF_STREAM_CHUNK_BYTES
const start = Math.min(stream.offset, stream.data.length)
const end = Math.min(start + size, stream.data.length)
const chunk = stream.data.subarray(start, end)
stream.offset = end
return { data: chunk.toString('base64'), eof: end >= stream.data.length }
}
close(params: Record<string, unknown>): void {
const handle = typeof params.handle === 'string' ? params.handle : ''
this.delete(handle)
}
clear(): void {
for (const stream of this.streams.values()) {
clearTimeout(stream.cleanupTimer)
}
this.streams.clear()
}
private scheduleCleanup(handle: string): ReturnType<typeof setTimeout> {
const cleanupTimer = setTimeout(() => {
this.delete(handle)
}, PDF_STREAM_TTL_MS)
const maybeNodeTimer = cleanupTimer as { unref?: () => void }
maybeNodeTimer.unref?.()
return cleanupTimer
}
private refreshCleanup(handle: string, stream: PdfStream): void {
clearTimeout(stream.cleanupTimer)
stream.cleanupTimer = this.scheduleCleanup(handle)
}
private delete(handle: string): void {
const stream = this.streams.get(handle)
if (!stream) {
return
}
clearTimeout(stream.cleanupTimer)
this.streams.delete(handle)
}
}
@@ -0,0 +1,394 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { CdpWsProxy } from './cdp-ws-proxy'
import {
connect,
createMockWebContents,
getSendCommandCalls,
getSendCommandMethods,
sendAndReceive,
type MockWebContents
} from './cdp-ws-proxy-test-harness'
vi.mock('electron', () => ({
webContents: { fromId: vi.fn() }
}))
// Why: the proxy focuses the guest webContents natively before Input.insertText,
// which blurs any element a prior DOM.focus targeted. These tests pin the replay
// that re-applies that focus so text lands in the intended field.
describe('CdpWsProxy DOM.focus replay', () => {
let mock: MockWebContents
let proxy: CdpWsProxy
let endpoint: string
beforeEach(async () => {
mock = createMockWebContents()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
proxy = new CdpWsProxy(mock.webContents as any)
endpoint = await proxy.start()
})
afterEach(async () => {
await proxy.stop()
})
it('replays DOM.focus before Input.insertText in the root session', async () => {
const client = await connect(endpoint)
const focusResponse = await sendAndReceive(client, {
id: 14,
method: 'DOM.focus',
params: { backendNodeId: 99 }
})
const insertResponse = await sendAndReceive(client, {
id: 15,
method: 'Input.insertText',
params: { text: 'hello' }
})
expect(focusResponse.id).toBe(14)
expect(insertResponse.id).toBe(15)
expect(insertResponse.result).toEqual({})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 99 }],
['DOM.focus', { backendNodeId: 99 }],
['Input.insertText', { text: 'hello' }]
])
client.close()
})
it('replays DOM.focus before Input.insertText for OOPIF sessions', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 16,
method: 'DOM.focus',
params: { backendNodeId: 123 },
sessionId: 'oopif-session-123'
})
const insertResponse = await sendAndReceive(client, {
id: 17,
method: 'Input.insertText',
params: { text: 'frame text' },
sessionId: 'oopif-session-123'
})
expect(insertResponse.id).toBe(17)
expect(insertResponse.result).toEqual({})
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
['Input.insertText', { text: 'frame text' }, 'oopif-session-123']
])
client.close()
})
it('does not replay DOM.focus after adjacent eval traffic', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 18,
method: 'DOM.focus',
params: { backendNodeId: 44 }
})
await sendAndReceive(client, {
id: 19,
method: 'Runtime.callFunctionOn',
params: { functionDeclaration: '() => document.activeElement?.id' }
})
const insertResponse = await sendAndReceive(client, {
id: 20,
method: 'Input.insertText',
params: { text: 'after eval' }
})
expect(insertResponse.id).toBe(20)
expect(insertResponse.result).toEqual({})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 44 }],
['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }],
['Input.insertText', { text: 'after eval' }]
])
client.close()
})
it('does not replay a failed DOM.focus on the next Input.insertText', async () => {
let domFocusAttempt = 0
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
domFocusAttempt += 1
if (domFocusAttempt === 1) {
throw new Error('Node not found')
}
}
return {}
})
const client = await connect(endpoint)
const focusResponse = await sendAndReceive(client, {
id: 21,
method: 'DOM.focus',
params: { backendNodeId: 55 }
})
const insertResponse = await sendAndReceive(client, {
id: 22,
method: 'Input.insertText',
params: { text: 'fallback' }
})
expect(focusResponse).toEqual({
id: 21,
error: { code: -32000, message: 'Node not found' }
})
expect(insertResponse.id).toBe(22)
expect(insertResponse.result).toEqual({})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 55 }],
['Input.insertText', { text: 'fallback' }]
])
client.close()
})
it('returns the replay error when the stored DOM.focus fails before Input.insertText', async () => {
let domFocusAttempt = 0
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
domFocusAttempt += 1
if (domFocusAttempt === 2) {
throw new Error('Focus target went stale')
}
}
return {}
})
const client = await connect(endpoint)
const focusResponse = await sendAndReceive(client, {
id: 23,
method: 'DOM.focus',
params: { backendNodeId: 77 }
})
const insertResponse = await sendAndReceive(client, {
id: 24,
method: 'Input.insertText',
params: { text: 'blocked' }
})
expect(focusResponse.id).toBe(23)
expect(focusResponse.result).toEqual({})
expect(insertResponse).toEqual({
id: 24,
error: { code: -32000, message: 'Focus target went stale' }
})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 77 }],
['DOM.focus', { backendNodeId: 77 }]
])
client.close()
})
it('still replays DOM.focus when Input.insertText is dispatched while DOM.focus is still in flight', async () => {
let resolveFocus: (v: Record<string, unknown>) => void
const focusPromise = new Promise<Record<string, unknown>>((r) => {
resolveFocus = r
})
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
return focusPromise
}
return {}
})
const client = await connect(endpoint)
const responses: Record<string, unknown>[] = []
client.on('message', (data) => {
responses.push(JSON.parse(data.toString()))
})
client.send(JSON.stringify({ id: 25, method: 'DOM.focus', params: { backendNodeId: 66 } }))
await new Promise((r) => setTimeout(r, 10))
// Why: dispatch the next message before the in-flight DOM.focus sendCommand
// resolves, reproducing the pipelining race the fix closes.
client.send(
JSON.stringify({ id: 26, method: 'Input.insertText', params: { text: 'pipelined' } })
)
await new Promise((r) => setTimeout(r, 20))
resolveFocus!({})
await new Promise((r) => setTimeout(r, 20))
expect(responses).toHaveLength(2)
const focusResponse = responses.find((r) => r.id === 25)
const insertResponse = responses.find((r) => r.id === 26)
expect(focusResponse?.result).toEqual({})
expect(insertResponse?.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'DOM.focus',
'Input.insertText'
])
client.close()
})
it('clears the pending DOM.focus replay when Page.bringToFront intervenes', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 27,
method: 'DOM.focus',
params: { backendNodeId: 88 }
})
await sendAndReceive(client, { id: 28, method: 'Page.bringToFront', params: {} })
const insertResponse = await sendAndReceive(client, {
id: 29,
method: 'Input.insertText',
params: { text: 'no replay' }
})
expect(insertResponse.id).toBe(29)
expect(insertResponse.result).toEqual({})
// Why: both Page.bringToFront and Input.insertText natively call focus(),
// independent of the (now-cleared) DOM.focus replay.
expect(mock.webContents.focus).toHaveBeenCalledTimes(2)
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Input.insertText'
])
client.close()
})
it('clears the pending DOM.focus replay when Page.captureScreenshot intervenes', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 30,
method: 'DOM.focus',
params: { backendNodeId: 91 }
})
await sendAndReceive(client, { id: 31, method: 'Page.captureScreenshot', params: {} })
const insertResponse = await sendAndReceive(client, {
id: 32,
method: 'Input.insertText',
params: { text: 'no replay after screenshot' }
})
expect(insertResponse.id).toBe(32)
expect(insertResponse.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Page.captureScreenshot',
'Input.insertText'
])
client.close()
})
it('does not replay a pending DOM.focus across a client reconnect', async () => {
const first = await connect(endpoint)
await sendAndReceive(first, { id: 33, method: 'DOM.focus', params: { backendNodeId: 12 } })
first.close()
// Why: a new client connection replaces the previous one; the stale focus
// stored by the departed client must not leak into the new client's insert.
const second = await connect(endpoint)
const insertResponse = await sendAndReceive(second, {
id: 34,
method: 'Input.insertText',
params: { text: 'fresh client' }
})
expect(insertResponse.id).toBe(34)
expect(insertResponse.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Input.insertText'
])
second.close()
})
it('does not replay or insert once the client disconnects mid-DOM.focus', async () => {
let resolveFocus: (v: Record<string, unknown>) => void = () => {}
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
return new Promise<Record<string, unknown>>((resolve) => {
resolveFocus = resolve
})
}
return {}
})
const client = await connect(endpoint)
client.send(JSON.stringify({ id: 35, method: 'DOM.focus', params: { backendNodeId: 7 } }))
await new Promise((r) => setTimeout(r, 10))
// Pipeline the insert while DOM.focus is still in flight, then drop the client.
client.send(JSON.stringify({ id: 36, method: 'Input.insertText', params: { text: 'gone' } }))
await new Promise((r) => setTimeout(r, 10))
client.close()
await new Promise((r) => setTimeout(r, 10))
// Resolve the in-flight DOM.focus only after the client is gone.
resolveFocus({})
await new Promise((r) => setTimeout(r, 20))
// Why: a disconnected client's focus replay and insert must not reach the live page.
const methods = getSendCommandMethods(mock)
expect(methods.filter((m) => m === 'DOM.focus')).toHaveLength(1)
expect(methods).not.toContain('Input.insertText')
})
it('does not insert once the client disconnects during the DOM.focus replay', async () => {
let domFocusCalls = 0
let resolveReplay: (v: Record<string, unknown>) => void = () => {}
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
domFocusCalls += 1
// Why: let the first DOM.focus resolve so a replay is queued, then hang the
// replay so the client can disconnect while it is in flight.
if (domFocusCalls === 2) {
return new Promise<Record<string, unknown>>((resolve) => {
resolveReplay = resolve
})
}
}
return {}
})
const client = await connect(endpoint)
await sendAndReceive(client, { id: 37, method: 'DOM.focus', params: { backendNodeId: 9 } })
client.send(JSON.stringify({ id: 38, method: 'Input.insertText', params: { text: 'late' } }))
await new Promise((r) => setTimeout(r, 10))
client.close()
await new Promise((r) => setTimeout(r, 10))
resolveReplay({})
await new Promise((r) => setTimeout(r, 20))
expect(getSendCommandMethods(mock)).not.toContain('Input.insertText')
})
})
@@ -0,0 +1,118 @@
import { vi, type Mock } from 'vitest'
import WebSocket from 'ws'
type DebuggerListener = (...args: unknown[]) => void
type MockDebugger = {
isAttached: Mock<() => boolean>
attach: Mock<() => void>
detach: Mock<() => void>
sendCommand: Mock<
(
method?: string,
params?: Record<string, unknown>,
sessionId?: string
) => Promise<Record<string, unknown>>
>
on: Mock<(event: string, handler: DebuggerListener) => void>
removeListener: Mock<(event: string, handler: DebuggerListener) => void>
}
// Why: annotate the return explicitly so the exported inferred type stays nameable under
// composite declaration emit — otherwise the vi.fn() mocks leak @vitest/spy's Procedure
// and tsgo reports TS2883.
export type MockWebContents = {
webContents: {
debugger: MockDebugger
isDestroyed: () => boolean
focus: Mock<() => void>
printToPDF: Mock<() => Promise<Buffer>>
reload: Mock<() => void>
reloadIgnoringCache: Mock<() => void>
getTitle: Mock<() => string>
getURL: Mock<() => string>
}
listeners: Map<string, DebuggerListener[]>
destroy: () => void
emit: (event: string, ...args: unknown[]) => void
}
export function createMockWebContents(): MockWebContents {
const listeners = new Map<string, DebuggerListener[]>()
let debuggerAttached = false
let destroyed = false
const debuggerObj = {
isAttached: vi.fn(() => debuggerAttached),
attach: vi.fn(() => {
debuggerAttached = true
}),
detach: vi.fn(() => {
debuggerAttached = false
}),
sendCommand: vi.fn(
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
listeners.set(event, arr)
}),
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
listeners.set(
event,
arr.filter((h) => h !== handler)
)
})
}
return {
webContents: {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
printToPDF: vi.fn(async () => Buffer.from('%PDF-test')),
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
getURL: vi.fn(() => 'https://example.com')
},
listeners,
destroy() {
destroyed = true
},
emit(event: string, ...args: unknown[]) {
for (const handler of listeners.get(event) ?? []) {
handler(...args)
}
}
}
}
export type SendCommandCall = [string, Record<string, unknown>?, string?]
export function connect(endpoint: string): Promise<WebSocket> {
return new Promise((resolve) => {
const ws = new WebSocket(endpoint)
ws.on('open', () => resolve(ws))
})
}
export function sendAndReceive(
ws: WebSocket,
msg: Record<string, unknown>
): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
ws.send(JSON.stringify(msg))
})
}
export function getSendCommandCalls(mock: MockWebContents): SendCommandCall[] {
return mock.webContents.debugger.sendCommand.mock.calls as unknown as SendCommandCall[]
}
export function getSendCommandMethods(mock: MockWebContents): string[] {
return getSendCommandCalls(mock).map((call) => call[0])
}
+252 -114
View File
@@ -1,67 +1,21 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import WebSocket from 'ws'
import { CdpWsProxy } from './cdp-ws-proxy'
import {
connect,
createMockWebContents,
getSendCommandCalls,
getSendCommandMethods,
sendAndReceive,
type MockWebContents
} from './cdp-ws-proxy-test-harness'
vi.mock('electron', () => ({
webContents: { fromId: vi.fn() }
}))
type DebuggerListener = (...args: unknown[]) => void
function createMockWebContents() {
const listeners = new Map<string, DebuggerListener[]>()
let debuggerAttached = false
let destroyed = false
const debuggerObj = {
isAttached: vi.fn(() => debuggerAttached),
attach: vi.fn(() => {
debuggerAttached = true
}),
detach: vi.fn(() => {
debuggerAttached = false
}),
sendCommand: vi.fn(
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
listeners.set(event, arr)
}),
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
listeners.set(
event,
arr.filter((h) => h !== handler)
)
})
}
return {
webContents: {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
getURL: vi.fn(() => 'https://example.com')
},
listeners,
destroy() {
destroyed = true
},
emit(event: string, ...args: unknown[]) {
for (const handler of listeners.get(event) ?? []) {
handler(...args)
}
}
}
}
describe('CdpWsProxy', () => {
let mock: ReturnType<typeof createMockWebContents>
let mock: MockWebContents
let proxy: CdpWsProxy
let endpoint: string
@@ -76,38 +30,14 @@ describe('CdpWsProxy', () => {
await proxy.stop()
})
function connect(): Promise<WebSocket> {
return new Promise((resolve) => {
const ws = new WebSocket(endpoint)
ws.on('open', () => resolve(ws))
})
function expectPdfStreamHandle(response: Record<string, unknown>): string {
const result = response.result as Record<string, unknown>
expect(result.data).toBe('')
expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/))
return result.stream as string
}
function sendAndReceive(
ws: WebSocket,
msg: Record<string, unknown>
): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
ws.send(JSON.stringify(msg))
})
}
type SendCommandCall = [string, Record<string, unknown>?, string?]
function getSendCommandCalls(): SendCommandCall[] {
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
string,
Record<string, unknown>?,
string?
][]
return calls
}
function getSendCommandMethods(): string[] {
const calls = getSendCommandCalls()
return calls.map((call) => call[0])
}
const defaultPdfMarginInches = 1 / 2.54
it('starts on a random port and returns ws:// URL', () => {
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
@@ -131,7 +61,7 @@ describe('CdpWsProxy', () => {
it('correlates CDP request/response IDs', async () => {
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' })
const ws = connect()
const ws = connect(endpoint)
const client = await ws
const response = await sendAndReceive(client, {
id: 42,
@@ -147,7 +77,7 @@ describe('CdpWsProxy', () => {
it('returns error response when sendCommand fails', async () => {
mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found'))
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 7,
method: 'DOM.describeNode',
@@ -160,7 +90,7 @@ describe('CdpWsProxy', () => {
})
it('returns an error instead of crashing when a command arrives after tab destruction', async () => {
const client = await connect()
const client = await connect(endpoint)
mock.destroy()
const response = await sendAndReceive(client, {
@@ -197,7 +127,7 @@ describe('CdpWsProxy', () => {
})
.mockResolvedValueOnce({ result: 'fast' })
const client = await connect()
const client = await connect(endpoint)
const responses: Record<string, unknown>[] = []
client.on('message', (data) => {
@@ -232,11 +162,11 @@ describe('CdpWsProxy', () => {
)
.mockResolvedValueOnce({ result: 'new-client' })
const firstClient = await connect()
const firstClient = await connect(endpoint)
firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
await new Promise((resolve) => setTimeout(resolve, 10))
const secondClient = await connect()
const secondClient = await connect(endpoint)
const responses: Record<string, unknown>[] = []
secondClient.on('message', (data) => {
responses.push(JSON.parse(data.toString()))
@@ -258,7 +188,7 @@ describe('CdpWsProxy', () => {
it('forwards sessionId to sendCommand for OOPIF support', async () => {
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 1,
method: 'DOM.enable',
@@ -277,7 +207,7 @@ describe('CdpWsProxy', () => {
// ── Event forwarding ──
it('forwards CDP events from debugger to client', async () => {
const client = await connect()
const client = await connect(endpoint)
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
client.on('message', (data) => resolve(JSON.parse(data.toString())))
@@ -292,7 +222,7 @@ describe('CdpWsProxy', () => {
})
it('forwards sessionId in events when present', async () => {
const client = await connect()
const client = await connect(endpoint)
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
client.on('message', (data) => resolve(JSON.parse(data.toString())))
@@ -306,7 +236,7 @@ describe('CdpWsProxy', () => {
})
it('does not focus the guest for Runtime.evaluate polling commands', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 9,
@@ -319,7 +249,7 @@ describe('CdpWsProxy', () => {
})
it('still focuses the guest for Input.insertText', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 10,
@@ -328,11 +258,16 @@ describe('CdpWsProxy', () => {
})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Input.insertText'
])
client.close()
})
it('primes lifecycle events for Page.navigate', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 11,
@@ -342,7 +277,7 @@ describe('CdpWsProxy', () => {
expect(response.id).toBe(11)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
@@ -354,7 +289,7 @@ describe('CdpWsProxy', () => {
})
it('primes lifecycle events for Page.reload and preserves response id', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 12,
@@ -363,7 +298,7 @@ describe('CdpWsProxy', () => {
expect(response.id).toBe(12)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
@@ -371,12 +306,12 @@ describe('CdpWsProxy', () => {
'Page.setLifecycleEventsEnabled'
])
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
expect(getSendCommandMethods()).not.toContain('Page.reload')
expect(getSendCommandMethods(mock)).not.toContain('Page.reload')
client.close()
})
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 14,
@@ -385,7 +320,7 @@ describe('CdpWsProxy', () => {
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
expect(getSendCommandCalls(mock).slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
@@ -395,7 +330,7 @@ describe('CdpWsProxy', () => {
})
it('forwards explicit Page.reload session after lifecycle priming', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 15,
@@ -404,7 +339,7 @@ describe('CdpWsProxy', () => {
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
expect(getSendCommandCalls(mock).slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
@@ -416,7 +351,7 @@ describe('CdpWsProxy', () => {
})
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 16,
@@ -433,12 +368,12 @@ describe('CdpWsProxy', () => {
})
expect(mock.webContents.reload).not.toHaveBeenCalled()
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
expect(getSendCommandMethods()).not.toContain('Network.enable')
expect(getSendCommandMethods(mock)).not.toContain('Network.enable')
client.close()
})
it('still reloads when lifecycle priming stalls', async () => {
const client = await connect()
const client = await connect(endpoint)
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
@@ -457,7 +392,7 @@ describe('CdpWsProxy', () => {
})
it('does not reload after the requesting client disconnects during priming', async () => {
const client = await connect()
const client = await connect(endpoint)
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
@@ -475,7 +410,7 @@ describe('CdpWsProxy', () => {
})
it('forwards Runtime.evaluate without lifecycle priming', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 13,
@@ -485,7 +420,7 @@ describe('CdpWsProxy', () => {
expect(response.id).toBe(13)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Runtime.evaluate'
@@ -493,12 +428,215 @@ describe('CdpWsProxy', () => {
client.close()
})
it('prints PDF data through native webContents printToPDF', async () => {
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 19,
method: 'Page.printToPDF',
params: {
landscape: true,
printBackground: true,
paperWidth: 8.5,
paperHeight: 11,
marginTop: 0.25,
marginBottom: 0.5,
marginLeft: 0.75,
marginRight: 1,
pageRanges: '1-2',
preferCSSPageSize: true
}
})
expect(response).toEqual({
id: 19,
result: { data: Buffer.from('%PDF-test').toString('base64') }
})
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
landscape: true,
printBackground: true,
pageSize: { width: 8.5, height: 11 },
margins: {
marginType: 'custom',
top: 0.25,
bottom: 0.5,
left: 0.75,
right: 1
},
pageRanges: '1-2',
preferCSSPageSize: true
})
expect(getSendCommandMethods(mock)).not.toContain('Page.printToPDF')
client.close()
})
it('keeps default PDF margins for omitted sides', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 20,
method: 'Page.printToPDF',
params: {
marginTop: 0.25
}
})
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
margins: {
marginType: 'custom',
top: 0.25,
bottom: defaultPdfMarginInches,
left: defaultPdfMarginInches,
right: defaultPdfMarginInches
}
})
client.close()
})
it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => {
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
const client = await connect(endpoint)
const printResponse = await sendAndReceive(client, {
id: 21,
method: 'Page.printToPDF',
params: { transferMode: 'ReturnAsStream' }
})
const handle = expectPdfStreamHandle(printResponse)
const firstRead = await sendAndReceive(client, {
id: 22,
method: 'IO.read',
params: { handle, size: 2 }
})
const secondRead = await sendAndReceive(client, {
id: 23,
method: 'IO.read',
params: { handle }
})
const closeResponse = await sendAndReceive(client, {
id: 24,
method: 'IO.close',
params: { handle }
})
const readAfterClose = await sendAndReceive(client, {
id: 25,
method: 'IO.read',
params: { handle }
})
expect(printResponse.id).toBe(21)
expect(firstRead).toEqual({
id: 22,
result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false }
})
expect(secondRead).toEqual({
id: 23,
result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true }
})
expect(closeResponse).toEqual({ id: 24, result: {} })
expect(readAfterClose).toEqual({
id: 25,
error: { code: -32000, message: 'Invalid stream handle' }
})
client.close()
})
it('clears streamed PDF data when the client disconnects', async () => {
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
const client = await connect(endpoint)
const printResponse = await sendAndReceive(client, {
id: 26,
method: 'Page.printToPDF',
params: { transferMode: 'ReturnAsStream' }
})
const handle = expectPdfStreamHandle(printResponse)
expect(printResponse.id).toBe(26)
client.close()
await new Promise((resolve) => setTimeout(resolve, 10))
const nextClient = await connect(endpoint)
const staleRead = await sendAndReceive(nextClient, {
id: 27,
method: 'IO.read',
params: { handle }
})
expect(staleRead).toEqual({
id: 27,
error: { code: -32000, message: 'Invalid stream handle' }
})
nextClient.close()
})
it('does not register a PDF stream when the client disconnects mid-print', async () => {
let resolvePrint: (buf: Buffer<ArrayBuffer>) => void = () => {}
mock.webContents.printToPDF.mockImplementationOnce(
() =>
new Promise<Buffer<ArrayBuffer>>((resolve) => {
resolvePrint = resolve
})
)
const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams
const createSpy = vi.spyOn(store, 'create')
const client = await connect(endpoint)
client.send(
JSON.stringify({
id: 30,
method: 'Page.printToPDF',
params: { transferMode: 'ReturnAsStream' }
})
)
await new Promise((resolve) => setTimeout(resolve, 10))
client.close()
await new Promise((resolve) => setTimeout(resolve, 10))
// Print resolves only after the client is gone: no stream must be created.
resolvePrint(Buffer.from('%PDF-late'))
await new Promise((resolve) => setTimeout(resolve, 10))
expect(createSpy).not.toHaveBeenCalled()
createSpy.mockRestore()
})
it('forwards non-PDF IO streams to the debugger', async () => {
mock.webContents.debugger.sendCommand
.mockResolvedValueOnce({ data: 'trace-data', eof: false })
.mockResolvedValueOnce({})
const client = await connect(endpoint)
const readResponse = await sendAndReceive(client, {
id: 28,
method: 'IO.read',
params: { handle: 'trace-stream', size: 64 }
})
const closeResponse = await sendAndReceive(client, {
id: 29,
method: 'IO.close',
params: { handle: 'trace-stream' }
})
expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } })
expect(closeResponse).toEqual({ id: 29, result: {} })
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', {
handle: 'trace-stream',
size: 64
})
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', {
handle: 'trace-stream'
})
client.close()
})
// ── Page.frameNavigated interception ──
// ── Cleanup ──
it('detaches debugger and closes server on stop', async () => {
const client = await connect()
const client = await connect(endpoint)
await proxy.stop()
expect(mock.webContents.debugger.detach).toHaveBeenCalled()
@@ -513,7 +651,7 @@ describe('CdpWsProxy', () => {
})
it('detaches client websocket listeners after client close', async () => {
const client = await connect()
const client = await connect(endpoint)
const serverClient = (proxy as unknown as { client: WebSocket | null }).client
expect(serverClient).toBeTruthy()
const offSpy = vi.spyOn(serverClient!, 'off')
@@ -543,7 +681,7 @@ describe('CdpWsProxy', () => {
})
)
const client = await connect()
const client = await connect(endpoint)
client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }))
await new Promise((r) => setTimeout(r, 10))
+166
View File
@@ -3,12 +3,19 @@ import { WebSocketServer, WebSocket } from 'ws'
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
import type { WebContents } from 'electron'
import { captureScreenshot } from './cdp-screenshot'
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
export class CdpWsProxy {
// Why: holds each session's last DOM.focus params to replay right before the next
// Input.insertText, countering the native webContents.focus() that would blur the target.
private pendingDomFocusBySession = new Map<
string | undefined,
Promise<Record<string, unknown> | undefined>
>()
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
private client: WebSocket | null = null
@@ -20,6 +27,7 @@ export class CdpWsProxy {
private attached = false
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
private clientSessionId: string | undefined = undefined
private readonly pdfStreams = new CdpPdfStreamStore()
constructor(private readonly webContents: WebContents) {}
@@ -51,6 +59,7 @@ export class CdpWsProxy {
const onClose = (): void => {
detach()
if (this.client === ws) {
this.pdfStreams.clear()
this.client = null
}
}
@@ -101,6 +110,9 @@ export class CdpWsProxy {
this.detachClientListeners?.()
this.detachClientListeners = null
this.client = null
// Why: a pending focus belongs to the departing client; never replay it for the next one.
this.pendingDomFocusBySession.clear()
this.pdfStreams.clear()
client?.close()
}
@@ -278,6 +290,12 @@ export class CdpWsProxy {
)
return
}
const effectiveSessionId = this.resolveDebuggerSessionId(msg.sessionId)
// Why: a stored focus is only valid for the immediately following Input.insertText;
// any other command may have moved DOM focus, so invalidate the replay in one place.
if (msg.method !== 'DOM.focus' && msg.method !== 'Input.insertText') {
this.pendingDomFocusBySession.delete(effectiveSessionId)
}
if (msg.method === 'Page.bringToFront') {
if (!this.webContents.isDestroyed()) {
this.webContents.focus()
@@ -285,11 +303,39 @@ export class CdpWsProxy {
this.sendResult(clientId, {}, client)
return
}
if (msg.method === 'DOM.focus') {
this.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId)
return
}
// Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests.
if (msg.method === 'Page.captureScreenshot') {
this.handleScreenshot(client, clientId, msg.params)
return
}
// Why: CDP Page.printToPDF is not available for Electron webview guests.
// Electron's native printToPDF path is the reliable equivalent.
if (msg.method === 'Page.printToPDF') {
void this.handlePrintToPdf(client, clientId, msg.params ?? {})
return
}
if (msg.method === 'IO.read') {
const params = msg.params ?? {}
if (this.pdfStreams.ownsHandle(params)) {
this.handleStreamRead(client, clientId, params)
return
}
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
return
}
if (msg.method === 'IO.close') {
const params = msg.params ?? {}
if (this.pdfStreams.ownsHandle(params)) {
this.handleStreamClose(client, clientId, params)
return
}
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
return
}
// Why: Input.insertText can still require native focus in Electron webviews.
// Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait
// polling and read-only JS probes use those methods heavily, and focusing on
@@ -297,6 +343,8 @@ export class CdpWsProxy {
// is running.
if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) {
this.webContents.focus()
void this.forwardInsertText(client, clientId, msg.params ?? {}, effectiveSessionId)
return
}
// Why: agent-browser waits for network idle to detect navigation completion.
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
@@ -439,6 +487,124 @@ export class CdpWsProxy {
}
}
// Why: this must stay synchronous up to the `.set()` call so the pending-focus
// entry exists before the event loop can dispatch a pipelined Input.insertText
// message, closing the race where the replay would otherwise be silently skipped.
private forwardDomFocus(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
effectiveSessionId?: string
): void {
const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId)
this.pendingDomFocusBySession.set(effectiveSessionId, focused)
}
private async sendDomFocus(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
effectiveSessionId?: string
): Promise<Record<string, unknown> | undefined> {
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return undefined
}
try {
const result = await this.sendDebuggerCommand('DOM.focus', params, effectiveSessionId)
this.sendResult(clientId, result, client)
return { ...params }
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
return undefined
}
}
private async forwardInsertText(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
effectiveSessionId?: string
): Promise<void> {
const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId)
this.pendingDomFocusBySession.delete(effectiveSessionId)
const pendingFocusParams = pendingFocus ? await pendingFocus : undefined
// Why: the client can disconnect while DOM.focus is in flight; don't replay its
// focus or forward its insert into the live page once it is no longer active.
if (!this.isActiveClient(client)) {
return
}
if (pendingFocusParams) {
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
try {
await this.sendDebuggerCommand('DOM.focus', pendingFocusParams, effectiveSessionId)
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
return
}
// Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished
// during it so its insert never lands in the live page.
if (!this.isActiveClient(client)) {
return
}
}
this.forwardCommand(client, clientId, 'Input.insertText', params, effectiveSessionId)
}
private async handlePrintToPdf(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
): Promise<void> {
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
try {
const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params))
// Why: printToPDF can resolve after the client disconnected (or was
// replaced). Bail before registering a stream so its buffer isn't
// orphaned in pdfStreams past the disconnect's clear() until the TTL.
if (!this.isActiveClient(client)) {
return
}
const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf)
if (params.transferMode === 'ReturnAsStream') {
const handle = this.pdfStreams.create(buffer)
this.sendResult(clientId, { data: '', stream: handle }, client)
return
}
this.sendResult(clientId, { data: buffer.toString('base64') }, client)
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
}
}
private handleStreamRead(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
): void {
const chunk = this.pdfStreams.read(params)
if (!chunk) {
this.sendError(clientId, 'Invalid stream handle', client)
return
}
this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client)
}
private handleStreamClose(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
): void {
this.pdfStreams.close(params)
this.sendResult(clientId, {}, client)
}
private handleScreenshot(
client: WebSocket,
clientId: number,
+6
View File
@@ -269,6 +269,12 @@ describe('ClaudeHookService.installRemote', () => {
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
expect(script).toContain('#!/bin/sh')
expect(script).toContain('DEVIN_PROJECT_DIR')
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive),
// matching the Windows curl.exe hook post.
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
})
+5 -2
View File
@@ -113,7 +113,10 @@ function getManagedScript(
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -123,7 +126,7 @@ function getManagedScript(
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+1 -1
View File
@@ -32,6 +32,6 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} --
`
}
function quoteShell(value: string): string {
export function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}
@@ -0,0 +1,130 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { isPackaged: true }
}))
import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher'
const created: string[] = []
async function makeFixture(): Promise<{ homePath: string; resourcesPath: string }> {
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-'))
created.push(root)
const resourcesPath = join(root, 'resources')
// The bundled orca-ide launcher must exist for the dispatcher to be written.
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
return { homePath: join(root, 'home'), resourcesPath }
}
afterEach(async () => {
await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('installLinuxBareOrcaDispatcher', () => {
it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => {
const { homePath, resourcesPath } = await makeFixture()
const result = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
const expectedTarget = join(resourcesPath, 'bin', 'orca-ide')
expect(result.state).toBe('installed')
expect(result.target).toBe(expectedTarget)
expect(result.dispatcherPath).toBe(join(homePath, '.local', 'bin', 'orca'))
const content = await readFile(result.dispatcherPath, 'utf8')
expect(content).toContain('#!/usr/bin/env bash')
// Single-quoted so a resources path with shell metacharacters can't break out.
expect(content).toContain(`exec '${expectedTarget}' "$@"`)
const mode = (await stat(result.dispatcherPath)).mode & 0o777
expect(mode & 0o111).not.toBe(0)
})
it('is idempotent — a second install rewrites its own dispatcher without throwing', async () => {
const { homePath, resourcesPath } = await makeFixture()
const first = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
const second = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
expect(second).toEqual(first)
expect(second.state).toBe('installed')
})
it('quotes a resources path containing spaces so the exec line cannot be split', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-'))
created.push(root)
const resourcesPath = join(root, 'App Support', 'resources')
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
const result = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath: join(root, 'home'),
appImagePath: null
})
const content = await readFile(result.dispatcherPath, 'utf8')
expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`)
})
it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => {
const { homePath, resourcesPath } = await makeFixture()
const appImagePath = join(homePath, 'Applications', 'Orca.AppImage')
const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath })
expect(result.state).toBe('installed')
expect(result.target).toBe(appImagePath)
const content = await readFile(result.dispatcherPath, 'utf8')
// The AppImage wrapper references the stable outer path, never resourcesPath.
expect(content).toContain(appImagePath)
expect(content).not.toContain(resourcesPath)
})
it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => {
const { homePath, resourcesPath } = await makeFixture()
const dispatcherPath = join(homePath, '.local', 'bin', 'orca')
await mkdir(join(homePath, '.local', 'bin'), { recursive: true })
await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8')
const result = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
expect(result.state).toBe('skipped-foreign')
expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n')
})
it('skips when the bundled orca-ide launcher is missing from the build', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-'))
created.push(root)
const result = await installLinuxBareOrcaDispatcher({
resourcesPath: join(root, 'resources'),
homePath: join(root, 'home'),
appImagePath: null
})
expect(result.state).toBe('skipped-launcher-missing')
expect(result.target).toBeNull()
})
})
+102
View File
@@ -0,0 +1,102 @@
import { existsSync } from 'node:fs'
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper'
import { getBundledLauncherPath } from './cli-installer'
// Why: marks a dispatcher this function wrote so repeat serve starts overwrite
// our own file idempotently but never clobber a user's own ~/.local/bin/orca.
const DISPATCHER_MARKER = '# orca-serve-bare-orca-dispatcher'
export type LinuxBareOrcaDispatcherOptions = {
/** Packaged app resources root; the bundled `orca-ide` launcher lives under it. */
resourcesPath: string
/** Test seam — defaults to the real home directory. */
homePath?: string
/** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */
appImagePath?: string | null
}
export type LinuxBareOrcaDispatcherState =
| 'installed'
| 'skipped-foreign'
| 'skipped-launcher-missing'
export type LinuxBareOrcaDispatcherResult = {
state: LinuxBareOrcaDispatcherState
dispatcherPath: string
/** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */
target: string | null
}
// Why: on Linux the CLI installs as `orca-ide`, not bare `orca`, to avoid
// shadowing GNOME Orca's /usr/bin/orca. But the Claude Team launcher typed into
// the initial managed terminal invokes the literal `orca claude-teams`, so a
// headless serve box needs a bare-`orca` dispatcher on the managed-terminal PATH
// (~/.local/bin, which patchPackagedProcessPath puts ahead of /usr/bin). It is a
// plain file, not a managed symlink, so CliInstaller.removeLegacyLinuxCommandIfManaged
// never reclaims it.
export async function installLinuxBareOrcaDispatcher(
options: LinuxBareOrcaDispatcherOptions
): Promise<LinuxBareOrcaDispatcherResult> {
const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca')
const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null
const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath)
if (!resolved) {
return { state: 'skipped-launcher-missing', dispatcherPath, target: null }
}
// Why: only (re)write a dispatcher we previously created; leave a user's own
// `orca` untouched rather than silently clobbering it on every serve start.
if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) {
return { state: 'skipped-foreign', dispatcherPath, target: resolved.target }
}
await mkdir(dirname(dispatcherPath), { recursive: true })
await writeFile(dispatcherPath, resolved.script, 'utf8')
await chmod(dispatcherPath, 0o755)
return { state: 'installed', dispatcherPath, target: resolved.target }
}
function resolveDispatcherScript(
resourcesPath: string,
appImagePath: string | null
): { script: string; target: string } | null {
if (appImagePath) {
// Why: an AppImage mounts resources under an ephemeral FUSE path per launch,
// so the dispatcher must exec the stable outer AppImage — reuse the same
// wrapper CliInstaller installs for the AppImage command.
return { script: withMarker(buildAppImageCliWrapper(appImagePath)), target: appImagePath }
}
const launcher = getBundledLauncherPath('linux', resourcesPath)
// Why: getBundledLauncherPath only joins the path; guard existence so we never
// write a dispatcher pointing at a missing launcher (which would fail at exec
// time with a confusing error instead of the command-not-found we fix).
if (!launcher || !existsSync(launcher)) {
return null
}
return {
script: `#!/usr/bin/env bash\n${DISPATCHER_MARKER}\nexec ${quoteShell(launcher)} "$@"\n`,
target: launcher
}
}
function withMarker(script: string): string {
const firstNewline = script.indexOf('\n')
if (firstNewline === -1) {
return `${script}\n${DISPATCHER_MARKER}\n`
}
// Keep the shebang on line 1; insert the marker immediately after it.
return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}`
}
async function isOwnedDispatcher(dispatcherPath: string): Promise<boolean> {
try {
return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER)
} catch {
return false
}
}
+5 -2
View File
@@ -712,7 +712,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -722,7 +725,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
@@ -121,7 +121,10 @@ export function buildCommandCodeManagedScript(
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -131,7 +134,7 @@ export function buildCommandCodeManagedScript(
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5 -2
View File
@@ -166,7 +166,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'if [ -z "$payload" ]; then',
' exit 0',
'fi',
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -177,7 +180,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5
View File
@@ -77,7 +77,12 @@ describe('CursorHookService', () => {
if (process.platform === 'win32') {
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
} else {
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('payload=$(cat)')
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
}
})
+5 -2
View File
@@ -104,7 +104,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5
View File
@@ -64,6 +64,11 @@ describe('DevinHookService', () => {
}
const script = readFileSync(getDevinManagedScriptPath(), 'utf8')
expect(script).toContain('/hook/devin')
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
})
it('preserves unrelated keys in Devin config when installing hooks', () => {
+5 -2
View File
@@ -80,7 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -90,7 +93,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5 -2
View File
@@ -92,7 +92,10 @@ function getManagedScript(): string {
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -102,7 +105,7 @@ function getManagedScript(): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5 -2
View File
@@ -97,7 +97,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -107,7 +110,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+263
View File
@@ -1,7 +1,25 @@
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner'
import { parseHostedRemote } from './hosted-remote-url'
import { resolveDefaultBaseRefViaExec } from './repo'
const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const
const GH_LOGIN_PROBE_TIMEOUT_MS = 2500
// Why: a timeout-killed gh can leave a grandchild holding the stdio pipes, so
// the exec promise may settle long after the kill. The wall keeps the resolver
// on schedule either way (issue #7225: a hung gh froze startup for 127s). It
// must exceed ghExecFileAsync's full worst-case envelope — 3 attempts ×
// GH_LOGIN_PROBE_TIMEOUT_MS plus its transient-retry backoff sleeps (250ms +
// 1000ms ≈ 8.75s total) — or a slow-but-recovering gh would be misread as
// stuck and start the retry cooldown. Retry-After sleeps can exceed any wall;
// bounding those is exactly what the wall is for.
const GH_LOGIN_PROBE_WALL_MS = 10_000
// Why: a timed-out probe says nothing about the account, so don't pin '' for
// the whole session — retry after a cooldown instead of hammering a stuck gh.
const GH_LOGIN_TIMEOUT_RETRY_MS = 5 * 60 * 1000
const LOCAL_GIT_READ_TIMEOUT_MS = 5000
export function normalizeGitUsername(value: string): string {
const trimmed = value.trim()
if (!trimmed) {
@@ -12,6 +30,13 @@ export function normalizeGitUsername(value: string): string {
return localPart.replace(/^\d+\+/, '')
}
/**
* A resolved username plus whether every probe on the way to it completed.
* Non-authoritative '' (a probe timed out) must not overwrite a previously
* persisted username; authoritative '' should clear one.
*/
export type ResolvedGitUsername = { username: string; authoritative: boolean }
export async function getSshGitUsername(
provider: SshGitProvider,
repoPath: string
@@ -31,3 +56,241 @@ export async function getSshGitUsername(
}
return ''
}
type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean }
type GhLoginOutcome = { login: string; timedOut: boolean }
// gh reports one account for the whole machine, so the login is cached
// per-process rather than per-repo (mirrors the old sync cache). Timed-out
// probes use the soft retry timestamp instead of the permanent cache.
let cachedGhLogin: string | null = null
let ghLoginTimedOutAt: number | null = null
let ghLoginProbeInFlight: Promise<GhLoginOutcome> | null = null
function isExecTimeoutError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
const { code, killed, signal } = err as { code?: unknown; killed?: unknown; signal?: unknown }
// Why: on Windows a timeout kill surfaces as killed/SIGTERM with a null
// code, not ETIMEDOUT — the old ETIMEDOUT-only check let a stuck first
// probe fall through to a second equally stuck probe (issue #7225).
return code === 'ETIMEDOUT' || killed === true || signal === 'SIGTERM'
}
async function runGhLoginProbe(args: string[]): Promise<GhLoginProbeResult> {
let wallTimer: ReturnType<typeof setTimeout> | undefined
const wall = new Promise<GhLoginProbeResult>((resolve) => {
wallTimer = setTimeout(
() => resolve({ stdout: '', stderr: '', timedOut: true }),
GH_LOGIN_PROBE_WALL_MS
)
wallTimer.unref?.()
})
const exec = ghExecFileAsync(args, { timeout: GH_LOGIN_PROBE_TIMEOUT_MS }).then(
({ stdout, stderr }) => ({ stdout, stderr, timedOut: false }),
(err: unknown) => {
// Why: `gh auth status` reports the login on stderr with a non-zero
// exit when partially authenticated, so failures still carry output.
const { stdout, stderr } = extractExecError(err)
return { stdout, stderr, timedOut: isExecTimeoutError(err) }
}
)
try {
return await Promise.race([exec, wall])
} finally {
if (wallTimer) {
clearTimeout(wallTimer)
}
}
}
// Why: `gh auth status` prints one block per account — the login line first,
// then `Active account: true/false`. Parse block-wise so a multi-account
// output resolves the ACTIVE account instead of whatever login line happens
// to follow the first `Active account: true` marker.
function parseGhAuthStatusLogin(output: string): string {
let currentLogin = ''
let firstLogin = ''
for (const line of output.split('\n')) {
const login = line.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)?.[1]
if (login) {
currentLogin = login
if (!firstLogin) {
firstLogin = login
}
continue
}
if (/Active account:\s+true/.test(line) && currentLogin) {
return currentLogin
}
}
return firstLogin
}
async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
const api = await runGhLoginProbe(['api', 'user', '-q', '.login'])
const apiLogin = normalizeGitUsername(api.stdout.trim())
if (apiLogin) {
return { login: apiLogin, timedOut: false }
}
if (api.timedOut) {
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
// same stuck keychain/network path. Keep resolution bounded to one probe.
return { login: '', timedOut: true }
}
const status = await runGhLoginProbe(['auth', 'status'])
if (status.timedOut) {
return { login: '', timedOut: true }
}
const output = `${status.stdout}\n${status.stderr}`
return { login: normalizeGitUsername(parseGhAuthStatusLogin(output)), timedOut: false }
}
async function getGhLoginOutcome(): Promise<GhLoginOutcome> {
if (cachedGhLogin !== null) {
return { login: cachedGhLogin, timedOut: false }
}
if (ghLoginTimedOutAt !== null && Date.now() - ghLoginTimedOutAt < GH_LOGIN_TIMEOUT_RETRY_MS) {
return { login: '', timedOut: true }
}
if (ghLoginProbeInFlight) {
return ghLoginProbeInFlight
}
const probe = probeGhLoginOnce()
.then((outcome) => {
if (outcome.timedOut) {
ghLoginTimedOutAt = Date.now()
} else {
cachedGhLogin = outcome.login
ghLoginTimedOutAt = null
}
return outcome
})
.finally(() => {
ghLoginProbeInFlight = null
})
ghLoginProbeInFlight = probe
return probe
}
/**
* Resolve the `gh` CLI's GitHub login without ever blocking the caller for
* longer than the probe wall. Never rejects; unknown resolves to ''.
*/
export async function getGhLoginAsync(): Promise<string> {
return (await getGhLoginOutcome()).login
}
async function readGitStdout(repoPath: string, args: string[]): Promise<string> {
try {
const { stdout } = await gitExecFileAsync(args, {
cwd: repoPath,
timeout: LOCAL_GIT_READ_TIMEOUT_MS
})
return stdout.trim()
} catch {
return ''
}
}
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
}
function getDefaultBranchName(shortRef: string, remoteName: string): string {
if (!shortRef.includes('/')) {
return shortRef
}
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
}
async function getConfiguredBranchRemote(repoPath: string, branch: string | null): Promise<string> {
if (!branch) {
return ''
}
const remote = await readGitStdout(repoPath, ['config', '--get', `branch.${branch}.remote`])
return remote === '.' ? '' : remote
}
/**
* Faithful async port of the old candidate-ordered GitHub gate: only the
* repo's *effective* remote (current-branch remote, default-branch remote,
* default-base remote, origin, or a lone remote) may authorize the gh login.
* Why: a GitLab-primary repo with a secondary GitHub mirror must NOT pick up
* the GitHub account name as its branch prefix.
*/
async function localRepoHasEffectiveGitHubRemote(repoPath: string): Promise<boolean> {
const remotes = (await readGitStdout(repoPath, ['remote'])).split('\n').filter(Boolean)
const defaultBaseRef = await resolveDefaultBaseRefViaExec((argv) =>
gitExecFileAsync(argv, { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS })
)
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
const defaultBranch = defaultBaseRef
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
: null
const currentBranch = await readGitStdout(repoPath, ['branch', '--show-current'])
const candidateRemotes = [
await getConfiguredBranchRemote(repoPath, currentBranch || null),
await getConfiguredBranchRemote(repoPath, defaultBranch),
defaultBaseRemote,
'origin',
remotes.length === 1 ? remotes[0] : ''
]
const seen = new Set<string>()
for (const remote of candidateRemotes) {
if (!remote || seen.has(remote)) {
continue
}
seen.add(remote)
const remoteUrl = await readGitStdout(repoPath, ['remote', 'get-url', remote])
if (remoteUrl && parseHostedRemote(remoteUrl)?.provider === 'github') {
return true
}
}
return false
}
/**
* Async replacement for the old sync `getGitUsername`: explicit config keys
* first, then the `gh` login — but only for repos whose effective remote is
* GitHub, since a GitHub account name would be the wrong branch prefix for
* GitLab/Bitbucket/self-hosted repos. Never rejects; unknown resolves to
* { username: '', authoritative: false }.
*/
export async function resolveLocalGitUsernameDetailed(
repoPath: string
): Promise<ResolvedGitUsername> {
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
try {
const { stdout } = await gitExecFileAsync(['config', '--get', key], {
cwd: repoPath,
timeout: LOCAL_GIT_READ_TIMEOUT_MS
})
const username = normalizeGitUsername(stdout)
if (username) {
return { username, authoritative: true }
}
} catch {
// Missing config keys are expected; try the next explicit username key.
}
}
if (await localRepoHasEffectiveGitHubRemote(repoPath)) {
const outcome = await getGhLoginOutcome()
return { username: outcome.login, authoritative: !outcome.timedOut }
}
return { username: '', authoritative: true }
}
export async function resolveLocalGitUsername(repoPath: string): Promise<string> {
return (await resolveLocalGitUsernameDetailed(repoPath)).username
}
export function resetGhLoginCacheForTests(): void {
cachedGhLogin = null
ghLoginTimedOutAt = null
ghLoginProbeInFlight = null
}
+191 -80
View File
@@ -1,160 +1,271 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ChildProcess from 'node:child_process'
import type * as RepoModule from './repo'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RunnerModule from './runner'
const execSyncMock = vi.hoisted(() => vi.fn())
const execFileSyncMock = vi.hoisted(() => vi.fn())
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
const ghExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof ChildProcess>('child_process')
vi.mock('./runner', async () => {
const actual = await vi.importActual<typeof RunnerModule>('./runner')
return {
...actual,
execSync: execSyncMock,
execFileSync: execFileSyncMock
gitExecFileAsync: gitExecFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock
}
})
describe('getGitUsername', () => {
import {
resolveLocalGitUsername,
resolveLocalGitUsernameDetailed,
resetGhLoginCacheForTests
} from './git-username'
function makeExecError(
message: string,
extra: { code?: string; killed?: boolean; signal?: string; stderr?: string } = {}
): Error {
return Object.assign(new Error(message), { stdout: '', stderr: '', ...extra })
}
describe('resolveLocalGitUsername', () => {
let gitConfig: Record<string, string>
let originRemoteUrl: string | undefined
let remoteUrls: Record<string, string>
let getGitUsername: typeof RepoModule.getGitUsername
let currentBranch: string
beforeEach(async () => {
vi.resetModules()
execSyncMock.mockReset()
execFileSyncMock.mockReset()
beforeEach(() => {
vi.resetAllMocks()
resetGhLoginCacheForTests()
gitConfig = {}
originRemoteUrl = undefined
remoteUrls = {}
currentBranch = ''
execFileSyncMock.mockImplementation((_binary: string, args: string[]) => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'config' && args[1] === '--get') {
const value = gitConfig[args[2]]
if (value !== undefined) {
return `${value}\n`
return { stdout: `${value}\n`, stderr: '' }
}
throw new Error(`missing config ${args[2]}`)
throw makeExecError(`missing config ${args[2]}`)
}
if (args[0] === 'remote' && args.length === 1) {
const remotes = new Set(Object.keys(remoteUrls))
if (originRemoteUrl) {
remotes.add('origin')
}
return `${[...remotes].join('\n')}\n`
return { stdout: `${[...remotes].join('\n')}\n`, stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'get-url') {
const remoteUrl = args[2] === 'origin' ? originRemoteUrl : remoteUrls[args[2]]
if (remoteUrl) {
return `${remoteUrl}\n`
return { stdout: `${remoteUrl}\n`, stderr: '' }
}
throw new Error(`missing ${args[2]} remote`)
throw makeExecError(`missing ${args[2]} remote`)
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
if (args[0] === 'branch' && args[1] === '--show-current') {
return { stdout: `${currentBranch}\n`, stderr: '' }
}
if (args[0] === 'symbolic-ref') {
// origin/HEAD unset — resolveDefaultBaseRefViaExec falls through to probes.
throw makeExecError('no origin/HEAD')
}
if (args[0] === 'rev-parse') {
throw makeExecError(`missing ref ${args.at(-1)}`)
}
throw makeExecError(`unexpected git args: ${args.join(' ')}`)
})
;({ getGitUsername } = await import('./repo'))
})
it('prefers explicit GitHub user config before checking GitHub CLI login', () => {
afterEach(() => {
vi.useRealTimers()
})
it('prefers explicit GitHub user config before checking GitHub CLI login', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['github.user'] = 'config-demo'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('config-demo')
expect(execSyncMock).not.toHaveBeenCalled()
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('config-demo')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('uses explicit username config before checking GitHub CLI login', () => {
it('uses explicit username config before checking GitHub CLI login', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['user.username'] = 'repo-demo'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('repo-demo')
expect(execSyncMock).not.toHaveBeenCalled()
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('repo-demo')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', () => {
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['user.email'] = 'demo@example.com'
gitConfig['user.name'] = 'Demo User'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('gh-demo')
expect(execSyncMock).toHaveBeenCalledTimes(1)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('uses GitHub CLI login for a single GitHub remote not named origin', () => {
it('uses GitHub CLI login for a single GitHub remote not named origin', async () => {
remoteUrls.upstream = 'https://github.com/stablyai/orca.git'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('gh-demo')
expect(execFileSyncMock.mock.calls).toEqual(
expect.arrayContaining([
['git', ['remote', 'get-url', 'upstream'], expect.objectContaining({ cwd: '/repo' })]
])
)
expect(execSyncMock).toHaveBeenCalledTimes(1)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', () => {
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', async () => {
remoteUrls.upstream = 'ssh://git@ssh.github.com:443/stablyai/orca.git'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('gh-demo')
expect(execSyncMock).toHaveBeenCalledTimes(1)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('does not derive GitHub username prefixes from non-GitHub author identity', () => {
it('does not derive GitHub username prefixes from non-GitHub remotes', async () => {
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
gitConfig['user.email'] = 'demo@example.com'
gitConfig['user.name'] = 'Demo User'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('')
expect(execSyncMock).not.toHaveBeenCalled()
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('bounds and caches failed GitHub CLI lookup', () => {
it('ignores a secondary GitHub mirror when the effective remote is GitLab', async () => {
// Why: a GitLab-primary repo with a GitHub mirror must not pick up the
// GitHub account name as its branch prefix — only the effective remote
// (branch remote / default base remote / origin / lone remote) counts.
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
remoteUrls['github-mirror'] = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('bounds and caches failed GitHub CLI lookup', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
execSyncMock.mockImplementation(() => {
throw new Error('gh unavailable')
})
ghExecFileAsyncMock.mockRejectedValue(makeExecError('gh unavailable'))
expect(getGitUsername('/repo')).toBe('')
expect(getGitUsername('/repo')).toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(execSyncMock).toHaveBeenCalledTimes(2)
for (const [, options] of execSyncMock.mock.calls) {
// api + auth-status fallback on the first resolution; cached afterwards.
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
for (const [, options] of ghExecFileAsyncMock.mock.calls) {
expect(options).toMatchObject({ timeout: 2500 })
}
})
it('skips auth status fallback when GitHub CLI API lookup times out', () => {
it('skips auth status fallback when GitHub CLI API lookup times out', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
execSyncMock.mockImplementationOnce(() => {
throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' })
})
ghExecFileAsyncMock.mockRejectedValueOnce(
makeExecError('spawnSync gh ETIMEDOUT', { code: 'ETIMEDOUT' })
)
expect(getGitUsername('/repo')).toBe('')
expect(getGitUsername('/repo')).toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(execSyncMock).toHaveBeenCalledTimes(1)
expect(execSyncMock.mock.calls[0][1]).toMatchObject({ timeout: 2500 })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('uses auth status fallback after fast GitHub CLI API failure', () => {
it('treats a Windows timeout kill (SIGTERM, no ETIMEDOUT code) as a timeout', async () => {
// Why: on Windows the exec timeout kill surfaces killed/SIGTERM without an
// ETIMEDOUT code; the old sync probe missed this and ran a second equally
// stuck probe (issue #7225).
originRemoteUrl = 'https://github.com/stablyai/orca.git'
execSyncMock
.mockImplementationOnce(() => {
throw new Error('gh api unavailable')
})
.mockImplementationOnce(
() =>
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
)
ghExecFileAsyncMock.mockRejectedValueOnce(
makeExecError('gh was killed', { killed: true, signal: 'SIGTERM' })
)
expect(getGitUsername('/repo')).toBe('demo-user')
expect(execSyncMock).toHaveBeenCalledTimes(2)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('marks a timed-out gh probe non-authoritative and retries after the cooldown', async () => {
vi.useFakeTimers()
originRemoteUrl = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock
.mockRejectedValueOnce(makeExecError('gh timeout', { code: 'ETIMEDOUT' }))
.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: '',
authoritative: false
})
// Within the cooldown the timeout result is reused without a new spawn.
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: '',
authoritative: false
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: 'gh-demo',
authoritative: true
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('reports authoritative empty for non-GitHub repos', async () => {
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: '',
authoritative: true
})
})
it('uses auth status fallback after fast GitHub CLI API failure', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
.mockResolvedValueOnce({
stdout: '',
stderr:
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
})
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('demo-user')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('settles within the wall even when the gh child never exits', async () => {
vi.useFakeTimers()
originRemoteUrl = 'https://github.com/stablyai/orca.git'
// A promise that never settles — models a killed gh whose grandchild
// keeps the stdio pipes open past the exec timeout.
ghExecFileAsyncMock.mockImplementation(() => new Promise(() => {}))
const resolution = resolveLocalGitUsernameDetailed('/repo')
await vi.advanceTimersByTimeAsync(10_100)
await expect(resolution).resolves.toEqual({ username: '', authoritative: false })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('picks the active account from a multi-account auth status output', async () => {
// Why: each account block prints its login line BEFORE its
// "Active account" marker; a cross-block regex would capture the next
// block's login instead of the active one.
originRemoteUrl = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
.mockResolvedValueOnce({
stdout: '',
stderr: [
'github.com',
' ✓ Logged in to github.com account active-user (keyring)',
' - Active account: true',
' - Git operations protocol: https',
' ✓ Logged in to github.com account inactive-user (keyring)',
' - Active account: false'
].join('\n')
})
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('active-user')
})
})
+1 -186
View File
@@ -1,5 +1,4 @@
/* oxlint-disable max-lines */
import { execSync } from 'node:child_process'
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { gitExecFileSync, gitExecFileAsync } from './runner'
@@ -8,14 +7,7 @@ import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-outp
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
import {
buildHostedRemoteCommitUrl,
buildHostedRemoteFileUrl,
parseHostedRemote
} from './hosted-remote-url'
import { normalizeGitUsername } from './git-username'
const GH_LOGIN_TIMEOUT_MS = 2500
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
type LocalGitExecOptions = {
wslDistro?: string
@@ -464,183 +456,6 @@ function getRemoteUrlByName(path: string, remote: string): string {
}).trim()
}
function listRemoteNamesSync(path: string): string[] {
try {
return gitExecFileSync(['remote'], { cwd: path })
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
function getConfiguredBranchRemote(path: string, branch: string | null): string {
if (!branch) {
return ''
}
const remote = getGitConfigValue(path, `branch.${branch}.remote`)
return remote === '.' ? '' : remote
}
function getCurrentBranchName(path: string): string {
try {
return gitExecFileSync(['branch', '--show-current'], { cwd: path }).trim()
} catch {
return ''
}
}
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
}
function getDefaultBranchName(shortRef: string, remoteName: string): string {
if (!shortRef.includes('/')) {
return shortRef
}
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
}
function getGitConfigValue(path: string, key: string): string {
try {
return gitExecFileSync(['config', '--get', key], {
cwd: path
}).trim()
} catch {
return ''
}
}
let cachedGhLogin: string | undefined
function isGhProbeTimeout(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false
}
const err = error as { code?: unknown; message?: unknown }
return (
err.code === 'ETIMEDOUT' ||
(typeof err.message === 'string' && /\bETIMEDOUT\b|timed out/i.test(err.message))
)
}
function getGhLogin(): string {
if (cachedGhLogin !== undefined) {
return cachedGhLogin
}
try {
const apiLogin = execSync('gh api user -q .login', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: GH_LOGIN_TIMEOUT_MS
}).trim()
if (apiLogin) {
cachedGhLogin = normalizeGitUsername(apiLogin)
return cachedGhLogin
}
} catch (err) {
if (isGhProbeTimeout(err)) {
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
// same stuck keychain/network path. Keep repo creation bounded to one probe.
cachedGhLogin = ''
return ''
}
// Fall through to auth status parsing
}
try {
// Why: gh auth status writes to stderr; redirect via shell so we can capture it.
// Use platform-appropriate shell — /bin/bash does not exist on Windows.
const output = execSync('gh auth status 2>&1', {
encoding: 'utf-8',
shell: process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/bash',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: GH_LOGIN_TIMEOUT_MS
})
const activeAccountMatch = output.match(
/Active account:\s+true[\s\S]*?account\s+([A-Za-z0-9-]+)/
)
if (activeAccountMatch?.[1]) {
cachedGhLogin = normalizeGitUsername(activeAccountMatch[1])
return cachedGhLogin
}
const accountMatch = output.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)
const login = normalizeGitUsername(accountMatch?.[1] ?? '')
if (login) {
cachedGhLogin = login
}
return login
} catch {
// Why: broken tokens/keychains can block the Electron main process.
// Keep the fallback best-effort for this app session.
cachedGhLogin = ''
return ''
}
}
function getGhLoginForGitHubRemote(path: string): string {
const remoteUrl = getGitHubRemoteUrlForGhLogin(path)
if (!remoteUrl) {
return ''
}
return getGhLogin()
}
function getGitHubRemoteUrlForGhLogin(path: string): string {
const remotes = listRemoteNamesSync(path)
const defaultBaseRef = getDefaultBaseRef(path)
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
const defaultBranch = defaultBaseRef
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
: null
const candidateRemotes = [
getConfiguredBranchRemote(path, getCurrentBranchName(path)),
getConfiguredBranchRemote(path, defaultBranch),
defaultBaseRemote,
'origin',
remotes.length === 1 ? remotes[0] : ''
]
const seen = new Set<string>()
for (const remote of candidateRemotes) {
if (!remote || seen.has(remote)) {
continue
}
seen.add(remote)
try {
const remoteUrl = getRemoteUrlByName(path, remote)
if (parseHostedRemote(remoteUrl)?.provider === 'github') {
return remoteUrl
}
} catch {
// Missing candidate remotes are expected; try the next repo-level fallback.
}
}
// Why: `gh` reports a GitHub account. For GitLab/Bitbucket/self-hosted
// repos, using that identity would create the wrong provider prefix.
return ''
}
/**
* Get the GitHub/explicit username-style branch prefix for the repo.
*/
export function getGitUsername(path: string): string {
// Why: this backs the "Git Username" branch-prefix setting. Commit author
// email/name are not hosted-account usernames, so keep them out of this path.
return normalizeGitUsername(
getGitConfigValue(path, 'github.user') ||
getGitConfigValue(path, 'user.username') ||
getGhLoginForGitHubRemote(path)
)
}
function hasGitRef(path: string, ref: string): boolean {
try {
gitExecFileSync(['rev-parse', '--verify', ref], {
+41 -1
View File
@@ -2,7 +2,47 @@
// (transient detection must propagate, not silently retry on 250ms cadence)
// and stderr extraction from execFile rejections (err.message is unreliable).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner'
import {
extractExecError,
isTransientGhError,
parseRetryAfterMs,
redirectPortedHostnameToEnv
} from './runner'
describe('redirectPortedHostnameToEnv', () => {
it('moves a ported --hostname into GITLAB_HOST and strips the flag', () => {
const { args, options } = redirectPortedHostnameToEnv(
['api', '--hostname', 'gitlab.example.com:8443', 'projects/foo%2Fbar/issues'],
{ cwd: '/repo' }
)
expect(args).toEqual(['api', 'projects/foo%2Fbar/issues'])
expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443')
expect(options.cwd).toBe('/repo')
})
it('leaves a port-less --hostname untouched', () => {
const input = ['api', '--hostname', 'gitlab.com', 'user']
const { args, options } = redirectPortedHostnameToEnv(input, {})
expect(args).toEqual(input)
expect(options.env).toBeUndefined()
})
it('is a no-op when no --hostname is present', () => {
const input = ['auth', 'status']
const { args, options } = redirectPortedHostnameToEnv(input, { env: { A: '1' } })
expect(args).toEqual(input)
expect(options.env).toEqual({ A: '1' })
})
it('preserves existing env entries alongside GITLAB_HOST', () => {
const { options } = redirectPortedHostnameToEnv(
['auth', 'status', '--hostname', 'gl.example.org:3001'],
{ env: { PATH: '/usr/bin' } }
)
expect(options.env?.PATH).toBe('/usr/bin')
expect(options.env?.GITLAB_HOST).toBe('gl.example.org:3001')
})
})
afterEach(() => {
vi.restoreAllMocks()
+40 -1
View File
@@ -976,6 +976,13 @@ export async function gitStreamStdout(
})
}
// Why: sync git calls run on the Electron main thread. Local git is normally
// fast, but a repo on a dead network drive / cloud-placeholder path can hang
// git on filesystem timeouts for minutes with no timeout set — the leading
// explanation for issue #7225's 127s "Not Responding" freeze. Callers needing
// longer operations should use the async runners instead.
const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000
/**
* Sync git command execution. Drop-in replacement for
* `execFileSync('git', args, { cwd, encoding, ... })`.
@@ -988,13 +995,15 @@ export function gitExecFileSync(
cwd: string
encoding?: BufferEncoding
stdio?: SpawnOptions['stdio']
timeout?: number
}
): string {
const resolved = resolveCommand('git', args, options.cwd)
return execFileSync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe']
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS
}) as string
}
@@ -1409,10 +1418,40 @@ type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
*
* Retry policy mirrors ghExecFileAsync.
*/
/**
* glab's `--hostname` flag rejects a host that carries a port
* ("error parsing --hostname: invalid hostname"). A self-hosted GitLab on a
* non-default port (e.g. `gitlab.example.com:8443`) must instead be selected
* via the `GITLAB_HOST` env var, which accepts `host:port`. Translate any
* `--hostname host:port` pair into `GITLAB_HOST` so every call site (`api`,
* `auth status`, …) works against ported self-hosted instances. Port-less
* `--hostname` values are left untouched.
*
* @internal exported for tests.
*/
export function redirectPortedHostnameToEnv(
args: string[],
options: GlabExecOptions
): { args: string[]; options: GlabExecOptions } {
const i = args.indexOf('--hostname')
if (i === -1 || i + 1 >= args.length) {
return { args, options }
}
const host = args[i + 1]
if (!/^[^/\s]+:\d+$/.test(host)) {
return { args, options }
}
return {
args: [...args.slice(0, i), ...args.slice(i + 2)],
options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } }
}
}
export async function glabExecFileAsync(
args: string[],
options: GlabExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
;({ args, options } = redirectPortedHostnameToEnv(args, options))
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
let lastError: unknown
let attemptedDefaultWslFallback = false
+168 -1
View File
@@ -2,7 +2,7 @@
owner reset safety, dirty worktree, diverged branch, custom remote) that each
need dedicated test coverage. Splitting into separate files would scatter related tests
without a meaningful boundary. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
() => ({
@@ -22,11 +22,80 @@ import {
addSparseWorktree,
addWorktree,
listWorktreeGraph,
listWorktrees,
moveWorktree,
parseWorktreeList,
removeWorktree
} from './worktree'
describe('listWorktrees in-flight sharing', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
// Later describes in this file assume a pristine mock (no global reset).
afterEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('shares one scan across concurrent calls for the same repo', async () => {
let resolveScan!: () => void
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = () => resolve({ stdout: scanOutput })
})
)
const first = listWorktrees('/repo')
const second = listWorktrees('/repo')
resolveScan()
const [a, b] = await Promise.all([first, second])
expect(a).toEqual(b)
expect(a[0]?.path).toBe('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('runs a fresh scan once the shared one has settled', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
await listWorktrees('/repo')
await listWorktrees('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not share scans across different repos', async () => {
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
Promise.resolve({
stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n`
})
)
await Promise.all([listWorktrees('/repo-one'), listWorktrees('/repo-two')])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not share scans for callers with an AbortSignal', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
const controller = new AbortController()
// Why: an aborted shared scan would reject for every caller, so signal
// callers must keep a private scan.
await Promise.all([
listWorktrees('/repo'),
listWorktrees('/repo', { signal: controller.signal })
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})
describe('parseWorktreeList', () => {
it('parses regular and bare worktree blocks from porcelain output', () => {
const output = `
@@ -237,6 +306,12 @@ bare
})
describe('listWorktreeGraph', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockClear()
})
it('returns the worktree graph without sparse-checkout annotation probes', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: `worktree /repo
@@ -272,6 +347,98 @@ branch refs/heads/feature/test
})
})
it('falls back when older Git rejects worktree list -z with usage exit code', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(
Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
)
.mockResolvedValueOnce({
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-main-2
HEAD def456
branch refs/heads/main-2
`
})
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
{
path: '/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/repo-main-2',
head: 'def456',
branch: 'refs/heads/main-2',
isBare: false,
isMainWorktree: false
}
])
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
])
})
it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => {
// Git under a non-English locale translates the usage text, so stderr
// matching alone misses it; the 129 exit code must still trigger fallback.
gitExecFileAsyncMock
.mockRejectedValueOnce(
Object.assign(new Error('git worktree list --porcelain -z'), {
code: 129,
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
})
)
.mockResolvedValueOnce({
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
})
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
{
path: '/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
])
})
it('does not retry without -z for non-usage Git failures', async () => {
// A fatal error (exit 128) is not an unsupported-flag signal, so the -z
// command must not be silently re-run without it.
gitExecFileAsyncMock.mockRejectedValueOnce(
Object.assign(new Error('git worktree list --porcelain -z'), {
code: 128,
stderr: 'fatal: unable to read tree\n'
})
)
await expect(listWorktreeGraph('/repo')).resolves.toEqual([])
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }]
])
})
it('returns an empty graph for paths Git reports as non-repositories', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository'))
+92 -6
View File
@@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean {
}
function isUnsupportedWorktreeListZError(error: unknown): boolean {
return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
getErrorText(error)
)
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage
// exit 129 signals the rejection in any locale; stderr match is a fallback.
if (getErrorCode(error) === '129') {
return true
}
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
@@ -605,10 +609,51 @@ export async function listWorktreeGraph(
}
}
// Why: a cold start lists every repo's worktrees from several independent
// paths (renderer worktrees fetch, lineage resolution, boot pty-registry
// hydration), and each `git worktree list` is a full process spawn —
// expensive on Windows (issue #7225). Sharing the in-flight promise collapses
// concurrent duplicate scans; nothing is served after a scan settles.
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
// Why: a caller listing AFTER a mutation completed must observe it, so it may
// not join a scan that started before the mutation. Bumping the generation on
// mutation completion retires older in-flight scans from sharing (they still
// settle for their original callers).
const worktreeScanGenerations = new Map<string, number>()
function bumpWorktreeScanGeneration(repoPath: string): void {
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
}
/**
* List all worktrees for a git repo at the given path.
* List all worktrees for a git repo at the given path. Concurrent calls for
* the same repo share one scan (unless the caller passes an AbortSignal,
* which must only cancel its own scan).
*/
export async function listWorktrees(
export function listWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
if (options.signal) {
return listWorktreesUnshared(repoPath, options)
}
const generation = worktreeScanGenerations.get(repoPath) ?? 0
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${generation}`
const inFlight = inFlightWorktreeScans.get(key)
if (inFlight) {
return inFlight
}
const scan = listWorktreesUnshared(repoPath, options).finally(() => {
if (inFlightWorktreeScans.get(key) === scan) {
inFlightWorktreeScans.delete(key)
}
})
inFlightWorktreeScans.set(key, scan)
return scan
}
async function listWorktreesUnshared(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
@@ -763,6 +808,30 @@ export async function addWorktree(
refreshLocalBaseRef = false,
noCheckout = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
try {
return await performAddWorktree(
repoPath,
worktreePath,
branch,
baseBranch,
refreshLocalBaseRef,
noCheckout,
options
)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
async function performAddWorktree(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch?: string,
refreshLocalBaseRef = false,
noCheckout = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
@@ -953,7 +1022,11 @@ export async function moveWorktree(
oldPath: string,
newPath: string
): Promise<void> {
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
try {
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
/**
@@ -968,6 +1041,19 @@ export async function removeWorktree(
// and must be removed outright. User-initiated deletes leave it false so unmerged
// commits are preserved.
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
try {
return await performRemoveWorktree(repoPath, worktreePath, force, options)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
async function performRemoveWorktree(
repoPath: string,
worktreePath: string,
force = false,
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
const removedWorktree =
options.knownRemovedWorktree ??
+8 -8
View File
@@ -245,7 +245,7 @@ export async function getProjectSlug(
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<ProjectRef | null> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
return getProjectRef(
repoPath,
knownHosts,
@@ -265,7 +265,7 @@ export async function getMergeRequest(
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<MRInfo | null> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
@@ -316,7 +316,7 @@ export async function getMergeRequestForBranch(
if (!branchName && linkedMRIid == null) {
return null
}
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
@@ -400,7 +400,7 @@ export async function listMergeRequests(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListMergeRequestsResult> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
// Why: MRs sit on `origin` in the fork model (the user's fork is where
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
// call site by going through the upstream/origin preference resolver
@@ -429,7 +429,7 @@ export async function listMergeRequests(
}
}
// Why: fallback — let glab infer project from cwd, same as listIssues.
// Used when the repo's remote host is not in getGlabKnownHosts()
// Used when the repo's remote host is not in getGlabKnownHosts(connectionId)
// (e.g. a fresh self-hosted instance), but glab itself can still
// resolve it from the local git config.
const stateFlag = mrListStateFlags(state)
@@ -598,7 +598,7 @@ export async function listWorkItems(
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabPagedResult<GitLabWorkItem>> {
const issueState = mrStateToIssueState(state)
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -734,7 +734,7 @@ export async function listTodos(
): Promise<GitLabTodo[]> {
const projectRef = await getProjectRef(
repoPath,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -813,7 +813,7 @@ async function withProjectRef<T>(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -20,7 +20,17 @@ export type LocalGitExecOptions = {
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
const projectRefCache = new Map<string, ProjectRef | null>()
let knownHostsCache: readonly string[] | null = null
// Why: known hosts are cached PER connection. A repo on an SSH connection
// authenticates against a different glab context than the local one, so a
// process-global cache would leak one connection's hosts into another (and
// poison a connection that probes before its tunnel is ready). The local
// context uses the `'local'` key.
const LOCAL_CONNECTION_KEY = 'local'
const knownHostsCacheByConnection = new Map<string, readonly string[]>()
function connectionCacheKey(connectionId?: string | null): string {
return connectionId ?? LOCAL_CONNECTION_KEY
}
/** @internal - exposed for tests only */
export function _resetProjectRefCache(): void {
@@ -35,7 +45,7 @@ export function _getProjectRefCacheSize(): number {
/** @internal - exposed for tests only */
export function _resetKnownHostsCache(): void {
knownHostsCache = null
knownHostsCacheByConnection.clear()
}
function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void {
@@ -108,7 +118,7 @@ async function resolveProjectRefForRemote(
localGitOptions
))
) {
rememberGlabKnownHost(remoteCandidate.host)
rememberGlabKnownHost(remoteCandidate.host, connectionId)
rememberProjectRefCacheEntry(cacheKey, remoteCandidate)
return remoteCandidate
}
@@ -220,12 +230,14 @@ export function glabHostnameArgs(
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
}
function rememberGlabKnownHost(host: string): void {
function rememberGlabKnownHost(host: string, connectionId?: string | null): void {
const normalizedHost = normalizeGitLabHost(host)
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
const key = connectionCacheKey(connectionId)
const cached = knownHostsCacheByConnection.get(key)
if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) {
return
}
knownHostsCache = [...knownHostsCache, normalizedHost]
knownHostsCacheByConnection.set(key, [...cached, normalizedHost])
}
async function isGlabConfiguredForRemoteHost(
@@ -251,30 +263,47 @@ async function isGlabConfiguredForRemoteHost(
}
}
export async function getGlabKnownHosts(): Promise<readonly string[]> {
if (knownHostsCache) {
return knownHostsCache
export async function getGlabKnownHosts(connectionId?: string | null): Promise<readonly string[]> {
const key = connectionCacheKey(connectionId)
const cached = knownHostsCacheByConnection.get(key)
if (cached) {
return cached
}
try {
// Why: `glab auth status` is host-scoped, not cwd-scoped — glab reads its
// own config to list authenticated hosts. The connectionId is threaded so
// the RESULT is cached per connection (a connected repo can have a
// different set of authenticated self-hosted hosts than the local one),
// mirroring how project-ref resolution caches per connection.
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'])
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
knownHostsCache = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
return knownHostsCache
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
knownHostsCacheByConnection.set(key, merged)
return merged
} catch {
knownHostsCache = [...DEFAULT_GITLAB_HOSTS]
return knownHostsCache
// Auth check failed (glab not installed, no auth, tunnel not ready,
// etc.) — fall back to the canonical default for THIS call, but do NOT
// cache the fallback. A later probe (e.g. after the SSH tunnel comes
// up) must be able to discover the real self-hosted host.
return [...DEFAULT_GITLAB_HOSTS]
}
}
export function parseGlabAuthStatusHosts(output: string): string[] {
const hosts = new Set<string>()
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) {
// Why: self-hosted GitLab can run on a non-default port (e.g.
// `gitlab.example.com:8443`); capture the optional `:port` so two services
// on the same hostname but different ports stay distinct downstream.
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) {
hosts.add(m[1].toLowerCase())
}
for (const line of output.split('\n')) {
const bareLine = line.trim()
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
if (
line === bareLine &&
/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine)
) {
hosts.add(hostLine.toLowerCase())
}
}
+72
View File
@@ -344,6 +344,30 @@ Self-hosted-git
it('returns empty list for output with no hosts', () => {
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
})
it('captures a non-default port on "Logged in to" lines', () => {
const out = '✓ Logged in to gitlab.example.com:8080 as user (token)'
expect(parseGlabAuthStatusHosts(out)).toEqual(['gitlab.example.com:8080'])
})
it('captures a non-default port on header-style lines', () => {
const out = `
gitlab.example.com:8080:
✓ Logged in as user
`
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com:8080')
})
it('keeps two services on the same host distinct by port', () => {
const out = `
✓ Logged in to gitlab.example.com:8443 as user (token)
✓ Logged in to gitlab.example.com:3030 as user (token)
`
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
'gitlab.example.com:3030',
'gitlab.example.com:8443'
])
})
})
describe('parseGlabApiResponse', () => {
@@ -428,4 +452,52 @@ describe('getGlabKnownHosts', () => {
await getGlabKnownHosts()
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('recognizes a self-hosted host on a non-default port', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
})
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
// A second probe for the same connection is served from cache.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
glabExecFileAsyncMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
// First probe fails → canonical default, NOT cached.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
// Re-probe (e.g. after tunnel comes up) discovers the real host.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})
+25 -21
View File
@@ -187,31 +187,35 @@ describe('gitlab issue operations', () => {
expect(result.error?.type).toBe('permission_denied')
})
it('falls back to glab issue list with updated ordering for unresolved self-hosted repos', async () => {
it('returns an isolated not_found error (never a cwd-inferred glab call) when the project is unresolved', async () => {
// Why: a cwd-inferred `glab issue list` would hit `git: exit status 128`
// on an SSH connection and, in an "All projects" aggregate, sink the
// whole panel. The unresolvable project must isolate to a structured
// error instead, and must not spawn any glab subprocess.
getIssueProjectRefMock.mockResolvedValueOnce(null)
const result = await listIssues('/repo-root', 5, undefined, 'opened', '@me')
expect(result.items).toEqual([])
expect(result.error?.type).toBe('not_found')
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
expect(acquireMock).not.toHaveBeenCalled()
})
it('returns null for getIssue (and spawns no glab call) when the project is unresolved', async () => {
getIssueProjectRefMock.mockResolvedValueOnce(null)
await expect(getIssue('/repo-root', 7)).resolves.toBeNull()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('threads connectionId into getGlabKnownHosts for listIssues', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await expect(listIssues('/repo-root', 5, undefined, 'opened', '@me')).resolves.toEqual({
items: []
})
await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7')
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'issue',
'list',
'--output',
'json',
'--per-page',
'5',
'--order',
'updated_at',
'--sort',
'desc',
'--assignee',
'@me'
],
{ cwd: '/repo-root' }
)
expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7')
})
it('creates an issue and returns its iid + web_url', async () => {
+45 -57
View File
@@ -44,25 +44,24 @@ export async function getIssue(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabIssueInfo | null> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions)
// Why: don't fall back to a cwd-inferred `glab issue view` when the project
// can't be resolved — on an SSH connection cwd is not the repo dir, so glab
// hits a non-repo dir and fails with `git: exit status 128`. Return null
// (the caller already treats a missing project as "no issue") instead of
// spawning a doomed cwd-dependent call.
if (!projectRef) {
return null
}
await acquire()
try {
if (projectRef) {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
}
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
const { stdout } = await glabExecFileAsync(
['issue', 'view', String(issueNumber), '--output', 'json'],
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout)
@@ -93,7 +92,7 @@ export async function listIssues(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<IssueListResult> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -101,49 +100,38 @@ export async function listIssues(
connectionId,
localGitOptions
)
await acquire()
try {
if (projectRef) {
const stateParam = state === 'all' ? '' : `&state=${state}`
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as Record<string, unknown>[]
// Why: GitLab's project issues endpoint returns true issues only
// (MRs are a separate endpoint), so no equivalent of GitHub's
// pull_request filter is needed here.
return {
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
// Why: when the project can't be resolved we must NOT fall back to an
// unscoped `glab issue list` that infers the project from cwd. For a repo
// on an SSH connection there is no local cwd matching the repo, so glab
// runs git resolution in a non-repo dir and fails with `git: exit status
// 128`. In an "All projects" aggregate one such failure must not sink the
// whole panel — return a structured, isolated result so the resolvable
// projects still load.
if (!projectRef) {
return {
items: [],
error: {
type: 'not_found',
message: 'Could not resolve a GitLab project for this repository.'
}
}
// Fallback — let glab infer project from cwd. glab issue list defaults
// to opened; only pass --closed / --all when explicitly requested.
const stateFlag = state === 'closed' ? ['--closed'] : state === 'all' ? ['--all'] : []
const assigneeFlag = assignee ? ['--assignee', assignee] : []
}
await acquire()
try {
const stateParam = state === 'all' ? '' : `&state=${state}`
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
const { stdout } = await glabExecFileAsync(
[
'issue',
'list',
'--output',
'json',
'--per-page',
String(limit),
'--order',
'updated_at',
'--sort',
'desc',
...stateFlag,
...assigneeFlag
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as unknown[]
const data = JSON.parse(stdout) as Record<string, unknown>[]
// Why: GitLab's project issues endpoint returns true issues only
// (MRs are a separate endpoint), so no equivalent of GitHub's
// pull_request filter is needed here.
return {
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
}
@@ -174,7 +162,7 @@ export async function createIssue(
if (!trimmedTitle) {
return { ok: false, error: 'Title is required' }
}
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -244,7 +232,7 @@ export async function updateIssue(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -381,7 +369,7 @@ export async function addIssueComment(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -439,7 +427,7 @@ export async function listLabels(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string[]> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -480,7 +468,7 @@ export async function listAssignableUsers(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabAssignableUser[]> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
+48 -1
View File
@@ -38,21 +38,68 @@ describe('gitlab project ref parsing', () => {
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
it('drops the SSH transport port from the GitLab host identity', () => {
// Why: for ssh remotes the port is a transport port (e.g. :2222), not the
// web/API endpoint, so it must not become part of the recognized host.
expect(
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('keeps the HTTP(S) port as part of the self-hosted GitLab host identity', () => {
// Why: a self-hosted GitLab served on a non-default web port (e.g. :8443)
// is identified by host:port end-to-end so `glab --hostname` targets it.
expect(
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
'gitlab.com',
'gitlab.example.com:8443'
])
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
})
it('matches a port-bearing http remote against a port-less legacy known host', () => {
// Why: a known host recorded without a port (legacy/bare entry) still
// recognizes a remote on any port of the same hostname.
expect(
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
})
it('distinguishes two services on the same host by port — only the GitLab one matches', () => {
// Why: a GitLab on :8443 and a Gitea on :3030 share a hostname but are
// different services. With only the GitLab port in known hosts, the Gitea
// remote must NOT be classified as GitLab.
const knownHosts = ['gitlab.com', 'gitea.example.com:8443']
expect(parseGitLabProjectRef('http://gitea.example.com:8443/team/api.git', knownHosts)).toEqual(
{ host: 'gitea.example.com:8443', path: 'team/api' }
)
expect(
parseGitLabProjectRef('http://gitea.example.com:3030/team/api.git', knownHosts)
).toBeNull()
})
it('matches an SCP-like self-hosted remote against a port-less known host', () => {
expect(
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('keeps gitlab.com (no port) recognized as a default host', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
it('rejects single-segment paths (host root or user-only)', () => {
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
+47 -3
View File
@@ -12,10 +12,33 @@ export function normalizeGitLabHost(value: string): string {
return value.trim().toLowerCase()
}
// Why: host recognition is port-aware so two services on the same hostname
// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not
// conflated. The hostname (port-less) part is kept for legacy known-host
// entries that were recorded without a port.
function hostnameOf(host: string): string {
// `host` may be `name` or `name:port`. Strip a trailing `:digits` port.
return host.replace(/:\d+$/, '')
}
function stripGitSuffix(path: string): string {
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
}
// Why: the GitLab host identity is the web/API endpoint, which is what `glab
// --hostname` and the known-hosts list speak in terms of. For http(s)
// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080),
// so it must be kept. For ssh/git remotes the port is a transport port
// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is
// dropped and only the hostname is used.
function hostIdentityFromUrl(url: URL): string {
const protocol = url.protocol.toLowerCase()
if (protocol === 'http:' || protocol === 'https:') {
return url.host
}
return url.hostname
}
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
@@ -27,6 +50,27 @@ function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef |
return { host: normalizedHost, path: normalizedPath }
}
/**
* Does `urlHost` (which may include a `:port`) match a known-host entry?
* - An exact match (including any port) always counts.
* - A known entry WITHOUT a port also matches a URL host on the same
* hostname regardless of the URL's port — this preserves recognition for
* legacy `gitlab.com` / bare-hostname known entries.
* - A known entry WITH a port only matches a URL host with the exact same
* port, so `gitlab.example.com:8443` does not accept a
* `gitea.example.com:3000` (or same-host different-port) remote.
*/
function knownHostMatches(urlHost: string, knownHost: string): boolean {
if (urlHost === knownHost) {
return true
}
if (hostnameOf(knownHost) === knownHost) {
// Known entry has no port — match on hostname alone.
return hostnameOf(urlHost) === knownHost
}
return false
}
function makeProjectRef(
host: string,
path: string,
@@ -34,7 +78,7 @@ function makeProjectRef(
): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
if (!normalizedKnownHosts.includes(normalizedHost)) {
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
return null
}
return makeProjectRefForTrustedHost(normalizedHost, path)
@@ -54,7 +98,7 @@ export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef |
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
return makeProjectRefForTrustedHost(hostIdentityFromUrl(url), url.pathname)
} catch {
return null
}
@@ -77,7 +121,7 @@ export function parseGitLabProjectRef(
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRef(url.hostname, url.pathname, knownHosts)
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
} catch {
return null
}
+1 -1
View File
@@ -361,7 +361,7 @@ export async function getWorkItemDetails(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
+5
View File
@@ -76,7 +76,12 @@ describe('GrokHookService', () => {
if (process.platform === 'win32') {
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
} else {
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('payload=$(cat)')
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
}
})
+5 -2
View File
@@ -90,7 +90,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -100,7 +103,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+108 -28
View File
@@ -91,13 +91,20 @@ import {
shouldBypassSingleInstanceLock
} from './startup/single-instance-lock'
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
import {
isStartupDiagnosticsEnabled,
logStartupDiagnostic,
logStartupMilestone
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import { RateLimitService } from './rate-limits/service'
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
import { attachMainWindowServices } from './window/attach-main-window-services'
import {
attachMainWindowServices,
ensureAutoUpdaterConfigured
} from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { createSystemTray, destroySystemTray } from './tray/system-tray'
import { focusExistingMainWindow } from './window/focus-existing-window'
@@ -171,6 +178,8 @@ import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-fac
import { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@@ -397,6 +406,11 @@ if (app.isPackaged && process.platform !== 'win32') {
}
configureDevUserDataPath(is.dev)
configureOrcaUserDataPathEnv()
// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback,
// so a window revealed on that path still gets its tray icon.
const TRAY_CREATE_FALLBACK_MS = 12_000
const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
if (startupDiagnosticsEnabled) {
logStartupDiagnostic('before-single-instance-lock', {
@@ -410,14 +424,6 @@ if (startupDiagnosticsEnabled) {
startEventLoopStallProbe()
}
// Why: startup benchmarking needs in-process timestamps — harness-side stderr
// arrival times include pipe buffering jitter. `t` is ms since process start.
function logStartupMilestone(event: string, details: Record<string, unknown> = {}): void {
if (startupDiagnosticsEnabled) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
}
}
function focusExistingWindow(): void {
focusExistingMainWindow({
app,
@@ -781,9 +787,45 @@ function openMainWindow(): BrowserWindow {
})
recordCrashBreadcrumb('main_window_created')
logStartupMilestone('window-created')
// Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for
// seconds while explorer.exe's notification area is busy (part of issue
// #7225's pre-paint stall), so create it after first paint. The timer
// fallback covers windows revealed without ready-to-show ever firing
// (createMainWindow's win32 10s reveal fallback) — those can still be
// hidden to the tray on close, so the icon must exist by then.
let trayCreated = false
const createSystemTrayDeferred = (): void => {
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
return
}
trayCreated = true
// Why: Windows-only system tray. createSystemTray is idempotent and a
// no-op off win32, so calling it on each window open keeps exactly one
// live icon.
createSystemTray({
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onQuit: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts;
// the window must be visible if a hidden-to-tray session vetoes
// shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close'
// handler proceeds to teardown instead of re-hiding to the tray.
isQuitting = true
app.quit()
}
})
logStartupMilestone('tray-created')
}
window.once('ready-to-show', () => {
logStartupMilestone('ready-to-show')
setImmediate(createSystemTrayDeferred)
})
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
trayCreateFallback.unref?.()
// Why: telemetry-plan.md§First-launch experience anchors default-on
// `app_opened` to the first main-window load. Existing users in the
@@ -879,23 +921,6 @@ function openMainWindow(): BrowserWindow {
stopAllSyntheticTitleSpinners()
})
mainWindow = window
// Why: Windows-only system tray. createSystemTray is idempotent and a no-op
// off win32, so calling it on each window open keeps exactly one live icon.
createSystemTray({
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onQuit: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts; the
// window must be visible if a hidden-to-tray session vetoes shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close' handler
// proceeds to teardown instead of re-hiding the window to the tray.
isQuitting = true
app.quit()
}
})
window.on('show', resumeSyntheticTitleSpinnerTimer)
window.on('restore', resumeSyntheticTitleSpinnerTimer)
window.on('hide', stopSyntheticTitleSpinnerTimer)
@@ -1845,7 +1870,12 @@ app.whenReady().then(async () => {
logStartupMilestone('i18n-ready')
registerAppMenu({
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
onCheckForUpdates: (options) => {
// Why: the menu is clickable before first paint; a manual check must
// run against a configured updater (see attach-main-window-services).
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
},
onBeforeReload: ({ ignoreCache, webContentsId }) => {
if (mainWindow?.webContents.id === webContentsId) {
markExpectedRendererReload(webContentsId)
@@ -1989,6 +2019,56 @@ app.whenReady().then(async () => {
throw error
})
installServeSignalHandlers()
// Why: the orca CLI command is normally installed by the renderer onboarding /
// Settings "Install CLI" flow via the cli:install IPC. Headless serve has no
// renderer, so the command is never created and an in-terminal `orca …` fails
// with command-not-found. Run the idempotent installer here for the platforms
// where it puts a resolvable command on the managed-terminal PATH: macOS (bare
// `orca` in /usr/local/bin or ~/.local/bin) and Linux (`orca-ide`; bare `orca`
// is added by the dispatcher below). Windows is excluded — there install() would
// only mutate the persistent user-registry PATH without helping the current
// serve's child terminals. Best-effort: a failure must not block serve start.
if (process.platform === 'darwin' || process.platform === 'linux') {
try {
// Why: serve is headless — never let a missing-write-permission fallback
// pop an osascript admin prompt (it would hang serve on a GUI host). Skip
// instead; the user-writable ~/.local/bin path needs no elevation anyway.
const cliStatus = await new CliInstaller({
privilegedRunner: async () => {
throw new Error('serve CLI auto-install must not request administrator privileges')
}
}).install()
console.log(
`[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}`
)
} catch (error) {
console.warn(
'[serve] orca CLI install skipped:',
error instanceof Error ? error.message : String(error)
)
}
}
// Why: on Linux the CLI installs as `orca-ide`, NOT bare `orca` (above), but the
// Claude Team launcher typed into the initial managed terminal invokes bare `orca`.
// Drop a bare-`orca` dispatcher on ~/.local/bin (ahead of /usr/bin on the managed
// terminal PATH) so `orca claude-teams` resolves. Best-effort: a failure must not
// block serve startup. See installLinuxBareOrcaDispatcher for the full rationale.
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
try {
const dispatcher = await installLinuxBareOrcaDispatcher({
resourcesPath: process.resourcesPath
})
console.log(
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
)
} catch (error) {
console.warn(
'[serve] bare orca dispatcher install skipped:',
error instanceof Error ? error.message : String(error)
)
}
}
await printServeReady(serveOptions)
return
}
-1
View File
@@ -71,7 +71,6 @@ vi.mock('../git/runner', () => ({
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn().mockReturnValue(true),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
searchBaseRefs: vi.fn().mockResolvedValue([])
-1
View File
@@ -22,7 +22,6 @@ vi.mock('../git/runner', () => ({
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn(),
getGitUsername: vi.fn(),
getRepoName: vi.fn(),
getBaseRefDefault: vi.fn(),
searchBaseRefs: vi.fn()
-1
View File
@@ -94,7 +94,6 @@ vi.mock('../git/repo', async () => {
// Stub only the functions that spawn git / touch the filesystem.
isGitRepo: vi.fn().mockReturnValue(true),
getGitRepoRoot: vi.fn((path: string) => path),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
getRemoteCount: vi.fn().mockResolvedValue(1),
@@ -35,7 +35,6 @@ vi.mock('electron', () => ({
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn().mockReturnValue(true),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
searchBaseRefs: vi.fn().mockResolvedValue([]),
+9 -3
View File
@@ -61,7 +61,6 @@ import { createNestedRepoImportTargetResolver } from '../project-groups/nested-r
import {
isGitRepo,
getGitRepoRoot,
getGitUsername,
getRepoName,
getBaseRefDefault,
getRemoteCount,
@@ -76,7 +75,8 @@ import {
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitUsername } from '../git/git-username'
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
import { enrichRepoGitUsernames } from '../repo-git-username-enrichment'
import { getActiveMultiplexer } from './ssh'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { track } from '../telemetry/client'
@@ -1163,6 +1163,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
// Why: username resolution spawns git/gh and must stay off this handler's
// synchronous path (issue #7225); the background pass notifies the
// renderer to re-list once values land.
enrichRepoGitUsernames(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getRepos()
})
@@ -2400,7 +2406,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
return getSshGitUsername(provider, repo.path)
}
return getGitUsername(repo.path)
return resolveLocalGitUsername(repo.path)
})
ipcMain.handle(
+3 -2
View File
@@ -27,7 +27,8 @@ import type {
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
import { getGitUsername, getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
import { resolveLocalGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
import type { ForgeProviderId } from '../source-control/forge-provider'
@@ -1872,7 +1873,7 @@ export async function createLocalWorktree(
return { ...options, ...localWorktreeGitOptions }
}
const username = getGitUsername(repo.path)
const username = await resolveLocalGitUsername(repo.path)
const requestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
const requestedDisplayName = args.displayName
+10 -5
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
const {
handleMock,
@@ -7,7 +8,7 @@ const {
assertWorktreeCleanForRemovalMock,
addWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
resolveLocalGitUsernameMock,
getDefaultBaseRefMock,
resolveDefaultBaseRefViaExecMock,
getBranchConflictKindMock,
@@ -35,7 +36,7 @@ const {
assertWorktreeCleanForRemovalMock: vi.fn(),
addWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
getGitUsernameMock: vi.fn(),
resolveLocalGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
resolveDefaultBaseRefViaExecMock: vi.fn(),
getBranchConflictKindMock: vi.fn(),
@@ -82,12 +83,16 @@ vi.mock('../git/runner', () => ({
}))
vi.mock('../git/repo', () => ({
getGitUsername: getGitUsernameMock,
getDefaultBaseRef: getDefaultBaseRefMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getBranchConflictKind: getBranchConflictKindMock
}))
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
return { ...actual, resolveLocalGitUsername: resolveLocalGitUsernameMock }
})
vi.mock('../github/client', () => ({
getPRForBranch: getPRForBranchMock,
createGitHubPullRequest: createGitHubPullRequestMock
@@ -157,7 +162,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
assertWorktreeCleanForRemovalMock.mockReset()
addWorktreeMock.mockReset()
removeWorktreeMock.mockReset()
getGitUsernameMock.mockReset()
resolveLocalGitUsernameMock.mockReset()
getDefaultBaseRefMock.mockReset()
resolveDefaultBaseRefViaExecMock.mockReset()
getBranchConflictKindMock.mockReset()
@@ -223,7 +228,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
})
store.getWorktreeMeta.mockReturnValue(undefined)
store.setWorktreeMeta.mockReturnValue({})
getGitUsernameMock.mockReturnValue('')
resolveLocalGitUsernameMock.mockResolvedValue('')
getDefaultBaseRefMock.mockReturnValue('origin/main')
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
getBranchConflictKindMock.mockResolvedValue(null)
+10 -5
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
@@ -24,7 +25,7 @@ const {
addSparseWorktreeMock,
removeWorktreeMock,
forceDeleteLocalBranchMock,
getGitUsernameMock,
resolveLocalGitUsernameMock,
getDefaultBaseRefMock,
resolveDefaultBaseRefViaExecMock,
getDefaultRemoteMock,
@@ -73,7 +74,7 @@ const {
addSparseWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
forceDeleteLocalBranchMock: vi.fn(),
getGitUsernameMock: vi.fn(),
resolveLocalGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
resolveDefaultBaseRefViaExecMock: vi.fn(),
getDefaultRemoteMock: vi.fn(),
@@ -127,13 +128,17 @@ vi.mock('../git/runner', () => ({
}))
vi.mock('../git/repo', () => ({
getGitUsername: getGitUsernameMock,
getDefaultBaseRef: getDefaultBaseRefMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getDefaultRemote: getDefaultRemoteMock,
getBranchConflictKind: getBranchConflictKindMock
}))
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
return { ...actual, resolveLocalGitUsername: resolveLocalGitUsernameMock }
})
vi.mock('../github/client', () => ({
getPRForBranch: getPRForBranchMock,
getWorkItem: getWorkItemMock,
@@ -286,7 +291,7 @@ describe('registerWorktreeHandlers', () => {
addSparseWorktreeMock,
removeWorktreeMock,
forceDeleteLocalBranchMock,
getGitUsernameMock,
resolveLocalGitUsernameMock,
getDefaultBaseRefMock,
resolveDefaultBaseRefViaExecMock,
getDefaultRemoteMock,
@@ -386,7 +391,7 @@ describe('registerWorktreeHandlers', () => {
}
])
store.getAllWorktreeLineage.mockReturnValue({})
getGitUsernameMock.mockReturnValue('')
resolveLocalGitUsernameMock.mockResolvedValue('')
getDefaultBaseRefMock.mockReturnValue('origin/main')
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
getDefaultRemoteMock.mockResolvedValue('origin')
+5
View File
@@ -55,6 +55,11 @@ describe('KimiHookService', () => {
// The managed script must exist and POST to the Kimi hook endpoint.
const script = readFileSync(scriptPath(), 'utf-8')
expect(script).toContain('/hook/kimi')
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
// The command Kimi runs points at the managed script via sh.
expect(config).toContain('agent-hooks/kimi-hook.sh')
})
+5 -2
View File
@@ -75,7 +75,10 @@ function getManagedScript(): string {
// Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/kimi" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/kimi" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -85,7 +88,7 @@ function getManagedScript(): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+39 -6
View File
@@ -123,10 +123,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('./git/repo', () => ({
getGitUsername: vi.fn().mockReturnValue('testuser')
}))
vi.mock('./telemetry/client', () => ({
track: trackMock
}))
@@ -1004,7 +1000,9 @@ describe('Store', () => {
// ── 2. Load from existing valid file ─────────────────────────────────
it('reads repos from an existing data file', async () => {
const repo = makeRepo()
// Why: hydration must serve the persisted username without spawning
// git/gh (issue #7225); resolution happens in background enrichment.
const repo = makeRepo({ gitUsername: 'testuser' })
writeDataFile({
schemaVersion: 1,
repos: [repo],
@@ -2828,6 +2826,24 @@ describe('Store', () => {
expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false)
})
it('round-trips selected terminal theme names across reload', async () => {
const store = await createStore()
store.updateSettings({
terminalThemeDark: 'One Light',
terminalThemeLight: 'GitHub Light'
})
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.settings.terminalThemeDark).toBe('One Light')
expect(persisted.settings.terminalThemeLight).toBe('GitHub Light')
const reopened = await createStore()
expect(reopened.getSettings().terminalThemeDark).toBe('One Light')
expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light')
})
// ── 5. addRepo and getRepo ──────────────────────────────────────────
it('addRepo stores a repo retrievable by getRepo', async () => {
@@ -2837,7 +2853,24 @@ describe('Store', () => {
const fetched = store.getRepo('r1')
expect(fetched).toBeDefined()
expect(fetched!.displayName).toBe('test')
expect(fetched!.gitUsername).toBe('testuser')
// No username has been resolved yet — hydration must not probe git/gh.
expect(fetched!.gitUsername).toBe('')
})
it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => {
const store = await createStore()
store.addRepo(makeRepo())
expect(store.getRepo('r1')!.gitUsername).toBe('')
expect(store.setResolvedRepoGitUsername('r1', 'testuser')).toBe(true)
expect(store.getRepo('r1')!.gitUsername).toBe('testuser')
// Unchanged value reports no change so callers can skip renderer notify.
expect(store.setResolvedRepoGitUsername('r1', 'testuser')).toBe(false)
expect(store.setResolvedRepoGitUsername('missing', 'x')).toBe(false)
store.flush()
const persisted = readDataFile() as PersistedState
expect(persisted.repos[0].gitUsername).toBe('testuser')
})
it('deleteProjectGroup ungroups repos from the deleted group subtree', async () => {
+35 -10
View File
@@ -86,7 +86,6 @@ import {
type SshTarget
} from '../shared/ssh-types'
import { isFolderRepo } from '../shared/repo-kind'
import { getGitUsername } from './git/repo'
import { getRepoExecutionHostId, parseExecutionHostId } from '../shared/execution-host'
import {
getDefaultPersistedState,
@@ -3805,9 +3804,8 @@ export class Store {
/**
* O(1) read of the persisted repo count. Use this when you only need the
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo and
* may run a synchronous git subprocess via `getGitUsername()`, which is
* wasteful when the caller only reads `.length`.
* count (e.g. cohort-classifier) — `getRepos()` hydrates each repo, which
* is wasteful when the caller only reads `.length`.
*/
getRepoCount(): number {
return this.state.repos.length
@@ -3818,6 +3816,32 @@ export class Store {
return repo ? this.hydrateRepo(repo) : undefined
}
/**
* Record a background-resolved git username (repo-git-username-enrichment).
* Kept out of updateRepo's whitelist so the renderer-facing update surface
* cannot write it directly. Returns true when the hydrated value changed.
*/
setResolvedRepoGitUsername(id: string, username: string): boolean {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return false
}
const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? ''
this.gitUsernameCache.set(repo.path, username)
if (previous === username) {
return false
}
if (username) {
// Why: persisting the resolved value lets the next launch hydrate repos
// with the right branch prefix before enrichment has re-run.
repo.gitUsername = username
} else {
delete repo.gitUsername
}
this.scheduleSave()
return true
}
getProjectGroups(): ProjectGroup[] {
return [...(this.state.projectGroups ?? [])].sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
@@ -4351,14 +4375,15 @@ export class Store {
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
// Why: username resolution spawns git/gh subprocesses, so it must never
// run inside hydration — the first getRepos() of a launch executes on the
// Electron main thread and a stuck probe froze startup for minutes on
// Windows (issue #7225). Hydration only reads the enrichment cache or the
// value persisted by a previous launch; repo-git-username-enrichment.ts
// refreshes both in the background.
const gitUsername = isFolderRepo(repo)
? ''
: (this.gitUsernameCache.get(repo.path) ??
(() => {
const username = getGitUsername(repo.path)
this.gitUsernameCache.set(repo.path, username)
return username
})())
: (this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '')
return {
...repoWithoutIcon,
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../shared/types'
import type { ResolvedGitUsername } from './git/git-username'
const resolveLocalGitUsernameDetailedMock = vi.hoisted(() => vi.fn())
vi.mock('./git/git-username', () => ({
resolveLocalGitUsernameDetailed: resolveLocalGitUsernameDetailedMock
}))
import {
enrichRepoGitUsernames,
flushRepoGitUsernameEnrichmentForTests,
resetRepoGitUsernameEnrichmentForTests
} from './repo-git-username-enrichment'
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'r1',
path: 'C:/repos/one',
displayName: 'One',
badgeColor: '#000',
addedAt: 1,
...overrides
} as Repo
}
function makeStore(repos: Repo[]): {
getRepos: () => Repo[]
setResolvedRepoGitUsername: ReturnType<typeof vi.fn<(id: string, username: string) => boolean>>
} {
return {
getRepos: () => repos,
setResolvedRepoGitUsername: vi.fn<(id: string, username: string) => boolean>(() => true)
}
}
function resolved(username: string, authoritative = true): ResolvedGitUsername {
return { username, authoritative }
}
describe('enrichRepoGitUsernames', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRepoGitUsernameEnrichmentForTests()
resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('demo-user'))
})
it('resolves and persists usernames, then notifies once', async () => {
const store = makeStore([makeRepo(), makeRepo({ id: 'r2', path: 'C:/repos/two' })])
const onChanged = vi.fn()
enrichRepoGitUsernames(store, { onChanged })
await flushRepoGitUsernameEnrichmentForTests()
expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(2)
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r1', 'demo-user')
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r2', 'demo-user')
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('skips folder and SSH repos', async () => {
const store = makeStore([
makeRepo({ id: 'folder', kind: 'folder' }),
makeRepo({ id: 'ssh', path: '/remote/repo', connectionId: 'conn-1' })
])
enrichRepoGitUsernames(store)
await flushRepoGitUsernameEnrichmentForTests()
expect(resolveLocalGitUsernameDetailedMock).not.toHaveBeenCalled()
})
it('probes each repo location at most once per session', async () => {
const store = makeStore([makeRepo()])
enrichRepoGitUsernames(store)
await flushRepoGitUsernameEnrichmentForTests()
enrichRepoGitUsernames(store)
await flushRepoGitUsernameEnrichmentForTests()
expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(1)
})
it('keeps persisted usernames on a non-authoritative empty resolution', async () => {
resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('', false))
const store = makeStore([makeRepo()])
const onChanged = vi.fn()
enrichRepoGitUsernames(store, { onChanged })
await flushRepoGitUsernameEnrichmentForTests()
expect(store.setResolvedRepoGitUsername).not.toHaveBeenCalled()
expect(onChanged).not.toHaveBeenCalled()
})
it('clears stale persisted usernames on an authoritative empty resolution', async () => {
// Why: the user removed github.user / logged out of gh — a completed
// probe returning '' must clear the stale prefix instead of pinning it.
resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('', true))
const store = makeStore([makeRepo()])
const onChanged = vi.fn()
enrichRepoGitUsernames(store, { onChanged })
await flushRepoGitUsernameEnrichmentForTests()
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r1', '')
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('does not notify when the store reports no change', async () => {
const store = makeStore([makeRepo()])
store.setResolvedRepoGitUsername.mockReturnValue(false)
const onChanged = vi.fn()
enrichRepoGitUsernames(store, { onChanged })
await flushRepoGitUsernameEnrichmentForTests()
expect(onChanged).not.toHaveBeenCalled()
})
it('re-runs after the in-flight pass for repos added mid-pass', async () => {
const repos = [makeRepo()]
const store = makeStore(repos)
let releaseFirstProbe!: () => void
resolveLocalGitUsernameDetailedMock.mockImplementationOnce(
() =>
new Promise<ResolvedGitUsername>((resolve) => {
releaseFirstProbe = () => resolve(resolved('demo-user'))
})
)
enrichRepoGitUsernames(store)
// A repo lands while the first pass is still probing r1.
repos.push(makeRepo({ id: 'r2', path: 'C:/repos/two' }))
enrichRepoGitUsernames(store)
releaseFirstProbe()
await flushRepoGitUsernameEnrichmentForTests()
expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(2)
expect(store.setResolvedRepoGitUsername).toHaveBeenCalledWith('r2', 'demo-user')
})
})
+95
View File
@@ -0,0 +1,95 @@
import type { Repo } from '../shared/types'
import { resolveLocalGitUsernameDetailed } from './git/git-username'
type RepoUsernameStore = {
getRepos(): Repo[]
setResolvedRepoGitUsername(id: string, username: string): boolean
}
type EnrichmentOptions = {
onChanged?: () => void
}
// Why: resolution spawns git (and possibly gh) subprocesses, so run it at most
// once per repo location per app session — hydrateRepo serves the persisted
// value in between, and a relaunch picks up config changes.
const attemptedLocations = new Set<string>()
let enrichmentInFlight: Promise<void> | null = null
let rerunRequested = false
function getRepoLocationKey(repo: Pick<Repo, 'path' | 'connectionId'>): string {
return `${repo.connectionId ?? 'local'}\0${repo.path}`
}
async function enrichRepoGitUsernamesInBackground(
store: RepoUsernameStore,
options: EnrichmentOptions
): Promise<void> {
const candidates = store.getRepos().filter(
(repo) =>
repo.kind !== 'folder' &&
// Why: SSH repo paths are remote; local git cannot inspect them. The
// SSH username path (getSshGitUsername) stays caller-driven.
!repo.connectionId &&
!attemptedLocations.has(getRepoLocationKey(repo))
)
let changed = false
for (const repo of candidates) {
attemptedLocations.add(getRepoLocationKey(repo))
const { username, authoritative } = await resolveLocalGitUsernameDetailed(repo.path)
// Why: a non-authoritative '' means a probe timed out and says nothing
// about the account — keep the persisted value. An authoritative result
// (including '') is the current truth: it must also CLEAR a stale
// persisted username after the user removes github.user or logs out.
if (!authoritative && !username) {
continue
}
if (store.setResolvedRepoGitUsername(repo.id, username)) {
changed = true
}
}
if (changed) {
options.onChanged?.()
}
}
/**
* Resolve git usernames for repos that haven't been probed this session, off
* the caller's critical path. Fire-and-forget by design: repos:list must stay
* subprocess-free (issue #7225 — a stuck sync probe froze startup for minutes).
*/
export function enrichRepoGitUsernames(
store: RepoUsernameStore,
options: EnrichmentOptions = {}
): void {
if (enrichmentInFlight) {
// Why: a repo added mid-pass would otherwise be dropped until some later
// repos:list happens to fire — queue one follow-up pass instead.
rerunRequested = true
return
}
enrichmentInFlight = enrichRepoGitUsernamesInBackground(store, options)
.catch((error: unknown) => {
console.error('[repo-username] Failed to enrich git usernames:', error)
})
.finally(() => {
enrichmentInFlight = null
if (rerunRequested) {
rerunRequested = false
enrichRepoGitUsernames(store, options)
}
})
}
export async function flushRepoGitUsernameEnrichmentForTests(): Promise<void> {
// A queued rerun replaces enrichmentInFlight when the first pass settles.
while (enrichmentInFlight) {
await enrichmentInFlight
}
}
export function resetRepoGitUsernameEnrichmentForTests(): void {
attemptedLocations.clear()
enrichmentInFlight = null
rerunRequested = false
}
@@ -3,6 +3,7 @@
* Tests the full lifecycle: mobile-fit → restore → verify PTY resized.
*/
import { describe, expect, it, vi } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
import { OrcaRuntimeService } from './orca-runtime'
vi.mock('../git/worktree', () => ({
@@ -38,11 +39,15 @@ vi.mock('../git/repo', async (importOriginal) => {
return {
...actual,
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
getBranchConflictKind: vi.fn().mockResolvedValue(null),
getGitUsername: vi.fn().mockReturnValue('')
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
})
const store = {
getRepo: () => ({
id: 'repo-1',
@@ -13,6 +13,7 @@
* - terminalDriverChanged notifications fire at the right transitions
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
import { OrcaRuntimeService } from './orca-runtime'
vi.mock('../git/worktree', () => ({
@@ -34,11 +35,15 @@ vi.mock('../git/repo', async (importOriginal) => {
return {
...actual,
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
getBranchConflictKind: vi.fn().mockResolvedValue(null),
getGitUsername: vi.fn().mockReturnValue('')
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
})
const store = {
getRepo: () => ({
id: 'repo-1',
@@ -5,6 +5,7 @@
* debounced restore, inline restore on timer cancel, and cleanup paths.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
import { OrcaRuntimeService } from './orca-runtime'
vi.mock('../git/worktree', () => ({
@@ -40,11 +41,15 @@ vi.mock('../git/repo', async (importOriginal) => {
return {
...actual,
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
getBranchConflictKind: vi.fn().mockResolvedValue(null),
getGitUsername: vi.fn().mockReturnValue('')
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
})
// Why: many tests pre-date the mobileAutoRestoreFitMs preference. Default
// the mock store to MIN (5_000ms — the new clamp floor) so legacy
// assertions about "restore fires after the configured delay" keep their
+171 -3
View File
@@ -11,14 +11,42 @@ const {
webContentsFromIdMock,
startBrowserScreencastMock,
waitForTabRegistrationMock,
waitForWorktreeTabRegistrationMock
waitForWorktreeTabRegistrationMock,
browserSessionRegistryMock
} = vi.hoisted(() => ({
ipcMainOnMock: vi.fn(),
ipcMainRemoveListenerMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
startBrowserScreencastMock: vi.fn(),
waitForTabRegistrationMock: vi.fn(),
waitForWorktreeTabRegistrationMock: vi.fn()
waitForWorktreeTabRegistrationMock: vi.fn(),
browserSessionRegistryMock: {
profiles: new Map([
[
'default',
{
id: 'default',
scope: 'default',
partition: 'persist:orca-browser',
label: 'Default',
source: null
}
],
[
'profile-isolated',
{
id: 'profile-isolated',
scope: 'isolated',
partition: 'persist:orca-browser-session-profile-isolated',
label: 'Isolated',
source: null
}
]
]),
getDefaultProfile: vi.fn(),
getProfile: vi.fn(),
resolveKnownPartition: vi.fn()
}
}))
vi.mock('electron', () => ({
@@ -35,6 +63,10 @@ vi.mock('../ipc/browser', () => ({
waitForWorktreeTabRegistration: waitForWorktreeTabRegistrationMock
}))
vi.mock('../browser/browser-session-registry', () => ({
browserSessionRegistry: browserSessionRegistryMock
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
@@ -83,6 +115,23 @@ describe('RuntimeBrowserCommands browser screencast', () => {
waitForTabRegistrationMock.mockResolvedValue(undefined)
waitForWorktreeTabRegistrationMock.mockReset()
waitForWorktreeTabRegistrationMock.mockResolvedValue(undefined)
browserSessionRegistryMock.getDefaultProfile.mockReset()
browserSessionRegistryMock.getDefaultProfile.mockImplementation(() =>
browserSessionRegistryMock.profiles.get('default')
)
browserSessionRegistryMock.getProfile.mockReset()
browserSessionRegistryMock.getProfile.mockImplementation(
(profileId: string) => browserSessionRegistryMock.profiles.get(profileId) ?? null
)
browserSessionRegistryMock.resolveKnownPartition.mockReset()
browserSessionRegistryMock.resolveKnownPartition.mockImplementation(
(profileId: string | null | undefined) => {
if (!profileId) {
return 'persist:orca-browser'
}
return browserSessionRegistryMock.profiles.get(profileId)?.partition ?? null
}
)
})
it('waits for explicit worktree browser registration after requesting a hidden mount', async () => {
@@ -191,14 +240,133 @@ describe('RuntimeBrowserCommands browser screencast', () => {
).resolves.toEqual({ browserPageId: 'page-new' })
expect(waitForWorktreeTabRegistrationMock).not.toHaveBeenCalled()
// Why: with no explicit profile, main must leave sessionProfileId/
// sessionPartition undefined so the renderer applies the user's configured
// default-profile inheritance instead of being forced onto the shared
// default partition.
expect(send).toHaveBeenCalledWith(
'browser:requestTabCreate',
expect.objectContaining({ url: 'about:blank', worktreeId: 'wt-1' })
expect.objectContaining({
url: 'about:blank',
worktreeId: 'wt-1',
sessionProfileId: undefined,
sessionPartition: undefined
})
)
expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-new')
expect(bridge.setActiveTab).toHaveBeenCalledWith(101, 'wt-1')
})
it('sends the resolved isolated profile partition when creating a renderer tab', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const webContents = { send: vi.fn() }
const send = vi.fn((channel: string, data: { requestId: string }) => {
expect(channel).toBe('browser:requestTabCreate')
const handler = ipcMainOnMock.mock.calls.find(
([eventName]) => eventName === 'browser:tabCreateReply'
)?.[1] as
| ((event: unknown, reply: { requestId: string; browserPageId?: string }) => void)
| undefined
handler?.({ sender: webContents } as never, {
requestId: data.requestId,
browserPageId: 'page-isolated'
})
})
webContents.send = send
const bridge = {
getRegisteredTabs: vi.fn(() => new Map([['page-isolated', 101]])),
getActivePageId: vi.fn(() => 'page-isolated'),
setActiveTab: vi.fn(),
tabList: vi.fn(() => ({ tabs: [] }))
} as unknown as AgentBrowserBridge
const commands = new RuntimeBrowserCommands(
createHost({
getAgentBrowserBridge: () => bridge,
getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never),
getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never)
})
)
await expect(
commands.browserTabCreate({
worktree: 'id:wt-1',
url: 'https://example.com',
profileId: 'profile-isolated'
})
).resolves.toEqual({ browserPageId: 'page-isolated' })
expect(send).toHaveBeenCalledWith(
'browser:requestTabCreate',
expect.objectContaining({
sessionProfileId: 'profile-isolated',
sessionPartition: 'persist:orca-browser-session-profile-isolated'
})
)
})
it('rejects unknown explicit profile ids before requesting a renderer tab', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const send = vi.fn()
const commands = new RuntimeBrowserCommands(
createHost({
getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never),
getAuthoritativeWindow: vi.fn(() => ({ webContents: { send } }) as never)
})
)
await expect(
commands.browserTabCreate({
worktree: 'id:wt-1',
url: 'https://example.com',
profileId: 'missing-profile'
})
).rejects.toThrow(/Browser profile missing-profile was not found/)
expect(send).not.toHaveBeenCalled()
})
it('sends the resolved partition when switching a renderer tab profile', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const webContents = { send: vi.fn() }
const send = vi.fn((channel: string, data: { requestId: string }) => {
expect(channel).toBe('browser:requestTabSetProfile')
const handler = ipcMainOnMock.mock.calls.find(
([eventName]) => eventName === 'browser:tabSetProfileReply'
)?.[1] as ((event: unknown, reply: { requestId: string; error?: string }) => void) | undefined
handler?.({ sender: webContents } as never, { requestId: data.requestId })
})
webContents.send = send
const bridge = {
getRegisteredTabs: vi.fn(() => new Map([['page-1', 101]])),
getActivePageId: vi.fn(() => 'page-1')
} as unknown as AgentBrowserBridge
const commands = new RuntimeBrowserCommands(
createHost({
getAgentBrowserBridge: () => bridge,
getAvailableAuthoritativeWindow: vi.fn(() => ({}) as never),
getAuthoritativeWindow: vi.fn(() => ({ webContents }) as never)
})
)
await expect(
commands.browserTabSetProfile({ worktree: 'id:wt-1', profileId: 'profile-isolated' })
).resolves.toEqual({
browserPageId: 'page-1',
profileId: 'profile-isolated',
profileLabel: 'Isolated'
})
expect(send).toHaveBeenCalledWith(
'browser:requestTabSetProfile',
expect.objectContaining({
browserPageId: 'page-1',
profileId: 'profile-isolated',
sessionPartition: 'persist:orca-browser-session-profile-isolated'
})
)
expect(waitForTabRegistrationMock).toHaveBeenCalledWith('page-1')
})
it('wakes the requested page instead of the first worktree tab for page-scoped commands', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
webContentsFromIdMock.mockReturnValue({ isDestroyed: () => true })
+21 -4
View File
@@ -1335,6 +1335,13 @@ export class RuntimeBrowserCommands {
const worktreeId = params.worktree
? (await this.host.resolveWorktreeSelector(params.worktree)).id
: undefined
const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId)
if (!sessionPartition) {
throw new BrowserError(
'invalid_argument',
`Browser profile ${params.profileId} was not found`
)
}
// Why: a desktop renderer mounts a <webview>; a headless serve has none and
// backs the page with a main-process offscreen WebContents instead. Both
// register into BrowserManager so all downstream commands resolve uniformly.
@@ -1356,6 +1363,7 @@ export class RuntimeBrowserCommands {
url,
worktreeId,
params.profileId,
params.profileId ? sessionPartition : undefined,
params.activate
)
@@ -1460,7 +1468,8 @@ export class RuntimeBrowserCommands {
win.webContents.send('browser:requestTabSetProfile', {
requestId,
browserPageId,
profileId: profile.id
profileId: profile.id,
sessionPartition: profile.partition
})
})
@@ -1520,7 +1529,8 @@ export class RuntimeBrowserCommands {
const created = await this.createBrowserTabInRenderer(
sourceTab.url,
sourceTab.worktreeId ?? target.worktreeId,
profile.id
profile.id,
profile.partition
)
// Why: parity with browserTabCreate. Wait for the cloned tab's webview to
// register so the returned browserPageId is operable by the next CLI call.
@@ -1781,8 +1791,9 @@ export class RuntimeBrowserCommands {
private async createBrowserTabInRenderer(
url: string,
worktreeId?: string,
profileId?: string,
worktreeId: string | undefined,
profileId: string | undefined,
sessionPartition: string | undefined,
activate?: boolean
): Promise<{ browserPageId: string }> {
const win = this.host.getAuthoritativeWindow()
@@ -1814,7 +1825,13 @@ export class RuntimeBrowserCommands {
requestId,
url,
worktreeId,
// Why: leave sessionProfileId/sessionPartition undefined when no explicit
// profile was chosen so the renderer still applies the user's configured
// default-profile inheritance. Only thread the resolved partition when a
// profile is named — sending null here would suppress inheritance and
// force the shared default partition.
sessionProfileId: profileId,
sessionPartition,
activate
})
})
+7 -2
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
import { performance } from 'node:perf_hooks'
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
@@ -492,11 +493,15 @@ vi.mock('../git/repo', async (importOriginal) => {
return {
...actual,
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
getBranchConflictKind: vi.fn().mockResolvedValue(null),
getGitUsername: vi.fn().mockReturnValue('')
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
})
function resetRuntimeTestMocks(): void {
resetPlatform()
advertisedUrlWatcher.clear()
+4 -4
View File
@@ -570,7 +570,6 @@ import type {
UpdatePullRequestBySlugArgs
} from '../../shared/github-project-types'
import {
getGitUsername,
getBaseRefDefault,
getDefaultBaseRef,
getDefaultRemote,
@@ -590,6 +589,7 @@ import {
getRecentDriftSubjects
} from '../git/repo'
import { hasLocalCommitObject } from '../git/commit-object-ref'
import { resolveLocalGitUsername } from '../git/git-username'
import {
listWorktrees,
listWorktreesStrict,
@@ -13518,7 +13518,7 @@ export class OrcaRuntimeService {
const requestedDisplayName = args.displayName?.trim() || undefined
const sanitizedName = sanitizeWorktreeName(args.name)
let effectiveSanitizedName = sanitizedName
const username = getGitUsername(repo.path)
const username = await resolveLocalGitUsername(repo.path)
const baseBranch =
args.baseBranch ||
@@ -15185,7 +15185,7 @@ export class OrcaRuntimeService {
} catch (error) {
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
}
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null)
const projectRef = await getGitLabProjectRefForRemote(
repo.path,
remote,
@@ -15310,7 +15310,7 @@ export class OrcaRuntimeService {
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
): Promise<string> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const localGitOptionArgs =
Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : []
if (preference === 'origin') {
+8
View File
@@ -29,3 +29,11 @@ export function logStartupDiagnostic(
.join(' ')
writeStartupDiagnosticLine(`[startup] ${event}${detailText ? ` ${detailText}` : ''}`, write)
}
// Why: startup benchmarking needs in-process timestamps — harness-side stderr
// arrival times include pipe buffering jitter. `t` is ms since process start.
export function logStartupMilestone(event: string, details: Record<string, unknown> = {}): void {
if (isStartupDiagnosticsEnabled()) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
}
}
@@ -101,6 +101,7 @@ type MainWindowStub = {
id?: number
isDestroyed?: MockFn
on: MockFn
once: MockFn
webContents: {
id?: number
isDestroyed?: MockFn
@@ -126,6 +127,7 @@ function createMainWindow(extraWebContents: { on?: MockFn; send?: MockFn } = {})
id: 1,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
once: vi.fn(),
webContents: {
id: 1,
isDestroyed: vi.fn(() => false),
@@ -167,6 +169,16 @@ function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] {
.map(([, handler]) => handler as () => void)
}
// Updater setup is deferred to first paint; fire the captured ready-to-show
// handler and flush its setImmediate hop.
async function fireReadyToShow(mainWindow: MainWindowStub): Promise<void> {
const handler = mainWindow.once.mock.calls.find(([event]) => event === 'ready-to-show')?.[1] as
| (() => void)
| undefined
handler?.()
await new Promise((resolve) => setImmediate(resolve))
}
describe('attachMainWindowServices', () => {
beforeEach(() => {
onMock.mockReset()
@@ -241,9 +253,10 @@ describe('attachMainWindowServices', () => {
it('passes injected update quit cleanup to the auto-updater', async () => {
const onBeforeUpdateQuit = vi.fn()
const store = createStore()
const mainWindow = createMainWindow()
attachMainWindowServices(
createMainWindow() as never,
mainWindow as never,
store,
createRuntime() as never,
undefined,
@@ -251,6 +264,9 @@ describe('attachMainWindowServices', () => {
{ onBeforeUpdateQuit }
)
// Deferred to first paint — must not be configured at attach time.
expect(setupAutoUpdaterMock).not.toHaveBeenCalled()
await fireReadyToShow(mainWindow)
expect(setupAutoUpdaterMock).toHaveBeenCalledTimes(1)
await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit()
@@ -260,9 +276,11 @@ describe('attachMainWindowServices', () => {
it('flushes the store before update quit when no cleanup is injected', async () => {
const store = createStore()
const mainWindow = createMainWindow()
attachMainWindowServices(createMainWindow() as never, store, createRuntime() as never)
attachMainWindowServices(mainWindow as never, store, createRuntime() as never)
await fireReadyToShow(mainWindow)
await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit()
expect(store.flush).toHaveBeenCalledTimes(1)
+64 -33
View File
@@ -41,6 +41,19 @@ import {
scheduleWorktreeBaseDirectoryWatcherSync,
setWorktreeBaseDirectoryWatcherSyncContext
} from '../ipc/worktree-base-directory-watcher'
import { logStartupMilestone } from '../startup/startup-diagnostics'
const UPDATER_SETUP_FALLBACK_MS = 15_000
// Why: updater setup is deferred past first paint, but a manual check (app
// menu or updater:check IPC) can arrive inside that window — it must run
// against a configured updater (listeners, autoDownload=false, window ref),
// so those entry points force the pending setup first.
let pendingAutoUpdaterSetup: (() => void) | null = null
export function ensureAutoUpdaterConfigured(): void {
pendingAutoUpdaterSetup?.()
}
let appReloadHandlerTokenCounter = 0
let activeAppReloadHandlerToken: number | null = null
@@ -116,37 +129,54 @@ export function attachMainWindowServices(
registerSshHandlers(store, () => mainWindow, runtime)
registerRemoteWorkspaceHandlers(store, () => mainWindow)
registerFileDropRelay(mainWindow)
setupAutoUpdater(mainWindow, {
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
onBeforeQuit: async () => {
try {
await options?.onBeforeUpdateQuit?.()
} finally {
store.flush()
}
},
setLastUpdateCheckAt: (timestamp) => {
store.updateUI({ lastUpdateCheckAt: timestamp })
},
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
setPendingUpdateNudgeId: (id) => {
// Why: the nudge lifecycle is owned by the main process. When applying a
// new campaign, persist the pending id AND clear the version dismissal
// together so relaunches cannot resurrect the old hidden-card state
// between nudge apply and renderer sync. When clearing (id is null),
// only touch pendingUpdateNudgeId — clearing dismissedUpdateVersion here
// would silently un-dismiss an update if the flow ever changes.
if (id) {
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
} else {
store.updateUI({ pendingUpdateNudgeId: null })
}
},
setDismissedUpdateNudgeId: (id) => {
store.updateUI({ dismissedUpdateNudgeId: id })
// Why: setupAutoUpdater's first getAutoUpdater() call synchronously
// require()s electron-updater in packaged builds — seconds on a cold
// Windows disk under Defender scanning (part of issue #7225's pre-paint
// stall) — so defer it past first paint. The timer fallback keeps update
// checks alive for renderers that crash-loop before ever painting.
let updaterSetupDone = false
const setupAutoUpdaterDeferred = (): void => {
if (updaterSetupDone || mainWindow.isDestroyed()) {
return
}
})
updaterSetupDone = true
setupAutoUpdater(mainWindow, {
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
onBeforeQuit: async () => {
try {
await options?.onBeforeUpdateQuit?.()
} finally {
store.flush()
}
},
setLastUpdateCheckAt: (timestamp) => {
store.updateUI({ lastUpdateCheckAt: timestamp })
},
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
setPendingUpdateNudgeId: (id) => {
// Why: the nudge lifecycle is owned by the main process. When applying a
// new campaign, persist the pending id AND clear the version dismissal
// together so relaunches cannot resurrect the old hidden-card state
// between nudge apply and renderer sync. When clearing (id is null),
// only touch pendingUpdateNudgeId — clearing dismissedUpdateVersion here
// would silently un-dismiss an update if the flow ever changes.
if (id) {
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
} else {
store.updateUI({ pendingUpdateNudgeId: null })
}
},
setDismissedUpdateNudgeId: (id) => {
store.updateUI({ dismissedUpdateNudgeId: id })
}
})
logStartupMilestone('updater-setup-done')
}
pendingAutoUpdaterSetup = setupAutoUpdaterDeferred
mainWindow.once('ready-to-show', () => setImmediate(setupAutoUpdaterDeferred))
const updaterSetupFallback = setTimeout(setupAutoUpdaterDeferred, UPDATER_SETUP_FALLBACK_MS)
updaterSetupFallback.unref?.()
registerRuntimeWindowLifecycle(mainWindow, runtime)
const allowedPermissions = new Set(['media', 'fullscreen', 'pointerLock'])
@@ -415,9 +445,10 @@ export function registerUpdaterHandlers(_store: Store): void {
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
ipcMain.handle('updater:getVersion', () => app.getVersion())
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) =>
checkForUpdatesFromMenu(options)
)
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => {
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
})
ipcMain.handle('updater:download', () => downloadUpdate())
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
ipcMain.handle('updater:dismissNudge', () => dismissNudge())
+8 -2
View File
@@ -2581,13 +2581,19 @@ export type PreloadApi = {
requestId: string
url: string
worktreeId?: string
sessionProfileId?: string
sessionProfileId?: string | null
sessionPartition?: string
activate?: boolean
}) => void
) => () => void
replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }) => void
onRequestTabSetProfile: (
callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void
callback: (data: {
requestId: string
browserPageId: string
profileId: string
sessionPartition?: string
}) => void
) => () => void
replyTabSetProfile: (reply: { requestId: string; error?: string }) => void
onRequestTabClose: (
+18 -4
View File
@@ -2230,6 +2230,7 @@ const api = {
screenY: number
pageUrl: string
linkUrl: string | null
selectionText: string
canGoBack: boolean
canGoForward: boolean
}) => void
@@ -2244,6 +2245,7 @@ const api = {
screenY: number
pageUrl: string
linkUrl: string | null
selectionText: string
canGoBack: boolean
canGoForward: boolean
}
@@ -3093,7 +3095,8 @@ const api = {
requestId: string
url: string
worktreeId?: string
sessionProfileId?: string
sessionProfileId?: string | null
sessionPartition?: string
activate?: boolean
}) => void
): (() => void) => {
@@ -3103,7 +3106,8 @@ const api = {
requestId: string
url: string
worktreeId?: string
sessionProfileId?: string
sessionProfileId?: string | null
sessionPartition?: string
activate?: boolean
}
) => callback(data)
@@ -3118,11 +3122,21 @@ const api = {
ipcRenderer.send('browser:tabCreateReply', reply)
},
onRequestTabSetProfile: (
callback: (data: { requestId: string; browserPageId: string; profileId: string }) => void
callback: (data: {
requestId: string
browserPageId: string
profileId: string
sessionPartition?: string
}) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { requestId: string; browserPageId: string; profileId: string }
data: {
requestId: string
browserPageId: string
profileId: string
sessionPartition?: string
}
) => callback(data)
ipcRenderer.on('browser:requestTabSetProfile', listener)
return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener)
+30
View File
@@ -1,5 +1,35 @@
import { describe, expect, it } from 'vitest'
import { parseStatusOutput } from './git-status-output-parser'
import { isUnsupportedWorktreeListZError } from './git-handler-utils'
describe('isUnsupportedWorktreeListZError', () => {
it('detects an unknown-switch usage error from stderr when the exit code is absent', () => {
// Isolates the regex fallback: no numeric code, so only the stderr text
// (a runner that dropped the exit code) can classify the rejection.
const error = Object.assign(new Error('worktree list -z'), {
stderr: "error: unknown switch `z'\nusage: git worktree list [<options>]\n"
})
expect(isUnsupportedWorktreeListZError(error)).toBe(true)
})
it('detects a localized (non-English) usage error via exit code 129', () => {
// The SSH remote may run under a non-English locale where the stderr text is
// translated; the numeric exit code must still classify the -z rejection.
const error = Object.assign(new Error('worktree list -z'), {
code: 129,
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
})
expect(isUnsupportedWorktreeListZError(error)).toBe(true)
})
it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => {
const error = Object.assign(new Error('fatal'), {
code: 128,
stderr: 'fatal: unable to read tree\n'
})
expect(isUnsupportedWorktreeListZError(error)).toBe(false)
})
})
describe('parseStatusOutput', () => {
it('parses upstream ahead/behind from porcelain v2 branch headers', () => {
+13 -3
View File
@@ -147,10 +147,20 @@ function getErrorText(error: unknown): string {
return String(error)
}
function getErrorCode(error: unknown): string | undefined {
return typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code)
: undefined
}
export function isUnsupportedWorktreeListZError(error: unknown): boolean {
return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
getErrorText(error)
)
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit
// 129 signals the rejection in any locale; key for SSH remotes on old Git.
if (getErrorCode(error) === '129') {
return true
}
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
export function parseWorktreeList(
+32 -9
View File
@@ -865,6 +865,25 @@ function App(): React.JSX.Element {
useAppStore.getState().settings,
getSystemPrefersDark()
)
// Why: these three reads are main-side store/file reads with no
// dependency on anything fetched below, so start them now and await
// them at their original positions — the round-trips overlap the
// repo/worktree scans instead of queuing after them. Browser session
// profiles are deliberately NOT started early: on a remote runtime
// they route through a runtime RPC that may not be connected this
// early, and a failed fetch clears the profile list. The floating
// .catch marks the rejection handled if an earlier awaited step
// throws first; each await still rethrows its own failure.
const uiGetPromise = timeRendererStartupStep('ui-get', () => window.api.ui.get())
uiGetPromise.catch(() => {})
const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () =>
actions.fetchKeybindings()
)
keybindingsPromise.catch(() => {})
const onboardingPromise = timeRendererStartupStep('onboarding-get', () =>
window.api.onboarding.get()
)
onboardingPromise.catch(() => {})
// Why: load local + every configured runtime environment (not just the
// active one) so a cold start that restored a remote workspace doesn't
// hide local repos. The sidebar "All hosts" scope then shows them all.
@@ -875,11 +894,17 @@ function App(): React.JSX.Element {
await timeRendererStartupStep('fetch-folder-workspaces', () =>
actions.fetchFolderWorkspacesForAllHosts()
)
await timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees())
await timeRendererStartupStep('fetch-worktree-lineage', () =>
actions.fetchWorktreeLineage()
)
const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get())
// Why: both of these fan out `git worktree list` per repo on the main
// process. Running them concurrently lets the main process share one
// in-flight scan per repo instead of paying the process-spawn fan-out
// twice back-to-back — the dominant renderer-chain cost on Windows
// (issue #7225). Lineage only reads settings + its own slice, so it
// does not depend on the worktrees fetch having landed.
await Promise.all([
timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()),
timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage())
])
const persistedUI = await uiGetPromise
uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () =>
hydratePersistedUIAfterStartupRead({
persistedUI,
@@ -894,7 +919,7 @@ function App(): React.JSX.Element {
const session = await timeRendererStartupStep('session-get', () =>
fetchWorkspaceSessionFromHosts(window.api.session, useAppStore.getState().repos)
)
await timeRendererStartupStep('fetch-keybindings', () => actions.fetchKeybindings())
await keybindingsPromise
if (!cancelled) {
timeRendererStartupSyncStep('hydrate-session-stores', () => {
actions.hydrateWorkspaceSession(session)
@@ -917,9 +942,7 @@ function App(): React.JSX.Element {
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
actions.fetchBrowserSessionProfiles()
)
const onboardingState = await timeRendererStartupStep('onboarding-get', () =>
window.api.onboarding.get()
)
const onboardingState = await onboardingPromise
if (!cancelled) {
setOnboarding(onboardingState)
setOnboardingLoaded(true)
+6
View File
@@ -219,6 +219,12 @@
pointer-events: none;
}
.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] {
background: var(--chart-2);
border: 0;
border-radius: 0;
}
.tab-drop-overlay__label {
font-size: 11px;
color: var(--primary-foreground);
+2 -2
View File
@@ -362,8 +362,8 @@ export default function Landing(): React.JSX.Element {
onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })}
>
<GitBranchPlus className="size-3.5" />
{translate('auto.components.Landing.76a95f7f47', 'Create')}
{createTargetLabel}
{translate('auto.components.Landing.76a95f7f47', 'Create')}{' '}
{createTargetLabel.toLowerCase()}
</button>
</div>
+2 -2
View File
@@ -63,6 +63,7 @@ import {
getEffectiveLayoutForWorktree as getEffectiveLayout,
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
} from './terminal/split-group-mount'
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
import {
@@ -1174,8 +1175,7 @@ function Terminal(): React.JSX.Element | null {
return
}
createBrowserTab(activeWorktreeId, source.url, {
title: source.title,
sessionProfileId: source.sessionProfileId
...buildDuplicatedBrowserTabOptions(source)
})
},
[activeWorktreeId, createBrowserTab]
@@ -77,14 +77,12 @@ import {
browserViewportPresetToOverride,
getBrowserViewportPreset
} from '../../../../shared/browser-viewport-presets'
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
import { rememberLiveBrowserUrl } from './browser-runtime'
import { ensureBrowserPageWebview } from './browser-page-webview'
import {
destroyPersistentWebview,
moveFocusToRendererBeforeWebviewDetach,
registerPersistentWebview,
registeredWebContentsIds,
webviewRegistry
registeredWebContentsIds
} from './webview-registry'
import {
applyBrowserPageViewportLayout,
@@ -283,6 +281,7 @@ type RemoteBrowserContextMenu = {
y: number
linkUrl: string | null
pageUrl: string
selectionText: string
}
type RemoteBrowserViewportSize = {
@@ -617,16 +616,21 @@ function buildRemoteContextMenuExpression(x: number, y: number): string {
return `(() => {
const target = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
const anchor = target && typeof target.closest === 'function' ? target.closest('a[href]') : null;
// Why: read the guest selection here so the remote/paired browser can offer
// the same Copy affordance as the local webview (there is no ContextMenuParams
// over the runtime RPC).
const selection = typeof window.getSelection === 'function' ? window.getSelection() : null;
return JSON.stringify({
linkUrl: anchor && anchor.href ? anchor.href : null,
pageUrl: location.href || 'about:blank'
pageUrl: location.href || 'about:blank',
selectionText: selection ? String(selection) : ''
});
})()`
}
function readRemoteContextMenuResult(
result: unknown
): Pick<RemoteBrowserContextMenu, 'linkUrl' | 'pageUrl'> | null {
): Pick<RemoteBrowserContextMenu, 'linkUrl' | 'pageUrl' | 'selectionText'> | null {
if (!result || typeof result !== 'object') {
return null
}
@@ -635,10 +639,16 @@ function readRemoteContextMenuResult(
return null
}
try {
const parsed = JSON.parse(raw) as { linkUrl?: unknown; pageUrl?: unknown }
const parsed = JSON.parse(raw) as {
linkUrl?: unknown
pageUrl?: unknown
selectionText?: unknown
}
return {
linkUrl: typeof parsed.linkUrl === 'string' && parsed.linkUrl ? parsed.linkUrl : null,
pageUrl: typeof parsed.pageUrl === 'string' && parsed.pageUrl ? parsed.pageUrl : 'about:blank'
pageUrl:
typeof parsed.pageUrl === 'string' && parsed.pageUrl ? parsed.pageUrl : 'about:blank',
selectionText: typeof parsed.selectionText === 'string' ? parsed.selectionText : ''
}
} catch {
return null
@@ -869,6 +879,7 @@ export default function BrowserPane({
workspaceId={browserTab.id}
worktreeId={browserTab.worktreeId}
sessionProfileId={browserTab.sessionProfileId ?? null}
sessionPartition={browserTab.sessionPartition ?? null}
isActive={isActive && page.id === activeBrowserPage?.id}
isAutomationVisible={automationVisiblePageIds.has(page.id)}
isMobileDriven={mobileDrivenPageIds.has(page.id)}
@@ -2159,7 +2170,9 @@ function RemoteBrowserPagePane({
x: event.clientX,
y: event.clientY,
linkUrl: null,
pageUrl: browserTab.url || 'about:blank'
pageUrl: browserTab.url || 'about:blank',
// Why: filled in below once the async eval reads the guest selection.
selectionText: ''
})
enqueueRemoteInput(async () => {
const operationToken = createRemoteOperationToken(pageId)
@@ -2184,7 +2197,8 @@ function RemoteBrowserPagePane({
? {
...current,
linkUrl: parsed.linkUrl,
pageUrl: redactKagiSessionToken(parsed.pageUrl)
pageUrl: redactKagiSessionToken(parsed.pageUrl),
selectionText: parsed.selectionText
}
: current
)
@@ -2442,6 +2456,21 @@ function RemoteBrowserPagePane({
<div className="my-1 h-px bg-border/70" />
</>
) : null}
{contextMenu.selectionText.trim() ? (
<>
<button
role="menuitem"
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
onClick={() => {
void window.api.ui.writeClipboardText(contextMenu.selectionText)
setContextMenu(null)
}}
>
{translate('auto.components.browser.pane.BrowserPane.2a4c4b8e1f', 'Copy')}
</button>
<div className="my-1 h-px bg-border/70" />
</>
) : null}
<button
role="menuitem"
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
@@ -2648,6 +2677,7 @@ function BrowserPagePane({
workspaceId,
worktreeId,
sessionProfileId,
sessionPartition,
isActive,
isAutomationVisible,
isMobileDriven,
@@ -2659,6 +2689,7 @@ function BrowserPagePane({
workspaceId: string
worktreeId: string
sessionProfileId: string | null
sessionPartition: string | null
isActive: boolean
isAutomationVisible: boolean
isMobileDriven: boolean
@@ -2760,6 +2791,7 @@ function BrowserPagePane({
y: number
linkUrl: string | null
pageUrl: string
selectionText: string
} | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const [findOpen, setFindOpen] = useState(false)
@@ -2814,7 +2846,7 @@ function BrowserPagePane({
const sessionProfile = sessionProfileId
? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null)
: null
const webviewPartition = sessionProfile?.partition ?? ORCA_BROWSER_PARTITION
const webviewPartition = sessionPartition ?? sessionProfile?.partition ?? ORCA_BROWSER_PARTITION
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState)
const showBrowserZoomFeedback = useCallback((level: number): void => {
@@ -3070,7 +3102,8 @@ function BrowserPagePane({
x,
y,
linkUrl: event.linkUrl,
pageUrl: event.pageUrl
pageUrl: event.pageUrl,
selectionText: event.selectionText ?? ''
})
})
}, [browserTab.id])
@@ -3576,31 +3609,23 @@ function BrowserPagePane({
return
}
let webview = webviewRegistry.get(browserTab.id)
let needsInitialNavigation = false
let needsInitialDefaultZoom = false
if (webview && webview.parentElement !== container) {
// Why: moving an Electron webview between DOM parents can recreate the
// guest document. Treat unexpected parent drift as stale state instead.
destroyPersistentWebview(browserTab.id)
webview = undefined
container = ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
if (!container) {
return
}
const ensuredWebview = ensureBrowserPageWebview({
browserTabId: browserTab.id,
container,
inputLocked: inputLockedRef.current,
webviewPartition,
resolveContainer: () =>
ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
})
if (!ensuredWebview) {
return
}
if (webview && webview.getAttribute('partition') !== webviewPartition) {
// Why: Electron partitions are immutable after creation. If restored state
// or another store path changes the profile, the persisted guest must be
// replaced rather than parked/reused with the stale session.
destroyPersistentWebview(browserTab.id)
webview = undefined
container = ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
if (!container) {
return
}
}
if (webview) {
container = ensuredWebview.container
const webview = ensuredWebview.webview
const needsInitialNavigation = ensuredWebview.created
let needsInitialDefaultZoom = ensuredWebview.created
if (!ensuredWebview.created) {
webview.style.pointerEvents = inputLockedRef.current ? 'none' : 'auto'
syncNavigationState(webview)
// Why: seed the ref with the store URL so the URL sync effect does not
@@ -3609,35 +3634,6 @@ function BrowserPagePane({
// last navigation event.
lastKnownWebviewUrlRef.current =
normalizeBrowserNavigationUrl(browserTabUrlRef.current) ?? null
} else {
webview = document.createElement('webview') as Electron.WebviewTag
webview.setAttribute('partition', webviewPartition)
webview.setAttribute('allowpopups', '')
// Why: keep HTML fullscreen contained to the <webview> element instead of
// letting guest content resize the host BrowserWindow into native macOS
// fullscreen. Without this, requestFullscreen() resizes the OS window but
// exitFullscreen() has nothing to restore, leaving the pane stuck
// fullscreen (issue #6442). Containing fullscreen to the element makes
// exit reliable and fires fullscreenchange as guests expect.
// Why: the key must be camelCase — Electron's <webview> webpreferences
// parser spreads keys verbatim into WebPreferences, so a lowercase key is
// silently ignored.
webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)
webview.style.display = 'flex'
webview.style.flex = '1'
webview.style.width = '100%'
webview.style.height = '100%'
webview.style.border = 'none'
webview.style.pointerEvents = inputLockedRef.current ? 'none' : 'auto'
// Why: default to white so sites that don't set an html/body background
// (e.g. httpbin.org/html) don't show through to Orca's dark chrome. Real
// browsers paint the viewport white by default; sites that specify their
// own background (including dark ones) still override this.
webview.style.background = '#ffffff'
registerPersistentWebview(browserTab.id, webview)
container.appendChild(webview)
needsInitialNavigation = true
needsInitialDefaultZoom = true
}
webviewRef.current = webview
@@ -4816,6 +4812,21 @@ function BrowserPagePane({
<div className="my-1 h-px bg-border/70" />
</>
) : null}
{contextMenu.selectionText.trim() ? (
<>
<button
role="menuitem"
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
onClick={() => {
void window.api.ui.writeClipboardText(contextMenu.selectionText)
setContextMenu(null)
}}
>
{translate('auto.components.browser.pane.BrowserPane.2a4c4b8e1f', 'Copy')}
</button>
<div className="my-1 h-px bg-border/70" />
</>
) : null}
<button
role="menuitem"
disabled={!browserTab.canGoBack}
@@ -1,38 +1,106 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
const BROWSER_PANE_SOURCE = resolve(__dirname, 'BrowserPane.tsx')
const registryMocks = vi.hoisted(() => ({
destroyPersistentWebview: vi.fn(),
registerPersistentWebview: vi.fn(),
webviewRegistry: new Map<string, Electron.WebviewTag>()
}))
function browserPaneSource(): string {
return readFileSync(BROWSER_PANE_SOURCE, 'utf8')
}
vi.mock('./webview-registry', () => ({
destroyPersistentWebview: registryMocks.destroyPersistentWebview,
registerPersistentWebview: registryMocks.registerPersistentWebview,
webviewRegistry: registryMocks.webviewRegistry
}))
function sourceBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start)
const endIndex = source.indexOf(end, startIndex + start.length)
import { ensureBrowserPageWebview } from './browser-page-webview'
expect(startIndex).toBeGreaterThanOrEqual(0)
expect(endIndex).toBeGreaterThan(startIndex)
return source.slice(startIndex, endIndex)
function createContainer(id: string): HTMLDivElement {
const container = document.createElement('div')
container.dataset.testid = id
document.body.appendChild(container)
return container
}
describe('BrowserPane webview preferences', () => {
it('keeps HTML fullscreen contained inside the browser pane webview', () => {
const source = browserPaneSource()
const creationBlock = sourceBetween(
source,
"webview = document.createElement('webview')",
'registerPersistentWebview'
)
beforeEach(() => {
registryMocks.destroyPersistentWebview.mockReset()
registryMocks.registerPersistentWebview.mockReset()
registryMocks.webviewRegistry.clear()
document.body.innerHTML = ''
})
expect(source).toContain(
"import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'"
afterEach(() => {
document.body.innerHTML = ''
})
it('creates a webview with the resolved partition and shared guest webpreferences', () => {
const container = createContainer('initial')
const ensuredWebview = ensureBrowserPageWebview({
browserTabId: 'browser-page-1',
container,
inputLocked: false,
webviewPartition: 'persist:orca-browser-session-profile-1',
resolveContainer: () => container
})
expect(ensuredWebview).not.toBeNull()
expect(ensuredWebview?.created).toBe(true)
expect(ensuredWebview?.container).toBe(container)
expect(ensuredWebview?.webview.getAttribute('partition')).toBe(
'persist:orca-browser-session-profile-1'
)
expect(creationBlock).toContain(
"webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)"
expect(ensuredWebview?.webview.getAttribute('webpreferences')).toBe(
ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE
)
expect(creationBlock).not.toContain('disablehtmlfullscreenwindowresize')
expect(registryMocks.registerPersistentWebview).toHaveBeenCalledWith(
'browser-page-1',
ensuredWebview?.webview
)
expect(container.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element)
})
it('remounts the webview in a refreshed container when the stored resolved partition changes', () => {
const staleContainer = createContainer('stale')
const staleWebview = document.createElement('webview') as Electron.WebviewTag
staleWebview.setAttribute('partition', 'persist:orca-browser')
staleContainer.appendChild(staleWebview)
registryMocks.webviewRegistry.set('browser-page-1', staleWebview)
const refreshedContainer = document.createElement('div')
refreshedContainer.dataset.testid = 'refreshed'
const resolveContainer = vi.fn(() => {
if (!refreshedContainer.isConnected) {
document.body.appendChild(refreshedContainer)
}
return refreshedContainer
})
registryMocks.destroyPersistentWebview.mockImplementation(() => {
staleWebview.remove()
staleContainer.remove()
registryMocks.webviewRegistry.delete('browser-page-1')
})
const ensuredWebview = ensureBrowserPageWebview({
browserTabId: 'browser-page-1',
container: staleContainer,
inputLocked: true,
webviewPartition: 'persist:orca-browser-session-profile-1',
resolveContainer
})
expect(registryMocks.destroyPersistentWebview).toHaveBeenCalledWith('browser-page-1')
expect(resolveContainer).toHaveBeenCalledTimes(1)
expect(ensuredWebview).not.toBeNull()
expect(ensuredWebview?.created).toBe(true)
expect(ensuredWebview?.container).toBe(refreshedContainer)
expect(ensuredWebview?.webview).not.toBe(staleWebview)
expect(ensuredWebview?.webview.getAttribute('partition')).toBe(
'persist:orca-browser-session-profile-1'
)
expect(ensuredWebview?.webview.style.pointerEvents).toBe('none')
expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element)
})
})
@@ -102,12 +102,14 @@ export function BrowserToolbarMenu({
return
}
const targetId = pendingSwitchProfileId ?? 'default'
const profile = browserSessionProfiles.find((p) => p.id === targetId)
// Why: Must destroy before store update. The webviewRegistry is keyed by
// workspace ID (stable across switches). Without explicit destroy, the mount
// effect would reclaim the old webview with the stale partition.
onDestroyWebview()
switchBrowserTabProfile(workspaceId, pendingSwitchProfileId)
const profile = browserSessionProfiles.find((p) => p.id === targetId)
// Why: persist the resolved partition alongside the profile so the tab stays
// isolated even if the renderer profile mirror is later stale (issue #6923).
switchBrowserTabProfile(workspaceId, pendingSwitchProfileId, profile?.partition ?? null)
toast.success(
translate(
'auto.components.browser.pane.BrowserToolbarMenu.3ccd29d771',
@@ -147,7 +149,7 @@ export function BrowserToolbarMenu({
setNewProfileName('')
onDestroyWebview()
switchBrowserTabProfile(workspaceId, profile.id)
switchBrowserTabProfile(workspaceId, profile.id, profile.partition)
toast.success(
translate(
'auto.components.browser.pane.BrowserToolbarMenu.a7a86702b3',
@@ -0,0 +1,67 @@
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
import {
destroyPersistentWebview,
registerPersistentWebview,
webviewRegistry
} from './webview-registry'
export function ensureBrowserPageWebview({
browserTabId,
container,
inputLocked,
webviewPartition,
resolveContainer
}: {
browserTabId: string
container: HTMLDivElement
inputLocked: boolean
webviewPartition: string
resolveContainer: () => HTMLDivElement | null
}): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null {
let webview = webviewRegistry.get(browserTabId)
let created = false
let activeContainer = container
// Why: a persisted guest must be torn down and rebuilt when its DOM parent
// drifted (moving a <webview> across parents can recreate the guest document)
// or when its partition no longer matches — Electron partitions are immutable
// after creation, so reuse would keep the stale session. Re-resolve the
// viewport container the teardown may have detached; bail if it is gone.
if (
webview &&
(webview.parentElement !== container || webview.getAttribute('partition') !== webviewPartition)
) {
destroyPersistentWebview(browserTabId)
webview = undefined
const refreshedContainer = resolveContainer()
if (!refreshedContainer) {
return null
}
activeContainer = refreshedContainer
}
if (webview) {
webview.style.pointerEvents = inputLocked ? 'none' : 'auto'
return { container: activeContainer, created, webview }
}
webview = document.createElement('webview') as Electron.WebviewTag
webview.setAttribute('partition', webviewPartition)
webview.setAttribute('allowpopups', '')
// Why: Electron spreads the webpreferences keys verbatim, so the shared
// camelCase attribute must stay intact for fullscreen containment to work.
webview.setAttribute('webpreferences', ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE)
webview.style.display = 'flex'
webview.style.flex = '1'
webview.style.width = '100%'
webview.style.height = '100%'
webview.style.border = 'none'
webview.style.pointerEvents = inputLocked ? 'none' : 'auto'
// Why: some pages never paint a background, and a white viewport matches
// normal browser behavior instead of leaking Orca chrome through the guest.
webview.style.background = '#ffffff'
registerPersistentWebview(browserTabId, webview)
activeContainer.appendChild(webview)
created = true
return { container: activeContainer, created, webview }
}
@@ -357,7 +357,10 @@ describe('Cmd+J project and repo-group search', () => {
])
})
it('uses project header keys for multi-setup projects on one host', () => {
it('splits independent same-host checkouts of one project into per-setup keys', () => {
// Why: two `cloned` checkouts share the project's remote identity but are
// distinct user clones; the palette follows the sidebar and surfaces each as
// its own jump target rather than collapsing them. See #5374.
const results = searchCmdJProjectResults({
query: 'platform',
projectGroups: [],
@@ -369,6 +372,27 @@ describe('Cmd+J project and repo-group search', () => {
]
})
expect(results.map((result) => result.rowKey)).toEqual([
'project:project-1::setup:repo-1',
'project:project-1::setup:repo-2'
])
})
it('keeps a provisioned runtime copy under one project key alongside a same-host checkout', () => {
// Why: a `provisioned` (recipe-created ephemeral) copy shares the project's
// remote identity but must not split the user's real checkout; it nests
// under the single project key. Mirrors the sidebar grouping. See #6320 / #5374.
const results = searchCmdJProjectResults({
query: 'platform',
projectGroups: [],
repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-runtime')],
projects: [project('project-1', 'Platform')],
projectHostSetups: [
setup('setup-1', 'project-1', 'local', 'repo-1'),
{ ...setup('setup-2', 'project-1', 'local', 'repo-2'), setupMethod: 'provisioned' }
]
})
expect(results.map((result) => result.rowKey)).toEqual(['project:project-1'])
})
@@ -10,6 +10,7 @@ import { useEditorScrollRestore } from './useEditorScrollRestore'
import { useModifierHeldClass } from './useModifierHeldClass'
import { registerPendingEditorFlush } from './editor-pending-flush'
import type { MarkdownTocItem } from './markdown-table-of-contents'
import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target'
import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate'
import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface'
import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance'
@@ -331,18 +332,11 @@ export default function RichMarkdownEditor({
const navigateToTableOfContentsItem = useCallback(
(id: string): void => {
const target = flatTableOfContentsItems.find((item) => item.id === id)
const container = scrollContainerRef.current
if (!target || !container) {
if (!container) {
return
}
const sameTitleIndex = flatTableOfContentsItems
.filter((item) => item.title === target.title)
.findIndex((item) => item.id === target.id)
const matchingHeadings = Array.from(
container.querySelectorAll<HTMLElement>('h1, h2, h3')
).filter((candidate) => candidate.textContent?.trim() === target.title)
const heading = matchingHeadings.at(Math.max(0, sameTitleIndex))
const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id)
heading?.scrollIntoView({ block: 'center' })
},
[flatTableOfContentsItems]
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import type { MarkdownTocItem } from './markdown-table-of-contents'
import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target'
function makeTocItem(
id: string,
level: MarkdownTocItem['level'],
title: string,
children: MarkdownTocItem[] = []
): MarkdownTocItem {
return { id, level, title, children }
}
describe('findRichMarkdownTocHeadingTarget', () => {
it('finds deep headings that the table of contents exposes', () => {
const container = document.createElement('div')
container.innerHTML = `
<h1>Intro</h1>
<h4>Configure</h4>
<h5>Options</h5>
`
const headings = container.querySelectorAll<HTMLElement>('h1, h4, h5')
const items = [
makeTocItem('intro', 1, 'Intro'),
makeTocItem('configure', 4, 'Configure'),
makeTocItem('options', 5, 'Options')
]
expect(findRichMarkdownTocHeadingTarget(container, items, 'configure')).toBe(headings[1])
expect(findRichMarkdownTocHeadingTarget(container, items, 'options')).toBe(headings[2])
})
it('keeps duplicate heading navigation aligned with TOC slug order', () => {
const container = document.createElement('div')
container.innerHTML = `
<h4>Repeat</h4>
<h5>Repeat</h5>
`
const headings = container.querySelectorAll<HTMLElement>('h4, h5')
const items = [makeTocItem('repeat', 4, 'Repeat'), makeTocItem('repeat-1', 5, 'Repeat')]
expect(findRichMarkdownTocHeadingTarget(container, items, 'repeat')).toBe(headings[0])
expect(findRichMarkdownTocHeadingTarget(container, items, 'repeat-1')).toBe(headings[1])
})
})
@@ -0,0 +1,26 @@
import type { MarkdownTocItem } from './markdown-table-of-contents'
// Why: must cover every level the TOC exposes (MarkdownTocLevel = 1-5). The
// original bug was this selector stopping at h3 while the TOC listed h4/h5, so
// those rows rendered but never resolved a heading to scroll to.
const RICH_MARKDOWN_TOC_HEADING_SELECTOR = 'h1, h2, h3, h4, h5'
export function findRichMarkdownTocHeadingTarget(
container: ParentNode,
items: readonly MarkdownTocItem[],
id: string
): HTMLElement | undefined {
const target = items.find((item) => item.id === id)
if (!target) {
return undefined
}
const sameTitleIndex = items
.filter((item) => item.title === target.title)
.findIndex((item) => item.id === target.id)
const matchingHeadings = Array.from(
container.querySelectorAll<HTMLElement>(RICH_MARKDOWN_TOC_HEADING_SELECTOR)
).filter((candidate) => candidate.textContent?.trim() === target.title)
return matchingHeadings.at(Math.max(0, sameTitleIndex))
}
@@ -50,6 +50,7 @@ type FloatingPanelStoreState = {
activate?: boolean
focusAddressBar?: boolean
sessionProfileId?: string | null
sessionPartition?: string | null
title?: string
targetGroupId?: string
}
@@ -1241,6 +1242,7 @@ describe('FloatingTerminalPanel close behavior', () => {
canGoForward: false,
loadError: null,
sessionProfileId: 'profile-1',
sessionPartition: 'persist:orca-browser-session-profile-1',
createdAt: 1
}
]
@@ -1272,6 +1274,7 @@ describe('FloatingTerminalPanel close behavior', () => {
{
title: 'Example',
sessionProfileId: 'profile-1',
sessionPartition: 'persist:orca-browser-session-profile-1',
targetGroupId: 'floating-group',
browserRuntimeEnvironmentId: null
}
@@ -28,6 +28,7 @@ import { appendUniqueOpenFileIds } from '@/components/terminal/unsaved-close-que
import { getConnectionId } from '@/lib/connection-context'
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
import { detectLanguage } from '@/lib/language-detect'
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite'
import {
@@ -1490,8 +1491,7 @@ export function FloatingTerminalPanel({
return
}
createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, {
title: source.title,
sessionProfileId: source.sessionProfileId,
...buildDuplicatedBrowserTabOptions(source),
targetGroupId: activeGroup?.id,
browserRuntimeEnvironmentId: null
})
@@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac
// Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection
// (Settings → Mobile) both need the same network selector. This wraps the
// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net)
// and copy. Discovered interfaces come from the OS; "Add custom address…"
// opens a dialog for an address the OS didn't surface — the only way to pair
// across networks.
// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123
// hostname — including Tailscale *.ts.net and DDNS domains — optionally
// with :port) and copy. Discovered interfaces come from the OS; "Add custom
// address…" opens a dialog for an address the OS didn't surface — the only
// way to pair across networks.
export type NetworkInterfacePickerProps = {
networkInterfaces: readonly MobileNetworkInterface[]
@@ -81,11 +82,11 @@ export function NetworkInterfacePicker({
inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'),
placeholder: translate(
'auto.components.mobile.CustomNetworkAddressDialog.placeholder',
'my-mac.ts.net or 192.168.1.50'
'my-mac.ts.net, home.example.com, or 192.168.1.50'
),
hint: translate(
'auto.components.mobile.CustomNetworkAddressDialog.hint',
'Enter an IP address or a Tailscale hostname (ends in .ts.net).'
'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.'
),
cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'),
confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address')
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
const ANDROID_COPY: InstallCopy = {
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk'
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
}
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
@@ -210,6 +210,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const getSessionResumeState = useCallback(
(session: AiVaultSession) =>
resolveAiVaultSessionResumeState({
sessionFilePath: session.filePath,
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
worktrees: allWorktrees,
@@ -229,6 +230,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const getSessionResumeActions = useCallback(
(session: AiVaultSession) =>
resolveAiVaultSessionResumeActions({
sessionFilePath: session.filePath,
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
worktrees: allWorktrees,
@@ -101,12 +101,13 @@ export function VaultSessionRow({
sessionId: session.sessionId,
title: session.title,
command: resumeStartup.command,
sessionFilePath: session.filePath,
...(resumeStartup.env ? { env: resumeStartup.env } : {}),
...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {})
})
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT))
},
[resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup]
[resumeDisabled, session, resumeStartup]
)
return (
@@ -12,8 +12,8 @@ import {
} from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import {
getAiVaultResumeWorkspaceTargetStatus,
isSupportedAiVaultResumeTargetStatus
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
@@ -78,6 +78,7 @@ export function useAiVaultSessionLaunchActions({
const handleResume = useCallback(
(session: AiVaultSession, targetWorktreeId?: string): void => {
const targetId = resolveAiVaultSessionLaunchTarget({
sessionFilePath: session.filePath,
activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null,
targetWorktreeId,
targetState
@@ -93,17 +94,7 @@ export function useAiVaultSessionLaunchActions({
}
if (targetId.status === 'unsupported') {
toast.error(
targetId.targetStatus === 'runtime'
? translate(
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
'Resume from history is not available in runtime-hosted workspaces.'
)
: translate(
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
'Open a local or SSH workspace before resuming a session.'
)
)
toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus))
return
}
@@ -138,6 +129,7 @@ export type AiVaultSessionLaunchTarget =
| { status: 'ready'; worktreeId: string }
export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: string | null
activeWorktreeId: string | null
targetWorktreeId?: string
targetState: AiVaultSessionResumeTargetState
@@ -151,13 +143,36 @@ export function resolveAiVaultSessionLaunchTarget(args: {
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) {
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
return { status: 'unsupported', targetStatus }
}
return { status: 'ready', worktreeId: targetWorktreeId }
}
function aiVaultResumeUnsupportedMessage(
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
): string {
if (targetStatus === 'runtime') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported',
'Resume from history is not available in runtime-hosted workspaces.'
)
}
// Why: 'ssh' only reaches the unsupported branch when the session file lives
// on this machine, so the message explains the host mismatch, not SSH itself.
if (targetStatus === 'ssh') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported',
"This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead."
)
}
return translate(
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
'Open a local or SSH workspace before resuming a session.'
)
}
function activateAiVaultResumeWorkspace(workspaceId: string): void {
const workspaceScope = parseWorkspaceKey(workspaceId)
if (workspaceScope?.type === 'folder') {
@@ -106,10 +106,15 @@ function makeWorktreeInfo(
}
}
const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl'
const WSL_SESSION_FILE =
'\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl'
describe('resolveAiVaultSessionResumeState', () => {
it('prefers the session worktree over the active workspace', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-1::/repo/other',
worktrees: [
@@ -128,6 +133,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('falls back to the active workspace when the session worktree is unavailable', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: 'repo-1::/repo/orca',
worktrees: [makeWorktree()],
@@ -143,6 +149,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('blocks missing worktrees even when the repo is SSH-owned', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [],
@@ -158,6 +165,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('allows SSH-owned session worktrees', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: WSL_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree()],
@@ -173,6 +181,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('allows SSH-stamped worktrees even when the repo owner is runtime', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: WSL_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })],
@@ -188,6 +197,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('blocks runtime-owned targets even when they have no SSH connection', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree()],
@@ -208,6 +218,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('blocks runtime-stamped worktrees even when the repo owner is local', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [makeWorktree({ hostId: 'runtime:env-1' })],
@@ -223,6 +234,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('prefers the session worktree when the active workspace is SSH-owned', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/remote/orca',
worktrees: [
@@ -245,6 +257,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('falls back to the active workspace when the session worktree is runtime-owned', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/repo/other',
worktrees: [
@@ -275,6 +288,7 @@ describe('resolveAiVaultSessionResumeState', () => {
it('falls back to an active SSH folder workspace', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: WSL_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: folderWorkspaceKey('folder-1'),
worktrees: [],
@@ -288,9 +302,27 @@ describe('resolveAiVaultSessionResumeState', () => {
})
})
it('blocks host-stored sessions when only an SSH workspace is open', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: folderWorkspaceKey('folder-1'),
worktrees: [],
repos: [],
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
})
).toEqual({
blocked: true,
worktreeId: null,
usesSessionWorktree: false
})
})
it('blocks an active runtime folder workspace', () => {
expect(
resolveAiVaultSessionResumeState({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: folderWorkspaceKey('folder-1'),
worktrees: [],
@@ -309,6 +341,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
it('exposes separate session-worktree and active-workspace targets', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-1::/repo/other',
worktrees: [
@@ -326,6 +359,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
it('enables an SSH active-workspace action when the session worktree is local', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: WSL_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/remote/orca',
worktrees: [
@@ -344,9 +378,32 @@ describe('resolveAiVaultSessionResumeActions', () => {
})
})
it('disables the SSH active-workspace action for host-stored sessions', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/remote/orca',
worktrees: [
makeWorktree(),
makeWorktree({
id: 'repo-2::/remote/orca',
repoId: 'repo-2',
path: '/remote/orca'
})
],
repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true }
})
})
it('disables runtime-owned targets without disabling local targets', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/repo/other',
worktrees: [
@@ -376,6 +433,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
it('does not expose the active workspace as a duplicate new-tab target', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('current'),
activeWorktreeId: 'repo-1::/repo/orca',
worktrees: [makeWorktree()],
@@ -390,6 +448,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
it('enables the active folder workspace action when it is local or SSH-owned', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: WSL_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: folderWorkspaceKey('folder-1'),
worktrees: [],
@@ -405,6 +464,7 @@ describe('resolveAiVaultSessionResumeActions', () => {
it('disables the active folder workspace action when it is runtime-owned', () => {
expect(
resolveAiVaultSessionResumeActions({
sessionFilePath: HOST_SESSION_FILE,
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: folderWorkspaceKey('folder-1'),
worktrees: [],
@@ -422,6 +482,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
it('allows direct resume into an active SSH folder workspace', () => {
expect(
resolveAiVaultSessionLaunchTarget({
sessionFilePath: WSL_SESSION_FILE,
activeWorktreeId: folderWorkspaceKey('folder-1'),
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
})
@@ -431,9 +492,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => {
})
})
it('blocks direct resume of a host-stored session into an SSH folder workspace', () => {
expect(
resolveAiVaultSessionLaunchTarget({
sessionFilePath: HOST_SESSION_FILE,
activeWorktreeId: folderWorkspaceKey('folder-1'),
targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' })
})
).toEqual({
status: 'unsupported',
targetStatus: 'ssh'
})
})
it('blocks direct resume into a runtime folder workspace', () => {
expect(
resolveAiVaultSessionLaunchTarget({
sessionFilePath: WSL_SESSION_FILE,
activeWorktreeId: folderWorkspaceKey('folder-1'),
targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' })
})
@@ -1,7 +1,7 @@
import type { Repo, Worktree } from '../../../../shared/types'
import {
getAiVaultResumeWorkspaceTargetStatus,
isSupportedAiVaultResumeTargetStatus
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
@@ -33,6 +33,7 @@ export type AiVaultSessionResumeActions = {
}
export function resolveAiVaultSessionResumeState(args: {
sessionFilePath: string | null
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
@@ -54,6 +55,7 @@ export function resolveAiVaultSessionResumeState(args: {
for (const worktreeId of candidateWorktreeIds) {
const targetId = resolveSupportedResumeWorktreeId({
sessionFilePath: args.sessionFilePath,
worktreeId,
targetState
})
@@ -75,6 +77,7 @@ export function resolveAiVaultSessionResumeState(args: {
}
export function resolveAiVaultSessionResumeActions(args: {
sessionFilePath: string | null
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
@@ -88,10 +91,12 @@ export function resolveAiVaultSessionResumeActions(args: {
const targetState = resolveAiVaultResumeTargetState(args)
const sessionTargetId = resolveSupportedResumeWorktreeId({
sessionFilePath: args.sessionFilePath,
worktreeId: sessionWorktreeId,
targetState
})
const activeTargetId = resolveSupportedResumeWorktreeId({
sessionFilePath: args.sessionFilePath,
worktreeId:
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
@@ -136,6 +141,7 @@ export function isKnownAiVaultResumeWorkspaceTarget(
}
function resolveSupportedResumeWorktreeId(args: {
sessionFilePath: string | null
worktreeId: string | null
targetState: AiVaultSessionResumeTargetState
}): string | null {
@@ -148,7 +154,7 @@ function resolveSupportedResumeWorktreeId(args: {
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId)
if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) {
if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) {
return null
}
@@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
const ORCA_ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
@@ -0,0 +1,122 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import { RepositoryIconPicker } from './RepositoryIconPicker'
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn(),
getActiveRuntimeTarget: () => ({ kind: 'local' })
}))
vi.mock('./RepositoryIconColorSection', () => ({
RepositoryIconColorSection: () => null
}))
vi.mock('./RepositoryIconTabs', () => ({
RepositoryIconTabs: () => null
}))
const apiMocks = {
repoSlug: vi.fn(),
repoUpstream: vi.fn()
}
let container: HTMLDivElement
let root: Root
// @ts-expect-error test window mock
globalThis.window = { api: { gh: apiMocks } }
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/orca',
displayName: 'orca',
badgeColor: '#2563eb',
addedAt: 1,
kind: 'git',
...overrides
}
}
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('RepositoryIconPicker GitHub avatar refresh', () => {
beforeEach(() => {
apiMocks.repoSlug.mockReset()
apiMocks.repoUpstream.mockReset()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
document.body.replaceChildren()
})
it('refreshes stale GitHub avatar metadata lazily when repo settings opens', async () => {
const updateRepo = vi.fn()
// Non-fork repo (upstream resolved to null) transferred stablyai -> parkerrex.
const repo = makeRepo({
upstream: null,
repoIcon: {
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
}
})
apiMocks.repoUpstream.mockResolvedValueOnce(null)
apiMocks.repoSlug.mockResolvedValueOnce({ owner: 'parkerrex', repo: 'orca' })
act(() => {
root.render(<RepositoryIconPicker repo={repo} updateRepo={updateRepo} />)
})
await flushEffects()
expect(updateRepo).toHaveBeenCalledExactlyOnceWith('repo-1', {
repoIcon: {
type: 'image',
src: 'https://github.com/parkerrex.png?size=64',
source: 'github',
label: 'parkerrex/orca'
}
})
})
it('does not clobber a fork identity when the live upstream lookup fails offline', async () => {
const updateRepo = vi.fn()
// A fork whose avatar tracks its parent org, resolved earlier while online.
const repo = makeRepo({
upstream: { owner: 'stablyai', repo: 'orca' },
repoIcon: {
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
}
})
// Offline/unauthed: the parent lookup returns null. The fork's own origin
// owner must NOT be persisted over the parent identity.
apiMocks.repoUpstream.mockResolvedValueOnce(null)
apiMocks.repoSlug.mockResolvedValueOnce({ owner: 'parkerrex', repo: 'orca' })
act(() => {
root.render(<RepositoryIconPicker repo={repo} updateRepo={updateRepo} />)
})
await flushEffects()
expect(updateRepo).not.toHaveBeenCalled()
expect(apiMocks.repoSlug).not.toHaveBeenCalled()
})
})
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { RotateCcw } from 'lucide-react'
import type { Repo } from '../../../../shared/types'
import { githubAvatarIcon, type RepoIcon } from '../../../../shared/repo-icon'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { DEFAULT_REPO_BADGE_COLOR } from '../../../../shared/constants'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { Button } from '../ui/button'
@@ -15,7 +15,8 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import { RepositoryIconColorSection } from './RepositoryIconColorSection'
import { RepositoryIconTabs } from './RepositoryIconTabs'
import {
resolveRepositoryGitHubAvatarIcon,
buildRepositoryGitHubAvatarUpdate,
resolveRepositoryGitHubAvatar,
resolveRepositoryUpstreamLive
} from './repository-icon-github'
import { translate } from '@/i18n/i18n'
@@ -72,19 +73,20 @@ export function RepositoryIconPicker({
[runtimeTarget, repo]
)
const resolveGitHubAvatarIcon = useCallback(
() => resolveRepositoryGitHubAvatarIcon(runtimeTarget, repo),
const resolveGitHubAvatar = useCallback(
(options?: { forceLive?: boolean }) =>
resolveRepositoryGitHubAvatar(runtimeTarget, repo, options),
[runtimeTarget, repo]
)
const handleUseGitHubAvatar = async () => {
setLoadingGitHub(true)
try {
const icon = await resolveGitHubAvatarIcon()
const resolution = await resolveGitHubAvatar({ forceLive: true })
if (!mountedRef.current) {
return
}
if (!icon) {
if (!resolution.repoIcon) {
toast.error(
translate(
'auto.components.settings.RepositoryIconPicker.f79972271a',
@@ -93,7 +95,11 @@ export function RepositoryIconPicker({
)
return
}
setIcon(icon)
// A null build means the stored icon/upstream already match — nothing to write.
const updates = buildRepositoryGitHubAvatarUpdate(repo, resolution)
if (updates) {
updateRepo(repo.id, updates)
}
} catch {
if (mountedRef.current) {
toast.error(
@@ -113,11 +119,16 @@ export function RepositoryIconPicker({
const handleResetToDefault = async () => {
setResetting(true)
try {
const icon = await resolveGitHubAvatarIcon().catch(() => null)
const resolution = await resolveGitHubAvatar({ forceLive: true }).catch(() => null)
if (!mountedRef.current) {
return
}
setIcon(icon)
const updates = resolution
? buildRepositoryGitHubAvatarUpdate(repo, resolution, { clearMissingIcon: true })
: { repoIcon: null }
if (updates) {
updateRepo(repo.id, updates)
}
} finally {
if (mountedRef.current) {
setResetting(false)
@@ -125,33 +136,39 @@ export function RepositoryIconPicker({
}
}
const upstreamBackfilledRef = useRef<string | null>(null)
const githubIdentityRefreshedRef = useRef<string | null>(null)
useEffect(() => {
if (repo.upstream !== undefined || upstreamBackfilledRef.current === repo.id) {
const hasGitHubAvatar = repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github'
const shouldRefresh = hasGitHubAvatar || repo.upstream === undefined
if (!shouldRefresh || githubIdentityRefreshedRef.current === repo.id) {
return
}
upstreamBackfilledRef.current = repo.id
githubIdentityRefreshedRef.current = repo.id
let cancelled = false
void (async () => {
let upstream
let updates: Partial<Repo> | null
try {
upstream = await resolveUpstreamLive()
if (hasGitHubAvatar) {
// Why: stored upstream/icon metadata can outlive a GitHub repo transfer.
// Refresh only when settings opens for the affected GitHub-avatar repo.
const resolution = await resolveGitHubAvatar({ forceLive: true })
updates = buildRepositoryGitHubAvatarUpdate(repo, resolution)
} else {
const upstream = await resolveUpstreamLive()
updates = { upstream: upstream ?? null }
}
} catch {
return
}
if (cancelled || !mountedRef.current) {
if (cancelled || !mountedRef.current || !updates) {
return
}
const updates: Partial<Repo> = { upstream: upstream ?? null }
if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') {
updates.repoIcon = githubAvatarIcon(upstream)
}
updateRepo(repo.id, updates)
})()
return () => {
cancelled = true
}
}, [repo.id, repo.upstream, repo.repoIcon, resolveUpstreamLive, updateRepo, mountedRef])
}, [repo, resolveGitHubAvatar, resolveUpstreamLive, updateRepo, mountedRef])
return (
<div className="space-y-3">

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