mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/sta-3077-reattach-pane-cardinality
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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?.()),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user