Merge remote-tracking branch 'origin/main' into nwparker/sta-3077-reattach-pane-cardinality

This commit is contained in:
Neil
2026-08-12 20:24:09 -07:00
277 changed files with 15276 additions and 4431 deletions
File diff suppressed because one or more lines are too long
+57 -1
View File
@@ -1,5 +1,5 @@
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { callMock, remoteMock } = vi.hoisted(() => ({
callMock: vi.fn(),
@@ -29,6 +29,9 @@ import { main } from '../index'
import { okFixture, queueFixtures } from '../test-fixtures'
describe('orca emulator CLI handlers', () => {
const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID
const originalWorktreeId = process.env.ORCA_WORKTREE_ID
beforeEach(() => {
vi.restoreAllMocks()
callMock.mockReset()
@@ -38,6 +41,19 @@ describe('orca emulator CLI handlers', () => {
process.exitCode = undefined
})
afterEach(() => {
if (originalWorkspaceId === undefined) {
delete process.env.ORCA_WORKSPACE_ID
} else {
process.env.ORCA_WORKSPACE_ID = originalWorkspaceId
}
if (originalWorktreeId === undefined) {
delete process.env.ORCA_WORKTREE_ID
} else {
process.env.ORCA_WORKTREE_ID = originalWorktreeId
}
})
it('resolves relative APK paths before calling the runtime', async () => {
queueFixtures(callMock, okFixture('req_install', { ok: true }))
@@ -73,6 +89,46 @@ describe('orca emulator CLI handlers', () => {
)
})
it('uses the folder workspace exported by the current Orca terminal', async () => {
process.env.ORCA_WORKSPACE_ID = 'folder:folder-1'
delete process.env.ORCA_WORKTREE_ID
callMock.mockResolvedValue(
okFixture('req_attach', {
attached: true,
info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' }
})
)
await main(['emulator', 'attach', 'device-1'], '/folder/project')
expect(callMock).toHaveBeenCalledOnce()
expect(callMock).toHaveBeenCalledWith(
'emulator.attach',
{ device: 'device-1', worktree: 'folder:folder-1', focus: false },
{ timeoutMs: 180_000 }
)
})
it('uses the current git worktree exported by the Orca terminal', async () => {
process.env.ORCA_WORKSPACE_ID = 'folder:stale-parent'
process.env.ORCA_WORKTREE_ID = 'repo-1::/repo/project '
callMock.mockResolvedValue(
okFixture('req_attach', {
attached: true,
info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' }
})
)
await main(['emulator', 'attach', 'device-1'], '/repo/project')
expect(callMock).toHaveBeenCalledOnce()
expect(callMock).toHaveBeenCalledWith(
'emulator.attach',
{ device: 'device-1', worktree: 'repo-1::/repo/project ', focus: false },
{ timeoutMs: 180_000 }
)
})
it('rejects relative APK paths for remote runtimes', async () => {
remoteMock.mockReturnValue(true)
+8
View File
@@ -237,6 +237,14 @@ export async function getEmulatorWorktreeSelector(
if (client.isRemote) {
return undefined
}
const terminalWorktreeId = process.env.ORCA_WORKTREE_ID
if (terminalWorktreeId?.trim()) {
return terminalWorktreeId
}
const folderWorkspaceId = process.env.ORCA_WORKSPACE_ID?.trim()
if (folderWorkspaceId?.startsWith('folder:')) {
return folderWorkspaceId
}
try {
return await resolveCurrentWorktreeSelector(cwd, client)
} catch {
@@ -1,5 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveRelayGrokHome } from './managed-hook-runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeChildProcess from 'node:child_process'
// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every
// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling
// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the
// first case silently flips to the fallback path.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof NodeChildProcess>()
return { ...actual, execFile: vi.fn() }
})
const { execFile } = await import('node:child_process')
const execFileMock = vi.mocked(execFile)
const { resolveRelayGrokHome } = await import('./managed-hook-runtime')
type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void
function stubProbeOutput(stdout: string): void {
execFileMock.mockImplementation(((...args: unknown[]) => {
;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' })
return undefined
}) as unknown as typeof execFile)
}
function stubProbeFailure(error: Error): void {
execFileMock.mockImplementation(((...args: unknown[]) => {
;(args.at(-1) as ExecFileCallback)(error)
return undefined
}) as unknown as typeof execFile)
}
beforeEach(() => {
execFileMock.mockReset()
})
afterEach(() => {
vi.unstubAllEnvs()
@@ -8,14 +41,39 @@ afterEach(() => {
describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => {
it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => {
vi.stubEnv('SHELL', '/bin/sh')
vi.stubEnv('GROK_HOME', '/srv/grok///')
stubProbeOutput('/srv/grok///\n')
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok')
const [shell, args] = execFileMock.mock.calls[0] ?? []
expect(shell).toBe('/bin/sh')
// `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test.
expect(args?.[0]).toBe('-c')
})
it('passes -lc to a login shell that supports it', async () => {
vi.stubEnv('SHELL', '/bin/zsh')
stubProbeOutput('/srv/grok\n')
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok')
const [shell, args] = execFileMock.mock.calls[0] ?? []
expect(shell).toBe('/bin/zsh')
expect(args?.[0]).toBe('-lc')
})
it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => {
vi.stubEnv('SHELL', '/bin/sh')
vi.stubEnv('GROK_HOME', '../relative')
stubProbeOutput('../relative\n')
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok')
})
// Why: this is the branch that made the old test flaky — pin it so a probe failure is
// an asserted fallback rather than an invisible substitution for a real answer.
it('falls back when the probe fails or times out', async () => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true }))
await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok')
})
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { walkSessionFiles } from './session-scanner-discovery'
describe('walkSessionFiles WSL gate refusals', () => {
it('rethrows gate refusals instead of reporting an empty tree', async () => {
const refusal = new WslTranscriptFsError('timeout', 'slow share')
await expect(
walkSessionFiles('\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions', 'codex', [], {
extensions: new Set(['.jsonl']),
readDirectory: async () => {
throw refusal
}
})
).rejects.toBe(refusal)
})
it('still treats ordinary readdir failures as an empty tree', async () => {
await expect(
walkSessionFiles('/missing/root', 'codex', [], {
extensions: new Set(['.jsonl']),
readDirectory: async () => {
throw Object.assign(new Error('no such directory'), { code: 'ENOENT' })
}
})
).resolves.toEqual([])
})
})
@@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import { extname, join } from 'node:path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
@@ -64,8 +65,13 @@ export async function walkSessionFiles(
entries = options.readDirectory
? await options.readDirectory(dirPath)
: await readdir(dirPath, { withFileTypes: true })
} catch {
} catch (error) {
options.signal?.throwIfAborted()
// Why: a gate refusal means the scan could not run, not that the tree is
// empty — swallowing it would misreport a stalled distro as "no transcript".
if (error instanceof WslTranscriptFsError) {
throw error
}
return []
}
@@ -1,5 +1,6 @@
import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title'
import { toHostReadableTranscriptPath } from '../native-chat/host-readable-transcript-path'
import { wslTranscriptFsRefusal } from '../native-chat/wsl-transcript-fs-gate'
export function resolveHostReadableAiVaultTitleRequests(
requests: AiVaultSessionTitleRequest[],
@@ -10,7 +11,15 @@ export function resolveHostReadableAiVaultTitleRequests(
if (!request.transcriptPath || signal?.aborted) {
return request
}
const transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath)
let transcriptPath: string | null
try {
transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath)
} catch (error) {
// Why: one stalled distro's path must not fail the whole titles batch;
// degrade to the id-only shape like any unreadable path.
void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal
transcriptPath = null
}
return transcriptPath && !signal?.aborted
? { ...request, transcriptPath }
: { agent: request.agent, sessionId: request.sessionId }
@@ -1,169 +0,0 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { applyTerminalAttributionEnv } from './terminal-attribution'
describe('applyTerminalAttributionEnv PATH key casing', () => {
let tmpRoot: string | null = null
afterEach(() => {
if (tmpRoot) {
rmSync(tmpRoot, { force: true, recursive: true })
tmpRoot = null
}
})
function makeTmpRoot(): string {
tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-path-casing-'))
return tmpRoot
}
// Why: a Windows child that inherits both spellings crashes the packaged orca.exe launcher
// with "Item has already been added. Key in dictionary: 'PATH'" (stablyai/orca#12046).
function pathEnvKeys(env: Record<string, string>): string[] {
return Object.keys(env).filter((key) => /^path$/i.test(key))
}
it('writes back the inherited Windows `Path` spelling instead of adding `PATH`', () => {
const root = makeTmpRoot()
const userDataPath = join(root, 'user-data')
const baseEnv: Record<string, string> = {
Path: 'C:\\Windows\\system32;C:\\Program Files\\Git\\cmd',
SystemRoot: 'C:\\Windows'
}
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
platform: 'win32',
shellFamily: 'native-windows',
userDataPath
})
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
const pathEntries = baseEnv.Path.split(';')
expect(pathEnvKeys(baseEnv)).toEqual(['Path'])
expect(pathEntries[0]).toBe(win32Dir)
expect(pathEntries).toContain('C:\\Program Files\\Git\\cmd')
})
// Why: Win32 resolves a duplicated variable by taking the first match in the block, so the
// shim must land on the first-listed spelling and the shadowed one must keep its value —
// dropping it would promote it the moment anything downstream removes the live key.
it.each([
{ first: 'PATH', second: 'Path' },
{ first: 'Path', second: 'PATH' }
])(
'prepends the shim onto the first-listed `$first` and leaves `$second` untouched',
({ first, second }) => {
const root = makeTmpRoot()
const userDataPath = join(root, 'user-data')
const baseEnv: Record<string, string> = {
[first]: 'C:\\tools\\bin;C:\\Windows\\system32',
[second]: 'C:\\Windows\\system32'
}
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
platform: 'win32',
shellFamily: 'native-windows',
userDataPath
})
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
expect(baseEnv[first].split(';')).toEqual([
win32Dir,
'C:\\tools\\bin',
'C:\\Windows\\system32'
])
expect(baseEnv[second]).toBe('C:\\Windows\\system32')
}
)
it('keeps a shadowed duplicate when the live spelling strips down to nothing', () => {
const root = makeTmpRoot()
const userDataPath = join(root, 'user-data')
const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32')
const baseEnv: Record<string, string> = { Path: shimDir, PATH: 'C:\\Windows\\system32' }
applyTerminalAttributionEnv(baseEnv, {
enabled: false,
platform: 'win32',
shellFamily: 'native-windows',
userDataPath
})
expect(pathEnvKeys(baseEnv)).toEqual(['PATH'])
expect(baseEnv.PATH).toBe('C:\\Windows\\system32')
})
it('strips shim entries from the inherited `Path` when attribution is disabled', () => {
const root = makeTmpRoot()
const userDataPath = join(root, 'user-data')
const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32')
const baseEnv: Record<string, string> = { Path: `${shimDir};C:\\Windows\\system32` }
applyTerminalAttributionEnv(baseEnv, {
enabled: false,
platform: 'win32',
shellFamily: 'native-windows',
userDataPath
})
expect(pathEnvKeys(baseEnv)).toEqual(['Path'])
expect(baseEnv.Path).toBe('C:\\Windows\\system32')
})
// Why: the daemon receives a sparse env patch with no path key and re-merges its own block
// underneath it, so guessing the spelling here hands the child both.
it.each([
{ hostKey: 'PATH', otherKey: 'Path' },
{ hostKey: 'Path', otherKey: 'PATH' }
])('adopts a host block spelt `$hostKey` on a path-less env', ({ hostKey, otherKey }) => {
const root = makeTmpRoot()
const userDataPath = join(root, 'user-data')
const inherited = { PATH: process.env.PATH, Path: process.env.Path }
delete process.env.PATH
delete process.env.Path
process.env[hostKey] = 'C:\\Windows\\system32'
try {
const baseEnv: Record<string, string> = { SystemRoot: 'C:\\Windows' }
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
platform: 'win32',
shellFamily: 'native-windows',
userDataPath
})
expect(pathEnvKeys(baseEnv)).toEqual([hostKey])
expect(baseEnv[otherKey]).toBeUndefined()
} finally {
delete process.env[hostKey]
for (const [key, value] of Object.entries(inherited)) {
if (value !== undefined) {
process.env[key] = value
}
}
}
})
it('leaves a case-sensitive POSIX `Path` variable alone', () => {
const root = makeTmpRoot()
const baseEnv: Record<string, string> = {
PATH: '/usr/bin:/bin',
Path: '/decoy'
}
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
platform: 'linux',
userDataPath: join(root, 'user-data')
})
expect(baseEnv.Path).toBe('/decoy')
expect(baseEnv.PATH.split(':')).toContain('/usr/bin')
})
})
@@ -1,100 +0,0 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { applyTerminalAttributionEnv } from './terminal-attribution'
// Why (#12046 follow-up): `powershell.exe -File <script> ... -` fails argument
// binding with PSArgumentException before the script runs, so any command
// carrying a bare `-` must never be dispatched to the PowerShell wrapper.
describe('win32 attribution wrappers with a bare `-` argument', () => {
let tmpRoot: string | null = null
afterEach(() => {
if (tmpRoot) {
rmSync(tmpRoot, { force: true, recursive: true })
tmpRoot = null
}
})
function shimDir(): string {
tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-dash-'))
const userDataPath = join(tmpRoot, 'user-data')
const baseEnv: Record<string, string> = {
Path: 'C:\\Windows\\system32',
SystemRoot: 'C:\\Windows'
}
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
platform: 'win32',
shellFamily: 'native-windows',
userDataPath
})
return join(userDataPath, 'orca-terminal-attribution', 'win32')
}
it('guards the git PowerShell dispatch behind a bare-dash check', () => {
const wrapper = readFileSync(join(shimDir(), 'git.cmd'), 'utf8')
const guardAt = wrapper.indexOf('call :orca_has_bare_dash %*')
const dispatchAt = wrapper.indexOf('powershell.exe')
expect(guardAt).toBeGreaterThan(-1)
expect(guardAt).toBeLessThan(dispatchAt)
expect(wrapper).toContain('if not errorlevel 1 goto run')
expect(wrapper).toContain(':orca_has_bare_dash')
expect(wrapper).toContain('if "%~1"=="-" exit /b 0')
})
it('guards the gh PowerShell dispatch behind a bare-dash check', () => {
const wrapper = readFileSync(join(shimDir(), 'gh.cmd'), 'utf8')
const guardAt = wrapper.indexOf('call :orca_has_bare_dash %*')
const dispatchAt = wrapper.indexOf('powershell.exe')
expect(guardAt).toBeGreaterThan(-1)
expect(guardAt).toBeLessThan(dispatchAt)
expect(wrapper).toContain(':orca_has_bare_dash')
})
const itWindows = process.platform === 'win32' ? it : it.skip
// Why: the guard exists for a runtime failure, so the real regression is a
// real `git commit -F -` through the generated wrapper on a real Windows host.
itWindows('commits a message piped through `git commit -F -`', () => {
const dir = shimDir()
const gitCmd = join(dir, 'git.cmd')
const repo = mkdtempSync(join(tmpdir(), 'orca-attribution-dash-repo-'))
try {
const git = (...args: string[]): void => {
spawnSync('git', args, { cwd: repo, encoding: 'utf8' })
}
git('init', '-q', '-b', 'main')
git('config', 'user.email', 'test@example.com')
git('config', 'user.name', 'Test')
writeFileSync(join(repo, 'a.txt'), 'hello\n')
git('add', '.')
const env = {
...process.env,
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
ORCA_REAL_GIT: spawnSync('where', ['git.exe'], { encoding: 'utf8' }).stdout.split(
/\r?\n/
)[0]
}
const result = spawnSync('cmd.exe', ['/d', '/c', gitCmd, 'commit', '-F', '-'], {
cwd: repo,
env,
input: 'piped subject\n',
encoding: 'utf8'
})
expect(result.stderr).not.toContain('PSArgumentException')
expect(result.status).toBe(0)
expect(
spawnSync('git', ['log', '-1', '--format=%B'], { cwd: repo, encoding: 'utf8' }).stdout
).toContain('piped subject')
} finally {
rmSync(repo, { force: true, recursive: true })
}
})
})
@@ -1,824 +0,0 @@
import { execFileSync } from 'node:child_process'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { applyTerminalAttributionEnv, resolveAttributionShellFamily } from './terminal-attribution'
describe('applyTerminalAttributionEnv', () => {
let tmpRoot: string | null = null
// Why: these subprocess fixtures create extensionless Bash fake commands;
// native Windows command resolution is covered by wrapper/PATH assertions below.
const posixSubprocessIt = process.platform === 'win32' ? it.skip : it
afterEach(() => {
if (tmpRoot) {
rmSync(tmpRoot, { force: true, recursive: true })
tmpRoot = null
}
})
function makeTmpRoot(): string {
tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-'))
return tmpRoot
}
function stripInheritedAttributionPath(pathValue: string): string {
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
return pathValue
.split(pathDelimiter)
.filter((entry) => !entry.includes('orca-terminal-attribution'))
.join(pathDelimiter)
}
function cleanAttributionEnv(env?: Record<string, string>): Record<string, string> {
const base = { ...process.env }
delete base.ORCA_ENABLE_GIT_ATTRIBUTION
delete base.ORCA_GIT_COMMIT_TRAILER
delete base.ORCA_GH_PR_FOOTER
delete base.ORCA_GH_ISSUE_FOOTER
delete base.ORCA_ATTRIBUTION_SHIM_DIR
delete base.ORCA_REAL_GIT
delete base.ORCA_REAL_GH
base.PATH = stripInheritedAttributionPath(base.PATH ?? '')
const next = { ...base, ...env }
return next as Record<string, string>
}
function runGit(repo: string, args: string[], env?: Record<string, string>): string {
return execFileSync('git', args, {
cwd: repo,
encoding: 'utf8',
env: cleanAttributionEnv(env)
})
}
it('classifies Windows native and POSIX shell families for attribution shims', () => {
expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'powershell.exe' })).toBe(
'native-windows'
)
expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'cmd.exe' })).toBe(
'native-windows'
)
expect(
resolveAttributionShellFamily({
platform: 'win32',
shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe'
})
).toBe('posix')
expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'wsl.exe' })).toBe('posix')
expect(resolveAttributionShellFamily({ platform: 'win32', isWsl: true })).toBe('posix')
expect(resolveAttributionShellFamily({ platform: 'darwin', shellPath: '/bin/zsh' })).toBe(
undefined
)
})
posixSubprocessIt('does not amend HEAD when git commit --dry-run exits successfully', () => {
const root = makeTmpRoot()
const repo = join(root, 'repo')
mkdirSync(repo)
runGit(repo, ['init'])
runGit(repo, ['config', 'user.name', 'Orca Test'])
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
runGit(repo, ['commit', '-m', 'initial'])
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
const beforeHead = runGit(repo, ['rev-parse', 'HEAD']).trim()
writeFileSync(join(repo, 'second.txt'), 'second\n')
runGit(repo, ['add', 'second.txt'])
// Why: dry-run reports what would be committed but must not rewrite the
// existing HEAD just because the real git command returns success.
runGit(repo, ['commit', '--dry-run', '-m', 'second'], attributionEnv)
expect(runGit(repo, ['rev-parse', 'HEAD']).trim()).toBe(beforeHead)
expect(runGit(repo, ['log', '-1', '--format=%B'])).not.toContain('Co-authored-by: Orca')
runGit(repo, ['commit', '-m', 'second'], attributionEnv)
expect(runGit(repo, ['rev-parse', 'HEAD']).trim()).not.toBe(beforeHead)
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
'Co-authored-by: Orca <help@stably.ai>'
)
})
posixSubprocessIt('still adds the trailer when git commit uses --no-verify shorthand', () => {
const root = makeTmpRoot()
const repo = join(root, 'repo')
mkdirSync(repo)
runGit(repo, ['init'])
runGit(repo, ['config', 'user.name', 'Orca Test'])
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
runGit(repo, ['commit', '-n', '-m', 'initial'], attributionEnv)
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
'Co-authored-by: Orca <help@stably.ai>'
)
})
posixSubprocessIt('adds the trailer when git commit uses combined -am shorthand', () => {
const root = makeTmpRoot()
const repo = join(root, 'repo')
mkdirSync(repo)
runGit(repo, ['init'])
runGit(repo, ['config', 'user.name', 'Orca Test'])
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
runGit(repo, ['commit', '-m', 'initial'])
writeFileSync(join(repo, 'README.md'), 'changed\n')
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
runGit(repo, ['commit', '-am', 'combined message'], attributionEnv)
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
'Co-authored-by: Orca <help@stably.ai>'
)
})
posixSubprocessIt('adds the trailer when git commit follows global git config args', () => {
const root = makeTmpRoot()
const repo = join(root, 'repo')
mkdirSync(repo)
runGit(repo, ['init'])
runGit(repo, ['config', 'user.name', 'Orca Test'])
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
runGit(repo, ['-c', 'core.quotePath=false', 'commit', '-m', 'initial'], attributionEnv)
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
'Co-authored-by: Orca <help@stably.ai>'
)
})
posixSubprocessIt('adds the trailer to commit message files before git runs', () => {
const root = makeTmpRoot()
const repo = join(root, 'repo')
const messagePath = join(root, 'message.txt')
mkdirSync(repo)
runGit(repo, ['init'])
runGit(repo, ['config', 'user.name', 'Orca Test'])
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
writeFileSync(join(repo, 'README.md'), 'initial\n')
writeFileSync(messagePath, 'initial from file\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
runGit(repo, ['commit', '-F', messagePath], attributionEnv)
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
'Co-authored-by: Orca <help@stably.ai>'
)
expect(readFileSync(messagePath, 'utf8')).toBe('initial from file\n')
})
posixSubprocessIt(
'passes missing commit message files through without adding fallback message args',
() => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const argsPath = join(root, 'commit-args')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'git'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "commit" ]]; then
printf '%s\\n' "$@" >"${argsPath}"
exit 9
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
expect(() =>
execFileSync('git', ['commit', '-F', join(root, 'missing-message.txt')], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
).toThrow()
expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca')
}
)
posixSubprocessIt(
'passes reuse and fixup commit message modes through without attribution',
() => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const argsPath = join(root, 'commit-args')
const messagePath = join(root, 'message.txt')
mkdirSync(binDir)
writeFileSync(messagePath, 'from file\n')
writeFileSync(
join(binDir, 'git'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "commit" ]]; then
printf '%s\\n' "$@" >>"${argsPath}"
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
execFileSync('git', ['commit', '-C', 'HEAD'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
execFileSync('git', ['commit', '--fixup', 'HEAD'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
execFileSync('git', ['commit', '-F', messagePath, '--fixup', 'HEAD'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca')
}
)
posixSubprocessIt('adds the trailer before commit-msg hooks validate the commit', () => {
const root = makeTmpRoot()
const repo = join(root, 'repo')
mkdirSync(repo)
runGit(repo, ['init'])
runGit(repo, ['config', 'user.name', 'Orca Test'])
runGit(repo, ['config', 'user.email', 'orca-test@example.com'])
const hookPath = join(repo, '.git', 'hooks', 'commit-msg')
const hookCounterPath = join(repo, 'hook-count')
writeFileSync(
hookPath,
`#!/usr/bin/env bash
set -euo pipefail
count=0
if [[ -f "${hookCounterPath}" ]]; then
count="$(cat "${hookCounterPath}")"
fi
printf '%s\\n' "$((count + 1))" >"${hookCounterPath}"
grep -Fq 'Co-authored-by: Orca <help@stably.ai>' "$1"
`,
'utf8'
)
chmodSync(hookPath, 0o755)
writeFileSync(join(repo, 'README.md'), 'initial\n')
runGit(repo, ['add', 'README.md'])
const attributionEnv = { PATH: stripInheritedAttributionPath(process.env.PATH ?? '') }
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
runGit(repo, ['commit', '-m', 'initial'], attributionEnv)
expect(readFileSync(hookCounterPath, 'utf8').trim()).toBe('1')
expect(runGit(repo, ['log', '-1', '--format=%B'])).toContain(
'Co-authored-by: Orca <help@stably.ai>'
)
})
posixSubprocessIt('adds git attribution to the original commit command without amending', () => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const commitPath = join(root, 'commit-called')
const amendPath = join(root, 'amend-called')
const argsPath = join(root, 'commit-args')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'git'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2 $3" == "config --bool commit.gpgsign" ]]; then
printf '%s\\n' 'true'
exit 0
fi
if [[ "$1" == "commit" ]]; then
if [[ "\${2:-}" == "--amend" ]]; then
touch "${amendPath}"
else
printf '%s\\n' "$@" >"${argsPath}"
touch "${commitPath}"
fi
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
execFileSync('git', ['commit', '-m', 'signed commit'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(existsSync(commitPath)).toBe(true)
expect(existsSync(amendPath)).toBe(false)
expect(readFileSync(argsPath, 'utf8')).toContain('Co-authored-by: Orca <help@stably.ai>')
})
posixSubprocessIt('passes editor-based commits through without attribution', () => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const argsPath = join(root, 'commit-args')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'git'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "commit" ]]; then
printf '%s\\n' "$@" >"${argsPath}"
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'git'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
execFileSync('git', ['commit'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(readFileSync(argsPath, 'utf8')).toBe('commit\n')
})
posixSubprocessIt('preserves interactive gh pr create without guessing which PR to edit', () => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const markerPath = join(root, 'gh-edit-called')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'gh'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2" == "pr create" ]]; then
printf '%s\\n' 'interactive create complete'
exit 0
fi
if [[ "$1 $2 $3 $4" == "pr view --json url" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
exit 0
fi
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
printf '%s\\n' 'Existing body'
exit 0
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
touch "${markerPath}"
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
const output = execFileSync('gh', ['pr', 'create'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('interactive create complete\n')
expect(existsSync(markerPath)).toBe(false)
})
posixSubprocessIt('adds gh attribution for noninteractive create output URLs', () => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const prMarkerPath = join(root, 'pr-edit-called')
const issueMarkerPath = join(root, 'issue-edit-called')
const patchArgsPath = join(root, 'patch-args')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'gh'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2" == "pr create" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
exit 0
fi
if [[ "$1 $2" == "issue create" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
exit 0
fi
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
printf '%s\\n' 'PR body'
exit 0
fi
if [[ "$1 $2" == "api repos/stablyai/orca/issues/456" && "\${3:-}" == "--jq" ]]; then
printf '%s\\n' 'Issue body'
exit 0
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
printf '%s\\n' "$@" >"${patchArgsPath}"
touch "${prMarkerPath}"
exit 0
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
touch "${issueMarkerPath}"
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
expect(
execFileSync('gh', ['pr', 'create', '--fill'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
).toBe('https://github.com/stablyai/orca/pull/123\n')
expect(
execFileSync('gh', ['issue', 'create', '--title', 'Issue', '--body', 'Body'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
).toBe('https://github.com/stablyai/orca/issues/456\n')
expect(existsSync(prMarkerPath)).toBe(true)
expect(existsSync(issueMarkerPath)).toBe(true)
expect(readFileSync(patchArgsPath, 'utf8')).toContain('body=@')
expect(readFileSync(patchArgsPath, 'utf8')).not.toContain('PR body')
})
posixSubprocessIt('passes gh create help through without editing existing PRs or issues', () => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const markerPath = join(root, 'gh-edit-called')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'gh'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2 $3" == "pr create --help" ]]; then
printf '%s\\n' 'pr help'
exit 0
fi
if [[ "$1 $2 $3" == "issue create --help" ]]; then
printf '%s\\n' 'issue help'
exit 0
fi
if [[ "$1 $2 $3 $4" == "pr view --json url" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
exit 0
fi
if [[ "$1 $2" == "issue list" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
exit 0
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
touch "${markerPath}"
exit 0
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
touch "${markerPath}"
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
const output = execFileSync('gh', ['pr', 'create', '--help'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('pr help\n')
const issueOutput = execFileSync('gh', ['issue', 'create', '--help'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(issueOutput).toBe('issue help\n')
expect(existsSync(markerPath)).toBe(false)
})
posixSubprocessIt(
'preserves interactive gh issue create without guessing which issue to edit',
() => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const markerPath = join(root, 'gh-edit-called')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'gh'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2" == "issue create" ]]; then
printf '%s\\n' 'interactive issue create complete'
exit 0
fi
if [[ "$1 $2" == "issue list" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/issues/456'
exit 0
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then
touch "${markerPath}"
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
const output = execFileSync('gh', ['issue', 'create'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('interactive issue create complete\n')
expect(existsSync(markerPath)).toBe(false)
}
)
posixSubprocessIt('skips gh attribution edits when viewing the created item fails', () => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
const markerPath = join(root, 'gh-edit-called')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'gh'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2" == "pr create" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
exit 0
fi
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
exit 7
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
touch "${markerPath}"
exit 0
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
expect(existsSync(markerPath)).toBe(false)
})
posixSubprocessIt('keeps gh create successful when the attribution edit fails', () => {
const root = makeTmpRoot()
const binDir = join(root, 'bin')
mkdirSync(binDir)
writeFileSync(
join(binDir, 'gh'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2" == "pr create" ]]; then
printf '%s\\n' 'https://github.com/stablyai/orca/pull/123'
exit 0
fi
if [[ "$1 $2" == "api repos/stablyai/orca/pulls/123" && "\${3:-}" == "--jq" ]]; then
printf '%s\\n' 'Existing body'
exit 0
fi
if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/pulls/123" ]]; then
exit 9
fi
exit 1
`,
'utf8'
)
chmodSync(join(binDir, 'gh'), 0o755)
const attributionEnv = {
PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}`
}
applyTerminalAttributionEnv(attributionEnv, {
enabled: true,
userDataPath: join(root, 'user-data')
})
const output = execFileSync('gh', ['pr', 'create', '--fill'], {
encoding: 'utf8',
env: cleanAttributionEnv(attributionEnv)
})
expect(output).toBe('https://github.com/stablyai/orca/pull/123\n')
})
it('fails open when shim files cannot be written', () => {
const root = makeTmpRoot()
const blockedUserDataPath = join(root, 'not-a-directory')
writeFileSync(blockedUserDataPath, 'blocked\n')
const baseEnv: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
userDataPath: blockedUserDataPath
})
expect(baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(baseEnv.PATH).toBe('/usr/bin')
})
it('does not duplicate shim directories when applied to an already-injected env', () => {
const root = makeTmpRoot()
const baseEnv: Record<string, string> = {
PATH: stripInheritedAttributionPath(process.env.PATH ?? '')
}
const options = { enabled: true, userDataPath: join(root, 'user-data') }
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
applyTerminalAttributionEnv(baseEnv, options)
applyTerminalAttributionEnv(baseEnv, options)
const shimEntries = baseEnv.PATH.split(pathDelimiter).filter((entry) =>
entry.includes('orca-terminal-attribution')
)
expect(new Set(shimEntries).size).toBe(shimEntries.length)
})
it('puts only Windows shims on PATH for native Windows shells', () => {
const root = makeTmpRoot()
const userDataPath = join(root, 'user-data')
const baseEnv: Record<string, string> = { PATH: 'C:\\Git\\cmd;C:\\Windows\\System32' }
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
platform: 'win32',
shellFamily: 'native-windows',
userDataPath
})
const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix')
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
const pathEntries = baseEnv.PATH.split(';')
expect(pathEntries[0]).toBe(win32Dir)
expect(pathEntries).not.toContain(posixDir)
expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(existsSync(join(win32Dir, 'git.cmd'))).toBe(true)
})
it('keeps POSIX shims first for Windows Git Bash and WSL shells', () => {
const root = makeTmpRoot()
const userDataPath = join(root, 'user-data')
const baseEnv: Record<string, string> = { PATH: 'C:\\Program Files\\Git\\cmd;C:\\Windows' }
applyTerminalAttributionEnv(baseEnv, {
enabled: true,
platform: 'win32',
shellFamily: 'posix',
userDataPath
})
const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix')
const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32')
const pathEntries = baseEnv.PATH.split(';')
expect(pathEntries[0]).toBe(posixDir)
expect(pathEntries).not.toContain(win32Dir)
expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBe(posixDir)
expect(existsSync(join(posixDir, 'git'))).toBe(true)
})
it('writes PowerShell wrappers without raw-template backslash escapes', () => {
const root = makeTmpRoot()
applyTerminalAttributionEnv(
{ PATH: stripInheritedAttributionPath(process.env.PATH ?? '') },
{ enabled: true, userDataPath: join(root, 'user-data') }
)
const shimDir = join(root, 'user-data', 'orca-terminal-attribution', 'win32')
const gitWrapper = readFileSync(join(shimDir, 'git-wrapper.ps1'), 'utf8')
const ghWrapper = readFileSync(join(shimDir, 'gh-wrapper.ps1'), 'utf8')
expect(gitWrapper).toContain('Test-ExplicitCommitMessage')
expect(gitWrapper).toContain('"`r`n`r`n"')
expect(ghWrapper).toContain('$body.TrimEnd("`r", "`n")')
expect(ghWrapper).toContain('"`r`n`r`n"')
expect(gitWrapper).not.toContain('"\\`r"')
expect(ghWrapper).not.toContain('"\\`r"')
})
})
File diff suppressed because it is too large Load Diff
@@ -166,7 +166,7 @@ describe('native Chromium import excludes the Google cookie family', () => {
createChromiumCookieTestDatabase(targetCookiesPath, rows).close()
}
it('bulk clears once and restores the live Google cookies before importing', async () => {
it('never clears or reconstructs live Google cookies before importing', async () => {
const sourceCookiesPath = seedSource([
{ domain: '.google.com', name: 'SID', value: 'transplanted-sid' },
{ domain: '.example.com', name: 'session', value: 'new' }
@@ -186,13 +186,10 @@ describe('native Chromium import excludes the Google cookie family', () => {
googleCookiesSkipped: 1,
domains: ['example.com']
})
expect(clearStorageDataMock).toHaveBeenCalledOnce()
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
expect(cookiesRemoveMock).not.toHaveBeenCalled()
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([
'.google.com',
'.example.com'
])
expect(clearStorageDataMock).not.toHaveBeenCalled()
expect(cookiesRemoveMock).toHaveBeenCalledOnce()
expect(cookiesRemoveMock).toHaveBeenCalledWith('https://example.com/', 'stale')
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com'])
})
it('reports an excluded Google row even when its encrypted value is invalid', async () => {
@@ -270,24 +267,31 @@ describe('native Chromium import excludes the Google cookie family', () => {
])
})
it('fails the import and restores the full snapshot when the bulk clear rejects', async () => {
// Why (STA-4061): reconstruction drops partition keys, so a rejected removal must not rebuild
// anything — not the excluded Google rows, and not the non-Google rows that were already gone.
it('fails the import without reconstructing any cookie when selective removal rejects', async () => {
const sourceCookiesPath = seedSource([
{ domain: '.example.com', name: 'session', value: 'new' }
])
seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }])
cookiesGetMock.mockResolvedValue([
existingCookie('.google.com', 'SID'),
existingCookie('.example.com', 'stale')
existingCookie('.example.com', 'removed-first'),
existingCookie('.other.test', 'stale')
])
clearStorageDataMock.mockRejectedValue(new Error('cookie store unavailable'))
cookiesRemoveMock.mockImplementation(async (_url: string, name: string) => {
if (name === 'stale') {
throw new Error('cookie store unavailable')
}
})
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
expect(result).toMatchObject({ ok: false })
expect(result.ok || result.reason).toContain('Could not clear existing cookies')
expect(clearStorageDataMock).toHaveBeenCalledOnce()
expect(cookiesRemoveMock).not.toHaveBeenCalled()
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'stale'])
expect(clearStorageDataMock).not.toHaveBeenCalled()
expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['removed-first', 'stale'])
expect(cookiesSetMock).not.toHaveBeenCalled()
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,175 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
afterAll(() => {
for (const root of fixtureRoots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
type CdpCookie = { name: string; value: string; partitionKey?: Record<string, unknown> }
type FixtureResult = {
beforePartitionKey: Record<string, unknown> | undefined
clearError: string | null
remainingChips: CdpCookie[]
remainingPlain: CdpCookie[]
}
const EXPECTED_PARTITION_KEY = {
topLevelSite: 'https://top.example',
hasCrossSiteAncestor: true
}
function buildFixtureMain(policyPath: string, resultPath: string): string {
return `
const { app, BrowserWindow, session } = require('electron')
const { writeFileSync } = require('node:fs')
const { removeAllCookiesExcept, isNonTransplantableCookieDomain } = require(${JSON.stringify(policyPath)})
const resultPath = ${JSON.stringify(resultPath)}
let currentStep = 'starting'
const mark = (step) => {
currentStep = step
writeFileSync(resultPath, JSON.stringify({ step }))
}
async function run() {
const timeout = setTimeout(() => {
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
app.exit(1)
}, 20000)
await app.whenReady()
mark('ready')
const partition = 'persist:partition-rollback-cookie-test'
const targetSession = session.fromPartition(partition)
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
mark('window created')
await window.loadURL('data:text/html,<title>cookie rollback fixture</title>')
mark('window loaded')
const debug = window.webContents.debugger
debug.attach('1.3')
mark('debugger attached')
await debug.sendCommand('Network.enable')
mark('network enabled')
// Only CDP can create a partitioned cookie; Electron's cookies API has no partitionKey.
await debug.sendCommand('Network.setCookie', {
url: 'https://app.acme-chips.test/',
name: 'chips-auth',
value: 'keep-me',
secure: true,
sameSite: 'None',
partitionKey: ${JSON.stringify(EXPECTED_PARTITION_KEY)}
})
const beforeChips = (await debug.sendCommand('Network.getAllCookies')).cookies
.find((cookie) => cookie.name === 'chips-auth')
if (!beforeChips || !beforeChips.partitionKey) throw new Error('CHIPS fixture cookie was not stored partitioned')
mark('partitioned cookie set')
await targetSession.cookies.set({ url: 'https://plain.example/', name: 'plain', value: 'stale', secure: true })
await targetSession.cookies.set({ url: 'https://victim.example/', name: 'victim', value: 'stale', secure: true })
mark('removable cookies set')
const store = {
get: (filter) => targetSession.cookies.get(filter),
set: (details) => targetSession.cookies.set(details),
// Why: one rejecting removal is what drove the old rollback; the rest still succeed.
remove: async (url, name) => {
if (name === 'victim') throw new Error('forced victim removal failure')
return targetSession.cookies.remove(url, name)
}
}
let clearError = null
try {
await removeAllCookiesExcept(store, (cookie) => isNonTransplantableCookieDomain(cookie.domain ?? ''))
} catch (error) {
clearError = String(error?.message || error)
}
mark('clear finished')
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
const project = (name) => afterCookies
.filter((cookie) => cookie.name === name)
.map((cookie) => ({ name: cookie.name, value: cookie.value, partitionKey: cookie.partitionKey }))
clearTimeout(timeout)
writeFileSync(resultPath, JSON.stringify({
beforePartitionKey: beforeChips.partitionKey,
clearError,
remainingChips: project('chips-auth'),
remainingPlain: project('plain')
}))
debug.detach()
window.destroy()
app.exit(0)
}
run().catch((error) => {
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
app.exit(1)
})
`
}
async function runFixture(): Promise<FixtureResult> {
const root = mkdtempSync(join(tmpdir(), 'orca-partition-rollback-'))
fixtureRoots.push(root)
const policyPath = join(root, 'browser-cookie-import-policy.cjs')
const resultPath = join(root, 'result.json')
const fixturePath = join(root, 'main.cjs')
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'),
formats: ['cjs'],
fileName: () => 'browser-cookie-import-policy.cjs'
},
outDir: root,
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
writeFileSync(fixturePath, buildFixtureMain(policyPath, resultPath))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const run = spawnSync(executable, args, {
encoding: 'utf8',
env,
timeout: 60_000
})
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return JSON.parse(fixtureResult) as FixtureResult
}
describe('non-Google partitioned cookie under a failed Electron cookie clear', () => {
it('never resurrects a removed partitioned cookie', async () => {
const result = await runFixture()
expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY)
expect(result.clearError).toContain('Could not clear existing cookies')
// STA-4061: rollback rebuilt this cookie through cookies.set, which drops partitionKey.
expect(
result.remainingChips,
`chips-auth survived: ${JSON.stringify(result.remainingChips)}`
).toEqual([])
expect(result.remainingPlain).toEqual([])
}, 90_000)
})
@@ -0,0 +1,194 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
afterAll(() => {
for (const root of fixtureRoots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
type FixtureResult = {
before: Record<string, unknown>
after: Record<string, unknown>
electronCookieKeys: string[]
importResult: Record<string, unknown>
remainingNames: string[]
}
function buildFixtureMain(
importPath: string,
resultPath: string,
sourceCookiesPath: string
): string {
return `
const { app, BrowserWindow, session } = require('electron')
const { writeFileSync } = require('node:fs')
const { importCookiesFromBrowser } = require(${JSON.stringify(importPath)})
const resultPath = ${JSON.stringify(resultPath)}
let currentStep = 'starting'
const mark = (step) => {
currentStep = step
writeFileSync(resultPath, JSON.stringify({ step }))
}
async function run() {
const timeout = setTimeout(() => {
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
app.exit(1)
}, 15000)
await app.whenReady()
mark('ready')
const partition = 'persist:partition-cookie-test'
const targetSession = session.fromPartition(partition)
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
mark('window created')
await window.loadURL('data:text/html,<title>cookie fixture</title>')
mark('window loaded')
const debug = window.webContents.debugger
debug.attach('1.3')
mark('debugger attached')
await debug.sendCommand('Network.enable')
mark('network enabled')
await debug.sendCommand('Network.setCookie', {
url: 'https://accounts.google.com/',
name: 'partitioned-google',
value: 'live-value',
secure: true,
httpOnly: true,
sameSite: 'None',
partitionKey: { topLevelSite: 'https://example.com', hasCrossSiteAncestor: true }
})
mark('partitioned cookie set')
await targetSession.cookies.set({
url: 'https://stale.example/',
name: 'stale',
value: 'remove-me',
secure: true
})
mark('ordinary cookie set')
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
const before = beforeCookies.find((cookie) => cookie.name === 'partitioned-google')
const electronCookie = (await targetSession.cookies.get({ name: 'partitioned-google' }))[0]
if (!before || !electronCookie) throw new Error('Partitioned fixture cookie was not created')
mark('cookies read')
const importResult = await importCookiesFromBrowser({
family: 'chrome',
label: 'Chromium fixture',
cookiesPath: ${JSON.stringify(sourceCookiesPath)},
profiles: [{ name: 'Default', directory: 'Default' }],
selectedProfile: 'Default'
}, partition)
if (!importResult.ok) throw new Error('Native import failed: ' + importResult.reason)
mark('native import complete')
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
const after = afterCookies.find((cookie) => cookie.name === 'partitioned-google')
if (!after) throw new Error('Partitioned Google cookie was removed')
clearTimeout(timeout)
writeFileSync(resultPath, JSON.stringify({
before,
after,
electronCookieKeys: Object.keys(electronCookie).sort(),
importResult,
remainingNames: afterCookies.map((cookie) => cookie.name).sort()
}))
debug.detach()
window.destroy()
app.exit(0)
}
run().catch((error) => {
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
app.exit(1)
})
`
}
async function runFixture(): Promise<FixtureResult> {
const root = mkdtempSync(join(tmpdir(), 'orca-partition-cookie-'))
fixtureRoots.push(root)
const importPath = join(root, 'browser-cookie-import.cjs')
const registryStubPath = join(root, 'browser-session-registry.cjs')
const resultPath = join(root, 'result.json')
const sourceCookiesPath = join(root, 'source', 'Network', 'Cookies')
const fixturePath = join(root, 'main.cjs')
createChromiumCookieTestDatabase(sourceCookiesPath, [
{ domain: '.example.com', name: 'imported', value: 'new-value' }
]).close()
writeFileSync(
registryStubPath,
'exports.browserSessionRegistry = { clearPendingCookieImport() {}, setPendingCookieImport() {} }\n'
)
await buildVite({
configFile: false,
logLevel: 'silent',
plugins: [
{
name: 'stub-browser-session-registry',
resolveId(source, importer) {
return source === './browser-session-registry' &&
importer?.endsWith('browser-cookie-import.ts')
? registryStubPath
: null
}
}
],
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'),
formats: ['cjs'],
fileName: () => 'browser-cookie-import.cjs'
},
outDir: root,
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
writeFileSync(fixturePath, buildFixtureMain(importPath, resultPath, sourceCookiesPath))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const run = spawnSync(executable, args, {
encoding: 'utf8',
env,
timeout: 60_000
})
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return JSON.parse(fixtureResult) as FixtureResult
}
describe('native Chromium excluded partition cookie under Electron', () => {
it('survives selective clearing unchanged without a lossy Electron reconstruction', async () => {
const result = await runFixture()
expect(result.before.partitionKey).toEqual({
topLevelSite: 'https://example.com',
hasCrossSiteAncestor: true
})
expect(result.electronCookieKeys).not.toContain('partitionKey')
expect(result.after).toEqual(result.before)
expect(result.importResult).toMatchObject({
ok: true,
summary: { importedCookies: 1, domains: ['example.com'] }
})
expect(result.remainingNames).toEqual(['imported', 'partitioned-google'])
}, 90_000)
})
@@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import type { Cookie } from 'electron'
import {
bulkClearCookiesExcept,
isGoogleSourceBoundCookie,
isNonTransplantableCookieDomain,
NON_TRANSPLANTABLE_HOST_KEY_SQL,
normalizeCookieDomain,
removeAllCookiesExcept,
replaceCookiesForImportedDomains
} from './browser-cookie-import-policy'
@@ -207,87 +207,111 @@ describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => {
})
})
describe('bulkClearCookiesExcept', () => {
it('bulk clears a large jar once and restores only excluded cookies', async () => {
const existing = [
cookie('.google.com', 'SID'),
cookie('accounts.google.com', 'ACCOUNT'),
...Array.from({ length: 1_000 }, (_, index) =>
cookie(`site-${index}.example`, `session-${index}`)
)
]
const get = vi.fn().mockResolvedValue(existing)
describe('removeAllCookiesExcept', () => {
it('never removes or reconstructs excluded Google cookies', async () => {
const get = vi
.fn()
.mockResolvedValue([
cookie('.google.com', 'SID'),
cookie('accounts.google.com', 'ACCOUNT'),
cookie('.example.com', 'session'),
cookie('other.test', 'tracker', '/scoped')
])
const remove = vi.fn().mockResolvedValue(undefined)
const set = vi.fn().mockResolvedValue(undefined)
const clearStorageData = vi.fn().mockResolvedValue(undefined)
await bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, (existingCookie) =>
await removeAllCookiesExcept({ get, remove }, (existingCookie) =>
isNonTransplantableCookieDomain(existingCookie.domain ?? '')
)
expect(get).toHaveBeenCalledOnce()
expect(get).toHaveBeenCalledWith({})
expect(clearStorageData).toHaveBeenCalledOnce()
expect(clearStorageData).toHaveBeenCalledWith({ storages: ['cookies'] })
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'ACCOUNT'])
expect(remove.mock.calls).toEqual([
['https://example.com/', 'session'],
['https://other.test/scoped', 'tracker']
])
expect(set).not.toHaveBeenCalled()
})
it('restores the complete snapshot when the bulk clear rejects', async () => {
const existing = [
cookie('.google.com', 'SID'),
cookie('.example.com', 'first'),
cookie('.other.test', 'second')
]
const get = vi.fn().mockResolvedValue(existing)
// Why (STA-4061): reconstructing a removed cookie loses its partition key, and the snapshot
// cannot say which cookies had one, so a failed clear must stay failed.
it('never reconstructs removed cookies when another removal fails', async () => {
const get = vi
.fn()
.mockResolvedValue([
cookie('.google.com', 'SID'),
cookie('.example.com', 'first', '/one'),
cookie('.example.com', 'second', '/two'),
cookie('.example.com', 'third', '/three')
])
const remove = vi.fn().mockImplementation(async (_url: string, name: string) => {
if (name === 'second') {
throw new Error('store unavailable')
}
})
const set = vi.fn().mockResolvedValue(undefined)
const clearStorageData = vi.fn().mockRejectedValue(new Error('store unavailable'))
await expect(
bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, () => true)
).rejects.toThrow('Could not clear existing cookies')
expect(clearStorageData).toHaveBeenCalledOnce()
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'first', 'second'])
removeAllCookiesExcept({ get, remove }, (existingCookie) =>
isNonTransplantableCookieDomain(existingCookie.domain ?? '')
)
).rejects.toThrow('the session was left partially cleared')
expect(remove).toHaveBeenCalledTimes(3)
expect(set).not.toHaveBeenCalled()
})
it('rolls back the complete snapshot when excluded-cookie restoration fails', async () => {
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
const get = vi.fn().mockResolvedValue(existing)
let googleAttempts = 0
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
if (name === 'SID' && googleAttempts++ === 0) {
throw new Error('transient restore failure')
}
})
const clearStorageData = vi.fn().mockResolvedValue(undefined)
await expect(
bulkClearCookiesExcept(
{ cookies: { get, set }, clearStorageData },
(existingCookie) => existingCookie.domain === '.google.com'
it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => {
const get = vi
.fn()
.mockResolvedValue(
Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`))
)
).rejects.toThrow('Could not preserve excluded cookies')
let releaseRemovals: (() => void) | undefined
const removalsReleased = new Promise<void>((resolve) => {
releaseRemovals = resolve
})
let active = 0
let maxActive = 0
const remove = vi.fn().mockImplementation(async () => {
active++
maxActive = Math.max(maxActive, active)
await removalsReleased
active--
})
const set = vi.fn().mockResolvedValue(undefined)
expect(clearStorageData).toHaveBeenCalledOnce()
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
const clearing = removeAllCookiesExcept({ get, remove }, () => false)
await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8))
expect(maxActive).toBe(8)
releaseRemovals?.()
await clearing
expect(remove).toHaveBeenCalledTimes(12)
expect(set).not.toHaveBeenCalled()
})
it('reports when the complete-snapshot rollback also fails', async () => {
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
const get = vi.fn().mockResolvedValue(existing)
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
if (name === 'SID') {
throw new Error('persistent restore failure')
}
it('serializes cookies that share Electron removal coordinates', async () => {
const get = vi
.fn()
.mockResolvedValue([
cookie('.example.com', 'session'),
{ ...cookie('example.com', 'session'), hostOnly: true }
])
let releaseFirst: (() => void) | undefined
const firstReleased = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const clearStorageData = vi.fn().mockResolvedValue(undefined)
const remove = vi
.fn()
.mockImplementationOnce(() => firstReleased)
.mockResolvedValueOnce(undefined)
await expect(
bulkClearCookiesExcept(
{ cookies: { get, set }, clearStorageData },
(existingCookie) => existingCookie.domain === '.google.com'
)
).rejects.toThrow('Cookie preservation and rollback failed')
const clearing = removeAllCookiesExcept({ get, remove }, () => false)
await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce())
releaseFirst?.()
await clearing
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
expect(remove.mock.calls).toEqual([
['https://example.com/', 'session'],
['https://example.com/', 'session']
])
})
})
@@ -1,5 +1,6 @@
import type { Cookie, Cookies, Session } from 'electron'
import type { Cookie, Cookies } from 'electron'
import { parse as parseDomain } from 'psl'
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
'SIDCC',
@@ -63,6 +64,7 @@ export function normalizeCookieImportDomain(domain: string): string | null {
// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports
// users actually asked for.
const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const
const COOKIE_CLEAR_CONCURRENCY = 8
export function isNonTransplantableCookieDomain(domain: string): boolean {
const normalized = normalizeCookieDomain(domain)
@@ -194,53 +196,50 @@ export async function restoreImportedDomainCookies(
}
}
type CookieClearSession = {
cookies: Pick<Cookies, 'get' | 'set'>
clearStorageData: Session['clearStorageData']
}
async function restoreCookieClearSnapshot(
store: Pick<Cookies, 'set'>,
snapshot: readonly Cookie[],
originalError: unknown,
rollbackMessage: string
): Promise<never> {
try {
await restoreImportedDomainCookies(store, snapshot)
} catch (rollbackError) {
throw new AggregateError([originalError, rollbackError], rollbackMessage)
}
throw originalError
}
// Why: after a bulk clear starts, Electron cannot reveal whether a rejected operation mutated
// the jar, so keep the complete snapshot until excluded cookies have been restored.
export async function bulkClearCookiesExcept(
targetSession: CookieClearSession,
// Why: Electron cannot round-trip partition identity, so excluded cookies must never be removed.
// Why (STA-4061): the same gap forbids rolling a partial clear back. cookies.get() omits
// partitionKey and cookies.set() silently drops it, so every reconstruction is a coin flip that
// can downgrade a partitioned (CHIPS) cookie into an unpartitioned one — and nothing in the
// snapshot says which cookies are at risk. A partially cleared jar is a retryable import failure;
// a downgraded cookie is unrecoverable auth-state corruption that survives restart.
export async function removeAllCookiesExcept(
store: Pick<Cookies, 'get' | 'remove'>,
isExcluded: (cookie: Cookie) => boolean
): Promise<void> {
const snapshot = await targetSession.cookies.get({})
const excludedCookies = snapshot.filter(isExcluded)
try {
await targetSession.clearStorageData({ storages: ['cookies'] })
} catch (clearError) {
await restoreCookieClearSnapshot(
targetSession.cookies,
snapshot,
new AggregateError([clearError], 'Could not clear existing cookies'),
'Cookie bulk clear and rollback failed'
)
const existingCookies = await store.get({})
const removableGroups = new Map<string, { cookie: Cookie; url: string }[]>()
for (const cookie of existingCookies) {
if (isExcluded(cookie)) {
continue
}
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
const url = domain ? cookieRemovalUrl(cookie, domain) : null
if (!url) {
continue
}
const key = JSON.stringify([url, cookie.name])
const group = removableGroups.get(key) ?? []
group.push({ cookie, url })
removableGroups.set(key, group)
}
try {
await restoreImportedDomainCookies(targetSession.cookies, excludedCookies)
} catch (preservationError) {
await restoreCookieClearSnapshot(
targetSession.cookies,
snapshot,
new AggregateError([preservationError], 'Could not preserve excluded cookies'),
'Cookie preservation and rollback failed'
const results = await mapSettledWithConcurrency(
[...removableGroups.values()],
COOKIE_CLEAR_CONCURRENCY,
async (group) => {
// Why: identical removal coordinates must stay ordered instead of racing.
for (const { cookie, url } of group) {
await store.remove(url, cookie.name)
}
}
)
const failures = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (failures.length > 0) {
throw new AggregateError(
failures,
'Could not clear existing cookies; the session was left partially cleared'
)
}
}
@@ -511,8 +511,7 @@ describe('importCookiesFromBrowser Chromium', () => {
['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix))
).toEqual(sourceFilesBefore)
expect(cookiesRemoveMock).not.toHaveBeenCalled()
expect(clearStorageDataMock).toHaveBeenCalledOnce()
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
expect(clearStorageDataMock).not.toHaveBeenCalled()
// Why: STA-3514 — imports must never impersonate the source browser; the
// session keeps the engine UA the registry set at startup.
expect(setUserAgentMock).not.toHaveBeenCalled()
+2 -4
View File
@@ -75,12 +75,12 @@ import type {
} from '../../shared/types'
import { browserSessionRegistry } from './browser-session-registry'
import {
bulkClearCookiesExcept,
isGoogleSourceBoundCookie,
isNonTransplantableCookieDomain,
NON_TRANSPLANTABLE_HOST_KEY_SQL,
normalizeCookieDomain,
normalizeCookieImportDomain,
removeAllCookiesExcept,
replaceCookiesForImportedDomains,
restoreImportedDomainCookies,
type CookieImportMode
@@ -1780,11 +1780,9 @@ export async function importCookiesFromBrowser(
// Why: clear stale cookies first; mixing them with the imported set makes sites reject the
// session. Non-transplantable families are exempt — nothing was imported for them, and their
// live session is the only one that works.
await bulkClearCookiesExcept(targetSession, (cookie) =>
await removeAllCookiesExcept(targetSession.cookies, (cookie) =>
isNonTransplantableCookieDomain(cookie.domain ?? '')
)
// Why: after excluded cookies survive this boundary, restart staging owns later set failures;
// restoring stale non-Google cookies would recreate the mixed jar this import must replace.
diag(
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
)
@@ -167,7 +167,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
compactWorktreeCards: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
-1
View File
@@ -159,7 +159,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
compactWorktreeCards: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
+24
View File
@@ -335,6 +335,30 @@ describe('DaemonClient', () => {
client = new DaemonClient({ socketPath, tokenPath })
await expect(client.ensureConnected()).rejects.toThrow()
})
it('fails a retired endpoint at the connect, not at the token read', async () => {
rmSync(tokenPath)
client = new DaemonClient({ socketPath, tokenPath })
const error = (await client
.ensureConnected()
.catch((err: unknown) => err)) as NodeJS.ErrnoException
expect(error.syscall).toBe('connect')
expect(['ENOENT', 'ECONNREFUSED']).toContain(error.code)
})
it('still attempts the handshake when the token is gone but a daemon is listening', async () => {
const hellos: HelloMessage[] = []
await startMockDaemon({ onHello: (msg) => hellos.push(msg) })
rmSync(tokenPath)
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
expect(hellos.length).toBeGreaterThan(0)
expect(hellos[0]?.token).toBe('')
})
})
describe('RPC', () => {
+13 -1
View File
@@ -114,12 +114,24 @@ export class DaemonClient {
}
}
// Why: a missing token must not preempt the connect that proves whether the endpoint is gone.
private readToken(): string {
try {
return readFileSync(this.tokenPath, 'utf-8').trim()
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') {
return ''
}
throw error
}
}
private async doConnect(
timeoutMs: number,
attemptGeneration: number,
sharedBudget: boolean
): Promise<void> {
const token = readFileSync(this.tokenPath, 'utf-8').trim()
const token = this.readToken()
const deadlineMs = Date.now() + timeoutMs
const remainingMs = (): number =>
sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs
@@ -595,6 +595,42 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
})
it('respawns after a retired daemon regardless of how the connection ended', async () => {
// Why this shape: the daemon retires when its last authenticated client drops, and that drop
// is often our own disconnect() — which observes no socket close. Once the token read stops
// preempting the connect, the retired endpoint fails as a connect and isDaemonGoneError
// classifies it, so recovery no longer depends on having witnessed the drop.
let respawnServer: DaemonServer | undefined
const respawn = vi.fn(async () => {
respawnServer = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
await respawnServer.start()
})
const healingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { id } = await healingAdapter.spawn({ cols: 80, rows: 24 })
const client = (healingAdapter as unknown as { client: DaemonClient }).client
client.disconnect()
await server.shutdown()
expect(existsSync(tokenPath)).toBe(false)
expect(client.hasObservedAuthenticatedDisconnect()).toBe(false)
await expect(
healingAdapter.spawn({ sessionId: id, cols: 80, rows: 24 })
).resolves.toMatchObject({ id })
expect(respawn).toHaveBeenCalledTimes(1)
} finally {
warn.mockRestore()
healingAdapter.dispose()
await respawnServer?.shutdown()
}
})
it('does not spawn a daemon per keystroke after respawn fails', async () => {
const respawn = vi.fn(async () => {
throw new Error('daemon unavailable')
+9 -20
View File
@@ -1475,8 +1475,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
)
return processes
} catch (error) {
const missingAuthenticatedToken =
isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect()
const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing()
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
this.observeAuditFailure(
missingAuthenticatedToken
@@ -1960,7 +1959,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
// Why final=true not teardown: clean disconnect needs the full-depth snapshot as the restore source, but the
// Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the
// detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact.
private async checkpointAllSessions(): Promise<void> {
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
@@ -2122,14 +2121,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
return 'unavailable'
}
// Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart.
// Why: the token read no longer throws, so audit its absence directly after an authenticated drop.
private isRetiredEndpointTokenMissing(): boolean {
return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath)
}
private async withDaemonRetry<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (err) {
// Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon.
const missingRetiredEndpointToken =
isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing()
if (missingRetiredEndpointToken) {
this.observeAuditFailure(
'token_missing_after_authenticated_disconnect',
@@ -2137,11 +2138,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
['token_file']
)
}
if (
this.respawnAdoptionClosed ||
!this.respawnFn ||
(!isDaemonGoneError(err) && !missingRetiredEndpointToken)
) {
if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) {
throw err
}
if (!this.respawnPromise) {
@@ -2535,14 +2532,6 @@ export function isDaemonGoneError(err: unknown): boolean {
)
}
function isMissingTokenFileError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false
}
const errno = err as NodeJS.ErrnoException
return errno.code === 'ENOENT' && errno.syscall === 'open'
}
function isMissingWindowsNamedPipeError(err: unknown): boolean {
if (process.platform !== 'win32' || !(err instanceof Error)) {
return false
@@ -7,6 +7,7 @@ import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
import type { DaemonFileLog } from './daemon-file-log'
import type { SubprocessHandle } from './session'
@@ -93,14 +94,21 @@ describe('DaemonPtyRouter history handoff', () => {
}
await Promise.all([legacyServer?.shutdown(), currentServer?.shutdown()])
rmSync(testDir, { recursive: true, force: true })
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('moves a slept v29 session to v30 with its full large history', async () => {
const sessionId = 'large-legacy-session'
const marker = 'V29-HISTORY-HANDOFF-MARKER'
// Why: this fixture plays an OLD daemon binary, whose sessions retained ~5000 rows. New code
// applies the flat window at session create, which would shrink the history below the chunked
// seed threshold and silently skip the transfer path this test exists to cover.
vi.stubEnv('ORCA_DAEMON_SESSION_SCROLLBACK_ROWS', '5000')
await legacyAdapter.spawn({ sessionId, cols: 400, rows: 24 })
legacySubprocess.emitData(`${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}${marker}`)
// Keep only the played legacy session deep; the receiving daemon must use today's flat window.
vi.stubEnv('ORCA_DAEMON_SESSION_SCROLLBACK_ROWS', String(DAEMON_SESSION_SCROLLBACK_ROWS))
router = new DaemonPtyRouter({ current: currentAdapter, legacy: [legacyAdapter] })
await router.discoverLegacySessions()
const client = (
@@ -213,17 +213,52 @@ describe('daemon self-retirement respawn', () => {
expect(respawn).not.toHaveBeenCalled()
})
it('does not treat an initial missing token as respawn authority', async () => {
it('does not respawn a listening daemon whose token is absent', async () => {
await startServer()
rmSync(tokenPath)
const respawn = vi.fn(async () => {})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject(
{
code: 'ENOENT'
}
)
await expect(
adapter.spawn({ sessionId: 'startup-window', cols: 80, rows: 24 })
).rejects.toThrow(/Invalid token/i)
expect(respawn).not.toHaveBeenCalled()
adapter.dispose()
})
it('does not let stale disconnect evidence authorize respawning a listening daemon', async () => {
const original = await startServer()
const respawn = vi.fn(async () => {})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
await adapter.listProcesses()
const client = (
adapter as unknown as {
client: { hasObservedAuthenticatedDisconnect(): boolean }
}
).client
await original.shutdown()
await waitFor(() => client.hasObservedAuthenticatedDisconnect())
await startServer()
rmSync(tokenPath)
await expect(
adapter.spawn({ sessionId: 'replacement-startup-window', cols: 80, rows: 24 })
).rejects.toThrow(/Invalid token/i)
expect(respawn).not.toHaveBeenCalled()
adapter.dispose()
})
it('respawns when nothing is accepting on the endpoint', async () => {
const respawn = vi.fn(async () => {})
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn })
// Fails after the respawn attempt because the mock respawn starts no replacement.
await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toThrow()
expect(respawn).toHaveBeenCalledTimes(1)
adapter.dispose()
})
})
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Socket } from 'node:net'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonClient } from './client'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { SubprocessHandle } from './session'
import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host'
function createMockSubprocess(): SubprocessHandle {
let onExit: ((code: number) => void) | undefined
return {
pid: 55_555,
getForegroundProcess: () => null,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData: vi.fn(),
onExit(callback) {
onExit = callback
},
dispose: vi.fn()
}
}
type DaemonAttachmentPrivate = {
host: {
detach: (sessionId: string, token: symbol) => void
detachClients: (attachments: readonly { sessionId: string; token: symbol }[]) => void
createOrAttach: (options: CreateOrAttachOptions) => Promise<CreateOrAttachResult>
sessions: Map<string, { hasAttachedClients: boolean }>
}
clients: Map<string, { streamSocket: Socket | null }>
streamClientIdBySessionId: Map<string, string>
attachTokenBySessionId: Map<string, symbol>
}
describe('DaemonServer attachment lifecycle', () => {
let directory: string
let server: DaemonServer
let client: DaemonClient
beforeEach(async () => {
directory = mkdtempSync(join(tmpdir(), 'daemon-attachment-lifecycle-'))
const socketPath = getDaemonSocketPath(directory)
const tokenPath = join(directory, 'daemon.token')
server = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: createMockSubprocess
})
await server.start()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
})
afterEach(async () => {
client.disconnect()
await server.shutdown()
rmSync(directory, { recursive: true, force: true })
})
async function createSession(sessionId: string): Promise<void> {
await client.request('createOrAttach', { sessionId, cols: 80, rows: 24 })
}
it('parks attached sessions when their stream transport closes', async () => {
await createSession('transport-owned-session')
const daemon = server as unknown as DaemonAttachmentPrivate
const detachClients = vi.spyOn(daemon.host, 'detachClients')
const streamSocket = [...daemon.clients.values()][0]?.streamSocket
streamSocket?.destroy()
await vi.waitFor(() => expect(detachClients).toHaveBeenCalledOnce())
expect(detachClients.mock.calls[0]?.[0]).toEqual([
{ sessionId: 'transport-owned-session', token: expect.any(Symbol) }
])
expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false)
expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false)
})
it('parks only the requesting client attachment on explicit detach', async () => {
await createSession('explicitly-detached-session')
const daemon = server as unknown as DaemonAttachmentPrivate
const detach = vi.spyOn(daemon.host, 'detach')
await client.request('detach', { sessionId: 'explicitly-detached-session' })
expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol))
expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false)
expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false)
})
it('parks an attachment completed after its transport already closed', async () => {
const daemon = server as unknown as DaemonAttachmentPrivate
const originalCreateOrAttach = daemon.host.createOrAttach.bind(daemon.host)
let attachmentCreated!: () => void
let finishRequest!: () => void
const created = new Promise<void>((resolve) => {
attachmentCreated = resolve
})
const gate = new Promise<void>((resolve) => {
finishRequest = resolve
})
vi.spyOn(daemon.host, 'createOrAttach').mockImplementation(async (options) => {
const result = await originalCreateOrAttach(options)
attachmentCreated()
await gate
return result
})
const request = client
.request('createOrAttach', { sessionId: 'close-race-session', cols: 80, rows: 24 })
.catch(() => undefined)
await created
client.disconnect()
await vi.waitFor(() => expect(daemon.clients.size).toBe(0))
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true)
finishRequest()
await request
await vi.waitFor(() =>
expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false)
)
expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false)
expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false)
})
})
+55 -2
View File
@@ -184,6 +184,7 @@ export class DaemonServer {
}
})
private streamClientIdBySessionId = new Map<string, string>()
private attachTokenBySessionId = new Map<string, symbol>()
private lastInputAtBySessionId = new Map<string, number>()
private pendingPtySpawnPreparations = new Map<string, Set<PendingPtySpawnPreparation>>()
private historySeedTransfers = new TerminalHistorySeedTransferRegistry()
@@ -227,7 +228,17 @@ export class DaemonServer {
this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {})
this.host = new TerminalHost({
spawnSubprocess: opts.spawnSubprocess,
...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {})
// Why host-level and not the attach callback: a session whose client transport already dropped
// has no attachment left to fire exit bookkeeping, and the daemon must still notice it can idle.
onSessionReaped: (sessionId) => {
this.streamClientIdBySessionId.delete(sessionId)
this.attachTokenBySessionId.delete(sessionId)
this.lastInputAtBySessionId.delete(sessionId)
this.transientFactRelay.onSessionExit(sessionId)
this.streamDataBatcher.refreshSessionDroppability(sessionId)
opts.onPtySessionExit?.(sessionId)
this.reevaluateIdleShutdown()
}
})
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve())
@@ -774,6 +785,7 @@ export class DaemonServer {
this.historySeedTransfers.clearOwner(clientId)
const wasFullyAuthenticated = client.authenticatedPairEstablished
this.streamDataBatcher.clear(clientId)
this.detachClientSessions(clientId)
client.streamSocket?.destroy()
this.clients.delete(clientId)
this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated)
@@ -811,6 +823,7 @@ export class DaemonServer {
// Why: a preflight that outlives its output channel would create an unattached daemon PTY.
this.cancelPendingPtySpawnPreparationsForClient(client.clientId)
this.streamDataBatcher.clear(client.clientId)
this.detachClientSessions(client.clientId)
client.streamSocket = null
}
@@ -936,6 +949,36 @@ export class DaemonServer {
}
}
private detachClientSessions(clientId: string): void {
const attachments: { sessionId: string; token: symbol }[] = []
for (const [sessionId, attachedClientId] of this.streamClientIdBySessionId) {
if (attachedClientId !== clientId) {
continue
}
const token = this.attachTokenBySessionId.get(sessionId)
if (token) {
attachments.push({ sessionId, token })
}
this.streamClientIdBySessionId.delete(sessionId)
this.attachTokenBySessionId.delete(sessionId)
}
if (attachments.length > 0) {
this.host.detachClients(attachments)
}
}
private detachSessionForClient(sessionId: string, clientId: string): void {
if (this.streamClientIdBySessionId.get(sessionId) !== clientId) {
return
}
const token = this.attachTokenBySessionId.get(sessionId)
if (token) {
this.host.detach(sessionId, token)
}
this.streamClientIdBySessionId.delete(sessionId)
this.attachTokenBySessionId.delete(sessionId)
}
private async routeRequest(clientId: string, request: DaemonRequest): Promise<unknown> {
const client = this.clients.get(clientId)
@@ -1071,6 +1114,7 @@ export class DaemonServer {
this.transientFactRelay.onSessionExit(routedSessionId)
this.streamDataBatcher.refreshSessionDroppability(routedSessionId)
this.streamClientIdBySessionId.delete(routedSessionId)
this.attachTokenBySessionId.delete(routedSessionId)
this.lastInputAtBySessionId.delete(routedSessionId)
this.reevaluateIdleShutdown()
}
@@ -1081,7 +1125,16 @@ export class DaemonServer {
this.reevaluateIdleShutdown()
}
routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId
if (
this.clients.get(clientId) !== client ||
!client.authenticatedPairEstablished ||
client.streamSocket === null
) {
this.host.detach(routedSessionId, result.attachToken)
throw new TerminalAttachCanceledError(routedSessionId)
}
this.streamClientIdBySessionId.set(routedSessionId, clientId)
this.attachTokenBySessionId.set(routedSessionId, result.attachToken)
this.streamDataBatcher.refreshSessionDroppability(routedSessionId)
// Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head.
if (this.transientFactRelay.isBackgrounded(routedSessionId)) {
@@ -1228,7 +1281,7 @@ export class DaemonServer {
return {}
case 'detach':
// Note: detach token handling simplified — full impl would track tokens per client
this.detachSessionForClient(request.payload.sessionId, clientId)
this.log.log('session-detached', {
sessionId: request.payload.sessionId
})
@@ -0,0 +1,94 @@
/**
* OOM regression: a daemon owning 100+ terminals retained ~5000 rows of grid per session with no
* bound, grew to ~1.9 GB, and was killed under system memory pressure — losing every session it
* owned. Sessions now retain a flat window; deep scrolling on an open terminal is the renderer's
* live buffer, and the window is what a rebuild (reload/remount/restart/remote attach) restores.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TerminalHost } from './terminal-host'
import type { SubprocessHandle } from './session'
import {
DAEMON_SESSION_SCROLLBACK_ROWS,
resolveDaemonSessionScrollbackRows
} from './daemon-session-scrollback-window'
describe('resolveDaemonSessionScrollbackRows', () => {
it('defaults to the flat window', () => {
expect(resolveDaemonSessionScrollbackRows({} as NodeJS.ProcessEnv)).toBe(
DAEMON_SESSION_SCROLLBACK_ROWS
)
})
it('accepts the inclusive override bounds and rejects everything outside them', () => {
for (const raw of ['100', '2500', '5000']) {
const env = { ORCA_DAEMON_SESSION_SCROLLBACK_ROWS: raw } as NodeJS.ProcessEnv
expect(resolveDaemonSessionScrollbackRows(env)).toBe(Number(raw))
}
// Why bounded: 0 loses the visible screen's context; huge values silently reintroduce the
// unbounded retention this window exists to prevent.
for (const raw of ['0', '50', '99', '5001', '50000', '-1', '3.5', 'nonsense', '']) {
const env = { ORCA_DAEMON_SESSION_SCROLLBACK_ROWS: raw } as NodeJS.ProcessEnv
expect(resolveDaemonSessionScrollbackRows(env)).toBe(DAEMON_SESSION_SCROLLBACK_ROWS)
}
})
})
describe('daemon session scrollback window', () => {
let host: TerminalHost
let dataCb: ((data: string) => void) | null
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 4242,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
setTimeout(() => onExitCb?.(0), 1)
}),
forceKill: vi.fn(() => onExitCb?.(137)),
signal: vi.fn(),
onData(cb) {
dataCb = cb
},
onExit(cb) {
onExitCb = cb
},
dispose: vi.fn()
} as SubprocessHandle
}
beforeEach(() => {
dataCb = null
host = new TerminalHost({ spawnSubprocess: () => createMockSubprocess() })
})
afterEach(async () => {
await host.dispose()
})
it('caps retained rows at the window while keeping the newest content', async () => {
await host.createOrAttach({
sessionId: 'windowed',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const total = DAEMON_SESSION_SCROLLBACK_ROWS + 500
for (let i = 1; i <= total; i += 1) {
dataCb?.(`LINE_${String(i).padStart(5, '0')}\r\n`)
}
await vi.waitFor(() => {
const snapshot = host.getSnapshot('windowed')
expect(snapshot?.snapshotAnsi ?? snapshot?.scrollbackAnsi).toBeTruthy()
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.snapshotAnsi ?? ''}`
// Newest line always present; the oldest has scrolled past the window.
expect(text).toContain(`LINE_${String(total).padStart(5, '0')}`)
expect(text).not.toContain('LINE_00001')
const retainedMatches = text.match(/LINE_\d{5}/g) ?? []
expect(retainedMatches.length).toBeLessThanOrEqual(DAEMON_SESSION_SCROLLBACK_ROWS + 24)
expect(retainedMatches.length).toBeGreaterThan(DAEMON_SESSION_SCROLLBACK_ROWS - 50)
})
})
})
@@ -0,0 +1,24 @@
const DAEMON_SESSION_SCROLLBACK_ENV_VAR = 'ORCA_DAEMON_SESSION_SCROLLBACK_ROWS'
// Why a flat window and not full renderer depth: retained grid is the daemon's dominant heap term and
// session count is unbounded — a host owning 100+ terminals at full depth retained ~1 GB of grid and
// was OOM-killed, taking every session it owned with it. A terminal the user has open scrolls its full
// live renderer buffer; the daemon window is only what a REBUILD (reload, remount, restart, remote
// attach) restores, and 1000 rows is the generous end of what terminal products restore there.
export const DAEMON_SESSION_SCROLLBACK_ROWS = 1000
// Why: keep any override within sane terminal bounds — 0 would lose the visible screen's context and
// huge values silently reintroduce the unbounded-retention failure this window exists to prevent.
const MIN_OVERRIDE_ROWS = 100
const MAX_OVERRIDE_ROWS = 5000
export function resolveDaemonSessionScrollbackRows(env: NodeJS.ProcessEnv = process.env): number {
const raw = env[DAEMON_SESSION_SCROLLBACK_ENV_VAR]?.trim()
if (!raw || !/^\d+$/.test(raw)) {
return DAEMON_SESSION_SCROLLBACK_ROWS
}
const parsed = Number(raw)
if (!Number.isSafeInteger(parsed) || parsed < MIN_OVERRIDE_ROWS || parsed > MAX_OVERRIDE_ROWS) {
return DAEMON_SESSION_SCROLLBACK_ROWS
}
return parsed
}
@@ -0,0 +1,93 @@
/**
* Attachment-leak regression: an attachment that outlives its transport leaves the session looking
* viewed forever — producer pause/resume and any attachment-gated behavior then act on a client that
* no longer exists. A dropped client transport must release exactly that client's attachments, and
* protocol detach must really detach (it was previously logging-only).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { Session, SubprocessHandle } from './session'
function fixtureSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 1234,
getForegroundProcess: () => null,
write: () => {},
resize: () => {},
kill: () => {
setTimeout(() => onExitCb?.(0), 1)
},
forceKill: () => onExitCb?.(137),
signal: () => {},
onData: () => {},
onExit: (cb) => {
onExitCb = cb
},
dispose: () => {}
} as SubprocessHandle
}
describe('transport-drop attachment release', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer | null
let adapter: DaemonPtyAdapter | null
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-attachment-release-'))
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'daemon.token')
server = null
adapter = null
})
afterEach(async () => {
await adapter?.disconnectOnly().catch(() => {})
await server?.shutdown().catch(() => {})
rmSync(dir, { recursive: true, force: true })
})
function sessionOf(started: DaemonServer, sessionId: string): Session | undefined {
const host = (started as unknown as { host: { sessions: Map<string, Session> } }).host
return host.sessions.get(sessionId)
}
it('releases a dropped client transport instead of leaving a phantom attachment', async () => {
const started = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => fixtureSubprocess()
})
await started.start()
server = started
adapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
profileScope: dir,
historyPath: join(dir, 'history')
})
await adapter.spawn({ sessionId: 'viewed-then-dropped', cols: 80, rows: 24 })
const session = sessionOf(started, 'viewed-then-dropped')
expect(session).toBeDefined()
expect(session!.hasAttachedClients).toBe(true)
// Drop the transport without any protocol detach — the crash/abrupt-exit case.
const client = (adapter as unknown as { client: { disconnect(): void } }).client
client.disconnect()
await vi.waitFor(() => {
expect(session!.hasAttachedClients).toBe(false)
})
// The session itself must survive the disconnect — only the attachment is released.
expect(session!.isAlive).toBe(true)
})
})
@@ -0,0 +1,32 @@
import type { Terminal } from '@xterm/headless'
import type { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
import type { TerminalModes } from './types'
type TerminalWithKittyKeyboard = Terminal & {
// Why: kitty keyboard flags aren't on the public IModes; read the core service the CSI u handlers mutate.
_core?: { coreService?: { kittyKeyboard?: { flags?: number } } }
}
export function readKittyKeyboardFlags(terminal: Terminal): number {
const flags = (terminal as TerminalWithKittyKeyboard)._core?.coreService?.kittyKeyboard?.flags
return typeof flags === 'number' ? flags : 0
}
/** Mode state a snapshot must carry so a restored pane behaves like the live one. */
export function readTerminalModes(
terminal: Terminal,
mouseModes: TerminalMouseModeMirror
): TerminalModes {
const buffer = terminal.buffer.active
const mouseTrackingMode = mouseModes.mouseTrackingMode
return {
bracketedPaste: terminal.modes.bracketedPasteMode,
mouseTracking: mouseTrackingMode !== 'none',
mouseTrackingMode,
sgrMouseMode: mouseModes.sgrMouseMode,
sgrMousePixelsMode: mouseModes.sgrMousePixelsMode,
applicationCursor: buffer.type === 'normal' ? terminal.modes.applicationCursorKeysMode : false,
alternateScreen: buffer.type === 'alternate',
kittyKeyboardFlags: readKittyKeyboardFlags(terminal)
}
}
+2 -19
View File
@@ -10,6 +10,7 @@ import {
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { readTerminalModes } from './headless-emulator-modes'
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
@@ -338,24 +339,6 @@ export class HeadlessEmulator {
}
private getModes(): TerminalModes {
const buffer = this.terminal.buffer.active
const mouseTrackingMode = this.mouseModes.mouseTrackingMode
return {
bracketedPaste: this.terminal.modes.bracketedPasteMode,
mouseTracking: mouseTrackingMode !== 'none',
mouseTrackingMode,
sgrMouseMode: this.mouseModes.sgrMouseMode,
sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode,
applicationCursor:
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
alternateScreen: buffer.type === 'alternate',
kittyKeyboardFlags: this.getKittyKeyboardFlags()
}
}
private getKittyKeyboardFlags(): number {
const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard
?.flags
return typeof flags === 'number' ? flags : 0
return readTerminalModes(this.terminal, this.mouseModes)
}
}
+3 -5
View File
@@ -80,7 +80,6 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
'ORCA_OPENCODE_CONFIG_DIR',
'ORCA_MIMOCODE_HOME',
'ORCA_PI_CODING_AGENT_DIR',
@@ -1760,7 +1759,7 @@ describe('createPtySubprocess', () => {
expect(spawnEnv.MY_VAR).toBe('test-value')
})
it('uses shell wrapper when attribution shims must survive shell startup', () => {
it('uses shell wrapper when managed env must survive shell startup', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -1773,7 +1772,7 @@ describe('createPtySubprocess', () => {
rows: 24,
env: {
SHELL: '/bin/zsh',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/orca-terminal-attribution/posix'
ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-config'
}
})
} finally {
@@ -2165,7 +2164,7 @@ describe('createPtySubprocess', () => {
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
envToDelete: ['TERM_PROGRAM']
})
const lastCall = spawnMock.mock.calls.at(-1)!
@@ -2173,7 +2172,6 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.TERM).toBe('screen-256color')
expect(lastCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
expect(lastCall[2].env.TERM_PROGRAM).toBeUndefined()
expect(lastCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('collapses its own env merge onto the requested Windows `Path` spelling', () => {
+5 -4
View File
@@ -6,7 +6,7 @@ import { delimiter, win32 as pathWin32 } from 'node:path'
import type { SubprocessHandle } from './session'
import { DaemonProtocolError } from './types'
import {
getAttributionShellLaunchConfig,
getMarkerlessShellLaunchConfig,
getShellReadyLaunchConfig,
resolvePtyShellPath
} from './shell-ready'
@@ -800,18 +800,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path.
shellLaunch = shouldWaitForShellReady
? getShellReadyLaunchConfig(shellPath)
: getAttributionShellLaunchConfig(shellPath)
: getMarkerlessShellLaunchConfig(shellPath)
} else if (opts.command) {
shellLaunch = getShellReadyLaunchConfig(shellPath)
} else {
shellLaunch =
env.ORCA_ATTRIBUTION_SHIM_DIR ||
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_MIMOCODE_HOME ||
env.ORCA_OMP_STATUS_EXTENSION ||
env.ORCA_CODEX_HOME ||
env.ORCA_AGENT_TEAMS_SHIM_DIR
? getAttributionShellLaunchConfig(shellPath)
? getMarkerlessShellLaunchConfig(shellPath)
: null
}
if (shellLaunch) {
@@ -1062,6 +1061,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
return {
pid: proc.pid,
shellPath,
shellCwd: spawnCwd,
shellPathEnv: env.PATH,
...(slavePath ? { slavePath } : {}),
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
getForegroundProcess: () => {
@@ -0,0 +1,419 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createPtySubprocess } from './pty-subprocess'
import { Session } from './session'
const describePosix = process.platform === 'win32' ? describe.skip : describe
const hasZsh = process.platform !== 'win32' && spawnSync('/bin/zsh', ['--version']).status === 0
const hasBash = process.platform !== 'win32' && spawnSync('/bin/bash', ['--version']).status === 0
const COMMAND_OUTPUT = 'ORCA_STARTUP_COMMAND_RAN'
const READ_STARTED_FILE = '.orca-read-started'
type ShellFixture = {
name: string
shellPath: string
startupFile: string
replacement: string
command: string
instrumentationOutput: string
secretRead: string
childRead: string
}
const FIXTURES: ShellFixture[] = [
{
name: 'zsh profile',
shellPath: '/bin/zsh',
startupFile: '.zprofile',
replacement: 'exec -a kiro-cli-term /bin/zsh -o noglobalrcs -l -i',
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
},
{
name: 'zsh environment',
shellPath: '/bin/zsh',
startupFile: '.zshenv',
replacement: 'exec /bin/zsh -o noglobalrcs -l -i',
command: `printf 'ORCA_STARTUP_%s:PF=%s\\n' COMMAND_RAN "\${(j:,:)precmd_functions}"\r`,
instrumentationOutput: `${COMMAND_OUTPUT}:PF=`,
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -sk 1\n`,
childRead: `/bin/zsh -fc ': > "$HOME/${READ_STARTED_FILE}"; read -sk 1'\n`
},
{
name: 'bash profile',
shellPath: '/bin/bash',
startupFile: '.bash_profile',
replacement: 'exec -a figterm-test /bin/bash --noprofile --norc -l -i',
command: `printf 'ORCA_STARTUP_%s:PC=%s\\n' COMMAND_RAN "$PROMPT_COMMAND"\r`,
instrumentationOutput: `${COMMAND_OUTPUT}:PC=`,
secretRead: `: > "$HOME/${READ_STARTED_FILE}"; read -s -n 1\n`,
childRead: `/bin/bash --noprofile --norc -c ': > "$HOME/${READ_STARTED_FILE}"; read -s -n 1'\n`
}
]
function count(text: string, needle: string): number {
return text.split(needle).length - 1
}
function waitForOutput(
subscribe: (settle: () => void) => void,
isDone: () => boolean,
timeoutMs = 5_000
): Promise<void> {
if (isDone()) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
const deadline = setTimeout(
() => reject(new Error('Timed out waiting for PTY output')),
timeoutMs
)
const settle = (): void => {
if (!isDone()) {
return
}
clearTimeout(deadline)
resolve()
}
subscribe(settle)
settle()
})
}
function waitForCondition(isDone: () => boolean, timeoutMs = 5_000): Promise<void> {
return new Promise<void>((resolve, reject) => {
const interval = setInterval(() => {
if (!isDone()) {
return
}
clearInterval(interval)
clearTimeout(deadline)
resolve()
}, 10)
const deadline = setTimeout(() => {
clearInterval(interval)
reject(new Error('Timed out waiting for fixture state'))
}, timeoutMs)
})
}
function runCleanupActions(...actions: (() => void)[]): void {
for (const action of actions) {
try {
action()
} catch {}
}
}
type RunningFixture = {
session: Session
subprocess: ReturnType<typeof createPtySubprocess>
output: () => string
readStarted: () => boolean
subscribe: (settle: () => void) => void
cleanup: () => Promise<void>
}
function startFixture(
fixture: ShellFixture,
startupContent: string,
extraFiles: Record<string, string> = {}
): RunningFixture {
const tempHome = mkdtempSync(join(tmpdir(), 'orca-shell-ready-exec-'))
const previousHome = process.env.HOME
const previousZdotdir = process.env.ZDOTDIR
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
let subprocess: ReturnType<typeof createPtySubprocess> | undefined
let session: Session | undefined
let consoleWarnSpy: { mockRestore: () => void } | undefined
try {
writeFileSync(join(tempHome, fixture.startupFile), startupContent)
for (const [fileName, content] of Object.entries(extraFiles)) {
writeFileSync(join(tempHome, fileName), content)
}
process.env.HOME = tempHome
delete process.env.ZDOTDIR
delete process.env.ORCA_ORIG_ZDOTDIR
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
subprocess = createPtySubprocess({
sessionId: `repro-13767-${fixture.startupFile}`,
cols: 80,
rows: 24,
cwd: tempHome,
command: fixture.command,
shellOverride: fixture.shellPath,
env: {
HOME: tempHome,
PATH: process.env.PATH ?? '/usr/bin:/bin',
SHELL: fixture.shellPath,
TERM: 'xterm-256color'
},
envToDelete: ['ORCA_EXEC_REPRO_DONE', 'ORCA_ORIG_ZDOTDIR', 'ZDOTDIR']
})
session = new Session({
sessionId: `repro-13767-${fixture.startupFile}`,
cols: 80,
rows: 24,
subprocess,
shellReadySupported: true
})
let output = ''
let onOutput = (): void => {}
session.attachClient({
onData: (data) => {
output += data
onOutput()
},
onExit: () => {}
})
session.write(fixture.command)
const runningSubprocess = subprocess
const runningSession = session
const activeConsoleWarnSpy = consoleWarnSpy
return {
session: runningSession,
subprocess: runningSubprocess,
output: () => output,
readStarted: () => existsSync(join(tempHome, READ_STARTED_FILE)),
subscribe: (settle) => {
onOutput = settle
},
cleanup: async () => {
if (runningSession.isAlive) {
try {
await runningSession.forceKillAndWaitForExit(3_000)
} catch {
runCleanupActions(() => runningSubprocess.forceKill())
}
}
runCleanupActions(
() => runningSession.dispose(),
() => activeConsoleWarnSpy.mockRestore(),
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
() => rmSync(tempHome, { recursive: true, force: true })
)
}
}
} catch (error) {
runCleanupActions(
() => subprocess?.forceKill(),
() => session?.dispose(),
() => !session && subprocess?.dispose(),
() => consoleWarnSpy?.mockRestore(),
() => restoreEnvironment(previousHome, previousZdotdir, previousOrigZdotdir),
() => rmSync(tempHome, { recursive: true, force: true })
)
throw error
}
}
function restoreEnvironment(
home: string | undefined,
zdotdir: string | undefined,
originalZdotdir: string | undefined
): void {
setEnvironmentValue('HOME', home)
setEnvironmentValue('ZDOTDIR', zdotdir)
setEnvironmentValue('ORCA_ORIG_ZDOTDIR', originalZdotdir)
}
function setEnvironmentValue(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
async function runExecOracle(fixture: ShellFixture): Promise<void> {
const running = startFixture(
fixture,
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
export ORCA_EXEC_REPRO_DONE=1
${fixture.replacement}
fi
`
)
try {
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
expect(running.session.shellState).toBe('ready')
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
expect(running.output()).toContain(fixture.instrumentationOutput)
expect(running.output()).not.toContain('orca-shell-start')
} finally {
await running.cleanup()
}
}
async function runReadOracle(fixture: ShellFixture, child: boolean): Promise<void> {
const running = startFixture(fixture, child ? fixture.childRead : fixture.secretRead)
try {
await waitForCondition(running.readStarted)
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
running.subprocess.write('x\r')
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
expect(running.output()).not.toContain('orca-shell-start')
} finally {
await running.cleanup()
}
}
describePosix('#13767 shell-ready marker loss across exec', () => {
it('continues setup cleanup after force-kill failure', () => {
const dispose = vi.fn()
const restore = vi.fn()
runCleanupActions(
() => {
throw new Error('force kill failed')
},
dispose,
restore
)
expect(dispose).toHaveBeenCalledOnce()
expect(restore).toHaveBeenCalledOnce()
})
for (const fixture of FIXTURES) {
const runnable = fixture.shellPath.endsWith('zsh') ? hasZsh : hasBash
const testCase = runnable ? it : it.skip
testCase(
`releases at the real ${fixture.name} prompt after exec`,
() => runExecOracle(fixture),
10_000
)
testCase(
`keeps queued input out of a real ${fixture.name} silent read`,
() => runReadOracle(fixture, false),
10_000
)
testCase(
`keeps queued input out of a child read during ${fixture.name} startup`,
() => runReadOracle(fixture, true),
10_000
)
}
const zshFixture = FIXTURES[0] as ShellFixture
const zshTest = hasZsh ? it : it.skip
zshTest(
'keeps queued input out of a zle-line-init read after exec',
async () => {
const running = startFixture(
zshFixture,
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
export ORCA_EXEC_REPRO_DONE=1
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
fi
`,
{
'.zshrc': `zle-line-init() {
print -n 'HOOK_SECRET> '
read -sk 1
print -r -- HOOK_DONE
}
zle -N zle-line-init
`
}
)
try {
try {
await waitForOutput(running.subscribe, () => running.output().includes('HOOK_SECRET> '))
} catch (error) {
throw new Error(`ZLE hook startup output: ${JSON.stringify(running.output())}`, {
cause: error
})
}
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
running.subprocess.write('x')
try {
await waitForOutput(running.subscribe, () => running.output().includes(COMMAND_OUTPUT))
} catch (error) {
throw new Error(`ZLE hook output: ${JSON.stringify(running.output())}`, { cause: error })
}
expect(running.output()).toContain('HOOK_DONE')
expect(count(running.output(), COMMAND_OUTPUT)).toBe(1)
} finally {
await running.cleanup()
}
},
10_000
)
const sqliteTest = hasZsh && existsSync('/usr/bin/sqlite3') ? it : it.skip
sqliteTest(
'does not treat an exec-replaced readline program as the shell prompt',
async () => {
const running = startFixture(zshFixture, 'exec /usr/bin/sqlite3\n')
try {
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
} finally {
await running.cleanup()
}
},
10_000
)
sqliteTest(
'does not trust a non-shell executable renamed to the shell basename',
async () => {
const running = startFixture(
zshFixture,
'ln -s /usr/bin/sqlite3 "$HOME/zsh" && exec "$HOME/zsh"\n'
)
try {
await waitForOutput(running.subscribe, () => running.output().includes('sqlite> '))
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
} finally {
await running.cleanup()
}
},
10_000
)
zshTest(
'retains the timeout backstop when zsh disables bracketed paste',
async () => {
const running = startFixture(
zshFixture,
`if [[ -z "\${ORCA_EXEC_REPRO_DONE:-}" ]]; then
export ORCA_EXEC_REPRO_DONE=1
exec env ZDOTDIR="$HOME" /bin/zsh -o noglobalrcs -l -i
fi
`,
{
'.zshrc': "zmodload zsh/zle\nunset zle_bracketed_paste\nPS1='NO_BRACKET_PROMPT> '\n"
}
)
try {
await waitForOutput(running.subscribe, () =>
running.output().includes('NO_BRACKET_PROMPT> ')
)
await new Promise((resolve) => setTimeout(resolve, 300))
expect(running.session.shellState).toBe('pending')
expect(running.output()).not.toContain(COMMAND_OUTPUT)
} finally {
await running.cleanup()
}
},
10_000
)
})
+74 -2
View File
@@ -13,6 +13,16 @@ import {
scanForShellReady,
type ShellReadyScanState
} from '../shell-ready-marker-scanner'
import {
createShellStartupIdentityScanState,
drainShellStartupIdentityHeldBytes,
scanForShellStartupIdentity,
type ShellStartupIdentityScanState
} from '../shell-startup-identity-scanner'
import {
createShellPromptReadinessProbe,
type ShellPromptReadinessProbe
} from '../shell-prompt-readiness-probe'
import { isPowerShellProcess } from '../../shared/shell-process-detection'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import type { TuiAgent } from '../../shared/types'
@@ -60,6 +70,8 @@ export type SubprocessHandle = {
/** Shell the subprocess actually spawned, after fallbacks. The host reconciles the caller's shell-ready
* assumption against it so a fallback shell without a ready marker never gates startup commands. */
shellPath?: string
shellCwd?: string
shellPathEnv?: string
/** Slave device path, so startup replies can read the line discipline's ECHO bit before
* writing. Absent on handles with no POSIX slave to read (ConPTY, tests). */
slavePath?: string
@@ -125,6 +137,9 @@ export class Session {
private releaseStartupDeviceAttributesResponder: (() => void) | null = null
private startupDeviceAttributesQueryFilter: StartupDeviceAttributesQueryFilter | null = null
private shellReadyScanState: ShellReadyScanState | null = null
private shellStartupIdentityScanState: ShellStartupIdentityScanState | null = null
private shellStartupPid: number | null = null
private shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
private shellReadyTimer: ReturnType<typeof setTimeout> | null = null
private killTimer: ReturnType<typeof setTimeout> | null = null
private postReadyFlushGate: PostReadyFlushGate
@@ -170,6 +185,7 @@ export class Session {
if (opts.shellReadySupported) {
this._shellState = 'pending'
this.shellReadyScanState = createShellReadyScanState()
this.shellStartupIdentityScanState = createShellStartupIdentityScanState()
// Why: `write` queues everything until the ready marker, including the renderer's DA1
// reply — and a shell that withholds its first prompt until DA1 is answered (fish) then
// never emits the marker that would release it. Answer from the daemon, past the queue.
@@ -195,6 +211,16 @@ export class Session {
onEmission: (emission) => this.emitSubprocessOutput(emission),
...(echoProbe ? { echoProbe } : {})
})
if (this._shellState === 'pending') {
this.shellPromptReadinessProbe = createShellPromptReadinessProbe({
slavePath: this.subprocess.slavePath,
shellPath: this.subprocess.shellPath,
shellCwd: this.subprocess.shellCwd,
shellPathEnv: this.subprocess.shellPathEnv,
getShellPid: () => this.shellStartupPid,
onPromptReady: () => this.onShellPromptReady()
})
}
this.subprocess.onData((data) => this.handleSubprocessData(data))
this.subprocess.onExit((code) => this.handleSubprocessExit(code))
}
@@ -219,6 +245,11 @@ export class Session {
return this._state !== 'exited'
}
/** A viewing client is attached; a dropped transport must clear this or pause/resume semantics leak. */
get hasAttachedClients(): boolean {
return this.attachedClients.length > 0
}
get isTerminating(): boolean {
return this._isTerminating
}
@@ -603,6 +634,8 @@ export class Session {
clearTimeout(this.shellReadyTimer)
this.shellReadyTimer = null
}
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
this.shellReadyScanState = null
this.preReadyStdinQueue = []
this.postReadyFlushGate.clear()
@@ -649,6 +682,14 @@ export class Session {
}
let releaseStartupDeviceAttributes = false
if (this._shellState === 'pending' && this.shellStartupIdentityScanState) {
const scanned = scanForShellStartupIdentity(this.shellStartupIdentityScanState, data)
data = scanned.output
if (scanned.shellPid) {
this.shellStartupPid = scanned.shellPid
this.shellStartupIdentityScanState = null
}
}
if (this._shellState === 'pending' && this.shellReadyScanState) {
const scanned = scanForShellReady(this.shellReadyScanState, data)
data = scanned.output
@@ -661,6 +702,9 @@ export class Session {
}
this.startupIngress.accept(data)
if (this._shellState === 'pending' && data.length > 0) {
this.shellPromptReadinessProbe?.notifyOutput(data)
}
if (releaseStartupDeviceAttributes) {
this.releaseStartupDeviceAttributes()
}
@@ -696,6 +740,8 @@ export class Session {
}
this.releaseStartupDeviceAttributes()
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
this.releaseHeldShellReadyBytes()
this.startupIngress.drainAndClose()
this._exitCode = code
@@ -727,10 +773,19 @@ export class Session {
}
private releaseHeldShellReadyBytes(): string {
if (!this.shellReadyScanState) {
if (!this.shellReadyScanState && !this.shellStartupIdentityScanState) {
return ''
}
const heldBytes = drainShellReadyHeldBytes(this.shellReadyScanState)
let heldBytes = this.shellStartupIdentityScanState
? drainShellStartupIdentityHeldBytes(this.shellStartupIdentityScanState)
: ''
this.shellStartupIdentityScanState = null
if (this.shellReadyScanState && heldBytes) {
heldBytes = scanForShellReady(this.shellReadyScanState, heldBytes).output
}
if (this.shellReadyScanState) {
heldBytes += drainShellReadyHeldBytes(this.shellReadyScanState)
}
this.shellReadyScanState = null
// Why: scanning strips marker bytes before fan-out; if readiness never completes, release any held prefix before timeout/exit discards it.
this.startupIngress.accept(heldBytes)
@@ -759,6 +814,9 @@ export class Session {
private transitionToReady(postMarkerBytesObserved = false): void {
this._shellState = 'ready'
this.shellReadyScanState = null
this.shellStartupIdentityScanState = null
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
if (this.shellReadyTimer) {
clearTimeout(this.shellReadyTimer)
this.shellReadyTimer = null
@@ -775,11 +833,25 @@ export class Session {
return
}
this._shellState = 'timed_out'
this.shellPromptReadinessProbe?.dispose()
this.shellPromptReadinessProbe = null
this.releaseStartupDeviceAttributes()
this.releaseHeldShellReadyBytes()
this.flushPreReadyQueue()
}
private onShellPromptReady(): void {
if (this._shellState !== 'pending') {
return
}
console.warn(
`[Session] ${this.sessionId}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.`
)
this.releaseHeldShellReadyBytes()
this.transitionToReady(true)
this.releaseStartupDeviceAttributes()
}
private flushPreReadyQueue(): void {
const queued = this.preReadyStdinQueue
this.preReadyStdinQueue = []
+5 -3
View File
@@ -258,10 +258,10 @@ describePosix('daemon shell-ready launch config', () => {
expect(init).toContain('functions -e __orca_shell_ready_marker')
})
it('keeps attribution-only fish spawns unwrapped', async () => {
const { getAttributionShellLaunchConfig } = await importFreshShellReady()
it('keeps markerless fish spawns unwrapped', async () => {
const { getMarkerlessShellLaunchConfig } = await importFreshShellReady()
const config = getAttributionShellLaunchConfig('/opt/homebrew/bin/fish')
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
})
@@ -462,6 +462,7 @@ describePosix('daemon shell-ready launch config', () => {
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
expectZdotdirSourceContext(zprofile, '.zprofile')
@@ -650,6 +651,7 @@ describePosix('daemon shell-ready launch config', () => {
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
expect(bashRc).toContain('printf "\\033]133;D;%s\\007"')
expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
expect(bashRc).toContain('printf "\\033]133;C\\007"')
// precmd is prepended (captures $? first), epilogue appended last, so a framework needing last position stays between them.
expect(bashRc).toContain(
+8 -28
View File
@@ -16,6 +16,7 @@ import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
getZshStartupFileSourceBlock
} from '../shell-templates'
@@ -88,6 +89,7 @@ function shellReadyWrappersExist(): boolean {
export function getDaemonBashShellReadyRcfileContent(): string {
return `# Orca daemon bash shell-ready wrapper
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
[[ -f /etc/profile ]] && source /etc/profile
if [[ -f "$HOME/.bash_profile" ]]; then
source "$HOME/.bash_profile"
@@ -101,14 +103,6 @@ fi
# treat each embedded newline as Enter and mangle the prompt into PS2
# continuation. Modern readline defaults this on; force it for the rest.
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
__orca_restore_attribution_path() {
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
@@ -222,14 +216,6 @@ ${getZshStartupFileSourceBlock({
interactiveOnly: true,
skipWhenHomeIsCurrentZdotdir: true
})}
__orca_restore_attribution_path() {
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
@@ -286,14 +272,6 @@ ${getZshStartupFileSourceBlock({ fileName: '.zprofile' })}
const zshRc = getDaemonZshShellReadyRcfileContent()
const zshLogin = `# Orca daemon zsh shell-ready wrapper
${getZshStartupFileSourceBlock({ fileName: '.zlogin', interactiveOnly: true })}
__orca_restore_attribution_path() {
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
@@ -384,7 +362,8 @@ function getWrappedShellLaunchConfig(
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
ZDOTDIR: join(root, 'zsh'),
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -396,7 +375,8 @@ function getWrappedShellLaunchConfig(
return {
args: ['--rcfile', join(root, 'bash', 'rcfile')],
env: {
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -415,7 +395,7 @@ function getWrappedShellLaunchConfig(
}
}
// Why: mirrors local-pty-shell-ready.ts; attribution-only fish stays unwrapped.
// Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped.
if (shellName === 'fish' && options.emitReadyMarker) {
return {
args: ['-l', '-C', getFishShellReadyInitCommand(SHELL_READY_MARKER)],
@@ -435,6 +415,6 @@ export function getShellReadyLaunchConfig(shellPath: string): ShellLaunchConfig
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: true })
}
export function getAttributionShellLaunchConfig(shellPath: string): ShellLaunchConfig {
export function getMarkerlessShellLaunchConfig(shellPath: string): ShellLaunchConfig {
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: false })
}
@@ -9,6 +9,7 @@ import type { CreateOrAttachResult } from './terminal-host-create-contract'
import type { TerminalHostOptions } from './terminal-host-options'
import type { TerminalHostTombstones } from './terminal-host-tombstones'
import type { TerminalSessionTeardown } from './terminal-session-teardown'
import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window'
import { SessionNotFoundError } from './types'
import { resolveWslSessionContext } from './wsl-session-context'
@@ -107,6 +108,7 @@ export async function createOrAttachTerminalSession(
wslDistro
}),
shellReadySupported,
scrollback: resolveDaemonSessionScrollbackRows(),
historySeedChunks: opts.historySeedChunks,
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
wslDistro,
@@ -3,8 +3,8 @@
*
* SessionIds are minted fresh per pane and never reused, so a `TerminalHost`
* that never removes exited sessions from its `sessions` map leaks one dead
* `Session` — each pinning a `@xterm/headless` emulator with ~5000 rows of
* scrollback — per terminal for the lifetime of the long-lived daemon process.
* `Session` and its `@xterm/headless` scrollback grid per terminal for the
* lifetime of the long-lived daemon process.
*
* The fix wires a Session `onExit` hook to `TerminalHost.reapSession`, which
* disposes the emulator and drops the entry from the map. These tests assert the
+10 -4
View File
@@ -57,7 +57,7 @@ export class TerminalHost {
if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) {
throw new Error('agent_session_claim_unavailable')
}
return await createOrAttachTerminalSession(options, {
const result = await createOrAttachTerminalSession(options, {
sessions: this.sessions,
sessionTeardown: this.sessionTeardown,
killedTombstones: this.killedTombstones,
@@ -72,6 +72,7 @@ export class TerminalHost {
this.reapSession(sessionId)
}
})
return result
}
})
}
@@ -114,7 +115,7 @@ export class TerminalHost {
return Promise.resolve(killed)
}
// Why: dispose a dead session's emulator so exited terminals don't pin ~5000 rows of scrollback for the daemon's life.
// Why: dispose a dead session's emulator so exited terminals don't pin their scrollback window for the daemon's life.
private reapSession(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (!session || session.isAlive) {
@@ -130,8 +131,13 @@ export class TerminalHost {
}
detach(sessionId: string, token: symbol): void {
const session = this.sessions.get(sessionId)
session?.detachClient(token)
this.detachClients([{ sessionId, token }])
}
detachClients(attachments: readonly { sessionId: string; token: symbol }[]): void {
for (const { sessionId, token } of attachments) {
this.sessions.get(sessionId)?.detachClient(token)
}
}
async getCwd(sessionId: string): Promise<string | null> {
+112 -13
View File
@@ -216,6 +216,95 @@ describe('EmulatorBridge helper ownership', () => {
expect(bridge.getActiveForWorktree('wt-1')).toBeNull()
})
it('does not clean up a failed attach while another start claim can register the device', async () => {
const waitForEndpointReady = vi.fn(async () => true)
execServeSimCommandMock.mockResolvedValue({
device: 'device-1',
streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3102'
})
const bridge = new EmulatorBridge({ waitForEndpointReady })
const failedLease = await bridge.acquireHelperForDevice('device-1')
const survivingLease = await bridge.acquireHelperForDevice('device-1')
await failedLease.release({ cleanupIfUnused: true })
bridge.registerActiveEmulator('wt-surviving', survivingLease.info, { managed: true })
await survivingLease.release()
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled()
expect(bridge.getActiveForWorktree('wt-surviving')).toMatchObject({
deviceUdid: 'device-1'
})
})
it('cleans up a failed attach when no start claim or registered workspace remains', async () => {
const waitForEndpointReady = vi.fn(async () => true)
execServeSimCommandMock.mockResolvedValue({
device: 'device-1',
streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3102'
})
const bridge = new EmulatorBridge({ waitForEndpointReady })
const lease = await bridge.acquireHelperForDevice('device-1')
await lease.release({ cleanupIfUnused: true })
expect(killServeSimHelperProcessesForDeviceMock).toHaveBeenCalledWith('device-1', {
helperPid: undefined,
includeOrphaned: true
})
expect(shutdownSimulatorDeviceMock).toHaveBeenCalledWith('device-1')
})
it('keeps a shared device alive when one registered workspace detaches', async () => {
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
bridge.registerActiveEmulator('wt-2', session('device-1'), { managed: true })
await bridge.stopActiveManagedForWorktree('wt-1', { shutdownDevice: true })
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled()
expect(bridge.getActiveForWorktree('wt-1')).toBeNull()
expect(bridge.getActiveForWorktree('wt-2')).toMatchObject({ deviceUdid: 'device-1' })
})
it('keeps a device alive when its last registered workspace closes during another attach', async () => {
let finishStart:
| ((info: Awaited<ReturnType<typeof execServeSimCommandMock>>) => void)
| undefined
execServeSimCommandMock.mockImplementation(
() =>
new Promise((resolve) => {
finishStart = resolve
})
)
const bridge = new EmulatorBridge({ waitForEndpointReady: vi.fn(async () => true) })
bridge.registerActiveEmulator('wt-closing', session('device-1'), { managed: true })
const leasePromise = bridge.acquireHelperForDevice('device-1')
await vi.waitFor(() => expect(execServeSimCommandMock).toHaveBeenCalledOnce())
const shutdown = bridge.shutdownActiveManagedForWorktree('wt-closing')
await Promise.resolve()
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
finishStart?.({
device: 'device-1',
streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3102'
})
const lease = await leasePromise
bridge.registerActiveEmulator('wt-attaching', lease.info, { managed: true })
await lease.release()
await shutdown
expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled()
expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled()
expect(bridge.getActiveForWorktree('wt-attaching')).toMatchObject({ deviceUdid: 'device-1' })
})
it('reuses the active helper for the same requested device', async () => {
const waitForEndpointReady = vi.fn(async () => true)
const bridge = new EmulatorBridge({ waitForEndpointReady })
@@ -267,7 +356,7 @@ describe('EmulatorBridge helper ownership', () => {
})
const bridge = new EmulatorBridge({ waitForEndpointReady })
const info = await bridge.startHelperForDevice('device-1')
const { info } = await bridge.acquireHelperForDevice('device-1')
expect(info.deviceUdid).toBe('device-1')
expect(waitForEndpointReady).toHaveBeenCalledTimes(2)
@@ -309,7 +398,7 @@ describe('EmulatorBridge helper ownership', () => {
})
const bridge = new EmulatorBridge({ waitForEndpointReady })
await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({
await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({
code: 'emulator_helper_failed'
})
@@ -334,7 +423,7 @@ describe('EmulatorBridge helper ownership', () => {
})
const bridge = new EmulatorBridge({ waitForEndpointReady })
await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({
await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({
code: 'emulator_helper_failed'
})
@@ -368,7 +457,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -401,7 +491,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -435,7 +526,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
bridge.registerActiveEmulator('wt-other', session('device-1'), { managed: true })
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -465,7 +557,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
bridge.registerActiveEmulator('wt-1', session('device-active'), { managed: true })
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -499,7 +592,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
)
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -530,7 +624,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
)
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -551,7 +646,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -575,7 +671,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
const bridge = new EmulatorBridge()
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: false,
@@ -599,7 +696,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
const bridge = new EmulatorBridge({ waitForEndpointReady })
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
@@ -641,7 +739,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
const bridge = new EmulatorBridge({ waitForEndpointReady })
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'),
resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
+27 -27
View File
@@ -5,6 +5,11 @@ import type { SimulatorDevice } from './simctl-simulator-devices'
import type { EmulatorBridgeOptions } from './emulator-bridge-types'
import type { EmulatorGesturePoint } from './emulator-gesture-sender'
import { EmulatorSessionRegistry } from './emulator-session-registry'
import {
EmulatorStartLeaseRegistry,
type EmulatorStartLease
} from './emulator-start-lease-registry'
import { listAvailableEmulatorDevices } from './emulator-device-inventory'
import { deriveAxUrlFromStreamUrl } from './serve-sim-detached-session'
import { IosEmulatorBackend } from './backends/ios-emulator-backend'
import { AndroidEmulatorBackend } from './backends/android-emulator-backend'
@@ -22,6 +27,7 @@ import type {
// backend a command targets (by the session's recorded backend, else by device).
export class EmulatorBridge {
private readonly sessionRegistry = new EmulatorSessionRegistry()
private readonly startLeases = new EmulatorStartLeaseRegistry()
private readonly backends: EmulatorBackend[]
private readonly iosBackend: IosEmulatorBackend
private readonly androidBackend: AndroidEmulatorBackend
@@ -41,19 +47,7 @@ export class EmulatorBridge {
// Aggregated device list across host-supported backends (iOS simulators +
// Android devices/AVDs), for the unified `orca emulator list`.
async listAllDevices(): Promise<EmulatorDevice[]> {
const perBackend = await Promise.all(
this.backends.map(async (backend) => {
if (!backend.isSupportedOnHost()) {
return []
}
try {
return await backend.listDevices()
} catch {
return []
}
})
)
return perBackend.flat()
return listAvailableEmulatorDevices(this.backends)
}
// iOS-specific passthroughs kept for back-compat with the runtime + availability code.
@@ -85,14 +79,10 @@ export class EmulatorBridge {
return this.sessionRegistry.getActiveForWorktree(worktreeId)
}
getActiveBackendKind(worktreeId: string): EmulatorBackendKind | null {
return this.backendForActiveWorktree(worktreeId)?.kind ?? null
}
// On a device switch, keep slow-to-boot Android emulators running for instant
// switch-back; shut down other backends' devices so they are not leaked.
async stopActiveForSwitch(worktreeId: string): Promise<string | null> {
const keepAlive = this.getActiveBackendKind(worktreeId) === 'android'
const keepAlive = this.backendForActiveWorktree(worktreeId)?.kind === 'android'
return this.stopActiveForWorktreeInternal(worktreeId, { shutdownDevice: !keepAlive })
}
@@ -146,18 +136,26 @@ export class EmulatorBridge {
if (!session || (options.managedOnly && !session.managed)) {
return null
}
if (this.sessionRegistry.hasActiveWorktreeForSession(key)) {
return session.deviceUdid
}
const backend = this.backendForKind(session.backend)
if (!backend) {
return null
}
await backend.stopHelperForDevice(session.deviceUdid, {
helperPid: session.pid,
includeOrphaned: !options.managedOnly
})
if (options.shutdownDevice) {
await backend.shutdownDevice(session.deviceUdid).catch(() => {})
const sessionInfo = this.sessionRegistry.toSessionInfo(session)
await this.startLeases.cleanupWhenIdle(
backend,
sessionInfo,
(info) => this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid),
{
includeOrphaned: !options.managedOnly,
shutdownDevice: options.shutdownDevice
}
)
if (!this.sessionRegistry.hasActiveWorktreeForSession(key)) {
this.sessionRegistry.clearSessionAndWorktrees(key)
}
this.sessionRegistry.clearSessionAndWorktrees(key)
return session.deviceUdid
}
@@ -243,9 +241,11 @@ export class EmulatorBridge {
return run(backend, device)
}
async startHelperForDevice(device: string): Promise<EmulatorSessionInfo> {
async acquireHelperForDevice(device: string): Promise<EmulatorStartLease> {
const backend = await this.backendForDevice(device)
return backend.startSession(device)
return this.startLeases.acquire(backend, device, (info) =>
this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid)
)
}
async kill(device?: string, worktreeId?: string): Promise<string> {
@@ -0,0 +1,19 @@
import type { EmulatorBackend, EmulatorDevice } from './backends/emulator-backend'
export async function listAvailableEmulatorDevices(
backends: EmulatorBackend[]
): Promise<EmulatorDevice[]> {
const perBackend = await Promise.all(
backends.map(async (backend) => {
if (!backend.isSupportedOnHost()) {
return []
}
try {
return await backend.listDevices()
} catch {
return []
}
})
)
return perBackend.flat()
}
@@ -51,6 +51,14 @@ export class EmulatorSessionRegistry {
return this.sessions.get(key)
}
toSessionInfo(session: EmulatorSessionState): EmulatorSessionInfo {
return toSessionInfo(session)
}
hasActiveWorktreeForSession(key: string): boolean {
return [...this.activeByWorktree.values()].some((activeKey) => activeKey === key)
}
listSessions(): EmulatorSessionState[] {
return [...this.sessions.values()]
}
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest'
import type { EmulatorBackend } from './backends/emulator-backend'
import { EmulatorStartLeaseRegistry } from './emulator-start-lease-registry'
import type { EmulatorSessionInfo } from './emulator-types'
function makeBackend(): {
backend: EmulatorBackend
resolveDeviceId: ReturnType<typeof vi.fn>
startSession: ReturnType<typeof vi.fn>
stopHelperForDevice: ReturnType<typeof vi.fn>
shutdownDevice: ReturnType<typeof vi.fn>
} {
const resolveDeviceId = vi.fn(async () => {
throw new Error('shutdown AVD cannot resolve before boot')
})
const startSession = vi.fn(
async (): Promise<EmulatorSessionInfo> => ({
deviceUdid: 'emulator-5554',
streamUrl: 'scrcpy://emulator-5554',
wsUrl: '',
streamCodec: 'h264',
backend: 'android'
})
)
const stopHelperForDevice = vi.fn(async () => {})
const shutdownDevice = vi.fn(async () => {})
const backend: EmulatorBackend = {
kind: 'android',
streamCodec: 'h264',
capabilities: {
install: false,
launch: false,
permissions: false,
accessibilityTree: false,
logcat: false
},
isSupportedOnHost: () => true,
checkAvailability: vi.fn(),
listDevices: vi.fn(async () => []),
ownsDevice: vi.fn(async () => true),
resolveDeviceId,
startSession,
stopHelperForDevice,
shutdownDevice,
isSessionReusable: vi.fn(async () => true),
tap: vi.fn(async () => {}),
gesture: vi.fn(async () => {}),
type: vi.fn(async () => {}),
button: vi.fn(async () => {}),
rotate: vi.fn(async () => {}),
exec: vi.fn(async () => undefined)
}
return { backend, resolveDeviceId, startSession, stopHelperForDevice, shutdownDevice }
}
describe('EmulatorStartLeaseRegistry', () => {
it('lets the backend boot a shutdown device before a canonical id exists', async () => {
const { backend, resolveDeviceId, startSession } = makeBackend()
const registry = new EmulatorStartLeaseRegistry()
const lease = await registry.acquire(backend, 'Pixel_Tablet', () => false)
await lease.release()
expect(resolveDeviceId).not.toHaveBeenCalled()
expect(startSession).toHaveBeenCalledWith('Pixel_Tablet')
expect(lease.info.deviceUdid).toBe('emulator-5554')
})
it('waits for pending cleanup before starting a new lease', async () => {
const { backend, startSession, stopHelperForDevice, shutdownDevice } = makeBackend()
let finishCleanup: (() => void) | undefined
stopHelperForDevice.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishCleanup = resolve
})
)
const registry = new EmulatorStartLeaseRegistry()
const first = await registry.acquire(backend, 'Pixel_Tablet', () => false)
const cleanup = first.release({ cleanupIfUnused: true })
await vi.waitFor(() => expect(stopHelperForDevice).toHaveBeenCalledOnce())
const second = registry.acquire(backend, 'Pixel_Tablet', () => false)
await Promise.resolve()
expect(startSession).toHaveBeenCalledOnce()
finishCleanup?.()
await cleanup
const secondLease = await second
expect(shutdownDevice).toHaveBeenCalledOnce()
expect(startSession).toHaveBeenCalledTimes(2)
await secondLease.release()
})
it('claims synchronously before an earlier lease can start cleanup', async () => {
const { backend, stopHelperForDevice, shutdownDevice } = makeBackend()
const registry = new EmulatorStartLeaseRegistry()
const first = await registry.acquire(backend, 'Pixel_Tablet', () => false)
const second = registry.acquire(backend, 'Pixel_Tablet', () => false)
await first.release({ cleanupIfUnused: true })
expect(stopHelperForDevice).not.toHaveBeenCalled()
const secondLease = await second
await secondLease.release()
expect(stopHelperForDevice).toHaveBeenCalledOnce()
expect(shutdownDevice).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,127 @@
import type { EmulatorBackend } from './backends/emulator-backend'
import type { EmulatorSessionInfo } from './emulator-types'
export type EmulatorStartLease = {
info: EmulatorSessionInfo
release(options?: { cleanupIfUnused?: boolean }): Promise<void>
}
type PendingCleanup = {
info: EmulatorSessionInfo
isRegistered: (info: EmulatorSessionInfo) => boolean
includeOrphaned: boolean
shutdownDevice: boolean
}
export class EmulatorStartLeaseRegistry {
private readonly claimsByBackend = new Map<EmulatorBackend, number>()
private readonly cleanupByBackend = new Map<EmulatorBackend, Promise<void>>()
private readonly pendingCleanupByBackend = new Map<EmulatorBackend, Map<string, PendingCleanup>>()
async acquire(
backend: EmulatorBackend,
device: string,
isRegistered: (info: EmulatorSessionInfo) => boolean
): Promise<EmulatorStartLease> {
this.claimsByBackend.set(backend, (this.claimsByBackend.get(backend) ?? 0) + 1)
try {
await this.cleanupByBackend.get(backend)
const info = await backend.startSession(device)
let released = false
return {
info,
release: async (options = {}) => {
if (released) {
return
}
released = true
if (options.cleanupIfUnused) {
this.addPendingCleanup(backend, info, isRegistered, {
includeOrphaned: true,
shutdownDevice: true
})
}
await this.release(backend)
}
}
} catch (error) {
await this.release(backend)
throw error
}
}
async cleanupWhenIdle(
backend: EmulatorBackend,
info: EmulatorSessionInfo,
isRegistered: (info: EmulatorSessionInfo) => boolean,
options: { includeOrphaned?: boolean; shutdownDevice?: boolean } = {}
): Promise<void> {
this.addPendingCleanup(backend, info, isRegistered, options)
await this.drainCleanup(backend)
}
private async release(backend: EmulatorBackend): Promise<void> {
const remaining = this.decrement(backend)
if (remaining > 0) {
return
}
await this.drainCleanup(backend)
}
private async drainCleanup(backend: EmulatorBackend): Promise<void> {
await this.cleanupByBackend.get(backend)
if ((this.claimsByBackend.get(backend) ?? 0) > 0) {
return
}
const pending = this.pendingCleanupByBackend.get(backend)
this.pendingCleanupByBackend.delete(backend)
if (!pending) {
return
}
const cleanup = Promise.allSettled(
[...pending.values()].map(async ({ info, isRegistered, includeOrphaned, shutdownDevice }) => {
if (isRegistered(info)) {
return
}
await backend.stopHelperForDevice(info.deviceUdid, {
helperPid: info.helperPid,
includeOrphaned
})
if (shutdownDevice) {
await backend.shutdownDevice(info.deviceUdid)
}
})
)
.then(() => undefined)
.finally(() => this.cleanupByBackend.delete(backend))
this.cleanupByBackend.set(backend, cleanup)
await cleanup
}
private addPendingCleanup(
backend: EmulatorBackend,
info: EmulatorSessionInfo,
isRegistered: (info: EmulatorSessionInfo) => boolean,
options: { includeOrphaned?: boolean; shutdownDevice?: boolean }
): void {
const pending = this.pendingCleanupByBackend.get(backend) ?? new Map()
const existing = pending.get(info.deviceUdid)
pending.set(info.deviceUdid, {
info,
isRegistered,
includeOrphaned: options.includeOrphaned === true || existing?.includeOrphaned === true,
shutdownDevice: options.shutdownDevice === true || existing?.shutdownDevice === true
})
this.pendingCleanupByBackend.set(backend, pending)
}
private decrement(backend: EmulatorBackend): number {
const next = Math.max(0, (this.claimsByBackend.get(backend) ?? 1) - 1)
if (next === 0) {
this.claimsByBackend.delete(backend)
} else {
this.claimsByBackend.set(backend, next)
}
return next
}
}
+5
View File
@@ -119,6 +119,7 @@ import {
configureElectronNetworkCompatibility,
configureDevUserDataPath,
configureOrcaUserDataPathEnv,
disableUnsupportedChromiumFeatures,
enableMainProcessGpuFeatures,
installDevParentDisconnectQuit,
installDevParentSignalQuit,
@@ -184,6 +185,7 @@ import {
logStartupMilestone
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { removeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import {
createServeDesktopActivationGate,
@@ -846,6 +848,7 @@ if (hasSingleInstanceLock) {
platform: process.platform,
...getMainProcessLifecycleIdentity()
})
disableUnsupportedChromiumFeatures()
configureElectronNetworkCompatibility()
enableRendererHeapHeadroom()
maybeApplyGpuFallbackForThisLaunch()
@@ -2168,6 +2171,8 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
removeLegacyTerminalShimDir(app.getPath('userData'))
const windowsShellPathHydration = createWindowsShellPathHydration()
configureWindowsHostGitEnvironmentReadiness(
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
+75 -128
View File
@@ -26,14 +26,6 @@ const expectedOmpStatusExtension = posix.join(
'omp-managed-status-extension',
'orca-agent-status.ts'
)
function expectedAttributionShimDir(): string {
return join(
'/tmp/orca-user-data',
'orca-terminal-attribution',
process.platform === 'win32' ? 'win32' : 'posix'
)
}
const {
handleMock,
onMock,
@@ -1874,7 +1866,6 @@ describe('registerPtyHandlers', () => {
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
agentStatusHooksEnabled?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
@@ -1972,7 +1963,6 @@ describe('registerPtyHandlers', () => {
isPackaged: true,
userDataPath: '/tmp/orca-user-data',
selectedCodexHomePath: null,
githubAttributionEnabled: false,
agentStatusHooksEnabled: false,
isWsl: context?.isWsl,
wslDistro: context?.wslDistro
@@ -3136,59 +3126,6 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined()
})
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: true
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca <help@stably.ai>')
expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋')
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('skips git/gh attribution shims when attribution is disabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: false
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
})
it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => {
const daemonSpawn = vi.fn(async (options) => ({ id: 'daemon-pty', pid: 123, ...options }))
setLocalPtyProvider({
spawn: daemonSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {}
})
const env = daemonSpawn.mock.calls.at(-1)![0].env
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => {
const env = await spawnAndGetEnv(
undefined,
@@ -3490,7 +3427,6 @@ describe('registerPtyHandlers', () => {
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
@@ -3552,7 +3488,6 @@ describe('registerPtyHandlers', () => {
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => string | null,
getSettings?: () => {
enableGitHubAttribution?: boolean
httpProxyUrl?: string
httpProxyBypassRules?: string
},
@@ -4877,33 +4812,38 @@ describe('registerPtyHandlers', () => {
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
command: 'claude',
env: {
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
// Why: dev mode makes buildPtyHostEnv prepend its own CLI shim on every platform, so
// the promotion below has something to beat instead of passing trivially.
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prevPackaged = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
command: 'claude',
env: {
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca'
},
envToDelete: ['TERM_PROGRAM']
})
} finally {
mockedApp.isPackaged = prevPackaged
}
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
const spawnedPath = spawnOptions.env.PATH.split(delimiter)
expect(spawnedPath[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnedPath.some((entry) => entry.includes(join('cli', 'bin')))).toBe(true)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['TERM_PROGRAM']))
})
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
@@ -4923,38 +4863,38 @@ describe('registerPtyHandlers', () => {
}
})
it('prepends attribution shims on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
enableGitHubAttribution: true
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1')
expect(env.PATH).toContain(expectedAttributionShimDir())
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
undefined,
() => ({ enableGitHubAttribution: true }),
undefined,
{
command: 'claude',
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
}
)
// Why: dev mode makes buildPtyHostEnv prepend its own CLI shim on every platform, so
// the promotion below has something to beat instead of passing trivially.
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prevPackaged = mockedApp.isPackaged
mockedApp.isPackaged = false
let spawnOptions: Awaited<ReturnType<typeof daemonSpawnAndGetOptions>>
try {
spawnOptions = await daemonSpawnAndGetOptions(
{
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca'
},
undefined,
undefined,
undefined,
{
command: 'claude',
envToDelete: ['TERM_PROGRAM']
}
)
} finally {
mockedApp.isPackaged = prevPackaged
}
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir())
const spawnedPath = spawnOptions.env.PATH.split(delimiter)
expect(spawnedPath[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnedPath.some((entry) => entry.includes(join('cli', 'bin')))).toBe(true)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
expect(spawnOptions.envToDelete).toEqual(expect.arrayContaining(['TERM_PROGRAM']))
})
it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => {
@@ -4990,6 +4930,24 @@ describe('registerPtyHandlers', () => {
}
})
it('drops a legacy shim PATH entry inherited from the host process on the daemon path', async () => {
// Why: the daemon path passes a sparse env, so the prepends re-read PATH from
// process.env — the scrub must outlive that fallback (pre-upgrade host or parent pane).
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
PATH: `/tmp/orca-user-data/orca-terminal-attribution/posix${delimiter}/system/bin`
})
expect(env.PATH).not.toContain('orca-terminal-attribution')
expect(env.PATH).toContain('/system/bin')
} finally {
mockedApp.isPackaged = prev
}
})
it('defers indexed Git prompt guards from the daemon wire environment', async () => {
const env = await daemonSpawnAndGetEnv(
{
@@ -5169,14 +5127,6 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/ambient/pi/agent')
})
it('skips attribution shims on the daemon path when the setting is disabled', async () => {
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }, undefined, () => ({
enableGitHubAttribution: false
}))
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir())
})
it('does not mutate the caller-provided args.env on the daemon path', async () => {
// Why: the handler clones baseEnv so IPC-provided env stays pristine; a regression would leak Orca host env back into the renderer's reused copy.
const daemonSpawn = setupDaemonAdapter()
@@ -5357,7 +5307,6 @@ describe('registerPtyHandlers', () => {
// Why: host-local vars must be absent over SSH (they point at the local host/disk) — shipping them is useless or a credential leak.
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
@@ -8330,8 +8279,7 @@ describe('registerPtyHandlers', () => {
TMUX: '/tmp/orca-claude-agent-teams/team-stale,0,1',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-stale',
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
TERM_PROGRAM: 'Orca'
},
launchConfig: {
agentCommand: 'claude --teammate-mode auto',
@@ -8364,7 +8312,6 @@ describe('registerPtyHandlers', () => {
})
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/fresh-agent-teams')
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(result.launchConfig?.agentEnv).toMatchObject({
CLAUDE_PROFILE: 'captured',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
+11 -32
View File
@@ -111,11 +111,11 @@ import {
markClaudePtyExited,
markClaudePtySpawned
} from '../claude-accounts/live-pty-gate'
import {
applyTerminalAttributionEnv,
resolveAttributionShellFamily
} from '../attribution/terminal-attribution'
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
import {
isLegacyTerminalShimPathEntry,
stripLegacyTerminalShimEnv
} from '../pty/legacy-terminal-shim-dir'
import { registerPty, unregisterPty } from '../memory/pty-registry'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { track } from '../telemetry/client'
@@ -1218,14 +1218,12 @@ export type BuildPtyHostEnvOptions = {
* and strip only an inherited Orca-owned override so nested Orca panes do not
* leak the parent's managed home. A user-set CODEX_HOME is preserved. */
stripInheritedOrcaCodexHome?: boolean
githubAttributionEnabled: boolean
/** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent
* extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from
* disk presence (cross-agent shadowing when both dirs exist). */
launchCommand?: string
/** Trusted agent identity for wrapped commands that cannot be recognized from text. */
launchAgent?: TuiAgent
shellPath?: string
isWsl?: boolean
/** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */
wslDistro?: string | null
@@ -1253,7 +1251,9 @@ function promoteAgentTeamsShimPath(
return
}
const shimPath = firstPathEntry(requestedPath)
if (!shimPath) {
// Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that
// reached the front would be re-prepended here and outlive the scrub.
if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) {
return
}
const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path'
@@ -1987,22 +1987,9 @@ export function buildPtyHostEnv(
: bundledCliBin
}
// Why: PATH shims keep GitHub attribution scoped to Orca's own PTYs without rewriting user git config.
if (!opts.githubAttributionEnabled) {
delete baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION
delete baseEnv.ORCA_GIT_COMMIT_TRAILER
delete baseEnv.ORCA_GH_PR_FOOTER
delete baseEnv.ORCA_GH_ISSUE_FOOTER
delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR
}
applyTerminalAttributionEnv(baseEnv, {
enabled: opts.githubAttributionEnabled,
userDataPath: opts.userDataPath,
shellFamily: resolveAttributionShellFamily({
shellPath: opts.shellPath,
isWsl: opts.isWsl
})
})
// Why: must run after the prepends above — they re-read PATH from the unscrubbed
// process.env when baseEnv carries none, which is the daemon path's normal shape.
stripLegacyTerminalShimEnv(baseEnv, process.platform)
return baseEnv
}
@@ -2549,10 +2536,8 @@ export function registerPtyHandlers(
skipCodexHomeEnv,
settings: getSettings?.()
}),
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: ctx?.command,
launchAgent: ctx?.launchAgent,
shellPath: ctx?.shellPath,
isWsl: ctx?.isWsl,
wslDistro: ctx?.wslDistro ?? null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
@@ -4778,10 +4763,8 @@ export function registerPtyHandlers(
selectedCodexHomePath,
skipCodexHomeEnv,
stripInheritedOrcaCodexHome,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: daemonShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
@@ -6293,9 +6276,7 @@ export function registerPtyHandlers(
const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID
? baseEnv[resolvePathEnvKey(baseEnv, process.platform)]
: undefined
const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv
? ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
: undefined
const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined
if (baseEnv && stablePaneKey) {
baseEnv.ORCA_PANE_KEY = stablePaneKey
if (typeof args.tabId === 'string') {
@@ -6434,10 +6415,8 @@ export function registerPtyHandlers(
selectedCodexHomePath,
skipCodexHomeEnv,
stripInheritedOrcaCodexHome,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
+22
View File
@@ -2167,6 +2167,28 @@ describe('repos:add + repos:clone', () => {
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
})
it('persists agent worktree visibility through local repos:update', () => {
const updated = {
id: 'repo-agent-visibility',
path: '/tmp/repo-agent-visibility',
displayName: 'repo-agent-visibility',
kind: 'git',
badgeColor: '#22c55e',
agentWorktreeVisibility: 'show'
}
mockStore.updateRepo.mockReturnValue(updated)
const result = handlers.get('repos:update')!(null, {
repoId: updated.id,
updates: { agentWorktreeVisibility: 'show' }
})
expect(result).toBe(updated)
expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, {
agentWorktreeVisibility: 'show'
})
})
it('prepares and invalidates roots when project host setup update changes worktree base path', () => {
const repo = {
id: 'repo-setup-update-root',
+12 -1
View File
@@ -41,6 +41,7 @@ import { isTuiAgent } from '../../shared/tui-agent-config'
import { TaskSourceContextSchema } from '../../shared/task-source-context-schema'
import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context'
import { DiffCommentSchema } from '../../shared/diff-comment-schema'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'node:child_process'
import { access, mkdir, readdir, rm } from 'node:fs/promises'
@@ -951,7 +952,8 @@ const FolderWorkspaceUpdateArgs = z.object({
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional(),
firstAgentMessageRenameError: z.string().nullable().optional(),
lastActivityAt: z.number().finite().optional()
lastActivityAt: z.number().finite().optional(),
diffComments: z.array(DiffCommentSchema).optional()
})
.superRefine(assertFolderWorkspaceLinkedSourceContextMatch)
})
@@ -2124,6 +2126,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'projectGroupId'
| 'projectGroupOrder'
>
@@ -2194,6 +2197,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
) {
delete updates.externalWorktreeVisibility
}
if (
'agentWorktreeVisibility' in updates &&
updates.agentWorktreeVisibility !== undefined &&
updates.agentWorktreeVisibility !== 'hide' &&
updates.agentWorktreeVisibility !== 'show'
) {
delete updates.agentWorktreeVisibility
}
if (
'externalWorktreeVisibilityPromptDismissedAt' in updates &&
updates.externalWorktreeVisibilityPromptDismissedAt !== undefined &&
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
createLineEditorReadyOutputScanState,
scanForLineEditorReadyOutput
} from './line-editor-ready-output-scanner'
describe('line editor ready output scanner', () => {
it.each(['\x1b[?2004h', '\x1b[?1034h'])('detects %j across chunks', (sequence) => {
const state = createLineEditorReadyOutputScanState()
expect(scanForLineEditorReadyOutput(state, `prompt${sequence.slice(0, 4)}`)).toBe(false)
expect(scanForLineEditorReadyOutput(state, sequence.slice(4))).toBe(true)
})
it('ignores ordinary prompt output', () => {
expect(
scanForLineEditorReadyOutput(createLineEditorReadyOutputScanState(), 'user@host % ')
).toBe(false)
})
})
@@ -0,0 +1,19 @@
const LINE_EDITOR_READY_SEQUENCES = ['\x1b[?2004h', '\x1b[?1034h'] as const
const MAX_SEQUENCE_LENGTH = Math.max(...LINE_EDITOR_READY_SEQUENCES.map((value) => value.length))
export type LineEditorReadyOutputScanState = {
tail: string
}
export function createLineEditorReadyOutputScanState(): LineEditorReadyOutputScanState {
return { tail: '' }
}
export function scanForLineEditorReadyOutput(
state: LineEditorReadyOutputScanState,
data: string
): boolean {
const combined = state.tail + data
state.tail = combined.slice(-(MAX_SEQUENCE_LENGTH - 1))
return LINE_EDITOR_READY_SEQUENCES.some((sequence) => combined.includes(sequence))
}
@@ -20,6 +20,7 @@ import {
resetHostReadableTranscriptPathCacheForTests,
toHostReadableTranscriptPath
} from './host-readable-transcript-path'
import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate'
type AccessControl = {
resolve: () => void
@@ -97,4 +98,27 @@ describe('WSL transcript filesystem gate', () => {
releaseWsl?.()
await expect(wslProbe).resolves.toBe(firstUncPath)
})
// Last on purpose: the never-settling Ubuntu probe leaves its gate permit
// stuck for the rest of this module's lifetime.
it('falls through to the next distro when a probe exceeds the gate deadline', async () => {
vi.useFakeTimers()
const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\ada'
const debianUncPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\first.jsonl'
try {
fsMocks.access.mockImplementation((path) =>
path === firstUncPath ? new Promise<void>(() => {}) : Promise.resolve()
)
const resolved = toHostReadableTranscriptPath(firstGuestPath, {
platform: 'win32',
listWslHomeDirs: async () => [UBUNTU_HOME, DEBIAN_HOME]
})
await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledWith(firstUncPath))
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
await expect(resolved).resolves.toBe(debianUncPath)
} finally {
vi.useRealTimers()
}
})
})
@@ -7,6 +7,7 @@ import {
toHostReadableTranscriptPath,
wslCodexSessionsDirs
} from './host-readable-transcript-path'
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\other'
@@ -109,6 +110,38 @@ describe('toHostReadableTranscriptPath', () => {
).resolves.toBeNull()
})
it('prefers a later distro hit over an earlier gate refusal', async () => {
// Guest path under Debian's $HOME so the refusing Debian probe ranks first.
await expect(
toHostReadableTranscriptPath('/home/other/x.jsonl', {
platform: 'win32',
pathExists: async (candidate) => {
if (candidate.includes('Debian')) {
throw new WslTranscriptFsError('timeout', 'slow share')
}
return candidate.includes('Ubuntu')
},
listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME]
})
).resolves.toBe('\\\\wsl.localhost\\Ubuntu\\home\\other\\x.jsonl')
})
it('reports unavailability, not a miss, when a refused distro was never probed', async () => {
const refusal = new WslTranscriptFsError('timeout', 'slow share')
await expect(
toHostReadableTranscriptPath('/home/other/x.jsonl', {
platform: 'win32',
pathExists: async (candidate) => {
if (candidate.includes('Debian')) {
throw refusal
}
return false
},
listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME]
})
).rejects.toBe(refusal)
})
it('does not translate guest paths off Windows', async () => {
await expect(
toHostReadableTranscriptPath('/home/ada/rollout.jsonl', {
@@ -4,7 +4,11 @@ import { isAbsolute } from 'node:path'
import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate'
import {
runWslTranscriptFsTask,
wslTranscriptFsRefusal,
type WslTranscriptFsError
} from './wsl-transcript-fs-gate'
/**
* True for guest-absolute Linux paths that Win32 cannot open as-is.
@@ -136,12 +140,22 @@ export async function toHostReadableTranscriptPath(
const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs)
// Sequential on purpose: the ranked order picks the owning distro, and probing
// every distro at once would fan out 9P calls to ones the user left stopped.
let unavailable: WslTranscriptFsError | undefined
for (const distro of rankDistrosForGuestPath(homeDirs, path)) {
const uncPath = toWindowsWslPath(path, distro)
if (await pathExists(uncPath)) {
return uncPath
try {
if (await pathExists(uncPath)) {
return uncPath
}
} catch (error) {
// Why: one stalled distro must not hide another distro's hit.
unavailable = wslTranscriptFsRefusal(error)
}
}
// No hit and at least one distro never probed: "couldn't look", not "absent".
if (unavailable) {
throw unavailable
}
return null
}
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WslTranscriptFsGateModule from './wsl-transcript-fs-gate'
const WSL_SESSIONS_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions'
const DEBIAN_SESSIONS_DIR = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions'
const LOCAL_SESSIONS_DIR = 'C:\\Users\\ada\\.codex\\sessions'
const mocks = vi.hoisted(() => ({
gate: vi.fn(async () => []),
gate: vi.fn(async (_options: { path: string }) => []),
walk: vi.fn(
async (
dir: string,
@@ -18,7 +20,8 @@ const mocks = vi.hoisted(() => ({
)
}))
vi.mock('./wsl-transcript-fs-gate', () => ({
vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => ({
...(await importOriginal<typeof WslTranscriptFsGateModule>()),
runWslTranscriptFsTask: mocks.gate
}))
vi.mock('../ai-vault/session-scanner-discovery', () => ({
@@ -26,6 +29,7 @@ vi.mock('../ai-vault/session-scanner-discovery', () => ({
}))
import { resolveSessionFilePath } from './session-file-resolver'
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
beforeEach(() => {
mocks.gate.mockClear()
@@ -53,4 +57,77 @@ describe('Codex WSL scan gate', () => {
expect(mocks.gate).not.toHaveBeenCalled()
expect(mocks.walk).toHaveBeenCalledTimes(1)
})
it("still surfaces a later root's hit when an earlier root is gate-refused", async () => {
const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`
mocks.gate.mockImplementation(async (options) => {
if (options.path.includes('Ubuntu')) {
throw new WslTranscriptFsError('timeout', 'slow share')
}
return []
})
mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => {
await options.readDirectory?.(dir)
return dir === DEBIAN_SESSIONS_DIR ? [hit] : []
})
await expect(
resolveSessionFilePath('codex', 'session-id', {
codexSessionsDirs: [WSL_SESSIONS_DIR, DEBIAN_SESSIONS_DIR]
})
).resolves.toBe(hit)
})
it('reports unavailability, not a miss, when every scanned root is gate-refused', async () => {
const refusal = new WslTranscriptFsError('unavailable', 'stuck permits')
mocks.gate.mockRejectedValue(refusal)
await expect(
resolveSessionFilePath('codex', 'session-id', {
codexSessionsDirs: [WSL_SESSIONS_DIR]
})
).rejects.toBe(refusal)
})
it('falls through a gate-refused hook path to an id-based hit', async () => {
const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`
mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => {
if (options.operation === 'access') {
throw new WslTranscriptFsError('timeout', 'slow share')
}
return []
})
mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => {
await options.readDirectory?.(dir)
return dir === DEBIAN_SESSIONS_DIR ? [hit] : []
})
await expect(
resolveSessionFilePath('codex', 'session-id', {
transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`,
codexSessionsDirs: [DEBIAN_SESSIONS_DIR]
})
).resolves.toBe(hit)
})
it('surfaces the hook-path refusal when the id search also misses', async () => {
const refusal = new WslTranscriptFsError('unavailable', 'stuck permits')
mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => {
if (options.operation === 'access') {
throw refusal
}
return []
})
mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => {
await options.readDirectory?.(dir)
return []
})
await expect(
resolveSessionFilePath('codex', 'session-id', {
transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`,
codexSessionsDirs: [LOCAL_SESSIONS_DIR]
})
).rejects.toBe(refusal)
})
})
+52 -14
View File
@@ -1,7 +1,10 @@
import { homedir } from 'node:os'
import { basename, extname, join } from 'node:path'
import type { AgentType } from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import {
resolveNativeChatTranscriptAgent,
type NativeChatTranscriptAgent
} from '../../shared/native-chat-agent-support'
import { isWslUncPath } from '../../shared/wsl-paths'
import { walkSessionFiles } from '../ai-vault/session-scanner-discovery'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts'
@@ -13,6 +16,7 @@ import {
} from '../../shared/grok-session-paths'
import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path'
import { findWslCodexSessionPath } from './wsl-codex-session-path-scan'
import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate'
// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads
// run in the main process against the runtime's own home directory; over SSH
@@ -95,14 +99,34 @@ export async function resolveSessionFilePath(
// beats reconstructing a path from the session id. Route it through the host
// readability check so a WSL guest path becomes an openable UNC on Windows;
// stale/missing paths fall through to the id-based search.
let unavailable: WslTranscriptFsError | undefined
const hookPath = options.transcriptPath?.trim()
if (hookPath && extname(hookPath) === '.jsonl') {
const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal })
if (hostReadable) {
return hostReadable
try {
const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal })
if (hostReadable) {
return hostReadable
}
} catch (error) {
// Why: the id-based search may still hit; surface the refusal only when
// it does not, so a stalled distro reads as unavailable, never "missing".
unavailable = wslTranscriptFsRefusal(error)
}
}
const resolved = await resolveSessionFileById(transcriptAgent, sessionId, options, signal)
if (!resolved && unavailable) {
throw unavailable
}
return resolved
}
async function resolveSessionFileById(
transcriptAgent: NativeChatTranscriptAgent,
sessionId: string,
options: ResolveSessionFileOptions,
signal?: AbortSignal
): Promise<string | null> {
const trimmedId = sessionId.trim()
if (!trimmedId) {
return null
@@ -132,6 +156,10 @@ export async function resolveSessionFilePath(
if (transcriptAgent === 'omp') {
return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal)
}
// Why: a new transcript agent must pick its own resolver. Falling through to
// OMP's scan would search the wrong root with a foreign session id, so fail
// the build here instead of resolving silently wrong at runtime.
transcriptAgent satisfies never
return null
}
@@ -178,6 +206,7 @@ async function findCodexRolloutInDirs(
sessionsDirs: string[],
signal?: AbortSignal
): Promise<string | null> {
let unavailable: WslTranscriptFsError | undefined
for (const sessionsDir of sessionsDirs) {
// Why: no existence pre-check — walkSessionFiles already yields [] for a
// missing/unreadable root, and a sync probe would block the main thread on a
@@ -190,18 +219,27 @@ async function findCodexRolloutInDirs(
}
}
const isWslRoot = isWslUncPath(sessionsDir)
const files = isWslRoot
? await findWslCodexSessionPath(sessionsDir, sessionId, signal)
: (
await walkSessionFiles(sessionsDir, 'codex', [], {
...scanOptions,
signal
})
)[0]
if (files) {
return files
try {
const files = isWslRoot
? await findWslCodexSessionPath(sessionsDir, sessionId, signal)
: (
await walkSessionFiles(sessionsDir, 'codex', [], {
...scanOptions,
signal
})
)[0]
if (files) {
return files
}
} catch (error) {
// Why: one stalled distro must not hide another root's hit.
unavailable = wslTranscriptFsRefusal(error)
}
}
// No hit and at least one root never scanned: "couldn't look", not "missing".
if (unavailable) {
throw unavailable
}
return null
}
@@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises'
import type { AgentType } from '../../shared/native-chat-types'
import { resolveSessionFilePath } from './session-file-resolver'
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
// Why: both the desktop IPC handler and the runtime RPC handler read the same
// host-filesystem transcript, so a single process-global cache keyed by the
@@ -89,7 +90,14 @@ export async function readNativeChatTranscriptCached(
/** Hook-reported authoritative transcript path, preferred over the id glob. */
transcriptPath?: string
): Promise<ReadTranscriptResult> {
const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
let filePath: string | null
try {
filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
} catch (err) {
// Why: gate refusal is transient unavailability — report it without caching
// or `notFound` so the next call re-resolves.
return { error: wslTranscriptFsRefusal(err).message }
}
if (!filePath) {
// Not cached (see below): a not-yet-flushed transcript should be re-checked
// on the next call, not pinned as a settled miss (#8401).
+9 -1
View File
@@ -7,6 +7,7 @@ import type {
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { errorMessage } from '../ai-vault/session-scanner-values'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
import {
decodeClaudeTranscriptLine,
decodeCodexTranscriptLine,
@@ -41,7 +42,14 @@ export async function readNativeChatTranscript(
sessionId: string,
options: ReadTranscriptOptions = {}
): Promise<ReadTranscriptResult> {
const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
let filePath: string | null
try {
filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
} catch (err) {
// Why: gate refusal is transient unavailability with retry guidance —
// `notFound` would settle callers into a false "missing" state.
return { error: wslTranscriptFsRefusal(err).message }
}
if (!filePath) {
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
}
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
resolve: vi.fn<() => Promise<string | null>>()
}))
vi.mock('./session-file-resolver', () => ({
resolveSessionFilePath: mocks.resolve
}))
import { readNativeChatTranscriptTail } from './transcript-tail-reader'
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
describe('native chat transcript tail under WSL gate refusals', () => {
beforeEach(() => {
mocks.resolve.mockReset()
})
it('reports a gate refusal as a retryable error, not a missing transcript', async () => {
mocks.resolve.mockRejectedValueOnce(new WslTranscriptFsError('timeout', 'slow share'))
await expect(
readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 })
).resolves.toEqual({ error: 'slow share' })
})
it('rethrows non-gate resolver failures', async () => {
mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed'))
await expect(
readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 })
).rejects.toThrow('resolver crashed')
})
})
+12 -3
View File
@@ -17,6 +17,7 @@ import {
nativeChatTurnLifecycleDecoderForAgent,
type NativeChatTurnLifecycleDecoder
} from './transcript-turn-lifecycle'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024
const TAIL_CHUNK_BYTES = 64 * 1024
@@ -231,12 +232,20 @@ export async function readNativeChatTranscriptTail(
> {
const decode = nativeChatLineDecoderForAgent(args.agent)
const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent)
const filePath =
args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal))
signal?.throwIfAborted()
if (!decode) {
return { error: 'Transcript unavailable' }
}
let filePath: string | null
try {
filePath =
args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal))
} catch (error) {
signal?.throwIfAborted()
// Why: gate refusal is transient unavailability with retry guidance —
// `notFound` would settle callers into a false "missing" state.
return { error: wslTranscriptFsRefusal(error).message }
}
signal?.throwIfAborted()
// Why: a new agent session can report its id before the first JSONL flush;
// callers keep that miss in loading/retry rather than showing a false error.
if (!filePath) {
@@ -20,6 +20,7 @@ vi.mock('./host-readable-transcript-path', async (importOriginal) => ({
}))
import { subscribeNativeChatTranscript } from './transcript-watch'
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
const realPlatform = process.platform
@@ -125,6 +126,41 @@ describe('native chat transcript resolve polling', () => {
subscription.unsubscribe()
})
it('degrades a gate-refused initial resolve to the poll fallback instead of failing', async () => {
const engine = { unsubscribe: vi.fn(), watching: true }
mocks.resolve
.mockRejectedValueOnce(new WslTranscriptFsError('unavailable', 'stuck permits'))
.mockResolvedValue('/home/ada/found.jsonl')
mocks.install.mockImplementation((filePath: string) => (filePath ? engine : null))
const subscription = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session-id',
resolvePollIntervalMs: 10,
onAppend: () => {}
})
expect(subscription.watching).toBe(true)
await vi.advanceTimersByTimeAsync(20)
expect(
mocks.install.mock.calls.some(([filePath]) => filePath === '/home/ada/found.jsonl')
).toBe(true)
subscription.unsubscribe()
expect(engine.unsubscribe).toHaveBeenCalled()
})
it('still fails subscribe on non-gate resolver errors', async () => {
mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed'))
await expect(
subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session-id',
onAppend: () => {}
})
).rejects.toThrow('resolver crashed')
})
it('cancels queued WSL resolution when the subscription closes', async () => {
setPlatform('win32')
let receivedSignal: AbortSignal | undefined
+11 -1
View File
@@ -11,6 +11,7 @@ import type {
SubscribeNativeChatTranscriptArgs
} from './transcript-watch-contract'
import { nativeChatLineDecoderForAgent } from './transcript-tail-reader'
import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
export { readNativeChatTranscriptTail } from './transcript-tail-reader'
export { getActiveNativeChatWatcherCount } from './transcript-watch-engine'
@@ -207,7 +208,16 @@ export async function subscribeNativeChatTranscript(
return { unsubscribe: () => {}, watching: false }
}
const installed = await attemptInstall(args, decode, setupSignal)
let installed: NativeChatTranscriptSubscription | null
try {
installed = await attemptInstall(args, decode, setupSignal)
} catch (error) {
setupSignal?.throwIfAborted()
// Why: a gate-refused resolve (stalled WSL distro) must degrade to the
// resolve-poll fallback below, not fail the subscribe outright.
void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal
installed = null
}
if (installed) {
return installed
}
@@ -1,5 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import {
runWslTranscriptFsTask,
WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS,
WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS,
WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK,
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
} from './wsl-transcript-fs-gate'
const SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
const CAPACITY_MESSAGE =
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
function deferred<T>(): {
promise: Promise<T>
@@ -28,6 +40,16 @@ function run(
}
describe('WSL transcript filesystem task scheduling', () => {
let warnSpy: MockInstance
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('does not block an exact probe behind a running scan on the same route', async () => {
const stalled = deferred<string>()
const scanTask = vi.fn(() => stalled.promise)
@@ -97,8 +119,9 @@ describe('WSL transcript filesystem task scheduling', () => {
const abandonedTask = vi.fn(async () => 'unused')
const abandoned = run('\\\\wsl.localhost\\Fedora\\a', 'scan', abandonedTask, controller.signal)
controller.abort(new Error('closed subscription'))
await expect(abandoned).rejects.toThrow('closed subscription')
const reason = new Error('closed subscription')
controller.abort(reason)
await expect(abandoned).rejects.toBe(reason)
ubuntu.resolve('ubuntu')
debian.resolve('debian')
await Promise.all([first, second])
@@ -125,8 +148,9 @@ describe('WSL transcript filesystem task scheduling', () => {
const second = run('\\\\wsl.localhost\\Ubuntu\\shared', 'exact', task, secondController.signal)
await vi.waitFor(() => expect(task).toHaveBeenCalledOnce())
firstController.abort(new Error('first closed'))
await expect(first).rejects.toThrow('first closed')
const reason = new Error('first closed')
firstController.abort(reason)
await expect(first).rejects.toBe(reason)
pending.resolve('second')
await expect(second).resolves.toBe('second')
expect(task).toHaveBeenCalledOnce()
@@ -175,4 +199,436 @@ describe('WSL transcript filesystem task scheduling', () => {
await expect(Promise.all(tasks)).resolves.toEqual(['0', '1', '2', '3'])
})
it.each([
['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS],
['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS]
] as const)('rejects a stalled %s waiter at its deadline', async (priority, deadlineMs) => {
vi.useFakeTimers()
const stalled = deferred<string>()
try {
const task = vi.fn(() => stalled.promise)
const pending = run(`\\\\wsl.localhost\\Deadline-${priority}\\a`, priority, task)
const rejected = expect(pending).rejects.toMatchObject({
name: 'WslTranscriptFsError',
code: 'timeout',
message: SLOW_MESSAGE
})
let settled = false
void pending.then(
() => (settled = true),
() => (settled = true)
)
await vi.advanceTimersByTimeAsync(0)
expect(task).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(deadlineMs - 1)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await rejected
} finally {
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it.each([
['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS],
['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS]
] as const)('completes healthy %s work below its deadline', async (priority, deadlineMs) => {
vi.useFakeTimers()
const work = deferred<string>()
try {
const pending = run(`\\\\wsl.localhost\\Healthy-${priority}\\a`, priority, () => work.promise)
await vi.advanceTimersByTimeAsync(deadlineMs - 1)
work.resolve('healthy')
await expect(pending).resolves.toBe('healthy')
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(deadlineMs)
expect(vi.getTimerCount()).toBe(0)
} finally {
work.resolve('healthy')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('does not let one timed-out waiter settle a later shared waiter', async () => {
vi.useFakeTimers()
const work = deferred<string>()
const task = vi.fn(() => work.promise)
try {
const path = '\\\\wsl.localhost\\Ubuntu\\staggered'
const first = run(path, 'exact', task)
const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(10_000)
const second = run(path, 'exact', task)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000)
await firstRejected
work.resolve('shared')
await expect(second).resolves.toBe('shared')
expect(task).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
} finally {
work.resolve('shared')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('rejects newest work when the pending queue is full', async () => {
vi.useFakeTimers()
const ubuntu = deferred<string>()
const debian = deferred<string>()
const queuedController = new AbortController()
try {
const ubuntuTask = vi.fn(() => ubuntu.promise)
const debianTask = vi.fn(() => debian.promise)
const first = run('\\\\wsl.localhost\\Ubuntu\\occupied', 'exact', ubuntuTask)
const second = run('\\\\wsl.localhost\\Debian\\occupied', 'exact', debianTask)
await vi.advanceTimersByTimeAsync(0)
const queued = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS }, (_, index) =>
run(
`\\\\wsl.localhost\\Queued-${index}\\a`,
'exact',
async () => String(index),
index === 0 ? queuedController.signal : undefined
)
)
const overflowTask = vi.fn(async () => 'overflow')
await expect(
run('\\\\wsl.localhost\\Overflow\\a', 'exact', overflowTask)
).rejects.toMatchObject({ code: 'capacity', message: CAPACITY_MESSAGE })
expect(overflowTask).not.toHaveBeenCalled()
const reason = new Error('queued caller closed')
queuedController.abort(reason)
await expect(queued[0]).rejects.toBe(reason)
const replacement = run('\\\\wsl.localhost\\Replacement\\a', 'exact', async () => 'new')
ubuntu.resolve('ubuntu')
debian.resolve('debian')
await expect(
Promise.all([first, second, ...queued.slice(1), replacement])
).resolves.toHaveLength(WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS + 2)
expect(vi.getTimerCount()).toBe(0)
} finally {
ubuntu.resolve('ubuntu')
debian.resolve('debian')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('caps callers waiting on shared work', async () => {
vi.useFakeTimers()
const stalled = deferred<string>()
const task = vi.fn(() => stalled.promise)
const firstController = new AbortController()
try {
const path = '\\\\wsl.localhost\\Ubuntu\\shared-cap'
const waiters = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK }, (_, index) =>
run(path, 'exact', task, index === 0 ? firstController.signal : undefined)
)
await vi.advanceTimersByTimeAsync(0)
await expect(run(path, 'exact', task)).rejects.toMatchObject({
code: 'capacity',
message: CAPACITY_MESSAGE
})
const reason = new Error('first waiter closed')
firstController.abort(reason)
await expect(waiters[0]).rejects.toBe(reason)
const replacement = run(path, 'exact', task)
stalled.resolve('shared')
await expect(Promise.all([...waiters.slice(1), replacement])).resolves.toEqual(
Array(WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK).fill('shared')
)
expect(task).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
} finally {
stalled.resolve('shared')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('fails new work fast after both running permits stall', async () => {
vi.useFakeTimers()
const ubuntu = deferred<string>()
const debian = deferred<string>()
try {
const first = run('\\\\wsl.localhost\\Ubuntu\\stalled', 'exact', () => ubuntu.promise)
const second = run('\\\\wsl.localhost\\Debian\\stalled', 'exact', () => debian.promise)
const firstRejected = expect(first).rejects.toBeInstanceOf(WslTranscriptFsError)
const secondRejected = expect(second).rejects.toBeInstanceOf(WslTranscriptFsError)
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
await Promise.all([firstRejected, secondRejected])
const laterTask = vi.fn(async () => 'later')
await expect(
run('\\\\wsl.localhost\\Fedora\\later', 'exact', laterTask)
).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE })
expect(laterTask).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
} finally {
ubuntu.resolve('ubuntu')
debian.resolve('debian')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('fails only work needing a stuck lane fast, keeping the other permit usable', async () => {
vi.useFakeTimers()
const stalled = deferred<string>()
try {
const stuck = run('\\\\wsl.localhost\\Ubuntu\\lane-stuck', 'exact', () => stalled.promise)
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
await stuckRejected
const sameLaneTask = vi.fn(async () => 'same-lane')
await expect(
run('\\\\wsl.localhost\\Ubuntu\\lane-stuck-sibling', 'exact', sameLaneTask)
).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE })
expect(sameLaneTask).not.toHaveBeenCalled()
await expect(
run('\\\\wsl.localhost\\Debian\\healthy-lane', 'exact', async () => 'debian')
).resolves.toBe('debian')
} finally {
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('fails scans and same-route probes fast while a scan is stuck', async () => {
vi.useFakeTimers()
const stalled = deferred<string>()
try {
const stuckScan = run('\\\\wsl.localhost\\Ubuntu\\stuck-tree', 'scan', () => stalled.promise)
const stuckRejected = expect(stuckScan).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS)
await stuckRejected
const otherScanTask = vi.fn(async () => 'other-scan')
await expect(
run('\\\\wsl.localhost\\Debian\\other-tree', 'scan', otherScanTask)
).rejects.toMatchObject({ code: 'unavailable' })
expect(otherScanTask).not.toHaveBeenCalled()
// The whole Ubuntu mount is hung — an exact probe there would only burn
// the remaining permit on it.
await expect(
run('\\\\wsl.localhost\\Ubuntu\\live-probe', 'exact', async () => 'exact')
).rejects.toMatchObject({ code: 'unavailable' })
await expect(
run('\\\\wsl.localhost\\Debian\\live-probe', 'exact', async () => 'exact')
).resolves.toBe('exact')
} finally {
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('keeps a stuck exact probe from feeding a doomed scan the second permit', async () => {
vi.useFakeTimers()
const stalled = deferred<string>()
try {
const stuck = run('\\\\wsl.localhost\\Ubuntu\\hung-file', 'exact', () => stalled.promise)
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
await stuckRejected
const doomedScanTask = vi.fn(async () => 'doomed')
await expect(
run('\\\\wsl.localhost\\Ubuntu\\tree', 'scan', doomedScanTask)
).rejects.toMatchObject({ code: 'unavailable' })
expect(doomedScanTask).not.toHaveBeenCalled()
await expect(
run('\\\\wsl.localhost\\Debian\\tree', 'scan', async () => 'debian-scan')
).resolves.toBe('debian-scan')
} finally {
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('admits new work again once stuck work finally settles', async () => {
vi.useFakeTimers()
const stalled = deferred<string>()
try {
const stuck = run('\\\\wsl.localhost\\Ubuntu\\recovering', 'exact', () => stalled.promise)
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
await stuckRejected
await expect(
run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'blocked')
).rejects.toMatchObject({ code: 'unavailable' })
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
await expect(
run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'recovered')
).resolves.toBe('recovered')
expect(vi.getTimerCount()).toBe(0)
} finally {
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('refuses to join in-flight work already past its own deadline', async () => {
vi.useFakeTimers()
const work = deferred<string>()
const task = vi.fn(() => work.promise)
try {
const path = '\\\\wsl.localhost\\Ubuntu\\join-stuck'
const first = run(path, 'exact', task)
const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(10_000)
const second = run(path, 'exact', task)
const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000)
await firstRejected
const joinTask = vi.fn(async () => 'join')
await expect(run(path, 'exact', joinTask)).rejects.toMatchObject({ code: 'unavailable' })
expect(joinTask).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(10_000)
await secondRejected
} finally {
work.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('fails joins onto queued work fast when stuck I/O keeps it from running', async () => {
vi.useFakeTimers()
const stalled = deferred<string>()
const queuedTask = vi.fn(async () => 'queued')
try {
const stuck = run('\\\\wsl.localhost\\Ubuntu\\trap-hung', 'exact', () => stalled.promise)
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(5_000)
// Queued behind the same route before the hang is detected.
const queuedPath = '\\\\wsl.localhost\\Ubuntu\\trap-queued'
const queued = run(queuedPath, 'exact', queuedTask)
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 5_000)
await stuckRejected
// Re-requests must not keep the doomed queued task alive with fresh
// deadlines — that would defeat the fail-fast for as long as I/O hangs.
await expect(run(queuedPath, 'exact', queuedTask)).rejects.toMatchObject({
code: 'unavailable'
})
await vi.advanceTimersByTimeAsync(5_000)
await queuedRejected
expect(queuedTask).not.toHaveBeenCalled()
} finally {
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('does not feed a stuck route the other permit from the queue', async () => {
vi.useFakeTimers()
const scanWork = deferred<string>()
const debianWork = deferred<string>()
const ubuntuTask = vi.fn(async () => 'ubuntu')
try {
const scanU = run(
'\\\\wsl.localhost\\Ubuntu\\queued-route-tree',
'scan',
() => scanWork.promise
)
const exactD = run(
'\\\\wsl.localhost\\Debian\\queued-route-d',
'exact',
() => debianWork.promise
)
const scanRejected = expect(scanU).rejects.toMatchObject({ code: 'timeout' })
const exactDRejected = expect(exactD).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(35_000)
await exactDRejected
// Queued before Ubuntu's hang is detected at the 60s scan deadline.
const queuedU = run('\\\\wsl.localhost\\Ubuntu\\queued-route-file', 'exact', ubuntuTask)
const queuedURejected = expect(queuedU).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(25_000)
await scanRejected
debianWork.resolve('debian')
await vi.advanceTimersByTimeAsync(0)
expect(ubuntuTask).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5_000)
await queuedURejected
expect(ubuntuTask).not.toHaveBeenCalled()
} finally {
scanWork.resolve('late')
debianWork.resolve('debian')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('warns once per task that outlives its deadline while running', async () => {
vi.useFakeTimers()
const stalled = deferred<string>()
try {
const stuck = run('\\\\wsl.localhost\\Ubuntu\\logged', 'exact', () => stalled.promise)
const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS * 3)
await stuckRejected
expect(warnSpy).toHaveBeenCalledOnce()
expect(warnSpy.mock.calls[0][0]).toContain('exact')
expect(warnSpy.mock.calls[0][0]).toContain('logged')
} finally {
stalled.resolve('late')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
it('does not mark a scan permit stuck before the scan deadline', async () => {
vi.useFakeTimers()
const exactWork = deferred<string>()
const scanWork = deferred<string>()
try {
const exact = run(
'\\\\wsl.localhost\\Ubuntu\\stalled-exact',
'exact',
() => exactWork.promise
)
const scanTask = vi.fn(() => scanWork.promise)
const scanPath = '\\\\wsl.localhost\\Debian\\healthy-scan'
const scan = run(scanPath, 'scan', scanTask)
const exactRejected = expect(exact).rejects.toMatchObject({ code: 'timeout' })
await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS)
await exactRejected
const duplicateTask = vi.fn(async () => 'duplicate')
const duplicate = run(scanPath, 'scan', duplicateTask)
scanWork.resolve('scan')
await expect(Promise.all([scan, duplicate])).resolves.toEqual(['scan', 'scan'])
expect(scanTask).toHaveBeenCalledOnce()
expect(duplicateTask).not.toHaveBeenCalled()
} finally {
exactWork.resolve('late')
scanWork.resolve('scan')
await vi.advanceTimersByTimeAsync(0)
vi.useRealTimers()
}
})
})
+169 -26
View File
@@ -1,6 +1,35 @@
import { parseWslUncPath } from '../../shared/wsl-paths'
const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2
export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000
export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000
// Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely.
export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64
export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64
const WSL_TRANSCRIPT_FS_SLOW_MESSAGE =
'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.'
const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE =
'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.'
export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable'
export class WslTranscriptFsError extends Error {
constructor(
readonly code: WslTranscriptFsFailureCode,
message: string
) {
super(message)
this.name = 'WslTranscriptFsError'
}
}
/** Narrow a caught error to a gate refusal, rethrowing anything else. */
export function wslTranscriptFsRefusal(error: unknown): WslTranscriptFsError {
if (error instanceof WslTranscriptFsError) {
return error
}
throw error
}
export type WslTranscriptFsTaskPriority = 'exact' | 'scan'
@@ -9,30 +38,39 @@ type TaskWaiter<T> = {
reject: (error: unknown) => void
signal?: AbortSignal
onAbort?: () => void
timeout?: ReturnType<typeof setTimeout>
}
type ScheduledTask<T> = {
key: string
laneKey: string
route: string
priority: WslTranscriptFsTaskPriority
operation: (signal: AbortSignal) => Promise<T>
controller: AbortController
waiters: Set<TaskWaiter<T>>
state: 'queued' | 'running' | 'settled'
// Set by the deadline timer, sharing the waiters' monotonic clock — wall time
// would misjudge stuckness across laptop sleep or NTP steps.
stuck: boolean
stuckTimer?: ReturnType<typeof setTimeout>
}
type UnknownScheduledTask = ScheduledTask<unknown>
let activeTaskCount = 0
let activeScanCount = 0
const activeLaneKeys = new Set<string>()
const queuedTasks: UnknownScheduledTask[] = []
const inFlightTasks = new Map<string, UnknownScheduledTask>()
const activeTasks = new Set<UnknownScheduledTask>()
function abortReason(signal: AbortSignal): unknown {
return signal.reason ?? new Error('WSL transcript filesystem task aborted')
}
// wsl$ and wsl.localhost spellings of one distro stay distinct routes on
// purpose (provider spelling can change behavior), so a stuck spelling never
// fast-fails its twin — the twin is bounded by its own waiter deadlines.
function routeKey(path: string): string {
const normalized = path.replace(/\\/g, '/')
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i)
@@ -55,11 +93,11 @@ function clearTask(task: UnknownScheduledTask): void {
}
}
function abandonTaskIfUnused(task: UnknownScheduledTask): void {
function abandonTaskIfUnused(task: UnknownScheduledTask, reason?: unknown): void {
if (task.waiters.size > 0 || task.state === 'settled') {
return
}
task.controller.abort()
task.controller.abort(reason)
// Running I/O keeps its permit; new callers need a reusable controller.
clearTask(task)
if (task.state === 'queued') {
@@ -69,24 +107,91 @@ function abandonTaskIfUnused(task: UnknownScheduledTask): void {
}
}
function attachWaiter<T>(task: ScheduledTask<T>, signal?: AbortSignal): Promise<T> {
if (signal?.aborted) {
abandonTaskIfUnused(task as UnknownScheduledTask)
return Promise.reject(abortReason(signal))
function removeWaiter<T>(task: ScheduledTask<T>, waiter: TaskWaiter<T>): boolean {
if (!task.waiters.delete(waiter)) {
return false
}
if (waiter.signal && waiter.onAbort) {
waiter.signal.removeEventListener('abort', waiter.onAbort)
}
if (waiter.timeout) {
clearTimeout(waiter.timeout)
}
return true
}
function timeoutMs(priority: WslTranscriptFsTaskPriority): number {
return priority === 'exact'
? WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS
: WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS
}
function timeoutError(): WslTranscriptFsError {
return new WslTranscriptFsError('timeout', WSL_TRANSCRIPT_FS_SLOW_MESSAGE)
}
function capacityError(): WslTranscriptFsError {
return new WslTranscriptFsError('capacity', WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE)
}
function unavailableError(): WslTranscriptFsError {
return new WslTranscriptFsError('unavailable', WSL_TRANSCRIPT_FS_SLOW_MESSAGE)
}
/**
* The stuck running task that would doom a new task's admission: one on the
* same route (the whole distro mount is hung, whatever the priority — spending
* the other permit on it escalates one bad distro into a global outage), one
* holding the single scan slot, or (with every permit stuck) any permit.
*/
function stuckBlocker(
route: string,
priority: WslTranscriptFsTaskPriority
): UnknownScheduledTask | undefined {
const stuck = [...activeTasks].filter((task) => task.stuck)
if (stuck.length === MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) {
return stuck[0]
}
return stuck.find(
(task) => task.route === route || (priority === 'scan' && task.priority === 'scan')
)
}
// Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields
// before the waiter is attached, so no aborted-signal recheck is needed.
function attachWaiter<T>(
task: ScheduledTask<T>,
priority: WslTranscriptFsTaskPriority,
signal?: AbortSignal
): Promise<T> {
if (task.waiters.size >= WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK) {
return Promise.reject(capacityError())
}
return new Promise<T>((resolve, reject) => {
const waiter: TaskWaiter<T> = { resolve, reject, signal }
task.waiters.add(waiter)
if (signal) {
waiter.onAbort = () => {
if (!task.waiters.delete(waiter)) {
const reason = abortReason(signal)
if (!removeWaiter(task, waiter)) {
return
}
reject(abortReason(signal))
abandonTaskIfUnused(task as UnknownScheduledTask)
reject(reason)
abandonTaskIfUnused(task as UnknownScheduledTask, reason)
}
signal.addEventListener('abort', waiter.onAbort, { once: true })
}
task.waiters.add(waiter)
// UNC/9P calls cannot be aborted; two blocked calls can retain both slots until settling or
// Orca restarts, while caller and queue retention remains bounded.
waiter.timeout = setTimeout(() => {
const error = timeoutError()
if (!removeWaiter(task, waiter)) {
return
}
reject(error)
abandonTaskIfUnused(task as UnknownScheduledTask, error)
}, timeoutMs(priority))
waiter.timeout.unref?.()
})
}
@@ -95,16 +200,15 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
return
}
task.state = 'settled'
activeTaskCount -= 1
if (task.priority === 'scan') {
activeScanCount -= 1
}
activeLaneKeys.delete(task.laneKey)
activeTasks.delete(task as UnknownScheduledTask)
clearTimeout(task.stuckTimer)
clearTask(task as UnknownScheduledTask)
for (const waiter of task.waiters) {
if (waiter.signal && waiter.onAbort) {
waiter.signal.removeEventListener('abort', waiter.onAbort)
}
removeWaiter(task, waiter)
if ('value' in result) {
waiter.resolve(result.value)
} else {
@@ -116,13 +220,21 @@ function settleTask<T>(task: ScheduledTask<T>, result: { value: T } | { error: u
}
function nextTaskIndex(): number {
// Tasks queued before their route hung must not be fed the other permit;
// their waiters drain by deadline while healthy routes use the slot.
const stuckRoutes = new Set(
[...activeTasks].filter((task) => task.stuck).map((task) => task.route)
)
for (const priority of ['exact', 'scan'] as const) {
// Why: keep one libuv slot available for a live transcript probe.
if (priority === 'scan' && activeScanCount > 0) {
continue
}
const index = queuedTasks.findIndex(
(task) => task.priority === priority && !activeLaneKeys.has(task.laneKey)
(task) =>
task.priority === priority &&
!activeLaneKeys.has(task.laneKey) &&
!stuckRoutes.has(task.route)
)
if (index !== -1) {
return index
@@ -132,7 +244,7 @@ function nextTaskIndex(): number {
}
function pumpTasks(): void {
while (activeTaskCount < MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) {
while (activeTasks.size < MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) {
const index = nextTaskIndex()
if (index === -1) {
return
@@ -142,11 +254,21 @@ function pumpTasks(): void {
continue
}
task.state = 'running'
activeTaskCount += 1
if (task.priority === 'scan') {
activeScanCount += 1
}
activeLaneKeys.add(task.laneKey)
activeTasks.add(task)
// Anchored at run start: queue wait says nothing about the I/O itself, so
// the fail-fast may lag admission by at most one deadline period.
task.stuckTimer = setTimeout(() => {
task.stuck = true
console.warn(
`[wsl-transcript-fs-gate] ${task.priority} filesystem task still running after ` +
`${timeoutMs(task.priority)}ms and holding a permit: ${task.key}`
)
}, timeoutMs(task.priority))
task.stuckTimer.unref?.()
void Promise.resolve()
.then(() => {
task.controller.signal.throwIfAborted()
@@ -169,28 +291,49 @@ export function runWslTranscriptFsTask<T>(
},
task: (signal: AbortSignal) => Promise<T>
): Promise<T> {
if (options.signal?.aborted) {
return Promise.reject(abortReason(options.signal))
}
// Why: route spelling and Linux path case can change provider behavior, so
// only byte-identical filesystem requests are safe to share.
const key = JSON.stringify([options.operation, options.path])
const existing = inFlightTasks.get(key) as ScheduledTask<T> | undefined
if (existing) {
return attachWaiter(existing, options.signal)
// A stuck target — or a queued target that stuck I/O keeps from ever
// running — dooms every joiner to a slow timeout, and each fresh joiner
// would keep the queued task alive past its own abandonment. Fail fast.
if (
existing.stuck ||
(existing.state === 'queued' && stuckBlocker(existing.route, existing.priority))
) {
return Promise.reject(unavailableError())
}
return attachWaiter(existing, options.priority, options.signal)
}
const route = routeKey(options.path)
// Fail fast when the permit, route, or scan slot this task needs is held by
// stuck I/O — queueing would only burn the caller's full deadline.
if (stuckBlocker(route, options.priority)) {
return Promise.reject(unavailableError())
}
if (queuedTasks.length >= WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS) {
return Promise.reject(capacityError())
}
const scheduled: ScheduledTask<T> = {
key,
laneKey: `${routeKey(options.path)}:${options.priority}`,
laneKey: `${route}:${options.priority}`,
route,
priority: options.priority,
operation: task,
controller: new AbortController(),
waiters: new Set(),
state: 'queued'
state: 'queued',
stuck: false
}
inFlightTasks.set(key, scheduled as UnknownScheduledTask)
const result = attachWaiter(scheduled, options.signal)
if (scheduled.state === 'queued' && scheduled.waiters.size > 0) {
queuedTasks.push(scheduled as UnknownScheduledTask)
queueMicrotask(pumpTasks)
}
const result = attachWaiter(scheduled, options.priority, options.signal)
queuedTasks.push(scheduled as UnknownScheduledTask)
queueMicrotask(pumpTasks)
return result
}
+40 -2
View File
@@ -4738,6 +4738,25 @@ describe('Store', () => {
expect(updated!.externalWorktreeVisibilityLegacy).toBe(true)
})
it('persists agent worktree visibility independently from external visibility', async () => {
const store = await createStore()
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
const updated = store.updateRepo('r1', { agentWorktreeVisibility: 'show' })
expect(updated).toMatchObject({
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'show'
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')).toMatchObject({
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'show'
})
})
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
const store = await createStore()
store.addRepo(
@@ -5044,7 +5063,19 @@ describe('Store', () => {
const updated = store.updateFolderWorkspace(workspace.id, {
comment: 'Coordinate api and web',
isPinned: true,
lastActivityAt: 123
lastActivityAt: 123,
diffComments: [
{
id: 'note-1',
worktreeId: folderWorkspaceKey(workspace.id),
filePath: 'README.md',
source: 'markdown',
lineNumber: 1,
body: 'Review this paragraph',
createdAt: 100,
side: 'modified'
}
]
})
expect(workspace.folderPath).toBe('/workspace/platform')
@@ -5056,9 +5087,16 @@ describe('Store', () => {
linkedTask,
comment: 'Coordinate api and web',
isPinned: true,
lastActivityAt: 123
lastActivityAt: 123,
diffComments: [expect.objectContaining({ id: 'note-1', body: 'Review this paragraph' })]
})
expect(store.getFolderWorkspaces()).toHaveLength(1)
store.flush()
const restored = await createStore()
expect(restored.getFolderWorkspace(workspace.id)?.diffComments).toEqual([
expect.objectContaining({ id: 'note-1', body: 'Review this paragraph' })
])
})
it('round-trips Jira item and source context for repo-less folder workspaces', async () => {
+5
View File
@@ -4574,6 +4574,7 @@ export class Store {
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
| 'diffComments'
>
>
): FolderWorkspace | null {
@@ -4647,6 +4648,9 @@ export class Store {
if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) {
workspace.lastActivityAt = updates.lastActivityAt
}
if (updates.diffComments !== undefined) {
workspace.diffComments = updates.diffComments
}
workspace.updatedAt = Date.now()
this.scheduleSave()
return workspace
@@ -4938,6 +4942,7 @@ export class Store {
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'projectGroupId'
| 'projectGroupOrder'
| 'projectHostSetupMethod'
@@ -62,6 +62,8 @@ export async function shellScriptTest(
const env: Record<string, string> = {
...config.env,
// Why: these examples inspect startup-file discovery, not the PTY-owner protocol stream.
ORCA_SHELL_STARTUP_IDENTITY: '0',
// Why: the framework creates user startup files under testHome after
// computing the wrapper config; route wrapper discovery to that fixture.
HOME: testHome,
+33 -8
View File
@@ -15,7 +15,8 @@ const {
readWindowsConptyProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsMock
wslUncDirectoryExistsMock,
createShellPromptReadinessProbeMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
@@ -28,7 +29,8 @@ const {
readWindowsConptyProcessIdsMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
isWslAvailableAsyncMock: vi.fn(),
wslUncDirectoryExistsMock: vi.fn()
wslUncDirectoryExistsMock: vi.fn(),
createShellPromptReadinessProbeMock: vi.fn()
}))
vi.mock('fs', () => ({
@@ -107,6 +109,10 @@ vi.mock('../wsl', () => ({
wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args)
}))
vi.mock('../shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
}))
import {
_resetLocalPtyProviderStateForTest,
LOCAL_PTY_FORCE_KILL_RETRY_MS,
@@ -174,6 +180,7 @@ describe('LocalPtyProvider', () => {
isWslAvailableAsyncMock.mockResolvedValue(true)
wslUncDirectoryExistsMock.mockReset()
wslUncDirectoryExistsMock.mockReturnValue(true)
createShellPromptReadinessProbeMock.mockReset()
exitCb = undefined
mockProc = {
@@ -534,6 +541,24 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
it('verifies shell identity against the exact spawn PATH', async () => {
provider.configure({
buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' })
})
await provider.spawn({
cols: 80,
rows: 24,
command: 'printf ready',
env: { PATH: '/pre-hook/bin' }
})
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/post-hook/bin')
expect(createShellPromptReadinessProbeMock).toHaveBeenCalledWith(
expect.objectContaining({ shellPathEnv: '/post-hook/bin' })
)
})
it('does not inherit NODE_ENV from the Orca process env', async () => {
// Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets
// `development` in dev runs); leaking it breaks `next build` and Vitest.
@@ -685,8 +710,8 @@ describe('LocalPtyProvider', () => {
provider.configure({
buildSpawnEnv: (_id, env) => {
env.TERM_PROGRAM = 'Orca'
env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-attribution'
env.PATH = `/tmp/orca-attribution:${env.PATH ?? ''}`
env.ORCA_STALE_TEST_ENV = '/tmp/orca-stale'
env.PATH = `/tmp/orca-stale:${env.PATH ?? ''}`
return env
}
})
@@ -699,7 +724,7 @@ describe('LocalPtyProvider', () => {
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
envToDelete: ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
})
const spawnCall = spawnMock.mock.calls.at(-1)!
@@ -707,7 +732,7 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.TERM).toBe('screen-256color')
expect(spawnCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnCall[2].env.TERM_PROGRAM).toBeUndefined()
expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnCall[2].env.ORCA_STALE_TEST_ENV).toBeUndefined()
})
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
@@ -817,9 +842,9 @@ describe('LocalPtyProvider', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => {
// Why: attribution collapses Windows PATH onto `Path` and prepends its own shim dir.
// Why: host env collapses Windows PATH onto `Path` and prepends its own shim dir.
delete env.PATH
env.Path = `/tmp/orca-attribution:${env.Path ?? ''}`
env.Path = `/tmp/orca-stale:${env.Path ?? ''}`
return env
}
})
+61 -8
View File
@@ -27,7 +27,7 @@ import {
} from './local-pty-utils'
import { prepareMacosTccLoginShell } from './macos-tcc-login-shell'
import {
getAttributionShellLaunchConfig,
getMarkerlessShellLaunchConfig,
getShellReadyLaunchConfig,
createShellReadyScanState,
drainShellReadyHeldBytes,
@@ -75,6 +75,15 @@ import {
createPtySlaveEchoProbe,
readPtySlavePath
} from '../../shared/pty-slave-line-discipline-echo'
import {
createShellStartupIdentityScanState,
drainShellStartupIdentityHeldBytes,
scanForShellStartupIdentity
} from '../shell-startup-identity-scanner'
import {
createShellPromptReadinessProbe,
type ShellPromptReadinessProbe
} from '../shell-prompt-readiness-probe'
import {
expandWindowsEnvironmentVariables,
expandWindowsPathEnvironmentVariables
@@ -782,7 +791,6 @@ export class LocalPtyProvider implements IPtyProvider {
if (!wslInfo && process.platform !== 'win32') {
// Why: OpenCode/Codex PATH restoration and OMP's status wrapper need shell-ready code after user startup files run.
const needsNoMarkerWrapper =
finalEnv.ORCA_ATTRIBUTION_SHIM_DIR ||
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
finalEnv.ORCA_MIMOCODE_HOME ||
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
@@ -799,16 +807,16 @@ export class LocalPtyProvider implements IPtyProvider {
getFallbackShellReadyConfig = (shell) =>
shouldWaitForShellReady
? getShellReadyLaunchConfig(shell)
: getAttributionShellLaunchConfig(shell)
: getMarkerlessShellLaunchConfig(shell)
shellLaunch = shouldWaitForShellReady
? getShellReadyLaunchConfig(shellPath)
: getAttributionShellLaunchConfig(shellPath)
: getMarkerlessShellLaunchConfig(shellPath)
} else if (args.command) {
getFallbackShellReadyConfig = (shell) => getShellReadyLaunchConfig(shell)
shellLaunch = getShellReadyLaunchConfig(shellPath)
} else if (needsNoMarkerWrapper) {
getFallbackShellReadyConfig = (shell) => getAttributionShellLaunchConfig(shell)
shellLaunch = getAttributionShellLaunchConfig(shellPath)
getFallbackShellReadyConfig = (shell) => getMarkerlessShellLaunchConfig(shell)
shellLaunch = getMarkerlessShellLaunchConfig(shellPath)
} else {
getFallbackShellReadyConfig = undefined
}
@@ -951,6 +959,11 @@ export class LocalPtyProvider implements IPtyProvider {
// Shell-ready startup command support
let resolveShellReady: ((signal: ShellReadySignal) => void) | null = null
let shellReadyTimeout: ReturnType<typeof setTimeout> | null = null
let shellStartupPid: number | null = null
let shellPromptReadinessProbe: ShellPromptReadinessProbe | null = null
let shellStartupIdentityScanState = shellReadyLaunch?.supportsReadyMarker
? createShellStartupIdentityScanState()
: null
const shellReadyScanState = shellReadyLaunch?.supportsReadyMarker
? createShellReadyScanState()
: null
@@ -967,6 +980,8 @@ export class LocalPtyProvider implements IPtyProvider {
clearTimeout(shellReadyTimeout)
shellReadyTimeout = null
}
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
const resolve = resolveShellReady
resolveShellReady = null
resolve(signal)
@@ -975,12 +990,35 @@ export class LocalPtyProvider implements IPtyProvider {
if (!shellReadyScanState) {
return
}
const heldBytes = drainShellReadyHeldBytes(shellReadyScanState)
let heldBytes = shellStartupIdentityScanState
? drainShellStartupIdentityHeldBytes(shellStartupIdentityScanState)
: ''
shellStartupIdentityScanState = null
if (heldBytes) {
heldBytes = scanForShellReady(shellReadyScanState, heldBytes).output
}
heldBytes += drainShellReadyHeldBytes(shellReadyScanState)
if (heldBytes.length === 0) {
return
}
startupIngress.accept(heldBytes)
}
if (shellReadyScanState) {
shellPromptReadinessProbe = createShellPromptReadinessProbe({
slavePath: readPtySlavePath(proc),
shellPath,
shellCwd: effectiveCwd,
shellPathEnv: finalEnv.PATH,
getShellPid: () => shellStartupPid,
onPromptReady: () => {
console.warn(
`[pty] ${id}: shell-ready wrapper was replaced before its marker; releasing at the identified shell prompt. OSC 133 integration may be unavailable.`
)
releaseHeldShellReadyBytes()
finishShellReady({ postMarkerBytesObserved: true })
}
})
}
if (args.command) {
if (shellReadyLaunch?.supportsReadyMarker) {
shellReadyTimeout = setTimeout(() => {
@@ -1002,20 +1040,33 @@ export class LocalPtyProvider implements IPtyProvider {
startupCommandCleanup?.()
startupCommandCleanup = null
resolveShellReady = null
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
})
}
const disposables: { dispose: () => void }[] = []
const onDataDisposable = proc.onData((rawData) => {
let data = rawData
if (shellStartupIdentityScanState && resolveShellReady) {
const scanned = scanForShellStartupIdentity(shellStartupIdentityScanState, data)
data = scanned.output
if (scanned.shellPid) {
shellStartupPid = scanned.shellPid
shellStartupIdentityScanState = null
}
}
if (shellReadyScanState && resolveShellReady) {
const scanned = scanForShellReady(shellReadyScanState, rawData)
const scanned = scanForShellReady(shellReadyScanState, data)
data = scanned.output
if (scanned.matched) {
finishShellReady({ postMarkerBytesObserved: scanned.postMarkerBytesObserved })
}
}
startupIngress.accept(data)
if (resolveShellReady && data.length > 0) {
shellPromptReadinessProbe?.notifyOutput(data)
}
})
if (onDataDisposable) {
disposables.push(onDataDisposable)
@@ -1033,6 +1084,8 @@ export class LocalPtyProvider implements IPtyProvider {
shellReadyTimeout = null
}
startupCommandCleanup?.()
shellPromptReadinessProbe?.dispose()
shellPromptReadinessProbe = null
clearPtyState(id)
startupIngress.drainAndClose()
startupIngressByPty.delete(id)
@@ -380,10 +380,10 @@ describePosix('local PTY shell-ready launch config', () => {
expect(init).toContain('functions -e __orca_shell_ready_marker')
})
it('keeps attribution-only fish spawns unwrapped', async () => {
const { getAttributionShellLaunchConfig } = await importFreshLocalPtyShellReady()
it('keeps markerless fish spawns unwrapped', async () => {
const { getMarkerlessShellLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getAttributionShellLaunchConfig('/opt/homebrew/bin/fish')
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
expect(config).toEqual({ args: null, env: {}, supportsReadyMarker: false })
})
@@ -485,6 +485,7 @@ describePosix('local PTY shell-ready launch config', () => {
const zshrc = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshrc'), 'utf8')
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
expectZdotdirSourceContext(zprofile, '.zprofile')
@@ -815,8 +816,6 @@ path=(/custom/bin $path)
}
delete cleanEnv.ZDOTDIR
delete cleanEnv.ORCA_ORIG_ZDOTDIR
// Why: this test isolates zsh top-level path scoping, not attribution shim ordering.
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir
const result = spawnSync(
@@ -855,7 +854,6 @@ path=(/custom/bin $path)
}
delete cleanEnv.ZDOTDIR
delete cleanEnv.ORCA_ORIG_ZDOTDIR
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
delete cleanEnv.USER_ZSHRC_LOADED
cleanEnv.ZDOTDIR = join(movedUserData, 'shell-ready', 'zsh')
@@ -903,7 +901,6 @@ path=(/custom/bin $path)
}
delete cleanEnv.ZDOTDIR
delete cleanEnv.ORCA_ORIG_ZDOTDIR
delete cleanEnv.ORCA_ATTRIBUTION_SHIM_DIR
delete cleanEnv.USER_ZSHRC_LOADED
cleanEnv.ZDOTDIR = join(nonAsciiUserData, 'shell-ready', 'zsh')
+8 -28
View File
@@ -21,6 +21,7 @@ import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
SHELL_STARTUP_IDENTITY_MARKER_BLOCK,
getZshStartupFileSourceBlock
} from '../shell-templates'
export {
@@ -92,6 +93,7 @@ function resolveOriginalZshenvSourceDir(): string {
export function getBashShellReadyRcfileContent(): string {
return `# Orca bash shell-ready wrapper
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
[[ -f /etc/profile ]] && source /etc/profile
if [[ -f "$HOME/.bash_profile" ]]; then
source "$HOME/.bash_profile"
@@ -108,14 +110,6 @@ fi
# Why: preserve bash's normal login-shell contract. Many users already source
# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate
# PATH edits, hooks, and prompt init in Orca startup-command shells.
__orca_restore_attribution_path() {
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
@@ -224,14 +218,6 @@ ${getZshStartupFileSourceBlock({
interactiveOnly: true,
skipWhenHomeIsCurrentZdotdir: true
})}
__orca_restore_attribution_path() {
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
@@ -285,14 +271,6 @@ ${getZshStartupFileSourceBlock({ fileName: '.zprofile' })}
const zshRc = getZshShellReadyRcfileContent()
const zshLogin = `# Orca zsh shell-ready wrapper
${getZshStartupFileSourceBlock({ fileName: '.zlogin', interactiveOnly: true })}
__orca_restore_attribution_path() {
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_ATTRIBUTION_SHIM_DIR}"|"\${ORCA_ATTRIBUTION_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
@@ -368,7 +346,8 @@ function getWrappedShellLaunchConfig(
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`,
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -379,7 +358,8 @@ function getWrappedShellLaunchConfig(
return {
args: ['--rcfile', `${getShellReadyWrapperRoot()}/bash/rcfile`],
env: {
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0',
ORCA_SHELL_STARTUP_IDENTITY: options.emitReadyMarker ? '1' : '0'
},
supportsReadyMarker: options.emitReadyMarker
}
@@ -398,7 +378,7 @@ function getWrappedShellLaunchConfig(
}
}
// Why: mirrors daemon/shell-ready.ts; attribution-only fish stays unwrapped.
// Why: mirrors daemon/shell-ready.ts; markerless fish stays unwrapped.
if (shellName === 'fish' && options.emitReadyMarker) {
return {
args: ['-l', '-C', getFishShellReadyInitCommand(SHELL_READY_MARKER_ESCAPED)],
@@ -418,7 +398,7 @@ export function getShellReadyLaunchConfig(shellPath: string): ShellReadyLaunchCo
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: true })
}
export function getAttributionShellLaunchConfig(shellPath: string): ShellReadyLaunchConfig {
export function getMarkerlessShellLaunchConfig(shellPath: string): ShellReadyLaunchConfig {
return getWrappedShellLaunchConfig(shellPath, { emitReadyMarker: false })
}
+3 -3
View File
@@ -428,7 +428,7 @@ describe('SshPtyProvider', () => {
it('preserves explicit TERM and forwards final env deletions to the relay', async () => {
mux.request.mockResolvedValue({ id: 'pty-env-precedence' })
const envToDelete = ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
const envToDelete = ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
await provider.spawn({
cols: 120,
@@ -436,7 +436,7 @@ describe('SshPtyProvider', () => {
env: {
TERM: 'screen-256color',
TERM_PROGRAM: 'stale-terminal',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
ORCA_STALE_TEST_ENV: '/tmp/stale-env'
},
envToDelete
})
@@ -453,7 +453,7 @@ describe('SshPtyProvider', () => {
})
const spawnCall = mux.request.mock.calls.find((call) => call[0] === 'pty.spawn')
expect(spawnCall?.[1]?.env).not.toHaveProperty('TERM_PROGRAM')
expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_ATTRIBUTION_SHIM_DIR')
expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_STALE_TEST_ENV')
})
it('forwards provider command delivery to the relay', async () => {
@@ -0,0 +1,124 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
__resetLegacyTerminalShimRemovalForTests,
removeLegacyTerminalShimDir,
stripLegacyTerminalShimEnv
} from './legacy-terminal-shim-dir'
// Why: the failure case is produced with a read-only parent dir, which Windows ignores and root bypasses.
const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it
describe('legacy terminal shim removal', () => {
beforeEach(() => {
__resetLegacyTerminalShimRemovalForTests()
})
it('deletes the orphaned wrapper directory left by older installs', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
mkdirSync(shimDir, { recursive: true })
writeFileSync(join(shimDir, 'git'), '#!/usr/bin/env bash\n')
removeLegacyTerminalShimDir(userData)
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
expect(existsSync(userData)).toBe(true)
})
it('does not throw when nothing is left to remove', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
expect(() => removeLegacyTerminalShimDir(userData)).not.toThrow()
})
itOnPosixNonRoot('stays retryable when the removal fails, and latches once it succeeds', () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-legacy-shim-'))
const legacyRoot = join(userData, 'orca-terminal-attribution')
mkdirSync(join(legacyRoot, 'posix'), { recursive: true })
writeFileSync(join(legacyRoot, 'posix', 'git'), '#!/usr/bin/env bash\n')
// Why: a read-only parent makes the unlink fail the way a locked Windows wrapper does.
chmodSync(userData, 0o500)
try {
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(true)
} finally {
chmodSync(userData, 0o700)
}
// The failed attempt must not have latched the guard.
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(false)
// A success latches it: a directory recreated afterwards is left alone.
mkdirSync(legacyRoot, { recursive: true })
removeLegacyTerminalShimDir(userData)
expect(existsSync(legacyRoot)).toBe(true)
})
it('drops inherited shim env and its PATH entry without touching real entries', () => {
// Why: a daemon predating the removal reseeds these from its own process.env.
const env: Record<string, string> = {
PATH: `/home/u/.orca/orca-terminal-attribution/posix:/usr/local/bin:/usr/bin`,
ORCA_ENABLE_GIT_ATTRIBUTION: '1',
ORCA_GIT_COMMIT_TRAILER: 'Co-authored-by: Orca <help@stably.ai>',
ORCA_GH_PR_FOOTER: 'footer',
ORCA_GH_ISSUE_FOOTER: 'footer',
ORCA_ATTRIBUTION_SHIM_DIR: '/home/u/.orca/orca-terminal-attribution/posix',
ORCA_REAL_GIT: '/usr/bin/git',
ORCA_REAL_GH: '/usr/bin/gh',
HOME: '/home/u'
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined()
expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined()
expect(env.ORCA_GH_PR_FOOTER).toBeUndefined()
expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined()
expect(env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(env.ORCA_REAL_GIT).toBeUndefined()
expect(env.ORCA_REAL_GH).toBeUndefined()
expect(env.HOME).toBe('/home/u')
})
it('strips the Windows shim entry on the inherited Path spelling', () => {
const env: Record<string, string> = {
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32`
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('matches a re-cased shim entry on case-insensitive filesystems', () => {
const env: Record<string, string> = {
Path: `C:\\Users\\u\\AppData\\Roaming\\Orca\\Orca-Terminal-Attribution\\win32;C:\\Windows\\System32`
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('keeps neighbouring directories that merely share the name prefix', () => {
const env: Record<string, string> = {
PATH: '/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe(
'/opt/orca-terminal-attribution:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
)
})
it('leaves an unrelated PATH untouched', () => {
const env: Record<string, string> = { PATH: '/usr/local/bin:/usr/bin' }
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe('/usr/local/bin:/usr/bin')
})
})
+71
View File
@@ -0,0 +1,71 @@
import { rmSync } from 'node:fs'
import { join } from 'node:path'
import { resolvePathEnvKey } from './windows-environment-path'
const LEGACY_SHIM_ROOT_DIR = 'orca-terminal-attribution'
const LEGACY_SHIM_ENV_KEYS = [
'ORCA_ENABLE_GIT_ATTRIBUTION',
'ORCA_GIT_COMMIT_TRAILER',
'ORCA_GH_PR_FOOTER',
'ORCA_GH_ISSUE_FOOTER',
'ORCA_ATTRIBUTION_SHIM_DIR',
'ORCA_REAL_GIT',
'ORCA_REAL_GH'
] as const
let removed = false
/** Why: a daemon that outlives an upgrade keeps seeding these from its own process.env
* (pty-subprocess reads process.env as the authoritative base), and the wrappers they
* point at are gated only on inherited env. Deleting the scripts makes both inert. */
export function removeLegacyTerminalShimDir(userDataPath: string): void {
if (removed) {
return
}
try {
// Why: a surviving pre-upgrade pane can hold the wrapper open on Windows; retry like
// the other userData removals rather than forfeiting cleanup for the whole run.
rmSync(join(userDataPath, LEGACY_SHIM_ROOT_DIR), {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 50
})
removed = true
} catch {
// Best-effort: a locked file must not block startup, and the next launch retries.
}
}
export function isLegacyTerminalShimPathEntry(entry: string): boolean {
return entry.replaceAll('\\', '/').toLowerCase().includes(`/${LEGACY_SHIM_ROOT_DIR}/`)
}
export function stripLegacyTerminalShimEnv(
env: Record<string, string>,
platform: NodeJS.Platform = process.platform
): void {
for (const key of LEGACY_SHIM_ENV_KEYS) {
delete env[key]
}
const pathKey = resolvePathEnvKey(env, platform)
const current = env[pathKey]
if (!current) {
return
}
const delimiter = platform === 'win32' ? ';' : ':'
const cleaned = current
.split(delimiter)
.filter((entry) => entry && !isLegacyTerminalShimPathEntry(entry))
.join(delimiter)
if (cleaned) {
env[pathKey] = cleaned
} else {
delete env[pathKey]
}
}
/** Test-only: the module-level once-guard would otherwise leak across cases. */
export function __resetLegacyTerminalShimRemovalForTests(): void {
removed = false
}
@@ -61,7 +61,7 @@ describe('claude agent teams shim env', () => {
expect(plan).toMatchObject({
command: "claude --teammate-mode auto 'hello'",
env: expect.objectContaining({ TMUX_PANE: '%1' }),
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
envToDelete: ['TERM_PROGRAM']
})
expect(capturedShimBin).toBe(cliPath)
}
@@ -47,7 +47,7 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: {
return {
command: addClaudeTeammateModeAuto(args.command),
env,
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
envToDelete: ['TERM_PROGRAM']
}
}
@@ -117,7 +117,7 @@ export class ClaudeAgentTeamsTmuxDispatcher {
direction: splitTarget.direction,
command: parsed.positional.join(' ') || undefined,
env: paneEnv(team, fakePaneId),
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'],
envToDelete: ['TERM_PROGRAM'],
activate: false
})
const pane: TeamPane = {
@@ -168,7 +168,7 @@ export class ClaudeAgentTeamsTmuxDispatcher {
direction: pane.splitDirection ?? 'horizontal',
command,
env: paneEnv(team, pane.fakePaneId),
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'],
envToDelete: ['TERM_PROGRAM'],
activate: false
})
try {
@@ -0,0 +1,161 @@
import { describe, expect, it, vi } from 'vitest'
import type { EmulatorSessionInfo } from '../emulator/emulator-types'
import type { FolderWorkspace } from '../../shared/types'
import { OrcaRuntimeService } from './orca-runtime'
const FOLDER_WORKSPACE_ID = 'folder-workspace-1'
const FOLDER_WORKSPACE_KEY = `folder:${FOLDER_WORKSPACE_ID}`
const EMULATOR_INFO: EmulatorSessionInfo = {
deviceUdid: 'emulator-5554',
streamUrl: 'scrcpy://emulator-5554',
wsUrl: '',
streamCodec: 'h264',
backend: 'android'
}
function makeFolderWorkspace(): FolderWorkspace {
return {
id: FOLDER_WORKSPACE_ID,
projectGroupId: 'project-group-1',
name: 'Mobile app',
folderPath: '/tmp/mobile-app',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
}
describe('RuntimeEmulatorCommands folder workspace routing', () => {
it.each([FOLDER_WORKSPACE_KEY, `id:${FOLDER_WORKSPACE_KEY}`])(
'registers and publishes an attached device for selector %s',
async (selector) => {
const bridge = {
acquireHelperForDevice: vi.fn(async () => ({
info: EMULATOR_INFO,
release: vi.fn(async () => {})
})),
getReusableActiveForWorktree: vi.fn(async () => null),
registerActiveEmulator: vi.fn(),
stopActiveForSwitch: vi.fn(async () => null)
}
const send = vi.fn()
const runtime = new OrcaRuntimeService({
getFolderWorkspaces: () => [makeFolderWorkspace()],
getAllWorktreeMeta: () => new Map(),
getRepo: () => null,
getRepos: () => [],
getSettings: () => ({
mobileEmulatorEnabled: true,
mobileEmulatorDefaultDeviceUdid: null,
androidSdkPath: null
})
} as never)
runtime.setEmulatorBridge(bridge as never)
Object.assign(runtime, {
getAuthoritativeWindow: () => ({ webContents: { send } })
})
await expect(
runtime.emulatorAttach({
device: 'emulator-5554',
worktree: selector,
focus: true
})
).resolves.toEqual({ attached: true, info: EMULATOR_INFO })
expect(bridge.acquireHelperForDevice).toHaveBeenCalledOnce()
expect(bridge.registerActiveEmulator).toHaveBeenCalledWith(
FOLDER_WORKSPACE_KEY,
EMULATOR_INFO,
{ managed: true }
)
expect(send.mock.calls).toEqual([
['ui:emulatorAutoAttach', { worktreeId: FOLDER_WORKSPACE_KEY, info: EMULATOR_INFO }],
['emulator:pane-focus', { worktreeId: FOLDER_WORKSPACE_KEY }]
])
}
)
it.each([FOLDER_WORKSPACE_KEY, `id:${FOLDER_WORKSPACE_KEY}`])(
'cleans up an active emulator after selector %s is deleted',
async (selector) => {
let folderWorkspaces = [makeFolderWorkspace()]
const shutdownActiveManagedForWorktree = vi.fn(async () => EMULATOR_INFO.deviceUdid)
const runtime = new OrcaRuntimeService({
getFolderWorkspaces: () => folderWorkspaces,
getAllWorktreeMeta: () => new Map(),
getRepo: () => null,
getRepos: () => [],
getSettings: () => ({ androidSdkPath: null })
} as never)
runtime.setEmulatorBridge({ shutdownActiveManagedForWorktree } as never)
folderWorkspaces = []
await expect(
runtime.emulatorShutdown({ worktree: selector, managedOnly: true })
).resolves.toEqual({ ok: true, deviceUdid: EMULATOR_INFO.deviceUdid })
expect(shutdownActiveManagedForWorktree).toHaveBeenCalledWith(FOLDER_WORKSPACE_KEY)
}
)
it('cleans up an attach that finishes after its folder workspace is deleted', async () => {
let folderWorkspaces = [makeFolderWorkspace()]
const release = vi.fn(async () => {})
let finishHelperStart:
| ((lease: { info: EmulatorSessionInfo; release: typeof release }) => void)
| undefined
const bridge = {
acquireHelperForDevice: vi.fn(
() =>
new Promise<{ info: EmulatorSessionInfo; release: typeof release }>((resolve) => {
finishHelperStart = resolve
})
),
getReusableActiveForWorktree: vi.fn(async () => null),
registerActiveEmulator: vi.fn(),
shutdownActiveManagedForWorktree: vi.fn(async () => null),
stopActiveForSwitch: vi.fn(async () => null)
}
const runtime = new OrcaRuntimeService({
getFolderWorkspaces: () => folderWorkspaces,
getAllWorktreeMeta: () => new Map(),
getRepo: () => null,
getRepos: () => [],
getSettings: () => ({
mobileEmulatorEnabled: true,
mobileEmulatorDefaultDeviceUdid: null,
androidSdkPath: null
})
} as never)
runtime.setEmulatorBridge(bridge as never)
Object.assign(runtime, {
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } })
})
const attach = runtime.emulatorAttach({
device: EMULATOR_INFO.deviceUdid,
worktree: FOLDER_WORKSPACE_KEY
})
await vi.waitFor(() => expect(bridge.acquireHelperForDevice).toHaveBeenCalledOnce())
folderWorkspaces = []
await expect(
runtime.emulatorShutdown({ worktree: FOLDER_WORKSPACE_KEY, managedOnly: true })
).resolves.toEqual({ ok: true, deviceUdid: undefined })
finishHelperStart?.({ info: EMULATOR_INFO, release })
await expect(attach).rejects.toMatchObject({
code: 'emulator_no_active',
message: 'The workspace changed while the emulator was starting. Reattach the emulator.'
})
expect(bridge.registerActiveEmulator).not.toHaveBeenCalled()
expect(bridge.shutdownActiveManagedForWorktree).toHaveBeenCalledWith(FOLDER_WORKSPACE_KEY)
expect(release).toHaveBeenCalledWith({ cleanupIfUnused: true })
})
})
+34 -6
View File
@@ -22,7 +22,8 @@ type EmulatorHostSettings = Pick<
// Why: dedicated file for "one surface" separation (emulator), parallel to orca-runtime-browser.ts. Keeps OrcaRuntimeService focused; emulator routing easy to scan. No max-lines disable (split further if grows; per AGENTS + plan Phase 3).
export type RuntimeEmulatorCommandHost = {
getEmulatorBridge(): EmulatorBridge | null
resolveWorktreeSelector(selector: string): Promise<{ id: string }>
resolveEmulatorWorkspaceId(selector: string): Promise<string>
resolveEmulatorCleanupWorkspaceId(selector: string): Promise<string>
getAuthoritativeWindow(): BrowserWindow
getSettings(): EmulatorHostSettings
}
@@ -157,13 +158,36 @@ export class RuntimeEmulatorCommands {
// slow-to-boot Android emulator alive for instant switch-back.
await bridge.stopActiveForSwitch(worktreeId)
}
const info = await bridge.startHelperForDevice(device)
const lease = await bridge.acquireHelperForDevice(device)
const { info } = lease
if (worktreeId) {
try {
const currentWorktreeId = await this.resolveWorktreeId(params.worktree)
if (currentWorktreeId !== worktreeId) {
throw new EmulatorError(
'emulator_no_active',
'The workspace changed while the emulator was starting. Reattach the emulator.'
)
}
} catch (error) {
// Why: the workspace can disappear while a slow Android device boots.
await lease.release({ cleanupIfUnused: true }).catch(() => {})
if (error instanceof Error && error.message === 'selector_not_found') {
throw new EmulatorError(
'emulator_no_active',
'The workspace changed while the emulator was starting. Reattach the emulator.'
)
}
throw error
}
bridge.registerActiveEmulator(worktreeId, info, { managed: true })
await lease.release()
this.notifyRendererEmulatorAutoAttach(worktreeId, info)
if (params.focus) {
this.notifyRendererEmulatorPaneFocus(worktreeId)
}
} else {
await lease.release()
}
// Default: no auto steal (mirror browser tab create/switch). --focus sends emulator:pane-focus only when requested.
return { attached: true, info }
@@ -176,7 +200,7 @@ export class RuntimeEmulatorCommands {
async emulatorUnregisterActive(params: { worktree?: string }): Promise<{ ok: true }> {
const bridge = this.requireEmulatorBridge()
const worktreeId = await this.resolveWorktreeId(params.worktree)
const worktreeId = await this.resolveCleanupWorktreeId(params.worktree)
if (worktreeId) {
bridge.unregisterActiveEmulator(worktreeId)
}
@@ -203,7 +227,11 @@ export class RuntimeEmulatorCommands {
}
private async resolveWorktreeId(worktree?: string): Promise<string | undefined> {
return worktree ? (await this.host.resolveWorktreeSelector(worktree)).id : undefined
return worktree ? await this.host.resolveEmulatorWorkspaceId(worktree) : undefined
}
private async resolveCleanupWorktreeId(worktree?: string): Promise<string | undefined> {
return worktree ? await this.host.resolveEmulatorCleanupWorkspaceId(worktree) : undefined
}
async emulatorInstall(
@@ -272,7 +300,7 @@ export class RuntimeEmulatorCommands {
worktree?: string
}): Promise<{ ok: true; deviceUdid: string }> {
const bridge = this.requireEmulatorBridge()
const worktreeId = await this.resolveWorktreeId(params.worktree)
const worktreeId = await this.resolveCleanupWorktreeId(params.worktree)
const killedUdid = await bridge.kill(params.device ?? params.emulator, worktreeId)
return { ok: true, deviceUdid: killedUdid }
}
@@ -284,7 +312,7 @@ export class RuntimeEmulatorCommands {
managedOnly?: boolean
}): Promise<{ ok: true; deviceUdid?: string }> {
const bridge = this.requireEmulatorBridge()
const worktreeId = await this.resolveWorktreeId(params.worktree)
const worktreeId = await this.resolveCleanupWorktreeId(params.worktree)
if (params.managedOnly && worktreeId && !params.device && !params.emulator) {
const shutdownUdid = await bridge.shutdownActiveManagedForWorktree(worktreeId)
return { ok: true, deviceUdid: shutdownUdid ?? undefined }
+2 -4
View File
@@ -318,8 +318,7 @@ describe('RuntimeGitCommands', () => {
getRuntimeSettings: () =>
({
commitMessageAi: { enabled: true, agentId: 'codex' },
agentCmdOverrides: {},
enableGitHubAttribution: false
agentCmdOverrides: {}
}) as GlobalSettings,
getCommitMessageAgentEnvironment: () => ({
prepareForCodexLaunch: () => '/managed/codex-home'
@@ -374,8 +373,7 @@ describe('RuntimeGitCommands', () => {
getRuntimeSettings: () =>
({
commitMessageAi: { enabled: true, agentId: 'codex' },
agentCmdOverrides: {},
enableGitHubAttribution: false
agentCmdOverrides: {}
}) as GlobalSettings,
getCommitMessageAgentEnvironment: () => ({
prepareForCodexLaunch
+1 -4
View File
@@ -90,10 +90,7 @@ import type { HostedReviewProvider } from '../../shared/hosted-review'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
type RuntimeCommitMessageSettingsOverride = Partial<
Pick<
GlobalSettings,
'commitMessageAi' | 'sourceControlAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'
>
Pick<GlobalSettings, 'commitMessageAi' | 'sourceControlAi' | 'agentCmdOverrides'>
> & {
commitMessageDiscoveryHostKey?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
+90 -19
View File
@@ -7,6 +7,7 @@ import {
isCursorAgentTitle,
isCursorNativeAgentTitle,
isOpenCodeNativeTitle,
isQuarterCircleSpinnerOnlyAgentTitle,
isShellProcess,
normalizeTerminalTitle
} from '../../shared/agent-detection'
@@ -1317,6 +1318,8 @@ type RuntimePtyWorktreeRecord = {
paneKey: string | null
launchConfig: SleepingAgentLaunchConfig | null
launchToken: string | null
// Why: provider PTY IDs can be reused; launch identity belongs only to the process that received the token.
launchIncarnationId: PtyIncarnationId | null
launchAgent: TuiAgent | null
foregroundAgent: TuiAgent | null
connected: boolean
@@ -9390,6 +9393,7 @@ export class OrcaRuntimeService {
isTuiAgent(agentLaunchAuthority.launchAgent)
) {
pty.launchToken = agentLaunchAuthority.launchToken
pty.launchIncarnationId = binding.incarnationId
pty.launchAgent = agentLaunchAuthority.launchAgent
}
const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId)
@@ -12399,6 +12403,7 @@ export class OrcaRuntimeService {
}
this.restoredOrchestrationAuthorityByPtyId.delete(ptyId)
pty.launchToken = null
pty.launchIncarnationId = null
const paneKeys = new Set<string>()
if (pty.paneKey && parsePaneKey(pty.paneKey)) {
paneKeys.add(pty.paneKey)
@@ -16763,7 +16768,12 @@ export class OrcaRuntimeService {
}
}
if (terminal.titleStatus) {
if (isOpenCodeNativeTitle(terminal.title)) {
// Why: an OpenCode marker and a lone quarter-circle spinner (STA-4028) are activity,
// not identity, so resolve both through the identity/foreground evidence path.
if (
isOpenCodeNativeTitle(terminal.title) ||
isQuarterCircleSpinnerOnlyAgentTitle(terminal.title)
) {
const isRunningAgent = await this.isTerminalRunningAgent(handle)
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
return {
@@ -18459,6 +18469,7 @@ export class OrcaRuntimeService {
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
| 'diffComments'
>
>
): Promise<FolderWorkspace | null> {
@@ -19106,6 +19117,7 @@ export class OrcaRuntimeService {
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'projectGroupId'
| 'projectGroupOrder'
>
@@ -25743,6 +25755,7 @@ export class OrcaRuntimeService {
? copySleepingAgentLaunchConfig(effectiveLaunchConfig)
: null
pty.launchToken = launchToken ?? null
pty.launchIncarnationId = launchToken ? pty.incarnationId : null
pty.launchAgent = launchOpts.launchAgent ?? null
}
pty.tabId = tabId
@@ -28323,16 +28336,9 @@ export class OrcaRuntimeService {
private async resolveFolderWorkspaceLaunchScope(
selector: string
): Promise<TerminalWorkspaceLaunchScope | null> {
const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector
const parsed = parseWorkspaceKey(workspaceSelector)
if (parsed?.type !== 'folder') {
return null
}
const workspace = this.store
?.getFolderWorkspaces?.()
.find((entry) => entry.id === parsed.folderWorkspaceId)
const workspace = this.resolveFolderWorkspaceSelector(selector)
if (!workspace) {
throw new Error('selector_not_found')
return null
}
if (!this.store) {
throw new Error('runtime_unavailable')
@@ -28352,6 +28358,36 @@ export class OrcaRuntimeService {
}
}
private resolveFolderWorkspaceSelector(selector: string): FolderWorkspace | null {
const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector
const parsed = parseWorkspaceKey(workspaceSelector)
if (parsed?.type !== 'folder') {
return null
}
const workspace = this.store
?.getFolderWorkspaces?.()
.find((entry) => entry.id === parsed.folderWorkspaceId)
if (!workspace) {
throw new Error('selector_not_found')
}
return workspace
}
private async resolveEmulatorWorkspaceId(selector: string): Promise<string> {
const folderWorkspace = this.resolveFolderWorkspaceSelector(selector)
return folderWorkspace
? folderWorkspaceKey(folderWorkspace.id)
: (await this.resolveWorktreeSelector(selector)).id
}
private async resolveEmulatorCleanupWorkspaceId(selector: string): Promise<string> {
const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector
const parsed = parseWorkspaceKey(workspaceSelector)
return parsed?.type === 'folder'
? folderWorkspaceKey(parsed.folderWorkspaceId)
: this.resolveEmulatorWorkspaceId(selector)
}
private folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree {
const worktree = folderWorkspaceToWorktree(folderWorkspace)
return {
@@ -29367,6 +29403,7 @@ export class OrcaRuntimeService {
paneKey: state.paneKey ?? null,
launchConfig: null,
launchToken: null,
launchIncarnationId: null,
launchAgent: null,
foregroundAgent: null,
connected: state.connected ?? true,
@@ -31515,15 +31552,24 @@ export class OrcaRuntimeService {
return await this.isPtyRunningAgent(pty.pty, leaf)
}
const { leaf } = this.getLiveLeafForHandle(handle)
const trackedPty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : null
// Why: check the leaf pane title and the tab title, which already carries OSC-enriched agent indicators (e.g. ✳ prefix).
const paneTitle = getLatestLeafTitle(leaf, null)
const paneTitleClassification = classifyAgentTitle(paneTitle)
if (paneTitleClassification === 'agent' && !isOpenCodeNativeTitle(paneTitle)) {
if (
trackedPty
? ptyTitleProvesAgentPresence(trackedPty, paneTitle, paneTitleClassification)
: agentTitleProvesAgentPresence(paneTitle, paneTitleClassification)
) {
return true
}
const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null
const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral'
if (tabTitleClassification === 'agent' && !isOpenCodeNativeTitle(tabTitle)) {
if (
trackedPty
? ptyTitleProvesAgentPresence(trackedPty, tabTitle, tabTitleClassification)
: agentTitleProvesAgentPresence(tabTitle, tabTitleClassification)
) {
return true
}
const openCodeMarkerTitle = paneTitle ?? tabTitle
@@ -31572,7 +31618,7 @@ export class OrcaRuntimeService {
)
: null
const leafTitleClassification = classifyAgentTitle(leafTitle)
if (leafTitleClassification === 'agent' && !isOpenCodeNativeTitle(leafTitle)) {
if (ptyTitleProvesAgentPresence(pty, leafTitle, leafTitleClassification)) {
return true
}
const ptyTitle = getLatestAgentCandidateTitle(
@@ -31580,11 +31626,7 @@ export class OrcaRuntimeService {
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
const ptyTitleClassification = classifyAgentTitle(ptyTitle)
if (
leafTitle === null &&
ptyTitleClassification === 'agent' &&
!isOpenCodeNativeTitle(ptyTitle)
) {
if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyTitleClassification)) {
return true
}
const managementTitleClassification = classifyLatestAgentTitle({
@@ -35033,7 +35075,9 @@ export class OrcaRuntimeService {
private readonly emulatorCommands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => this.emulatorBridge,
resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector),
resolveEmulatorWorkspaceId: (selector) => this.resolveEmulatorWorkspaceId(selector),
resolveEmulatorCleanupWorkspaceId: (selector) =>
this.resolveEmulatorCleanupWorkspaceId(selector),
getAuthoritativeWindow: () => this.getAuthoritativeWindow(),
getSettings: () => this.requireStore().getSettings()
})
@@ -37542,6 +37586,33 @@ function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): s
)
}
// Why: an 'agent' title only proves an agent owns the pane when something other than a
// quarter-circle spinner carries it — those glyphs are generic progress frames (STA-4028).
function agentTitleProvesAgentPresence(
title: string | null,
classification: 'agent' | 'management' | 'neutral'
): boolean {
return (
classification === 'agent' &&
!isOpenCodeNativeTitle(title) &&
!isQuarterCircleSpinnerOnlyAgentTitle(title)
)
}
function ptyTitleProvesAgentPresence(
pty: RuntimePtyWorktreeRecord,
title: string | null,
classification: 'agent' | 'management' | 'neutral'
): boolean {
return (
agentTitleProvesAgentPresence(title, classification) ||
(isQuarterCircleSpinnerOnlyAgentTitle(title) &&
pty.launchAgent === 'claude' &&
pty.launchToken !== null &&
pty.launchIncarnationId === pty.incarnationId)
)
}
function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' {
if (!title) {
return 'neutral'
@@ -0,0 +1,212 @@
// STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs —
// ora, installers, any TUI animates them — so a title carrying nothing else must not
// authorize a guarded send, which auto-submits with Enter into whatever owns the pane.
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard'
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const TAB_ID = 'tab-1'
const WORKTREE_ID = 'wt-1'
const PTY_ID = 'pty-1'
// Captured from Claude Code 2.1.228 while it read this repository's package.json.
const SPINNER_ONLY_TITLE = '◑ Check package version in package.json'
const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code'
const BRAILLE_SPINNER_ONLY_TITLE = '⠂ Deploying release 4.2'
async function createRuntimeWithTitle(
paneTitle: string,
foregroundProcess: string | null,
launchAgent?: 'claude',
verifiedLaunch = true
): Promise<{
runtime: OrcaRuntimeService
handle: string
getForegroundProcess: ReturnType<typeof vi.fn>
}> {
const runtime = new OrcaRuntimeService(null)
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
}
vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
id: WORKTREE_ID,
path: '/repo/app',
connectionId: null,
repo: null,
folderWorkspace: null
})
const getForegroundProcess = vi.fn(async () => foregroundProcess)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'initial-incarnation' }),
write: () => true,
kill: () => true,
getForegroundProcess
})
const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, {
tabId: TAB_ID,
leafId: LEAF_ID,
title: 'Terminal',
...(launchAgent
? {
launchAgent,
...(verifiedLaunch
? { launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} } }
: {})
}
: {})
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 1,
ptyId: PTY_ID,
paneTitle
}
]
})
return { runtime, handle: terminal.handle, getForegroundProcess }
}
const AUTHORIZED = 'authorized'
async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise<string> {
try {
await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} })
return AUTHORIZED
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
describe('quarter-circle title send authorization (STA-4028)', () => {
it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => {
const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node')
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: false
})
await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent')
})
it('refuses a guarded send when the foreground process cannot be read at all', async () => {
// Why: SSH and folder-workspace panes can fail the foreground read; no evidence
// stays a refusal rather than falling back to the glyph.
const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null)
await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent')
})
it('authorizes a guarded send when the foreground process is a recognized agent', async () => {
const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'claude')
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true,
status: 'working'
})
await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED)
})
it('authorizes a managed Claude launch when the foreground process is unavailable', async () => {
const { runtime, handle, getForegroundProcess } = await createRuntimeWithTitle(
SPINNER_ONLY_TITLE,
null,
'claude'
)
expect(
(
runtime as unknown as {
ptysById: Map<string, { launchAgent: string | null; launchToken: string | null }>
}
).ptysById.get(PTY_ID)
).toMatchObject({ launchAgent: 'claude', launchToken: expect.any(String) })
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true,
status: 'working'
})
await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED)
expect(getForegroundProcess).not.toHaveBeenCalled()
})
it('does not trust an unverified Claude launch hint', async () => {
const { runtime, handle } = await createRuntimeWithTitle(
SPINNER_ONLY_TITLE,
null,
'claude',
false
)
await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent')
})
it('does not carry managed Claude identity into a replacement PTY incarnation', async () => {
const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null, 'claude')
const pty = (
runtime as unknown as {
ptysById: Map<
string,
{ incarnationId: string | null; launchIncarnationId: string | null; launchToken: string }
>
}
).ptysById.get(PTY_ID)
expect(pty).toMatchObject({
incarnationId: 'initial-incarnation',
launchIncarnationId: 'initial-incarnation'
})
runtime.onPtySpawned(PTY_ID, 'replacement-incarnation', { awaitsRegistration: false })
expect(pty).toMatchObject({
incarnationId: 'replacement-incarnation',
launchIncarnationId: 'initial-incarnation',
launchToken: expect.any(String)
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: false
})
await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent')
})
it('authorizes a guarded send when the busy title itself names the agent', async () => {
const { runtime, handle } = await createRuntimeWithTitle(SPINNER_WITH_IDENTITY_TITLE, null)
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true,
status: 'working'
})
await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED)
})
it('leaves braille-spinner authorization unchanged', async () => {
const { runtime, handle } = await createRuntimeWithTitle(BRAILLE_SPINNER_ONLY_TITLE, null)
await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED)
})
it('keeps the quarter-circle glyph a working activity signal (#13889)', async () => {
expect(detectAgentStatusFromTitle(SPINNER_ONLY_TITLE)).toBe('working')
expect(detectAgentStatusFromTitle(SPINNER_WITH_IDENTITY_TITLE)).toBe('working')
})
})
@@ -6,6 +6,7 @@ import { TaskSourceContextSchema } from '../../../../shared/task-source-context-
import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema'
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context'
import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context'
import { DiffCommentSchema } from '../../../../shared/diff-comment-schema'
const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable()
@@ -59,7 +60,8 @@ const FolderWorkspaceUpdate = z.object({
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional(),
firstAgentMessageRenameError: z.string().nullable().optional(),
lastActivityAt: OptionalFiniteNumber
lastActivityAt: OptionalFiniteNumber,
diffComments: z.array(DiffCommentSchema).optional()
})
.superRefine(assertLinkedTaskSourceContextMatch)
})
@@ -179,7 +179,6 @@ export const GitGenerateCommitMessage = WorktreeSelector.extend({
sourceControlAi: SourceControlAiSettings.optional(),
sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(),
agentCmdOverrides: z.record(z.string(), z.string()).optional(),
enableGitHubAttribution: z.boolean().optional(),
commitMessageDiscoveryHostKey: z.string().optional()
})
-2
View File
@@ -530,7 +530,6 @@ describe('git RPC methods', () => {
worktree: 'id:wt-1',
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true,
commitMessageDiscoveryHostKey: 'runtime:env-1'
})
)
@@ -538,7 +537,6 @@ describe('git RPC methods', () => {
expect(runtime.generateRuntimeCommitMessage).toHaveBeenCalledWith('id:wt-1', {
commitMessageAi,
agentCmdOverrides,
enableGitHubAttribution: true,
commitMessageDiscoveryHostKey: 'runtime:env-1'
})
})
-6
View File
@@ -33,7 +33,6 @@ type CommitMessageGenerationOverride = {
sourceControlAi?: GlobalSettings['sourceControlAi']
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
enableGitHubAttribution?: boolean
commitMessageDiscoveryHostKey?: string
}
@@ -44,7 +43,6 @@ function buildCommitMessageGenerationOverride(params: {
sourceControlAi?: unknown
sourceControlAiResolvedParams?: unknown
agentCmdOverrides?: unknown
enableGitHubAttribution?: boolean
commitMessageDiscoveryHostKey?: string
}): CommitMessageGenerationOverride | undefined {
if (
@@ -52,7 +50,6 @@ function buildCommitMessageGenerationOverride(params: {
params.sourceControlAi === undefined &&
params.sourceControlAiResolvedParams === undefined &&
params.agentCmdOverrides === undefined &&
params.enableGitHubAttribution === undefined &&
params.commitMessageDiscoveryHostKey === undefined
) {
return undefined
@@ -75,9 +72,6 @@ function buildCommitMessageGenerationOverride(params: {
agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides']
}
: {}),
...(params.enableGitHubAttribution !== undefined
? { enableGitHubAttribution: params.enableGitHubAttribution }
: {}),
...(params.commitMessageDiscoveryHostKey !== undefined
? { commitMessageDiscoveryHostKey: params.commitMessageDiscoveryHostKey }
: {})
@@ -54,6 +54,7 @@ export function createRepoUpdateSchema<T extends z.ZodRawShape>(
externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
importedExternalWorktreePaths: z.array(z.string()).optional(),
agentWorktreeVisibility: z.enum(['hide', 'show']).optional(),
externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(),
projectGroupId: OptionalString.nullable().optional(),
projectGroupOrder: OptionalFiniteNumber,
+27
View File
@@ -303,6 +303,33 @@ describe('repo RPC methods', () => {
})
})
it('persists agent worktree visibility updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepo: vi.fn().mockResolvedValue({
id: 'repo-1',
path: '/srv/repo',
agentWorktreeVisibility: 'show'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.update', {
repo: 'repo-1',
updates: { agentWorktreeVisibility: 'show' }
})
)
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
agentWorktreeVisibility: 'show'
})
expect(response).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', agentWorktreeVisibility: 'show' } }
})
})
it('persists resolved GitHub upstream metadata updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const lineEditorProbe = vi.hoisted(() => vi.fn())
const processReadinessProbe = vi.hoisted(() => vi.fn())
const resolveExecutablePath = vi.hoisted(() => vi.fn((value: string) => Promise.resolve(value)))
vi.mock('../shared/pty-slave-line-discipline-echo', () => ({
createPtySlaveLineEditorProbe: () => lineEditorProbe
}))
vi.mock('../shared/shell-process-readiness', () => ({
readShellProcessReadiness: processReadinessProbe,
resolveShellExecutablePath: resolveExecutablePath
}))
import { createShellPromptReadinessProbe } from './shell-prompt-readiness-probe'
describe('shell prompt readiness probe', () => {
beforeEach(() => {
vi.useFakeTimers()
lineEditorProbe.mockReset()
processReadinessProbe.mockReset()
resolveExecutablePath.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('accepts only the identified shell pid in line-editor mode and foreground', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(onPromptReady).toHaveBeenCalledOnce()
})
it('preserves an unset child PATH for executable resolution', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(resolveExecutablePath).toHaveBeenCalledWith('/bin/zsh', process.cwd(), undefined)
})
it.each([
['silent read', 'other', { executablePath: '/bin/zsh', foreground: true }],
['background shell', 'line-editor', { executablePath: '/bin/zsh', foreground: false }],
['different foreground process', 'line-editor', null],
[
'non-shell replacement image',
'line-editor',
{ executablePath: '/usr/bin/sqlite3', foreground: true }
],
[
'non-shell image with the shell basename',
'line-editor',
{ executablePath: '/tmp/zsh', foreground: true }
]
])('rejects %s', async (_name, terminalState, rows) => {
lineEditorProbe.mockResolvedValue(terminalState)
processReadinessProbe.mockResolvedValue(rows)
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(onPromptReady).not.toHaveBeenCalled()
if (terminalState === 'other') {
expect(processReadinessProbe).not.toHaveBeenCalled()
}
})
it('does no external work when the ready marker cancels the settle window', async () => {
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
probe?.dispose()
await vi.advanceTimersByTimeAsync(10)
expect(lineEditorProbe).not.toHaveBeenCalled()
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('invalidates an in-flight result when newer output arrives', async () => {
const pending: { resolve?: (value: string) => void } = {}
lineEditorProbe.mockImplementation(
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
)
processReadinessProbe.mockResolvedValue({ executablePath: '/bin/zsh', foreground: true })
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
probe?.notifyOutput('\x1b[?2004h')
pending.resolve?.('line-editor')
await vi.advanceTimersByTimeAsync(0)
expect(onPromptReady).not.toHaveBeenCalled()
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('does not inspect a process after disposal during a line-editor probe', async () => {
const pending: { resolve?: (value: string) => void } = {}
lineEditorProbe.mockImplementation(
() => new Promise((resolve) => (pending.resolve = resolve as (value: string) => void))
)
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
probe?.dispose()
pending.resolve?.('line-editor')
await vi.advanceTimersByTimeAsync(0)
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('invalidates process readiness that resolves after disposal', async () => {
const pending: { resolve?: (value: { executablePath: string; foreground: boolean }) => void } =
{}
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockImplementation(
() => new Promise((resolve) => (pending.resolve = resolve))
)
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
probe?.dispose()
pending.resolve?.({ executablePath: '/bin/zsh', foreground: true })
await vi.advanceTimersByTimeAsync(0)
expect(onPromptReady).not.toHaveBeenCalled()
})
it('ignores slow startup output until the line editor enables its protocol', async () => {
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
for (let index = 0; index < 20; index += 1) {
probe?.notifyOutput(`startup ${index}\n`)
await vi.advanceTimersByTimeAsync(20)
}
expect(lineEditorProbe).not.toHaveBeenCalled()
expect(processReadinessProbe).not.toHaveBeenCalled()
})
it('bounds rejected line-editor retries', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({
executablePath: '/usr/bin/sqlite3',
foreground: true
})
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/zsh',
getShellPid: () => 42,
onPromptReady: vi.fn(),
settleMs: 10
})
for (let index = 0; index < 10; index += 1) {
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
}
expect(lineEditorProbe).toHaveBeenCalledTimes(4)
expect(processReadinessProbe).toHaveBeenCalledTimes(4)
})
})
+107
View File
@@ -0,0 +1,107 @@
import { createPtySlaveLineEditorProbe } from '../shared/pty-slave-line-discipline-echo'
import {
readShellProcessReadiness,
resolveShellExecutablePath
} from '../shared/shell-process-readiness'
import {
createLineEditorReadyOutputScanState,
scanForLineEditorReadyOutput
} from './line-editor-ready-output-scanner'
import { basename } from 'node:path'
export const SHELL_PROMPT_PROBE_SETTLE_MS = 50
export const MAX_SHELL_PROMPT_PROBES = 4
export type ShellPromptReadinessProbe = {
notifyOutput(data: string): void
dispose(): void
}
export function createShellPromptReadinessProbe(options: {
slavePath: string | undefined
getShellPid: () => number | null
shellPath: string | undefined
shellCwd?: string
shellPathEnv?: string
onPromptReady: () => void
settleMs?: number
}): ShellPromptReadinessProbe | null {
const lineEditorProbe = createPtySlaveLineEditorProbe(options.slavePath)
if (!lineEditorProbe) {
return null
}
const settleMs = options.settleMs ?? SHELL_PROMPT_PROBE_SETTLE_MS
const expectedShellName = options.shellPath ? basename(options.shellPath).toLowerCase() : null
const outputScanState = createLineEditorReadyOutputScanState()
let disposed = false
let timer: ReturnType<typeof setTimeout> | null = null
let generation = 0
let probesStarted = 0
const probe = async (scheduledGeneration: number): Promise<void> => {
if (disposed || scheduledGeneration !== generation) {
return
}
const shellPid = options.getShellPid()
if (!shellPid || (await lineEditorProbe()) !== 'line-editor') {
return
}
if (disposed || scheduledGeneration !== generation) {
return
}
const [shell, expectedPath] = await Promise.all([
readShellProcessReadiness(shellPid),
options.shellPath
? resolveShellExecutablePath(
options.shellPath,
options.shellCwd ?? process.cwd(),
options.shellPathEnv
)
: Promise.resolve(null)
])
if (disposed || scheduledGeneration !== generation) {
return
}
if (
!shell?.foreground ||
!expectedShellName ||
!expectedPath ||
basename(shell.executablePath).toLowerCase() !== expectedShellName ||
shell.executablePath !== expectedPath
) {
return
}
disposed = true
options.onPromptReady()
}
return {
notifyOutput(data: string): void {
if (
disposed ||
probesStarted >= MAX_SHELL_PROMPT_PROBES ||
!scanForLineEditorReadyOutput(outputScanState, data)
) {
return
}
generation += 1
const scheduledGeneration = generation
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
timer = null
probesStarted += 1
void probe(scheduledGeneration).catch(() => {})
}, settleMs)
},
dispose(): void {
disposed = true
generation += 1
if (timer) {
clearTimeout(timer)
timer = null
}
}
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
createShellStartupIdentityScanState,
drainShellStartupIdentityHeldBytes,
scanForShellStartupIdentity
} from './shell-startup-identity-scanner'
describe('shell startup identity scanner', () => {
it('strips a split identity marker and returns its shell pid', () => {
const state = createShellStartupIdentityScanState()
expect(scanForShellStartupIdentity(state, 'before\x1b]777;orca-shell-st')).toEqual({
output: 'before',
shellPid: null
})
expect(scanForShellStartupIdentity(state, 'art:12345\x07after')).toEqual({
output: 'after',
shellPid: 12345
})
})
it('forwards lookalikes unchanged', () => {
const state = createShellStartupIdentityScanState()
const input = 'a\x1b]777;orca-shell-start:nope\x07b'
expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null })
})
it('forwards an unrelated OSC ending in digits', () => {
const state = createShellStartupIdentityScanState()
const input = '\x1b]1337;remote-session:12345'
expect(scanForShellStartupIdentity(state, input)).toEqual({ output: input, shellPid: null })
expect(state.heldBytes).toBe('')
})
it('releases an incomplete marker on teardown', () => {
const state = createShellStartupIdentityScanState()
scanForShellStartupIdentity(state, '\x1b]777;orca-shell-start:12')
expect(drainShellStartupIdentityHeldBytes(state)).toBe('\x1b]777;orca-shell-start:12')
})
it('does not retain an unbounded digit stream', () => {
const state = createShellStartupIdentityScanState()
const input = `\x1b]777;orca-shell-start:${'1'.repeat(100)}`
expect(scanForShellStartupIdentity(state, input).output).toBe(input)
expect(state.heldBytes).toBe('')
})
})
@@ -0,0 +1,72 @@
export const SHELL_STARTUP_IDENTITY_PREFIX = '\x1b]777;orca-shell-start:'
const POSSIBLE_PID_SUFFIX = /^\d{0,20}$/
export type ShellStartupIdentityScanState = {
heldBytes: string
}
export type ShellStartupIdentityScanResult = {
output: string
shellPid: number | null
}
export function createShellStartupIdentityScanState(): ShellStartupIdentityScanState {
return { heldBytes: '' }
}
export function drainShellStartupIdentityHeldBytes(state: ShellStartupIdentityScanState): string {
const heldBytes = state.heldBytes
state.heldBytes = ''
return heldBytes
}
function isPossibleMarker(candidate: string): boolean {
if (candidate.length <= SHELL_STARTUP_IDENTITY_PREFIX.length) {
return SHELL_STARTUP_IDENTITY_PREFIX.startsWith(candidate)
}
if (!candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) {
return false
}
const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length)
return POSSIBLE_PID_SUFFIX.test(suffix)
}
export function scanForShellStartupIdentity(
state: ShellStartupIdentityScanState,
data: string
): ShellStartupIdentityScanResult {
let pending = state.heldBytes + data
let output = ''
state.heldBytes = ''
while (pending.length > 0) {
const start = pending.indexOf(SHELL_STARTUP_IDENTITY_PREFIX[0] as string)
if (start === -1) {
output += pending
break
}
output += pending.slice(0, start)
const candidate = pending.slice(start)
if (isPossibleMarker(candidate)) {
state.heldBytes = candidate
break
}
if (candidate.startsWith(SHELL_STARTUP_IDENTITY_PREFIX)) {
const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length)
const terminator = suffix.indexOf('\x07')
const pidText = terminator === -1 ? '' : suffix.slice(0, terminator)
if (/^\d+$/.test(pidText)) {
const shellPid = Number(pidText)
const markerLength = SHELL_STARTUP_IDENTITY_PREFIX.length + terminator + 1
return {
output: output + candidate.slice(markerLength),
shellPid: Number.isSafeInteger(shellPid) && shellPid > 0 ? shellPid : null
}
}
}
output += candidate[0]
pending = candidate.slice(1)
}
return { output, shellPid: null }
}
+6
View File
@@ -5,11 +5,17 @@ function quotePosixSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `if [[ "\${ORCA_SHELL_STARTUP_IDENTITY:-0}" == "1" ]]; then
unset ORCA_SHELL_STARTUP_IDENTITY
printf "\\033]777;orca-shell-start:%s\\007" "$$"
fi`
export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string {
const header = headerPrefix
? `Orca ${headerPrefix} zsh shell-ready wrapper`
: 'Orca zsh shell-ready wrapper'
return `# ${header}
${SHELL_STARTUP_IDENTITY_MARKER_BLOCK}
# Why: capture the runtime wrapper dir before it is unset below. On WSL this
# file is generated with a Windows path but sourced via /mnt/c, so the baked
# literal is unusable there and ZDOTDIR must be restored from this value.
@@ -396,6 +396,46 @@ describe('configureElectronNetworkCompatibility', () => {
})
})
describe('disableUnsupportedChromiumFeatures', () => {
it('disables FedCM before Chromium sessions are created', async () => {
const { app } = await import('electron')
const { disableUnsupportedChromiumFeatures } = await import('./configure-process')
vi.mocked(app.commandLine.appendSwitch).mockClear()
disableUnsupportedChromiumFeatures()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-features', 'FedCm')
})
it('preserves existing disabled Chromium features', async () => {
const { app } = await import('electron')
const { disableUnsupportedChromiumFeatures } = await import('./configure-process')
vi.mocked(app.commandLine.getSwitchValue).mockReturnValueOnce('ExistingFeature')
vi.mocked(app.commandLine.appendSwitch).mockClear()
disableUnsupportedChromiumFeatures()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
'disable-features',
'FedCm,ExistingFeature'
)
})
it('does not duplicate FedCM when disable-features already includes it', async () => {
const { app } = await import('electron')
const { disableUnsupportedChromiumFeatures } = await import('./configure-process')
vi.mocked(app.commandLine.getSwitchValue).mockReturnValueOnce('FedCm,ExistingFeature')
vi.mocked(app.commandLine.appendSwitch).mockClear()
disableUnsupportedChromiumFeatures()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
'disable-features',
'FedCm,ExistingFeature'
)
})
})
describe('enableMainProcessGpuFeatures', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR
+15 -5
View File
@@ -66,6 +66,20 @@ export function configureElectronNetworkCompatibility(
app.commandLine.appendSwitch('disable-http2')
}
export function disableUnsupportedChromiumFeatures(): void {
appendDisabledChromiumFeatures(['FedCm'])
}
function appendDisabledChromiumFeatures(features: string[]): void {
const existingFeatures = app.commandLine
.getSwitchValue('disable-features')
.split(',')
.map((feature) => feature.trim())
.filter(Boolean)
const disabledFeatures = Array.from(new Set([...features, ...existingFeatures])).join(',')
app.commandLine.appendSwitch('disable-features', disabledFeatures)
}
function getProcessPathDelimiter(): string {
return process.platform === 'win32' ? ';' : ':'
}
@@ -300,11 +314,7 @@ export function enableMainProcessGpuFeatures(): void {
app.commandLine.appendSwitch('enable-features', features)
}
const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features')
// Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s.
// This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path.
const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures]
.filter(Boolean)
.join(',')
app.commandLine.appendSwitch('disable-features', disabledFeatures)
appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling'])
}

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