test: remove 872 low-value PTY and shell tests (#38943)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-06 02:03:00 -08:00
committed by GitHub
co-authored by OrcaWin
parent a7e74b57a1
commit 30724404d8
59 changed files with 53 additions and 18556 deletions
-2
View File
@@ -861,8 +861,6 @@ jobs:
src/main/pty/shell-startup/omp-shell-wrapper-alias-safety.test.ts \
src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts \
src/main/pty/shell-startup/fish-xdg-data-dirs-handoff.test.ts \
src/main/pty/shell-startup/shell-startup-feature-channel.test.ts \
src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts \
src/main/pty/history/terminal-history-fish-session.node-pty.test.ts \
src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts \
src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
+2 -12
View File
@@ -18,10 +18,7 @@
1 test-no-in-test-import-actual:src/main/linear/linear-workspace-registry.test.ts
1 test-no-in-test-import-actual:src/main/omp/omp-session-cwd-lookup.test.ts
1 test-no-in-test-import-actual:src/main/orcad/electron-serve-provider-selection.test.ts
1 test-no-in-test-import-actual:src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts
1 test-no-in-test-import-actual:src/main/pty/scrollback/overlay-mirror.test.ts
1 test-no-in-test-import-actual:src/main/pty/shell-startup/legacy-terminal-shim-dir.test.ts
1 test-no-in-test-import-actual:src/main/rate-limits/claude-profile-usage.test.ts
1 test-no-in-test-import-actual:src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts
1 test-no-in-test-import-actual:src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts
1 test-no-in-test-import-actual:src/main/runtime/rpc/server/pairing/mobile-pairing-userdata-path.test.ts
@@ -71,7 +68,6 @@
1 test-no-polling-loop:src/main/provider-process/provider-process-supervisor.integration.test.ts
1 test-no-polling-loop:src/main/providers/startup-line-typed-length.live-shell.test.ts
1 test-no-polling-loop:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts
1 test-no-polling-loop:src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts
1 test-no-polling-loop:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts
1 test-no-polling-loop:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts
1 test-no-polling-loop:src/main/runtime/agent-session/environment/structured-agent-shell-environment.test.ts
@@ -264,11 +260,10 @@
6 test-no-real-time-wait:src/main/opencode/hook-plugin-module-contract.test.ts
4 test-no-real-time-wait:src/main/orcad/orcad-stop-request-listener.test.ts
1 test-no-real-time-wait:src/main/provider-process/provider-group-orphan-guard.integration.test.ts
3 test-no-real-time-wait:src/main/provider-process/provider-process-supervisor.integration.test.ts
2 test-no-real-time-wait:src/main/provider-process/provider-process-supervisor.integration.test.ts
1 test-no-real-time-wait:src/main/providers/agent-foreground-process-git-bash.win32.test.ts
2 test-no-real-time-wait:src/main/providers/startup-line-typed-length.live-shell.test.ts
2 test-no-real-time-wait:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts
2 test-no-real-time-wait:src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts
1 test-no-real-time-wait:src/main/pty/process-retirement/node-pty-master-fd-retirement.test.ts
1 test-no-real-time-wait:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts
5 test-no-real-time-wait:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts
@@ -673,18 +668,13 @@
2 test-no-wall-clock:src/main/persistence/tracking-repos/persistence-deregistered-repo-residue.test.ts
1 test-no-wall-clock:src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts
2 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.integration.test.ts
1 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.test.ts
18 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts
3 test-no-wall-clock:src/main/provider-process/provider-process-teardown.test.ts
4 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts
2 test-no-wall-clock:src/main/providers/startup-line-typed-length.live-shell.test.ts
1 test-no-wall-clock:src/main/pty/history/fish-history-session.test.ts
2 test-no-wall-clock:src/main/pty/history/terminal-history-fish-session.node-pty.test.ts
1 test-no-wall-clock:src/main/pty/history/terminal-history-gc-fs-call-count.test.ts
2 test-no-wall-clock:src/main/pty/process-retirement/posix-pty-process-groups.integration.test.ts
4 test-no-wall-clock:src/main/pty/process-retirement/windows-live-tree-kill.win32.test.ts
1 test-no-wall-clock:src/main/rate-limits/claude-oauth-usage-error.test.ts
2 test-no-wall-clock:src/main/rate-limits/claude-profile-usage.test.ts
1 test-no-wall-clock:src/main/rate-limits/cursor-current-period-usage.test.ts
1 test-no-wall-clock:src/main/rate-limits/kimi-fetcher-wsl-home.test.ts
1 test-no-wall-clock:src/main/rate-limits/opencode-go-usage-source-selection.test.ts
43 test-no-wall-clock:src/main/rate-limits/service-account-target-selection.test.ts
File diff suppressed because one or more lines are too long
@@ -2006,7 +2006,6 @@
"src/main/ghostty/parser.test.ts": 51,
"src/main/ghostty/theme-import.test.ts": 100,
"src/main/ghostty/theme-resolution.test.ts": 62,
"src/main/pty/shell-startup/git-bash.test.ts": 60,
"src/main/git/base-ref-search-selector.test.ts": 257,
"src/main/git/branch-rename.test.ts": 66,
"src/main/git/canonical-repo-key.test.ts": 221,
@@ -3632,17 +3631,12 @@
"src/main/providers/agent-foreground-process.test.ts": 227,
"src/main/providers/filesystem-markdown-listing.test.ts": 93,
"src/main/providers/local-filesystem-provider.test.ts": 344,
"src/main/providers/local-git-provider.test.ts": 95,
"src/main/providers/local-pty/local-pty-child-process-verdict.test.ts": 676,
"src/main/providers/local-pty/local-pty-default-shell.test.ts": 272,
"src/main/providers/local-pty/local-pty-executable-spawn.test.ts": 96,
"src/main/providers/local-pty/local-pty-foreground-inspection-cheap-tier.test.ts": 266,
"src/main/providers/local-pty/local-pty-provider-foreground-process.test.ts": 520,
"src/main/providers/local-pty/local-pty-provider-io-events.test.ts": 493,
"src/main/providers/local-pty/local-pty-provider-shell-readiness.test.ts": 551,
"src/main/providers/local-pty/local-pty-provider-shutdown.test.ts": 731,
"src/main/providers/local-pty/local-pty-provider-spawn-cwd-safety.test.ts": 504,
"src/main/providers/local-pty/local-pty-provider-spawn-env.test.ts": 527,
"src/main/providers/local-pty/local-pty-provider-spawn-session.test.ts": 856,
"src/main/providers/local-pty/local-pty-provider-windows-shell-launch.test.ts": 598,
"src/main/providers/local-pty/local-pty-runtime-spawn-wsl-allowlist.test.ts": 85,
@@ -3667,7 +3661,6 @@
"src/main/providers/windows-foreground-process-rows.test.ts": 97,
"src/main/providers/windows-powershell-executable.test.ts": 65,
"src/main/providers/windows-shell-alone-in-job.test.ts": 45,
"src/main/providers/windows-shell-args.test.ts": 220,
"src/main/providers/windows-shell-preflight-runtime.windows.test.ts": 126,
"src/main/providers/working-directory-validation-retention.test.ts": 132,
"src/main/providers/working-directory-validation.test.ts": 163,
@@ -3694,38 +3687,27 @@
"src/main/pty/shell-startup/powershell-profile-env.test.ts": 88,
"src/main/pty/process-retirement/pty-master-foreground-native.test.ts": 112,
"src/main/pty/process-retirement/pty-master-foreground-observation.test.ts": 120,
"src/main/pty/shell-startup/shell-startup-env.test.ts": 116,
"src/main/pty/environment/windows-environment-path.test.ts": 116,
"src/main/pty/shell-startup/wsl-orca-env.test.ts": 104,
"src/main/qoder/real-cli.test.ts": 602,
"src/main/startup/quit/quit-path-durable-write-blocking.test.ts": 3722,
"src/main/startup/quit/quit-teardown-deadline.test.ts": 48,
"src/main/rate-limits/account-runtime-target-sync.test.ts": 262,
"src/main/rate-limits/antigravity-usage-error.test.ts": 47,
"src/main/rate-limits/antigravity-usage-fetcher.test.ts": 188,
"src/main/rate-limits/antigravity-usage-response.test.ts": 67,
"src/main/rate-limits/auth-filesystem-operation-retention.test.ts": 131,
"src/main/rate-limits/auth-filesystem-operation.test.ts": 48,
"src/main/rate-limits/claude-oauth-usage-error.test.ts": 49,
"src/main/rate-limits/claude-oauth-usage-extra-usage.test.ts": 119,
"src/main/rate-limits/claude-profile-usage.test.ts": 218,
"src/main/rate-limits/claude-usage-error-classification.test.ts": 56,
"src/main/rate-limits/codex-auth-presence.test.ts": 65,
"src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 214,
"src/main/rate-limits/codex-fetcher-backend.test.ts": 277,
"src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 251,
"src/main/rate-limits/codex-fetcher-process-contract.test.ts": 454,
"src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 341,
"src/main/rate-limits/codex-fetcher.test.ts": 222,
"src/main/rate-limits/codex-probe-termination.test.ts": 73,
"src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 53,
"src/main/rate-limits/codex-rate-limit-wsl-home-gate.test.ts": 105,
"src/main/rate-limits/codex-rpc-failure-kind.test.ts": 41,
"src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 119,
"src/main/rate-limits/codex-rpc-worker-backpressure.test.ts": 615,
"src/main/rate-limits/cursor-auth.test.ts": 126,
"src/main/rate-limits/cursor-current-period-usage.test.ts": 211,
"src/main/rate-limits/cursor-fetcher.test.ts": 196,
"src/main/rate-limits/cursor-session-token.test.ts": 96,
"src/main/rate-limits/cursor-usage-mapping.test.ts": 141,
"src/main/rate-limits/gemini-oauth-sources.test.ts": 97,
@@ -5020,7 +5002,6 @@
"src/main/runtime/wsl-moved-project-chat-runtime.test.ts": 5094,
"src/main/updater/serve-update-handoff.test.ts": 62,
"src/main/server/serve-readiness.test.ts": 52,
"src/main/pty/shell-startup/shell-prompt-readiness-probe.test.ts": 87,
"src/main/pty/shell-startup/shell-startup-identity-scanner.test.ts": 39,
"src/main/pty/shell-startup/shell-startup-output-scanner.test.ts": 58,
"src/main/pty/shell-startup/shell-wrapper-content-address.test.ts": 49,
@@ -5426,8 +5407,6 @@
"src/main/pty/history/terminal-history-gc-fs-call-count.test.ts": 157,
"src/main/pty/history/terminal-history-gc.test.ts": 824,
"src/main/pty/history/terminal-history-tombstone-retry.test.ts": 715,
"src/main/pty/history/terminal-history-tombstone-scan-cost.test.ts": 339,
"src/main/pty/history/terminal-history.test.ts": 153,
"src/main/text-generation/agent-failure-output.test.ts": 49,
"src/main/text-generation/antigravity-generation-stream.test.ts": 479,
"src/main/text-generation/command-environment-process.test.ts": 1044,
@@ -31,8 +31,6 @@ export const UNIT_EXCLUDE = [
'src/main/providers/local-pty/local-pty-shell-ready-zsh-launch-environment.test.ts',
'src/main/pty/shell-startup/omp-shell-wrapper-alias-safety.test.ts',
'src/main/pty/shell-startup/omp-shell-wrapper.node-pty.test.ts',
'src/main/pty/shell-startup/shell-startup-feature-channel.test.ts',
'src/main/pty/shell-startup/zsh-deferred-startup-line-init.live-shell.test.ts',
'src/main/pty/history/terminal-history-fish-session.node-pty.test.ts',
'src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts',
'src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
@@ -93,7 +93,6 @@ const NODE_RUNTIME_INCLUDE = [
'src/**/node-pty-*.test.ts',
'src/**/*.live-shell.test.ts',
// These drive live zsh through node-pty and rely on Node's native PTY stream delivery.
'src/main/pty/shell-startup/shell-startup-feature-channel.test.ts',
'src/main/daemon/shell-readiness/testing/shell-ready.test.ts',
'src/main/daemon/shell-readiness/testing/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/**/*real-pty*.test.ts',
@@ -1,359 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
createNestedProjectGroupResolver,
resolveNestedRepoImportPaths,
resolveNestedRepoSelection
} from './nested-repo-import'
import type { ProjectGroup } from '../../shared/project-group-types'
function createGroupRecorder(): {
groups: ProjectGroup[]
createGroup: (input: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroup['createdFrom']
}) => ProjectGroup
} {
const groups: ProjectGroup[] = []
return {
groups,
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
}
}
import { resolveNestedRepoImportPaths } from './nested-repo-import'
describe('createNestedProjectGroupResolver', () => {
it('creates sparse folder scopes for nested repos in grouped imports', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace',
groupName: 'workspace',
mode: 'group',
repoPaths: [
'/workspace/gateway-api',
'/workspace/services/payments/api',
'/workspace/services/payments/worker'
],
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
})
const direct = resolver.getGroupForRepo('/workspace/gateway-api')
const nested = resolver.getGroupForRepo('/workspace/services/payments/api')
const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker')
expect(direct?.name).toBe('workspace')
expect(nested?.name).toBe('services/payments')
expect(sibling?.id).toBe(nested?.id)
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['workspace', null, '/workspace'],
['services/payments', 'group-0', '/workspace/services/payments']
])
expect(resolver.getRootGroup()?.id).toBe('group-0')
})
it('skips intermediate folders that only lead to one meaningful child scope', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace/platform',
groupName: 'Platform',
mode: 'group',
repoPaths: [
'/workspace/platform/api',
'/workspace/platform/web',
'/workspace/platform/packages/shared/repo1',
'/workspace/platform/packages/shared/repo2'
],
createGroup
})
const api = resolver.getGroupForRepo('/workspace/platform/api')
const repo1 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
const repo2 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo2')
expect(api?.name).toBe('Platform')
expect(repo1?.name).toBe('packages/shared')
expect(repo2?.id).toBe(repo1?.id)
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['Platform', null, '/workspace/platform'],
['packages/shared', 'group-0', '/workspace/platform/packages/shared']
])
})
it('creates a parent folder scope when it has direct repos and nested descendants', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace/platform',
groupName: 'Platform',
mode: 'group',
repoPaths: ['/workspace/platform/services/api', '/workspace/platform/services/jobs/worker'],
createGroup
})
const direct = resolver.getGroupForRepo('/workspace/platform/services/api')
const nested = resolver.getGroupForRepo('/workspace/platform/services/jobs/worker')
expect(direct?.name).toBe('services')
expect(nested?.id).toBe(direct?.id)
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['Platform', null, '/workspace/platform'],
['services', 'group-0', '/workspace/platform/services']
])
})
it('does not create groups for separate imports', () => {
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace',
groupName: 'workspace',
mode: 'separate',
get repoPaths(): readonly string[] {
throw new Error('separate imports must not build unused folder scopes')
},
createGroup: () => {
throw new Error('should not create a group')
}
})
expect(resolver.getGroupForRepo('/workspace/services/api')).toBeUndefined()
expect(resolver.getCreatedGroups()).toEqual([])
})
it('leaves every separate-import repo ungrouped even when repo paths are supplied', () => {
const { groups, createGroup } = createGroupRecorder()
const repoPaths = [
'/workspace/services/api',
'/workspace/services/worker',
'/workspace/platform/packages/shared'
]
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace',
groupName: 'workspace',
mode: 'separate',
repoPaths,
createGroup
})
expect(repoPaths.map((repoPath) => resolver.getGroupForRepo(repoPath))).toEqual([
undefined,
undefined,
undefined
])
expect(resolver.getRootGroup()).toBeUndefined()
expect(groups).toEqual([])
})
it('preserves filesystem root parent paths when creating the root group', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: '/',
groupName: 'root',
mode: 'group',
repoPaths: ['/api', '/services/api'],
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
})
resolver.getGroupForRepo('/api')
resolver.getGroupForRepo('/services/api')
expect(groups.map((group) => group.parentPath)).toEqual(['/'])
})
it('preserves Windows drive roots when creating the root group', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: 'C:\\',
groupName: 'C',
mode: 'group',
repoPaths: ['C:\\api', 'C:\\services\\api'],
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
})
resolver.getGroupForRepo('C:\\api')
resolver.getGroupForRepo('C:\\services\\api')
expect(groups.map((group) => group.parentPath)).toEqual(['C:/'])
})
it('creates sparse folder scopes for Windows repo paths', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: 'C:\\workspace\\platform',
groupName: 'Platform',
mode: 'group',
repoPaths: [
'C:\\workspace\\platform\\apps\\web',
'C:\\workspace\\platform\\packages\\shared\\repo1',
'C:\\workspace\\platform\\packages\\shared\\repo2'
],
createGroup
})
const web = resolver.getGroupForRepo('C:\\workspace\\platform\\apps\\web')
const repo1 = resolver.getGroupForRepo('C:\\workspace\\platform\\packages\\shared\\repo1')
expect(web?.name).toBe('Platform')
expect(repo1?.name).toBe('packages/shared')
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['Platform', null, 'C:/workspace/platform'],
['packages/shared', 'group-0', 'C:/workspace/platform/packages/shared']
])
})
it('preserves SSH provenance on grouped folder scopes', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace/platform',
groupName: 'Platform',
mode: 'group',
connectionId: 'ssh-1',
repoPaths: [
'/workspace/platform/packages/shared/repo1',
'/workspace/platform/packages/shared/repo2'
],
createGroup
})
resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
expect(groups.map((group) => [group.name, group.connectionId])).toEqual([
['Platform', 'ssh-1'],
['packages/shared', 'ssh-1']
])
})
it('falls back to the selected parent folder basename for blank group names', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace/platform',
groupName: ' ',
mode: 'group',
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
})
resolver.getGroupForRepo('/workspace/platform/apps/web')
expect(groups.map((group) => group.name)).toEqual(['platform'])
})
it('resolves Windows-style repo paths back to canonical scan output', () => {
const selection = resolveNestedRepoSelection({
scan: {
selectedPath: 'C:\\workspace',
selectedPathKind: 'non_git_folder',
repos: [
{ path: 'C:\\workspace\\Services\\API', displayName: 'API', depth: 2 },
{ path: 'C:\\workspace\\tools', displayName: 'tools', depth: 1 }
],
truncated: false,
timedOut: false,
stopped: false,
durationMs: 1,
maxDepth: 3,
maxRepos: 100,
timeoutMs: null
},
projectPaths: ['c:/workspace/services/api', 'C:/workspace/services/api', 'D:/other/repo']
})
expect(selection.selectedPaths).toEqual(['C:\\workspace\\Services\\API'])
expect(selection.rejectedPaths).toEqual(['D:/other/repo'])
})
it('accepts stopped-scan import paths inside the selected parent without rescanning', () => {
const selection = resolveNestedRepoImportPaths({
parentPath: '/workspace/platform',
projectPaths: [
'/workspace/platform/api',
'/workspace/platform/api',
'/workspace/platform/apps/web',
'/workspace/other/repo'
]
})
expect(selection.selectedPaths).toEqual([
'/workspace/platform/api',
'/workspace/platform/apps/web'
])
expect(selection.rejectedPaths).toEqual(['/workspace/other/repo'])
})
it('rejects stopped-scan import paths that escape the selected parent with dot segments', () => {
const selection = resolveNestedRepoImportPaths({
parentPath: '/workspace/platform',
@@ -370,31 +18,4 @@ describe('createNestedProjectGroupResolver', () => {
'/workspace/platform/apps/../../other-outside-repo'
])
})
it('rejects stopped-scan import requests with a relative parent path', () => {
const selection = resolveNestedRepoImportPaths({
parentPath: 'workspace/platform',
projectPaths: ['workspace/platform/api', '/workspace/platform/api']
})
expect(selection.selectedPaths).toEqual([])
expect(selection.rejectedPaths).toEqual(['workspace/platform/api', '/workspace/platform/api'])
})
it('normalizes accepted stopped-scan import paths before importing', () => {
const selection = resolveNestedRepoImportPaths({
parentPath: 'C:\\workspace\\platform',
projectPaths: [
'C:\\workspace\\platform\\api',
'C:\\workspace\\platform\\apps\\..\\tools',
'C:\\workspace\\outside'
]
})
expect(selection.selectedPaths).toEqual([
'C:/workspace/platform/api',
'C:/workspace/platform/tools'
])
expect(selection.rejectedPaths).toEqual(['C:\\workspace\\outside'])
})
})
@@ -6,10 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PipedProcessSpawner } from '@orca/process-host/process-spec'
import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child'
import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './provider-process-supervisor'
import { PROVIDER_SPAWN_FAILURE_MARKER } from './provider-spawn-failure-report'
import { spawnManagedProviderProcess } from './managed-provider-process'
import type { DescendantTreeVerdict } from '../pty/process-retirement/pty-descendant-exit-verification'
import type { ProviderProcessTree } from './provider-process-close'
const mocks = vi.hoisted(() => ({
capture: vi.fn(async () => null),
@@ -54,88 +51,7 @@ function launch(fixture: ReturnType<typeof fakeChild>, platform: NodeJS.Platform
)
}
function fakeTree(initial: DescendantTreeVerdict = 'unverifiable') {
let verdict = initial
const tree: ProviderProcessTree = {
capture: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
reap: vi.fn(async () => verdict),
get treeVerdict() {
return verdict
}
}
return {
tree,
setVerdict: (next: DescendantTreeVerdict) => {
verdict = next
}
}
}
describe('managed provider process', () => {
it('applies launch environment and uses the supervisor on its execution platform', () => {
const fixture = fakeChild()
const managed = spawnManagedProviderProcess(
{
command: 'fixture-provider',
args: [],
env: { AGENT_HOME: '/pinned' },
envToDelete: ['SECRET']
},
{
spawnImpl: fixture.spawn,
platform: 'darwin',
inheritedEnv: { SECRET: 'inherited', PATH: '/bin' },
site: 'fixture',
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited',
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
}
)
expect(managed.supervised).toBe(true)
expect(fixture.spawn).toHaveBeenCalledWith(
expect.objectContaining({
detached: true,
env: expect.objectContaining({ AGENT_HOME: '/pinned', PATH: '/bin' })
})
)
expect(fixture.spawn.mock.calls[0][0].env).not.toHaveProperty('SECRET')
expect(managed.rootVerdict).toBe('live')
})
it('observes exit once across exit and close, including a late subscriber', async () => {
const fixture = fakeChild()
const managed = launch(fixture)
const onExit = vi.fn()
managed.onExit(onExit)
fixture.child.emit('exit', 7, 'SIGTERM')
fixture.child.emit('close', 7, 'SIGTERM')
fixture.child.emit('exit', 8, 'SIGKILL')
await managed.exitPromise
expect(onExit).toHaveBeenCalledExactlyOnceWith({
code: 7,
signal: 'SIGTERM',
processless: false
})
const late = vi.fn()
managed.onExit(late)
expect(late).toHaveBeenCalledExactlyOnceWith({ code: 7, signal: 'SIGTERM', processless: false })
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
expect(fixture.child.kill).not.toHaveBeenCalled()
})
it('proves stdin-close exit without forcing and clears the grace timer', async () => {
vi.useFakeTimers()
const fixture = fakeChild()
const managed = launch(fixture)
fixture.child.stdin.once('finish', () => fixture.child.emit('exit', 0, null))
const closing = managed.close()
expect(fixture.child.stdin.writableEnded).toBe(true)
await vi.advanceTimersByTimeAsync(0)
await expect(closing).resolves.toMatchObject({ root: 'exited' })
expect(fixture.child.kill).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('joins a close, keeps root live after the kill deadline, and retries on the same child', async () => {
vi.useFakeTimers()
const fixture = fakeChild()
@@ -157,21 +73,6 @@ describe('managed provider process', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('still reports a root exit once after an unconfirmed close', async () => {
vi.useFakeTimers()
const fixture = fakeChild()
const managed = launch(fixture)
const report = vi.fn()
managed.onExit(report)
const close = managed.close()
await vi.advanceTimersByTimeAsync(150)
await expect(close).resolves.toMatchObject({ root: 'live' })
fixture.child.emit('exit', 0, null)
fixture.child.emit('close', 0, null)
expect(report).toHaveBeenCalledOnce()
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
})
it('requires error then close for a processless spawn to settle', async () => {
const fixture = fakeChild(null)
const managed = launch(fixture)
@@ -186,46 +87,6 @@ describe('managed provider process', () => {
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
})
it('names a missing provider executable from a direct spawn error or a supervisor report', async () => {
const direct = fakeChild(null)
const directManaged = launch(direct)
direct.child.emit(
'error',
Object.assign(new Error('spawn fixture-provider ENOENT'), {
code: 'ENOENT',
path: 'fixture-provider'
})
)
expect(directManaged.executableMissing).toBe(true)
const supervisedLaunch = (fixture: ReturnType<typeof fakeChild>) =>
spawnManagedProviderProcess(
{ command: 'fixture-provider', args: ['serve'], cwd: tmpdir() },
{
spawnImpl: fixture.spawn,
platform: 'linux',
site: 'fixture-provider-teardown',
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
}
)
const supervised = fakeChild()
const supervisedManaged = supervisedLaunch(supervised)
const report = (code: string): string =>
`${PROVIDER_SPAWN_FAILURE_MARKER}${JSON.stringify({ thrown: false, code, message: `spawn fixture-provider ${code}` })}\n`
supervised.child.stderr.write(report('ENOENT'))
await new Promise((resolve) => setImmediate(resolve))
expect(supervisedManaged.executableMissing).toBe(false)
supervised.child.emit('exit', 127, null)
expect(supervisedManaged.executableMissing).toBe(true)
const denied = fakeChild()
const deniedManaged = supervisedLaunch(denied)
denied.child.stderr.write(report('EACCES'))
await new Promise((resolve) => setImmediate(resolve))
denied.child.emit('exit', 127, null)
expect(deniedManaged.executableMissing).toBe(false)
})
// Direct (Windows) and supervised (POSIX) spawns: Node names what it spawned, never the folder.
it.each(['win32', 'linux'] as const)(
'names a deleted working folder, not the CLI, when a real %s spawn fails there',
@@ -251,33 +112,6 @@ describe('managed provider process', () => {
}
)
it("never blames the agent's CLI when the supervisor binary itself is gone", () => {
const execPath = process.execPath
// Orca's binary replaced mid-update: the supervisor spawn finds no such file.
const gone = join(tmpdir(), 'orca-updated-away', 'Orca')
Object.defineProperty(process, 'execPath', { configurable: true, value: gone })
try {
const fixture = fakeChild(null)
const managed = spawnManagedProviderProcess(
{ command: 'fixture-provider', args: ['serve'], cwd: tmpdir() },
{
spawnImpl: fixture.spawn,
platform: 'linux',
site: 'fixture-provider-teardown',
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
}
)
fixture.child.emit(
'error',
Object.assign(new Error(`spawn ${gone} ENOENT`), { code: 'ENOENT', path: gone })
)
expect(managed.spawnAbsence).toBeNull()
expect(managed.executableMissing).toBe(false)
} finally {
Object.defineProperty(process, 'execPath', { configurable: true, value: execPath })
}
})
it('does not turn a transport close with an existing pid into Claude exit proof', () => {
const fixture = fakeChild()
const managed = launch(fixture)
@@ -286,104 +120,4 @@ describe('managed provider process', () => {
expect(managed.rootVerdict).toBe('live')
expect(managed.processless).toBe(false)
})
it('uses Windows tree teardown and still requires observed root exit', async () => {
vi.useFakeTimers()
const fixture = fakeChild()
const managed = launch(fixture, 'win32')
expect(managed.supervised).toBe(false)
const first = managed.close()
await vi.advanceTimersByTimeAsync(150)
await expect(first).resolves.toMatchObject({ root: 'live' })
expect(mocks.windowsTree).toHaveBeenCalledWith(fixture.child.pid, {
site: 'fixture-provider-teardown'
})
expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGTERM')
fixture.child.emit('exit', null, 'SIGKILL')
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
})
it('preserves a caller requiring tree proof, including live and unverifiable descendants', async () => {
const fixture = fakeChild()
const managed = spawnManagedProviderProcess(
{ command: 'fixture-provider', args: [] },
{
spawnImpl: fixture.spawn,
platform: 'darwin',
site: 'fixture',
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited',
policy: () => ({ gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS, forcedExitMs: 50 })
}
)
const proof = fakeTree()
fixture.child.emit('exit', 0, null)
await expect(managed.close(proof.tree)).resolves.toEqual({
root: 'exited',
tree: 'unverifiable'
})
proof.setVerdict('live')
await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'live' })
proof.setVerdict('exited')
await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' })
await expect(managed.close(proof.tree)).resolves.toEqual({ root: 'exited', tree: 'exited' })
expect(proof.tree.capture).toHaveBeenCalledTimes(3)
})
it('reports failed tree cleanup separately after observed root exit', async () => {
vi.useFakeTimers()
const fixture = fakeChild()
const managed = launch(fixture, 'win32')
mocks.windowsTree.mockImplementationOnce(async () => {
fixture.child.emit('exit', null, 'SIGKILL')
throw new Error('tree teardown unavailable')
})
const close = managed.close()
await vi.advanceTimersByTimeAsync(150)
await expect(close).resolves.toMatchObject({ root: 'exited' })
expect(managed.lastCloseResult).toEqual({ root: 'exited', tree: 'unverifiable' })
})
it('keeps the cleanup diagnostic tied to the close that finished before a late root exit', async () => {
vi.useFakeTimers()
const fixture = fakeChild()
const managed = launch(fixture, 'win32')
mocks.windowsTree.mockRejectedValueOnce(new Error('tree teardown unavailable'))
const close = managed.close()
await vi.advanceTimersByTimeAsync(150)
await expect(close).resolves.toMatchObject({ root: 'live' })
fixture.child.emit('exit', null, 'SIGKILL')
await expect(managed.close()).resolves.toMatchObject({ root: 'exited' })
expect(managed.lastCloseResult).toEqual({ root: 'live', tree: 'unverifiable' })
})
it('lets a supervised caller signal immediately and waits its configured grace before forcing', async () => {
vi.useFakeTimers()
const fixture = fakeChild()
const managed = spawnManagedProviderProcess(
{ command: 'fixture-provider', args: [] },
{
spawnImpl: fixture.spawn,
platform: 'darwin',
site: 'fixture',
acceptClose: (result) => result.root === 'exited' && result.tree === 'exited',
policy: () => ({
gracefulExitMs: PROVIDER_SUPERVISOR_MAX_STOP_MS + 500,
forcedExitMs: 50,
signalSupervisorOnClose: true
})
}
)
fixture.child.kill.mockImplementation(() => {
setTimeout(() => fixture.child.emit('exit', 0, 'SIGTERM'), 550)
return true
})
const close = managed.close()
await vi.advanceTimersByTimeAsync(549)
expect(fixture.child.kill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(managed.rootVerdict).toBe('live')
await vi.advanceTimersByTimeAsync(1)
await expect(close).resolves.toMatchObject({ root: 'exited' })
expect(fixture.child.kill).not.toHaveBeenCalledWith('SIGKILL')
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -2,18 +2,13 @@ import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import {
guardSupervisedProviderGroup,
sweepOrphanedProviderGroup,
type ProviderGroupSweepVerdict
sweepOrphanedProviderGroup
} from './provider-group-orphan-guard'
import type { ProcessRowRead } from './provider-process-row-read'
const STARTED = 'Mon Oct 19 10:00:00 2026'
const identity = { pgid: 4242, startedAt: STARTED }
function errno(code: string): Error {
return Object.assign(new Error(code), { code })
}
function row(overrides: Partial<Exclude<ProcessRowRead, null | 'unverifiable'>> = {}) {
return {
pid: 4242,
@@ -26,22 +21,6 @@ function row(overrides: Partial<Exclude<ProcessRowRead, null | 'unverifiable'>>
}
describe('sweepOrphanedProviderGroup', () => {
it('does nothing when the group is already gone', async () => {
const signalGroup = vi.fn(() => {
throw errno('ESRCH')
})
const readProcessRow = vi.fn()
await expect(
sweepOrphanedProviderGroup(identity, {
site: 'test',
supervised: true,
signalGroup,
readProcessRow
})
).resolves.toBe('absent')
expect(readProcessRow).not.toHaveBeenCalled()
})
it('kills a surviving group whose leader is the recorded process', async () => {
const signalGroup = vi.fn()
await expect(
@@ -84,15 +63,6 @@ describe('sweepOrphanedProviderGroup', () => {
).resolves.toBe('refused')
expect(signalGroup).not.toHaveBeenCalledWith(4242, 'SIGKILL')
})
it('refuses a group this user cannot signal', async () => {
const signalGroup = vi.fn(() => {
throw errno('EPERM')
})
await expect(
sweepOrphanedProviderGroup(identity, { site: 'test', supervised: true, signalGroup })
).resolves.toBe('refused')
})
})
class FakeSupervisor extends EventEmitter {
@@ -105,67 +75,7 @@ class FakeSupervisor extends EventEmitter {
}
}
function guard(
child: FakeSupervisor,
readProcessRow: (pid: number) => Promise<ProcessRowRead>,
signalGroup = vi.fn()
): Promise<ProviderGroupSweepVerdict | 'unproven'> {
return new Promise((resolve) =>
guardSupervisedProviderGroup(child, {
site: 'test',
supervised: true,
readProcessRow,
signalGroup,
onSwept: resolve
})
)
}
describe('guardSupervisedProviderGroup', () => {
it('sweeps the reported group once the supervisor exits', async () => {
const child = new FakeSupervisor()
const signalGroup = vi.fn()
const swept = guard(child, async () => row({ ppid: 777 }), signalGroup)
child.report({ pgid: 4242 })
child.emit('exit', null, 'SIGKILL')
await expect(swept).resolves.toBe('killed')
expect(signalGroup).toHaveBeenLastCalledWith(4242, 'SIGKILL')
})
it('never records a reported pid that its supervisor no longer parents', async () => {
const child = new FakeSupervisor()
const signalGroup = vi.fn()
const swept = guard(child, async () => row({ ppid: 1 }), signalGroup)
child.report({ pgid: 4242 })
child.emit('exit', null, 'SIGKILL')
await expect(swept).resolves.toBe('unproven')
expect(signalGroup).not.toHaveBeenCalled()
})
it('re-reads a leader started in the read second, so a same-second reuse cannot match', async () => {
const child = new FakeSupervisor()
const startedAtMs = Date.parse(STARTED)
const clock = { now: startedAtMs + 300 }
const readProcessRow = vi.fn(async () => row({ ppid: 777 }))
const sleep = vi.fn(async (ms: number) => {
clock.now += ms
})
const proven = new Promise((resolve) =>
guardSupervisedProviderGroup(child, {
site: 'test',
supervised: true,
readProcessRow,
now: () => clock.now,
sleep,
onProven: resolve
})
)
child.report({ pgid: 4242 })
await expect(proven).resolves.toEqual(identity)
expect(readProcessRow).toHaveBeenCalledTimes(2)
expect(clock.now).toBeGreaterThanOrEqual(startedAtMs + 1_000)
})
it('arms nothing when a same-second replacement holds the pid once the second ends', async () => {
const child = new FakeSupervisor()
const clock = { now: Date.parse(STARTED) + 300 }
@@ -194,98 +104,4 @@ describe('guardSupervisedProviderGroup', () => {
await expect(swept).resolves.toBe('unproven')
expect(signalGroup).not.toHaveBeenCalled()
})
it('trusts a Linux clock-tick start without waiting out the second', async () => {
const child = new FakeSupervisor()
const startedAt = 'clock-tick:123456'
const readProcessRow = vi.fn(async () =>
row({ ppid: 777, startedAt, startResolution: 'clock-tick' })
)
const sleep = vi.fn(async () => {})
const proven = new Promise((resolve) =>
guardSupervisedProviderGroup(child, {
site: 'test',
supervised: true,
readProcessRow,
now: () => Date.now(),
sleep,
onProven: resolve
})
)
child.report({ pgid: 4242 })
await expect(proven).resolves.toEqual({ pgid: 4242, startedAt })
expect(sleep).not.toHaveBeenCalled()
})
it('records nothing when every read stays inside the start second', async () => {
const child = new FakeSupervisor()
const now = Date.parse(STARTED) + 100
const signalGroup = vi.fn()
const swept = new Promise((resolve) =>
guardSupervisedProviderGroup(child, {
site: 'test',
supervised: true,
readProcessRow: async () => row({ ppid: 777 }),
signalGroup,
now: () => now,
sleep: async () => {},
onSwept: resolve
})
)
child.report({ pgid: 4242 })
child.emit('exit', null, 'SIGKILL')
await expect(swept).resolves.toBe('unproven')
expect(signalGroup).not.toHaveBeenCalled()
})
it('stops waiting out the start second as soon as the supervisor exits', async () => {
const child = new FakeSupervisor()
const readProcessRow = vi.fn(async () => row({ ppid: 777 }))
const swept = new Promise((resolve) =>
guardSupervisedProviderGroup(child, {
site: 'test',
supervised: true,
readProcessRow,
now: () => Date.parse(STARTED) + 100,
onSwept: resolve
})
)
child.report({ pgid: 4242 })
await vi.waitFor(() => expect(readProcessRow).toHaveBeenCalledTimes(1))
const exitedAt = performance.now()
child.emit('exit', null, 'SIGKILL')
await expect(swept).resolves.toBe('unproven')
expect(performance.now() - exitedAt).toBeLessThan(500)
expect(readProcessRow).toHaveBeenCalledTimes(1)
})
it('warns once and watches nothing for a supervised spawn without the report channel', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const child = new FakeSupervisor()
child.connected = false
expect(guardSupervisedProviderGroup(child, { site: 'test', supervised: true })).toBe(false)
expect(guardSupervisedProviderGroup(child, { site: 'test', supervised: true })).toBe(false)
expect(child.listenerCount('exit')).toBe(0)
expect(warn).toHaveBeenCalledTimes(1)
warn.mockRestore()
})
it('stays quiet for an unsupervised spawn', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(
guardSupervisedProviderGroup(new FakeSupervisor(), { site: 'test', supervised: false })
).toBe(false)
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})
it('ignores a malformed report', async () => {
const child = new FakeSupervisor()
const readProcessRow = vi.fn(async () => row({ ppid: 777 }))
const swept = guard(child, readProcessRow)
child.report({ pgid: 1 })
child.emit('exit', 0, null)
await expect(swept).resolves.toBe('unproven')
expect(readProcessRow).not.toHaveBeenCalled()
})
})
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
PROVIDER_SIGTERM_GRACE_MS,
PROVIDER_STDIN_END_GRACE_MS,
PROVIDER_SUPERVISOR_MAX_STOP_MS,
supervisedPosixLaunch,
@@ -48,9 +47,7 @@ const CLEANS_UP_AFTER_SIGTERM_PROVIDER = String.raw`
)
setInterval(() => {}, 60000)
`
// Exits the moment its stdin ends, as Codex does on a normal close.
const EXITS_ON_STDIN_END_PROVIDER = String.raw`
void String.raw`
process.stdin.on('end', () => process.exit(0)).resume()
process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n')
`
@@ -68,9 +65,7 @@ const FLUSHES_ON_STDIN_END_PROVIDER = String.raw`
}, 200)).resume()
process.stdout.write(JSON.stringify({ provider: process.pid }) + '\n')
`
// Ignores stdin end and SIGTERM, recording when SIGTERM arrived, so only SIGKILL ends it.
const RECORDS_SIGTERM_PROVIDER = String.raw`
void String.raw`
process.on('SIGTERM', () => {
require('node:fs').writeFileSync(process.env.ORCA_TEST_PROVIDER_SIGNAL_FILE, String(Date.now()))
})
@@ -272,20 +267,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
expect(alive(grandchild)).toBe(false)
})
it('kills the rest of a one-shot group at once when the provider exits on a stop', async () => {
const { supervisor, exit } = launchSupervisor({ lifetime: 'one-shot' })
const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild'])
const signalledAt = Date.now()
supervisor.kill('SIGTERM')
await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' })
// One-shot helpers must not impose the session grace after provider exit.
expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS / 3)
expect(alive(provider)).toBe(false)
expect(alive(grandchild)).toBe(false)
})
it('lets a session provider group finish its SIGTERM cleanup after the provider exits', async () => {
const cleanupFile = join(tempDir(), 'grandchild-cleaned-up')
const { supervisor, exit } = launchSupervisor(
@@ -301,31 +282,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
expect(existsSync(cleanupFile)).toBe(true)
})
it('escalates a SIGTERM-ignoring provider to SIGKILL after the grace from the spec', async () => {
const graceMs = 200
const { supervisor, exit } = launchSupervisor(
{ sigtermGraceMs: graceMs },
{ ORCA_TEST_PROVIDER_IGNORES_SIGTERM: '1' }
)
const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild'])
const signalledAt = Date.now()
supervisor.kill('SIGTERM')
let exitTimeout: ReturnType<typeof setTimeout> | undefined
const exited = await Promise.race([
exit,
new Promise((resolve) => {
exitTimeout = setTimeout(resolve, 5_000)
})
]).finally(() => clearTimeout(exitTimeout))
expect(exited).toEqual({ code: null, signal: 'SIGTERM' })
expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs)
expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS)
expect(alive(provider)).toBe(false)
expect(alive(grandchild)).toBe(false)
})
it('reaps a provider spawned in the instant before a stop arrives', async () => {
const dir = tempDir()
const preload = join(dir, 'signal-after-spawn.js')
@@ -387,101 +343,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
expect(alive(pids.grandchild)).toBe(false)
})
it('closes a provider that exits on stdin end without waiting out any grace', async () => {
const { supervisor, exit } = launchSupervisor(
{},
{},
{
command: process.execPath,
args: ['-e', EXITS_ON_STDIN_END_PROVIDER]
}
)
const { provider } = await readPids(supervisor, ['provider'])
const endedAt = Date.now()
supervisor.stdin!.end()
await expect(exit).resolves.toEqual({ code: 0, signal: null })
expect(Date.now() - endedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS)
expect(alive(provider)).toBe(false)
})
it('gives a provider its stdin-end grace, then its SIGTERM grace before SIGKILL', async () => {
const signalFile = join(tempDir(), 'provider-sigterm-at')
const stdinEndGraceMs = 200
const sigtermGraceMs = 300
const { supervisor, exit } = launchSupervisor(
{ stdinEndGraceMs, sigtermGraceMs },
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile },
{ command: process.execPath, args: ['-e', RECORDS_SIGTERM_PROVIDER] }
)
const { provider } = await readPids(supervisor, ['provider'])
const endedAt = Date.now()
supervisor.stdin!.end()
const exited = await exit
const exitedAt = Date.now()
const signalledAt = Number(readFileSync(signalFile, 'utf8'))
expect(exited).toEqual({ code: 137, signal: null })
// Timers may fire a tick early against another process's clock.
expect(signalledAt - endedAt).toBeGreaterThanOrEqual(stdinEndGraceMs - 20)
expect(exitedAt - signalledAt).toBeGreaterThanOrEqual(sigtermGraceMs - 20)
expect(exitedAt - endedAt).toBeLessThan(stdinEndGraceMs + sigtermGraceMs + 1_000)
expect(alive(provider)).toBe(false)
})
it('reaps the provider group and exits when its owner quits cleanly', async () => {
const { owner, pids } = await launchUnderOwner(
{ sigtermGraceMs: 300 },
{ ORCA_TEST_OWNER_QUITS_CLEANLY: '1' }
)
const ownerExit = new Promise((resolve) =>
owner.once('exit', (code, signal) => resolve({ code, signal }))
)
owner.kill('SIGUSR2')
await expect(ownerExit).resolves.toEqual({ code: 0, signal: null })
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true)
expect(alive(pids.grandchild)).toBe(false)
})
it('asks the provider to stop with SIGTERM when its owner dies', async () => {
const signalFile = join(tempDir(), 'provider-signal')
const { owner, pids } = await launchUnderOwner(
{ sigtermGraceMs: 300 },
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile }
)
owner.kill('SIGKILL')
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
expect(existsSync(signalFile) && readFileSync(signalFile, 'utf8')).toBe('SIGTERM')
})
it('stops a session closed with SIGTERM at once when its owner dies', async () => {
const signalFile = join(tempDir(), 'provider-sigterm-at')
const { owner, pids } = await launchUnderOwner(
{ sigtermGraceMs: 200 },
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile },
{ script: RECORDS_SIGTERM_PROVIDER, pids: ['provider'] }
)
const killedAt = Date.now()
owner.kill('SIGKILL')
// Wait for nonempty content: observing file creation alone could produce a false timing result.
const signalledAt = (): number =>
existsSync(signalFile) ? Number(readFileSync(signalFile, 'utf8')) : 0
expect(await waitFor(() => signalledAt() > 0, 3_000)).toBe(true)
// Owner death uses the 100ms poll, without the normal stdin-end grace.
expect(signalledAt() - killedAt).toBeGreaterThanOrEqual(0)
expect(signalledAt() - killedAt).toBeLessThan(PROVIDER_STDIN_END_GRACE_MS / 2)
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
})
it('gives a session closed by its stdin end that EOF and grace when its owner dies', async () => {
const dir = tempDir()
const signalFile = join(dir, 'provider-signal')
@@ -500,26 +361,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
expect(existsSync(signalFile)).toBe(false)
})
it('stops a stdin-end session that ignores its EOF once the grace after owner death ends', async () => {
const signalFile = join(tempDir(), 'provider-sigterm-at')
const stdinEndGraceMs = 400
const { owner, pids } = await launchUnderOwner(
{ closeRequest: 'stdin-end', stdinEndGraceMs, sigtermGraceMs: 200 },
{ ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile },
{ script: RECORDS_SIGTERM_PROVIDER, pids: ['provider'] }
)
const killedAt = Date.now()
owner.kill('SIGKILL')
expect(await waitFor(() => !alive(-pids.provider), 3_000)).toBe(true)
// Timers may fire a tick early against another process's clock.
expect(Number(readFileSync(signalFile, 'utf8')) - killedAt).toBeGreaterThanOrEqual(
stdinEndGraceMs - 20
)
expect(await waitFor(() => !alive(pids.supervisor), 3_000)).toBe(true)
})
describe('one-shot lifetime', () => {
it('lets a one-shot answer after its stdin ends and relays its exit code', async () => {
const signalFile = join(tempDir(), 'provider-signal')
@@ -566,25 +407,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
}
)
it('escalates a SIGTERM-ignoring one-shot to SIGKILL after the grace from the spec', async () => {
const graceMs = 200
const { supervisor, exit } = launchSupervisor(
{ lifetime: 'one-shot', sigtermGraceMs: graceMs },
{ ORCA_TEST_PROVIDER_IGNORES_SIGTERM: '1' }
)
const { provider, grandchild } = await readPids(supervisor, ['provider', 'grandchild'])
supervisor.stdin!.end()
const signalledAt = Date.now()
supervisor.kill('SIGTERM')
await expect(exit).resolves.toEqual({ code: null, signal: 'SIGTERM' })
expect(Date.now() - signalledAt).toBeGreaterThanOrEqual(graceMs)
expect(Date.now() - signalledAt).toBeLessThan(PROVIDER_SIGTERM_GRACE_MS)
expect(alive(provider)).toBe(false)
expect(alive(grandchild)).toBe(false)
})
it('keeps the user Node options from the supervisor and hands them to the provider', async () => {
const nodeOptions = `--require ${join(tempDir(), 'missing-preload.js')}`
const { supervisor, exit } = launchSupervisor(
@@ -621,24 +443,6 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe
})
})
it('hands the provider a near-cap argv prompt intact', async () => {
const prompt = 'x'.repeat(110 * 1024)
const { supervisor, exit } = launchSupervisor(
{ lifetime: 'one-shot' },
{},
{
command: process.execPath,
args: ['-e', 'process.stdout.write(String(process.argv[1].length))', prompt]
}
)
let stdout = ''
supervisor.stdout!.on('data', (chunk: Buffer) => (stdout += chunk.toString()))
supervisor.stdin!.end()
await expect(exit).resolves.toEqual({ code: 0, signal: null })
expect(stdout).toBe(String(prompt.length))
})
it('relays all of the provider output to a slow owner before exiting', async () => {
const bytes = 4 * 1024 * 1024
const { supervisor, exit } = launchSupervisor(
@@ -1,9 +1,6 @@
import type { ChildProcess } from 'node:child_process'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
findSelfInitiatedTreeKills,
resetSelfInitiatedTreeKillLogForTest
} from '../crash-reporting/self-initiated-tree-kill-log'
import { resetSelfInitiatedTreeKillLogForTest } from '../crash-reporting/self-initiated-tree-kill-log'
import type { DescendantTreeVerdict } from '../pty/process-retirement/pty-descendant-exit-verification'
import { terminateProviderProcessTree as terminateWithEvidence } from './provider-process-teardown'
@@ -12,8 +9,6 @@ async function terminateProviderProcessTree(...args: Parameters<typeof terminate
return typeof verdict === 'object' && verdict ? verdict.tree : verdict
}
const UNREACHABLE_PGID = 2_147_483_647
function child() {
return {
pid: 1234,
@@ -44,31 +39,6 @@ describe('terminateProviderProcessTree', () => {
expect(target.kill).toHaveBeenCalledWith('SIGKILL')
})
it('reports an unproven Windows tree when taskkill does not exit cleanly', async () => {
const target = child()
await expect(
terminateWithEvidence(target, {
site: 'codex-app-server-teardown',
platform: 'win32',
terminateWindowsTree: async () => false
})
).resolves.toBe('unverifiable')
expect(target.kill).toHaveBeenCalledWith('SIGKILL')
})
it('passes a non-Codex diagnostic label to Windows teardown', async () => {
const terminateWindowsTree = vi.fn(async () => true)
await terminateProviderProcessTree(child(), {
site: 'provider-test-teardown',
platform: 'win32',
terminateWindowsTree
})
expect(terminateWindowsTree).toHaveBeenCalledWith(1234, { site: 'provider-test-teardown' })
})
it('waits for an owned POSIX snapshot before killing the wrapper', async () => {
const target = child()
const signalProcessGroup = vi.fn()
@@ -91,97 +61,7 @@ describe('terminateProviderProcessTree', () => {
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
})
it('signals a proven dedicated POSIX process group without scanning descendants', async () => {
const target = child()
const captureDescendants = vi.fn()
const signalProcessGroup = vi.fn()
await expect(
terminateProviderProcessTree(target, {
site: 'provider-test-teardown',
platform: 'darwin',
dedicatedProcessGroup: true,
captureDescendants,
signalProcessGroup
})
).resolves.toBeNull()
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
expect(captureDescendants).not.toHaveBeenCalled()
expect(target.kill).not.toHaveBeenCalled()
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([
expect.objectContaining({ site: 'provider-test-teardown', scope: 'posix-process-group' })
])
})
it('keeps the dedicated-group wrapper reachable when signalling is unproven', async () => {
const target = child()
await expect(
terminateProviderProcessTree(target, {
site: 'codex-app-server-teardown',
platform: 'linux',
dedicatedProcessGroup: true,
signalProcessGroup: () => {
throw Object.assign(new Error('denied'), { code: 'EPERM' })
}
})
).resolves.toBe('unverifiable')
expect(target.kill).not.toHaveBeenCalled()
})
// An intercepted ESRCH must exercise the default signal path without creating a false kill breadcrumb.
it('does not claim a snapshot group that was already gone', async () => {
const target = { pid: UNREACHABLE_PGID, kill: vi.fn<ChildProcess['kill']>(() => true) }
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('already exited'), { code: 'ESRCH' })
})
try {
await expect(
terminateProviderProcessTree(target, {
site: 'codex-app-server-teardown',
platform: 'darwin',
captureDescendants: async () => ({
rootPgid: UNREACHABLE_PGID,
descendants: [],
capturedAtMs: 1
}),
terminateDescendants: async () => 'exited'
})
).resolves.toBe('exited')
expect(kill).toHaveBeenCalledWith(-UNREACHABLE_PGID, 'SIGKILL')
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([])
} finally {
kill.mockRestore()
}
})
it('claims a snapshot group the signal actually reached', async () => {
const target = child()
const signalProcessGroup = vi.fn()
await expect(
terminateProviderProcessTree(target, {
site: 'codex-app-server-teardown',
platform: 'darwin',
captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }),
terminateDescendants: async () => 'exited',
signalProcessGroup
})
).resolves.toBe('exited')
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([
expect.objectContaining({
pid: 1234,
site: 'codex-app-server-teardown',
scope: 'posix-process-group'
})
])
})
it.each([
['live', 'live'],
@@ -218,62 +98,6 @@ describe('terminateProviderProcessTree', () => {
)
}
)
it('claims no observation when the POSIX process table cannot be read', async () => {
const target = child()
await expect(
terminateProviderProcessTree(target, {
site: 'codex-app-server-teardown',
platform: 'darwin',
captureDescendants: async () => null
})
).resolves.toBeNull()
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
})
it('claims no observation from an ESRCH dedicated group and reports a spawnless child as unverifiable', async () => {
await expect(
terminateProviderProcessTree(child(), {
site: 'provider-test-teardown',
platform: 'linux',
dedicatedProcessGroup: true,
signalProcessGroup: () => {
throw Object.assign(new Error('gone'), { code: 'ESRCH' })
}
})
).resolves.toBeNull()
const spawnless = { pid: undefined, kill: vi.fn<ChildProcess['kill']>(() => true) }
await expect(
terminateProviderProcessTree(spawnless, { site: 'provider-test-teardown', platform: 'linux' })
).resolves.toBe('unverifiable')
})
it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => {
const killMocks = Array.from({ length: 40 }, () => vi.fn<ChildProcess['kill']>(() => true))
const targets = killMocks.map((kill, index) => ({
pid: 10_000 + index,
kill
}))
const captureDescendants = vi.fn()
const signalProcessGroup = vi.fn()
const results = await Promise.all(
targets.map((target) =>
terminateProviderProcessTree(target, {
site: 'codex-app-server-teardown',
platform: 'linux',
dedicatedProcessGroup: true,
captureDescendants,
signalProcessGroup
})
)
)
expect(results).toEqual(Array.from({ length: targets.length }, () => null))
expect(signalProcessGroup.mock.calls).toEqual(targets.map((target) => [target.pid, 'SIGKILL']))
expect(captureDescendants).not.toHaveBeenCalled()
expect(killMocks.every((kill) => kill.mock.calls.length === 0)).toBe(true)
})
})
it('counts the writer group kill, but never a supervisor-only kill, without an exit report', async () => {
@@ -14,7 +14,6 @@ import { resetProcessTableSnapshotForTests } from '../../shared/process-table-sn
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
import {
confirmShellForegroundProcess,
resolveAgentForegroundProcess,
resolveAgentForegroundProcessWithAvailability
} from './agent-foreground-process'
// Toolhelp denial may return an empty table; require the querying process to distinguish a readable snapshot.
@@ -61,13 +60,6 @@ function mockWindowsRows(rows: NativeProcessRow[] = DEFAULT_WINDOWS_ROWS): void
})
}
/** `undefined` rows are how the native snapshot reports a table it cannot read. */
function mockUnreadableWindowsTable(): void {
getAllProcessesMock.mockImplementation((cb: (snapshot: undefined) => void) => {
cb(undefined)
})
}
describe('resolveAgentForegroundProcess', () => {
let platform: PropertyDescriptor | undefined
@@ -91,155 +83,7 @@ describe('resolveAgentForegroundProcess', () => {
}
})
it('does not report a suspended agent when a non-agent holds the foreground', async () => {
// vim owns foreground (+); Ctrl-Z left Codex as a stopped background descendant.
mockPs(
[
'101 100 T node /Users/dev/.nvm/versions/node/bin/codex',
'102 100 S+ vim notes.txt'
].join('\n')
)
await expect(resolveAgentForegroundProcess(100, 'vim')).resolves.toBe('vim')
})
it('still reports a foreground agent', async () => {
mockPs(['101 100 S+ node /Users/dev/.nvm/versions/node/bin/codex'].join('\n'))
await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex')
})
// Why: OMP embeds Pi, but the outer process is the user-visible identity (#6364).
it('reports the outer omp wrapper, not the wrapped pi child', async () => {
mockPs(['101 100 S+ omp', '102 101 S+ pi'].join('\n'))
await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp')
})
it('reports omp even when the wrapped pi child holds the foreground alone', async () => {
// OMP identity must survive samples where only the wrapped Pi child owns foreground.
mockPs(['101 100 S omp', '102 101 S+ pi'].join('\n'))
await expect(resolveAgentForegroundProcess(100, 'omp')).resolves.toBe('omp')
})
it('reports bare pi when no omp wrapper is present', async () => {
mockPs(['101 100 S+ pi'].join('\n'))
await expect(resolveAgentForegroundProcess(100, 'pi')).resolves.toBe('pi')
})
it('reports the outer omp wrapper on Windows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'omp.exe',
commandLine: 'omp.exe'
},
{
pid: 102,
ppid: 101,
name: 'pi.exe',
commandLine: 'pi.exe'
}
])
await expect(resolveAgentForegroundProcess(100, 'pi.exe')).resolves.toBe('omp')
})
it('keeps the Windows omp ancestor when context selects one of multiple pi descendants', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'omp.exe',
commandLine: 'omp.exe'
},
{
pid: 102,
ppid: 101,
name: 'pi.exe',
commandLine: 'pi.exe --cwd C:\\repo\\orca'
},
{
pid: 103,
ppid: 100,
name: 'pi.exe',
commandLine: 'pi.exe --cwd C:\\repo\\other'
}
])
await expect(
resolveAgentForegroundProcess(100, 'pi.exe', { contextPaths: ['C:\\repo\\orca'] })
).resolves.toBe('omp')
})
it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => {
mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex')
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
).resolves.toEqual({ available: false, processName: 'zsh' })
})
it('treats failed POSIX scans as unavailable', async () => {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(new Error('ps unavailable'), { stdout: '', stderr: '' })
}
)
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true })
).resolves.toEqual({ available: false, processName: 'zsh' })
await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({
available: false,
processName: 'zsh'
})
await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh')
})
it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => {
mockPs(['100 99 100 100 Ss+ "/bin/zsh" -l', '101 100 101 100 S+ /bin/bash'].join('\n'))
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true)
})
it('uses spawned-shell identity instead of a lagging foreground child label', async () => {
mockPs(['100 99 100 100 Ss+ /bin/zsh -l'].join('\n'))
await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true)
})
it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => {
mockPs(
[
'100 99 100 101 Ss /usr/bin/login -pfl developer /bin/zsh',
'101 100 101 101 S+ -zsh',
'102 101 101 101 S+ (zsh)',
'103 102 101 101 S+ (sed)',
'104 102 101 101 R+ (git)'
].join('\n')
)
await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true)
})
it('rejects a foreground nested shell while the spawned shell remains suspended', async () => {
mockPs(
@@ -254,271 +98,12 @@ describe('resolveAgentForegroundProcess', () => {
await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false)
})
it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => {
mockPs(
[
'100 99 100 101 Ss /bin/zsh -l',
'101 100 101 101 S+ /usr/local/bin/agent-tui',
'102 101 101 101 S+ /bin/bash -i'
].join('\n')
)
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
})
it('rejects shell ownership while a stopped TUI remains resumable', async () => {
mockPs(['100 99 100 100 Ss+ /bin/zsh -l', '101 100 101 100 T agent-tui'].join('\n'))
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
})
it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => {
// The WSL job holds only wsl.exe and cannot prove guest-side shell state.
await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false)
expect(execFileMock).not.toHaveBeenCalled()
})
it('fails open when fresh shell ownership inspection is unavailable', async () => {
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: unknown) => {
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
callback(new Error('ps unavailable'), { stdout: '', stderr: '' })
}
)
await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false)
})
it('confirms a Windows shell from fresh root-only ConPTY membership', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100]))
await expect(
confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds })
).resolves.toBe(true)
expect(getAllProcessesMock).not.toHaveBeenCalled()
})
it('rejects Windows shell ownership with a child or unavailable membership', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
await expect(
confirmShellForegroundProcess(100, 'powershell.exe', {
readWindowsPtyJobProcessIds: async () => new Set([100, 101])
})
).resolves.toBe(false)
await expect(
confirmShellForegroundProcess(100, 'powershell.exe', {
readWindowsPtyJobProcessIds: async () => null
})
).resolves.toBe(false)
})
it('does not report Claude print-mode hook descendants as foreground agents', async () => {
mockPs(
[
'100 99 Ss bash -i',
'101 100 S+ claude --print --model haiku Analyze this conversation and determine next work'
].join('\n')
)
await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash')
})
it('does not report a stopped agent after the shell regains foreground', async () => {
mockPs(
['100 99 Ss+ bash -i', '101 100 T node /Users/dev/.nvm/versions/node/bin/codex'].join(
'\n'
)
)
await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash')
})
it('falls back to recognized descendants when no process in the PTY tree holds foreground', async () => {
// Detached descendants may have no foreground marker; retain agent recognition as a fallback.
mockPs(
['100 99 Ss bash -i', '101 100 S node /Users/dev/.nvm/versions/node/bin/codex'].join(
'\n'
)
)
await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex')
})
it('recognizes Windows wrapper-launched agents from descendant command lines', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows()
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('codex')
})
it('recognizes Windows shell-rooted agent launches from descendant command lines', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows()
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
})
it('recognizes the native Windows Cursor launcher process tree', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'cmd.exe',
commandLine: 'cmd.exe /c cursor-agent.cmd'
},
{
pid: 102,
ppid: 101,
name: 'powershell.exe',
commandLine:
'powershell.exe -File C:\\Users\\dev\\AppData\\Local\\cursor-agent\\cursor-agent.ps1'
},
{
pid: 103,
ppid: 102,
name: 'node.exe',
commandLine:
'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js'
},
{
pid: 104,
ppid: 103,
name: 'node.exe',
commandLine:
'node.exe C:\\Users\\dev\\AppData\\Local\\cursor-agent\\versions\\2026.07.09-a3815c0\\index.js worker-server'
},
{
pid: 105,
ppid: 100,
name: 'agent.exe',
commandLine: 'C:\\Users\\dev\\.grok\\bin\\agent.exe'
}
])
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('cursor-agent')
})
it('recognizes Windows Git Bash shell-rooted agent launches', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'bash.exe',
commandLine: 'C:\\Program Files\\Git\\bin\\bash.exe --login -i'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.cmd'
}
])
await expect(resolveAgentForegroundProcess(100, 'bash.exe')).resolves.toBe('codex')
})
it('keeps a multiline Windows command line inside its own row', async () => {
// Prompt text impersonates another row; keep it within its actual process command line.
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: [
'node',
'C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js',
'--prompt',
'"line one\r\nName=gemini.exe\r\nProcessId=999"'
].join(' ')
}
])
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe('codex')
})
it('distinguishes unavailable Windows enumeration from a confirmed shell', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockUnreadableWindowsTable()
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe')
).resolves.toEqual({ available: false, processName: 'powershell.exe' })
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
'powershell.exe'
)
})
it('treats an observed Windows shell with no children as authoritative', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
}
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe')
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
})
it('does not restore a recognized fallback that disappeared before confirmation', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
}
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'droid', {
fresh: true,
forceProcessScan: true
})
).resolves.toEqual({ available: true, processName: null })
})
it('reports a foreign anchor when its pid now runs an unrecognized command', async () => {
// Job membership cannot preserve an agent identity contradicted by immutable command-line evidence.
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 999, ppid: 100, name: 'node.exe', commandLine: 'node server.js' }
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
anchorProcessId: 999
})
).resolves.toEqual({
available: true,
processName: 'powershell.exe',
anchorPidForeign: true
})
})
it('detects a foreign anchor even when the squatter is orphaned out of the descendant walk', async () => {
// PID reuse can leave the squatter outside the shell ancestry while retaining job membership.
Object.defineProperty(process, 'platform', { value: 'win32' })
@@ -557,21 +142,6 @@ describe('resolveAgentForegroundProcess', () => {
})
})
it('does not flag an anchor whose row still recognizes as the cached agent', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{ pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' },
{ pid: 999, ppid: 500, name: 'node.exe', commandLine: 'node C:\\npm\\claude' }
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
anchorProcessId: 999,
anchorProcessName: 'claude'
})
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
})
it('treats a query-denied anchor row as inconclusive, never foreign', async () => {
// A denied query yields command === name; the agent may just be unreadable.
Object.defineProperty(process, 'platform', { value: 'win32' })
@@ -587,81 +157,6 @@ describe('resolveAgentForegroundProcess', () => {
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
})
it('treats a Windows snapshot missing the requested shell as unavailable', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 200,
ppid: 99,
name: 'unrelated.exe',
commandLine: 'unrelated.exe'
}
])
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe')
).resolves.toEqual({ available: false, processName: 'powershell.exe' })
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
'powershell.exe'
)
})
it('does not use unrelated Windows agent descendants for wrapper fallbacks', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:\\repo\\server.js'
},
{
pid: 102,
ppid: 100,
name: 'codex.exe',
commandLine: 'codex'
}
])
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe')
})
it('fails closed for ambiguous Windows shell-rooted agent descendants', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine:
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js'
},
{
pid: 102,
ppid: 100,
name: 'node.exe',
commandLine:
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs'
}
])
await expect(resolveAgentForegroundProcess(100, 'powershell.exe')).resolves.toBe(
'powershell.exe'
)
})
it('filters detached agents before resolving an otherwise ambiguous ConPTY tree', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
@@ -719,163 +214,6 @@ describe('resolveAgentForegroundProcess', () => {
expect(resolution.processName).toBe('powershell.exe')
})
it('recognizes a Windows shell-rooted agent when only one candidate matches the worktree path', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine:
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js --cwd C:\\repo\\orca'
},
{
pid: 102,
ppid: 100,
name: 'node.exe',
commandLine:
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@google\\gemini-cli\\bundle\\gemini.mjs --cwd C:\\repo\\other'
}
])
await expect(
resolveAgentForegroundProcess(100, 'powershell.exe', {
contextPaths: ['C:\\repo\\orca']
})
).resolves.toBe('codex')
})
it('recognizes the deepest Windows shell-rooted agent when candidates share one lineage', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'codex.exe',
commandLine: 'codex --cwd C:\\repo\\orca'
},
{
pid: 102,
ppid: 101,
name: 'gemini.exe',
commandLine: 'gemini --cwd C:\\repo\\orca'
}
])
await expect(
resolveAgentForegroundProcess(100, 'powershell.exe', {
contextPaths: ['C:\\repo\\orca']
})
).resolves.toBe('gemini')
})
it('fails closed for sibling Windows agents that both match the same worktree path', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'codex.exe',
commandLine: 'codex --cwd C:\\repo\\orca'
},
{
pid: 102,
ppid: 100,
name: 'gemini.exe',
commandLine: 'gemini --cwd C:\\repo\\orca'
}
])
await expect(
resolveAgentForegroundProcess(100, 'powershell.exe', {
contextPaths: ['C:\\repo\\orca']
})
).resolves.toBe('powershell.exe')
})
it('fails closed when Windows has multiple matching wrapper descendants', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'node.exe',
commandLine: 'node C:\\repo\\server.js'
},
{
pid: 102,
ppid: 100,
name: 'node.exe',
commandLine:
'node C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js'
}
])
await expect(resolveAgentForegroundProcess(100, 'node.exe')).resolves.toBe('node.exe')
})
it('does not enrich Windows foregrounds that are not interpreter wrappers', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
await expect(resolveAgentForegroundProcess(100, 'vim.exe')).resolves.toBe('vim.exe')
expect(getAllProcessesMock).not.toHaveBeenCalled()
})
it('authorizes a fresh Windows agent only when it still belongs to the ConPTY', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
},
{
pid: 101,
ppid: 100,
name: 'droid.exe',
commandLine: 'droid'
}
])
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 999]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({
available: true,
processName: 'droid',
processId: 101
})
expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1)
})
it('excludes a detached Windows Droid descendant from byte authority', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
@@ -900,25 +238,4 @@ describe('resolveAgentForegroundProcess', () => {
})
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
})
it('does not fork the ConPTY membership helper when no Windows agent is inferred', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
mockWindowsRows([
{
pid: 100,
ppid: 99,
name: 'powershell.exe',
commandLine: 'powershell.exe'
}
])
const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 999]))
await expect(
resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds
})
).resolves.toEqual({ available: true, processName: 'powershell.exe' })
expect(readWindowsConsoleAttachedProcessIds).not.toHaveBeenCalled()
})
})
@@ -1,348 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { git } = vi.hoisted(() => ({
git: {
getStatus: vi.fn(),
getSubmoduleStatus: vi.fn(),
checkIgnoredPaths: vi.fn(),
getHistory: vi.fn(),
commitChanges: vi.fn(),
getStagedCommitContext: vi.fn(),
getDiff: vi.fn(),
stageFile: vi.fn(),
unstageFile: vi.fn(),
bulkStageFiles: vi.fn(),
stageWorktreeChanges: vi.fn(),
bulkUnstageFiles: vi.fn(),
discardChanges: vi.fn(),
bulkDiscardChanges: vi.fn(),
detectConflictOperation: vi.fn(),
abortMerge: vi.fn(),
abortRebase: vi.fn(),
checkoutBranch: vi.fn(),
listLocalBranches: vi.fn(),
getBranchCompare: vi.fn(),
getCommitCompare: vi.fn(),
getCommitDiff: vi.fn(),
getUpstreamStatus: vi.fn(),
gitPush: vi.fn(),
gitPull: vi.fn(),
gitFastForward: vi.fn(),
gitPullRebaseFromBase: vi.fn(),
gitFetch: vi.fn(),
gitSyncForkDefaultBranch: vi.fn(),
listWorktrees: vi.fn(),
isGitRepo: vi.fn(),
gitExecFileAsync: vi.fn(),
awaitWindowsHostGitEnvironmentReady: vi.fn(),
getRemoteFileUrl: vi.fn(),
getRemoteCommitUrl: vi.fn()
}
}))
vi.mock('../git/source-control/status-read', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getStatus: git.getStatus
}))
vi.mock('../git/source-control/submodule-status', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getSubmoduleStatus: git.getSubmoduleStatus
}))
vi.mock('../git/source-control/commit-changes', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
commitChanges: git.commitChanges
}))
vi.mock('../git/source-control/staged-commit-context', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getStagedCommitContext: git.getStagedCommitContext
}))
vi.mock('../git/source-control/file-diff', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getDiff: git.getDiff
}))
vi.mock('../git/source-control/staging', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
stageFile: git.stageFile,
unstageFile: git.unstageFile,
bulkStageFiles: git.bulkStageFiles,
stageWorktreeChanges: git.stageWorktreeChanges,
bulkUnstageFiles: git.bulkUnstageFiles
}))
vi.mock('../git/source-control/discard-changes', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
discardChanges: git.discardChanges,
bulkDiscardChanges: git.bulkDiscardChanges
}))
vi.mock('../git/source-control/git-conflict-operation', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
detectConflictOperation: git.detectConflictOperation,
abortMerge: git.abortMerge,
abortRebase: git.abortRebase
}))
vi.mock('../git/source-control/branch-compare', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getBranchCompare: git.getBranchCompare
}))
vi.mock('../git/source-control/commit-compare', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getCommitCompare: git.getCommitCompare
}))
vi.mock('../git/source-control/commit-diff', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getCommitDiff: git.getCommitDiff
}))
vi.mock('../git/source-control/check-ignored-paths', () => ({
checkIgnoredPaths: git.checkIgnoredPaths
}))
vi.mock('../git/checkout', () => ({
checkoutBranch: git.checkoutBranch,
listLocalBranches: git.listLocalBranches
}))
vi.mock('../git/history', () => ({ getHistory: git.getHistory }))
vi.mock('../git/remote', () => git)
vi.mock('../git/fork-sync', () => ({ gitSyncForkDefaultBranch: git.gitSyncForkDefaultBranch }))
vi.mock('../git/source-control/upstream', () => ({ getUpstreamStatus: git.getUpstreamStatus }))
vi.mock('../git/worktrees/catalog/worktree-scan-cache', () => ({
listWorktrees: git.listWorktrees
}))
vi.mock('../git/repo', () => git)
vi.mock('../git/command-runner/git-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: git.gitExecFileAsync
}))
vi.mock('../git/command-runner/windows-host-git-environment', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
awaitWindowsHostGitEnvironmentReady: git.awaitWindowsHostGitEnvironmentReady
}))
import { createLocalGitProvider, type LocalGitProvider } from './local-git-provider'
const WT = '/repo/wt'
const pushTarget = { remoteName: 'origin', branchName: 'feature' }
const interactive = { admissionTier: 'interactive' }
// [provider call, free function, expected free-function args] — mirrors the runtime local arms.
type ParityCase = [string, (p: LocalGitProvider) => unknown, keyof typeof git, unknown[]]
const cases: ParityCase[] = [
['getStatus', (p) => p.getStatus(WT), 'getStatus', [WT, { admissionTier: 'status' }]],
[
'getStatus with options',
(p) => p.getStatus(WT, { includeIgnored: true, admissionTier: 'background' }),
'getStatus',
[WT, { includeIgnored: true, admissionTier: 'background' }]
],
[
'getSubmoduleStatus staged',
(p) => p.getSubmoduleStatus(WT, 'sub', 'staged'),
'getSubmoduleStatus',
[WT, 'sub', { ...interactive, staged: true }]
],
[
'getSubmoduleStatus default',
(p) => p.getSubmoduleStatus(WT, 'sub'),
'getSubmoduleStatus',
[WT, 'sub', interactive]
],
[
'checkIgnoredPaths',
(p) => p.checkIgnoredPaths(WT, ['a']),
'checkIgnoredPaths',
[WT, ['a'], interactive]
],
[
'getHistory',
(p) => p.getHistory(WT, { limit: 5 }),
'getHistory',
[WT, { limit: 5, ...interactive }]
],
['commit', (p) => p.commit(WT, 'msg'), 'commitChanges', [WT, 'msg', interactive]],
[
'getStagedCommitContext',
(p) => p.getStagedCommitContext(WT),
'getStagedCommitContext',
[WT, interactive]
],
['getDiff', (p) => p.getDiff(WT, 'a', true, true), 'getDiff', [WT, 'a', true, true, interactive]],
['stageFile', (p) => p.stageFile(WT, 'a'), 'stageFile', [WT, 'a', interactive]],
['unstageFile', (p) => p.unstageFile(WT, 'a'), 'unstageFile', [WT, 'a', interactive]],
[
'bulkStageFiles',
(p) => p.bulkStageFiles(WT, ['a']),
'bulkStageFiles',
[WT, ['a'], interactive]
],
[
'bulkStageFiles scoped',
(p) => p.bulkStageFiles(WT, [], 'all'),
'stageWorktreeChanges',
[WT, 'all', { ...interactive, sharedLinkPaths: [] }]
],
[
'bulkUnstageFiles',
(p) => p.bulkUnstageFiles(WT, ['a']),
'bulkUnstageFiles',
[WT, ['a'], interactive]
],
['discardChanges', (p) => p.discardChanges(WT, 'a'), 'discardChanges', [WT, 'a', interactive]],
[
'bulkDiscardChanges',
(p) => p.bulkDiscardChanges(WT, ['a']),
'bulkDiscardChanges',
[WT, ['a'], interactive]
],
[
'detectConflictOperation',
(p) => p.detectConflictOperation(WT),
'detectConflictOperation',
[WT, {}]
],
['abortMerge', (p) => p.abortMerge(WT), 'abortMerge', [WT, interactive]],
['abortRebase', (p) => p.abortRebase(WT), 'abortRebase', [WT, interactive]],
['checkoutBranch', (p) => p.checkoutBranch(WT, 'b'), 'checkoutBranch', [WT, 'b', interactive]],
['listLocalBranches', (p) => p.listLocalBranches(WT), 'listLocalBranches', [WT, {}]],
[
'getBranchCompare default tier',
(p) => p.getBranchCompare(WT, 'main'),
'getBranchCompare',
[WT, 'main', interactive]
],
[
'getBranchCompare background',
(p) => p.getBranchCompare(WT, 'main', { admissionTier: 'background' }),
'getBranchCompare',
[WT, 'main', { admissionTier: 'background' }]
],
[
'getCommitCompare',
(p) => p.getCommitCompare(WT, 'abc'),
'getCommitCompare',
[WT, 'abc', interactive]
],
[
'getCommitDiff',
(p) => p.getCommitDiff(WT, { commitOid: 'abc', filePath: 'a' }),
'getCommitDiff',
[WT, { commitOid: 'abc', filePath: 'a' }, interactive]
],
[
'getUpstreamStatus',
(p) => p.getUpstreamStatus(WT, pushTarget),
'getUpstreamStatus',
[WT, pushTarget, {}]
],
[
'pushBranch',
(p) => p.pushBranch(WT, true, pushTarget, { forceWithLease: true }),
'gitPush',
[WT, true, pushTarget, { forceWithLease: true, ...interactive }]
],
[
'pushBranch defaults',
(p) => p.pushBranch(WT),
'gitPush',
[WT, false, undefined, { forceWithLease: false, ...interactive }]
],
['pullBranch', (p) => p.pullBranch(WT, pushTarget), 'gitPull', [WT, pushTarget, interactive]],
[
'fastForwardBranch',
(p) => p.fastForwardBranch(WT, pushTarget),
'gitFastForward',
[WT, pushTarget, interactive]
],
[
'rebaseFromBase',
(p) => p.rebaseFromBase(WT, 'main'),
'gitPullRebaseFromBase',
[WT, 'main', interactive]
],
['fetchRemote', (p) => p.fetchRemote(WT), 'gitFetch', [WT, undefined, interactive]],
[
'syncForkDefaultBranch',
(p) => p.syncForkDefaultBranch(WT, { owner: 'acme', repo: 'orca' }),
'gitSyncForkDefaultBranch',
[WT, { owner: 'acme', repo: 'orca' }, interactive]
],
['listWorktrees', (p) => p.listWorktrees('/repo'), 'listWorktrees', ['/repo', {}]],
[
'exec',
(p) => p.exec(['status'], WT, { timeoutMs: 5 }),
'gitExecFileAsync',
[['status'], { cwd: WT, timeout: 5 }]
],
[
'getRemoteFileUrl',
(p) => p.getRemoteFileUrl(WT, 'a', 3, 8),
'getRemoteFileUrl',
[WT, 'a', 3, 8, {}]
],
['getRemoteCommitUrl', (p) => p.getRemoteCommitUrl(WT, 'abc'), 'getRemoteCommitUrl', [WT, 'abc']]
]
describe('createLocalGitProvider', () => {
beforeEach(() => {
for (const fn of Object.values(git)) {
fn.mockReset()
}
})
it.each(cases)('%s calls the local free function as the runtime local arm does', async (...c) => {
const [, call, fnName, expected] = c
await call(createLocalGitProvider())
expect(git[fnName]).toHaveBeenCalledTimes(1)
expect(git[fnName]).toHaveBeenCalledWith(...expected)
})
it.each(cases)('%s keeps the WSL distro on every call', async (...c) => {
const [, call, fnName] = c
await call(createLocalGitProvider({ wslDistro: 'Ubuntu' }))
const args = git[fnName].mock.calls[0]
const options = args.at(-1)
if (fnName === 'getRemoteCommitUrl') {
// URL reads require Windows-hosted Git readiness, matching local execution.
expect(git.awaitWindowsHostGitEnvironmentReady).toHaveBeenCalledWith({ cwd: WT })
return
}
expect(options).toMatchObject({ wslDistro: 'Ubuntu' })
})
it('applies shared link paths to status and scoped stage-all only', async () => {
const getSharedLinkPaths = vi.fn(() => ['node_modules'])
const provider = createLocalGitProvider({ getSharedLinkPaths })
await provider.getDiff(WT, 'a', false)
expect(getSharedLinkPaths).not.toHaveBeenCalled()
await provider.getStatus(WT)
await provider.bulkStageFiles(WT, [], 'all')
await provider.bulkStageFiles(WT, ['a'])
expect(git.getStatus).toHaveBeenCalledWith(WT, {
admissionTier: 'status',
sharedLinkPaths: ['node_modules']
})
expect(git.stageWorktreeChanges).toHaveBeenCalledWith(WT, 'all', {
...interactive,
sharedLinkPaths: ['node_modules']
})
expect(git.bulkStageFiles).toHaveBeenCalledWith(WT, ['a'], interactive)
})
it('lets the per-call status tier win over a caller-wide default tier', async () => {
await createLocalGitProvider({ admissionTier: 'background' }).getStatus(WT)
expect(git.getStatus).toHaveBeenCalledWith(WT, { admissionTier: 'status' })
})
it('passes errors from the free function through unchanged', async () => {
const error = new Error('fatal: not a git repository')
git.getStatus.mockRejectedValueOnce(error)
await expect(createLocalGitProvider().getStatus('/not-git')).rejects.toBe(error)
})
})
@@ -31,8 +31,6 @@ import { isRetiredPtyMaster } from '../../pty/process-retirement/node-pty-master
import { LocalPtyProvider } from './local-pty-provider'
import { ptyIncarnations, ptyProcesses, ptyShellPath } from './local-pty-provider-state'
import { inspectPtyProviderProcess } from '../pty-process-inspection'
import { readLaunchedAgentForeground } from '../../runtime/terminal/wait/launched-agent-foreground'
import type { TerminalForegroundSample } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation'
// Match the recorded basename so only the retired descriptor distinguishes the pane from idle.
const POSIX_SHELL = 'sh'
@@ -106,67 +104,6 @@ const describeOnPosix = process.platform === 'win32' ? describe.skip : describe
describe('inspectPtyProviderProcess child-process evidence', () => {
const provider = new LocalPtyProvider()
it('uses the owned Windows job without a static-name marker, preserving uncertain and replaced panes', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
registerPane('pty-win', 'powershell.exe', 'powershell.exe')
const inspect = () =>
inspectPtyProviderProcess(provider, 'pty-win', {
expectedIncarnationId: 'incarnation:pty-win'
})
jobMembers.mockReturnValue(new Set([4242]))
await expect(inspect()).resolves.toMatchObject({
childProcessEvidence: 'no-children',
hasChildProcesses: false
})
jobMembers.mockReturnValue(new Set([4242, 4243]))
await expect(inspect()).resolves.toMatchObject({
childProcessEvidence: 'children',
hasChildProcesses: true
})
jobMembers.mockReturnValue(null)
await expect(inspect()).resolves.toMatchObject({
childProcessEvidence: 'unverifiable',
hasChildProcesses: true
})
jobMembers.mockImplementationOnce(() => {
registerPane('pty-win', 'powershell.exe', 'powershell.exe')
return new Set([4242])
})
await expect(inspect()).resolves.toMatchObject({
childProcessEvidence: 'unverifiable',
hasChildProcesses: true
})
})
it('carries unverifiable evidence when the child read fails after foreground inspection', async () => {
let reads = 0
registerPane(
'pty-closing',
() => {
reads += 1
if (reads > 1) {
throw new Error('EBADF: bad file descriptor')
}
return '/bin/zsh'
},
'/bin/zsh'
)
await expect(inspectPtyProviderProcess(provider, 'pty-closing')).resolves.toEqual({
foregroundProcess: '/bin/zsh',
hasChildProcesses: true,
childProcessEvidence: 'unverifiable'
})
})
it('samples child evidence after foreground inspection', async () => {
let reads = 0
registerPane('pty-became-busy', () => (reads++ === 0 ? '/bin/zsh' : 'vim'), '/bin/zsh')
const inspection = await inspectPtyProviderProcess(provider, 'pty-became-busy')
expect(inspection.hasChildProcesses).toBe(true)
expect(inspection.childProcessEvidence).toBe('children')
})
it('refuses to pair one panes foreground with its replacements children', async () => {
registerPane('pty-swapped', '/bin/zsh', '/bin/zsh')
resolveForegroundMock.mockImplementation(async () => {
@@ -183,47 +120,6 @@ describe('inspectPtyProviderProcess child-process evidence', () => {
})
})
describe('fresh foreground observation', () => {
it.each([
['non-shell', 'claude', 'agent'],
['shell', '-zsh', 'shell']
] as const)(
'keeps a %s observation valid when the wall clock steps back during its read',
async (foregroundKind, processName, expected) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(1_000)
registerPane('pty-clock-step', '/bin/zsh', '/bin/zsh')
observeForegroundMock.mockImplementationOnce(async (): Promise<TerminalForegroundSample> => {
vi.setSystemTime(995)
return { verdict: 'observed', source: 'pty-master', foregroundKind, processName }
})
const provider = new LocalPtyProvider()
const inspectProcess = vi.fn((id: string, options?: { foregroundOnly?: boolean }) =>
provider.inspectProcess(id, options)
)
try {
expect(
await readLaunchedAgentForeground(
{
supportsForegroundObservation: () => true,
inspectProcess,
getForegroundProcess: async () => null
},
{ remote: false, windows: false },
'pty-clock-step',
'claude'
)
).toBe(expected)
await expect(inspectProcess.mock.results[0].value).resolves.toMatchObject({
foregroundObservation: { verdict: 'observed', capturedAgeMs: 0 }
})
} finally {
vi.useRealTimers()
}
}
)
})
describeOnPosix('inspectPtyProviderProcess on a retired master', () => {
const provider = new LocalPtyProvider()
@@ -46,152 +46,8 @@ vi.mock('../macos-tcc-login-shell', () => ({
wrapShellSpawnForMacosTccAttribution: wrapSpawnMock,
hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login'
}))
import { resolveUnixShellPath, spawnShellWithFallback } from './local-pty-executable-spawn'
import {
validateWorkingDirectory,
_resetWorkingDirectoryValidationStateForTest
} from '../working-directory-validation'
import { getFishXdgDataDirsLaunchEnv } from '../../pty/shell-startup/fish-xdg-data-dirs-handoff'
const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
const NATIVE_DIR = 'C:\\Users\\jin\\repo'
describe('validateWorkingDirectory', () => {
beforeEach(() => {
existsSyncMock.mockReset()
statSyncMock.mockReset()
statMock.mockReset()
wslUncDirectoryExistsAsyncMock.mockReset()
_resetWorkingDirectoryValidationStateForTest()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', async () => {
// Windows 9P stat can falsely report missing WSL worktrees; distro-side existence wins.
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
existsSyncMock.mockReturnValue(false)
await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined()
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR)
expect(existsSyncMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
})
it('returns to the event loop while the distro probe is still running', async () => {
const probe = Promise.withResolvers<boolean | null>()
wslUncDirectoryExistsAsyncMock.mockReturnValue(probe.promise)
let settled = false
const validation = validateWorkingDirectory(WSL_UNC_DIR).finally(() => {
settled = true
})
await new Promise((resolve) => setImmediate(resolve))
expect(settled).toBe(false)
probe.resolve(true)
await validation
expect(settled).toBe(true)
})
it('rejects a WSL UNC worktree that does not exist inside the distro', async () => {
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
await expect(validateWorkingDirectory(WSL_UNC_DIR)).rejects.toThrow(/does not exist/)
expect(existsSyncMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
})
it('falls back to the fs check when the distro answer is inconclusive', async () => {
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
statMock.mockResolvedValue(dirStats(true))
await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined()
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR)
expect(statMock).toHaveBeenCalledWith(WSL_UNC_DIR)
expect(existsSyncMock).not.toHaveBeenCalled()
})
it('validates native Windows paths via fs without consulting the distro', async () => {
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue(dirStats(true))
await expect(validateWorkingDirectory(NATIVE_DIR)).resolves.toBeUndefined()
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
expect(existsSyncMock).toHaveBeenCalledWith(NATIVE_DIR)
})
it('rejects a missing native Windows path', async () => {
existsSyncMock.mockReturnValue(false)
const previousVersion = process.env.ORCA_APP_VERSION
process.env.ORCA_APP_VERSION = '1.4.178-test'
try {
await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow(
/does not exist.*orca: 1\.4\.178-test/
)
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
} finally {
if (previousVersion === undefined) {
delete process.env.ORCA_APP_VERSION
} else {
process.env.ORCA_APP_VERSION = previousVersion
}
}
})
it('rejects a native Windows path that exists but is not a directory', async () => {
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue(dirStats(false))
await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow(/is not a directory/)
})
})
describe('resolveUnixShellPath', () => {
beforeEach(() => {
existsSyncMock.mockReset()
accessSyncMock.mockReset()
})
it('preserves non-absolute shells for execvp PATH and cwd resolution', () => {
expect(resolveUnixShellPath('bash')).toBe('bash')
expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell')
expect(existsSyncMock).not.toHaveBeenCalled()
})
it('selects an executable fallback before node-pty can fork a missing shell', () => {
existsSyncMock.mockImplementation((path) => path === '/bin/sh')
accessSyncMock.mockReturnValue(undefined)
expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh')
expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([
'/missing/zsh',
'/bin/zsh',
'/bin/bash',
'/bin/sh'
])
})
it('reports every attempted shell when no executable candidate exists', () => {
existsSyncMock.mockReturnValue(false)
expect(() => resolveUnixShellPath('/missing/zsh')).toThrow(
'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)'
)
})
it('dedupes a preferred shell that is already a fallback candidate', () => {
existsSyncMock.mockImplementation((path) => path === '/bin/sh')
accessSyncMock.mockReturnValue(undefined)
expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh')
expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh'])
})
})
import { spawnShellWithFallback } from './local-pty-executable-spawn'
import { _resetWorkingDirectoryValidationStateForTest } from '../working-directory-validation'
describe('spawnShellWithFallback macOS TCC login wrapping', () => {
let origPlatform: PropertyDescriptor | undefined
@@ -215,137 +71,6 @@ describe('spawnShellWithFallback macOS TCC login wrapping', () => {
vi.restoreAllMocks()
})
it('spawns the primary shell through the login wrapper', () => {
const ptySpawn = vi.fn().mockReturnValue({ pid: 1 })
const result = spawnShellWithFallback({
shellPath: '/bin/zsh',
shellArgs: ['-l'],
cols: 80,
rows: 24,
cwd: '/work',
env: {},
ptySpawn: ptySpawn as never
})
expect(wrapSpawnMock).toHaveBeenCalledWith('/bin/zsh', ['-l'], expect.any(Object))
expect(ptySpawn).toHaveBeenCalledWith(
'/wrapped/login',
['wrapped', '/bin/zsh', '-l'],
expect.objectContaining({ cwd: '/work', cols: 80, rows: 24 })
)
// Attribution wrapping must retain the actual shell path for identity checks.
expect(result.shellPath).toBe('/bin/zsh')
})
it('wraps fallback shells too when the primary fails to spawn', () => {
const ptySpawn = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('primary boom')
})
.mockReturnValue({ pid: 2 })
const result = spawnShellWithFallback({
shellPath: '/bin/zsh',
shellArgs: ['-l'],
cols: 80,
rows: 24,
cwd: '/work',
env: {},
ptySpawn: ptySpawn as never
})
// A login-wrapped fallback must receive corrected SHELL so login(1) preserves it.
expect(wrapSpawnMock).toHaveBeenLastCalledWith(
'/bin/bash',
['-l'],
expect.objectContaining({ SHELL: '/bin/bash' })
)
expect(ptySpawn).toHaveBeenLastCalledWith(
'/wrapped/login',
['wrapped', '/bin/bash', '-l'],
expect.objectContaining({ cwd: '/work' })
)
expect(result.shellPath).toBe('/bin/bash')
})
it('drops the primary shell’s launch env when an unwrapped fallback takes over', () => {
// Unwrapped fallbacks must not inherit ZDOTDIR/features that would wrap an unrelated nested shell.
const zshLaunchEnv = {
ZDOTDIR: '/userdata/shell-ready/zsh',
ORCA_ORIG_ZDOTDIR: '/home/jin',
ORCA_SHELL_FEATURES: 'history'
}
const env: Record<string, string> = { HOME: '/home/jin', ...zshLaunchEnv }
const ptySpawn = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('primary boom')
})
.mockReturnValue({ pid: 3 })
spawnShellWithFallback({
shellPath: '/bin/zsh',
shellArgs: ['-l'],
cols: 80,
rows: 24,
cwd: '/work',
env,
ptySpawn: ptySpawn as never,
preLaunchEnv: {
ZDOTDIR: undefined,
ORCA_ORIG_ZDOTDIR: undefined,
ORCA_SHELL_FEATURES: undefined
},
getShellReadyConfig: (shell) =>
shell === '/bin/zsh' ? { args: ['-l'], env: zshLaunchEnv } : { args: null, env: {} }
})
expect(env.ORCA_SHELL_FEATURES).toBeUndefined()
expect(env.ZDOTDIR).toBeUndefined()
expect(env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
expect(env.HOME).toBe('/home/jin')
})
it.each<[string, Record<string, string>, Record<string, string>]>([
[
'/opt/homebrew/bin/fish',
{ XDG_DATA_DIRS: '/opt/a:/opt/b' },
getFishXdgDataDirsLaunchEnv('/userdata/wrappers', '/opt/a:/opt/b')
],
[
'/bin/zsh',
{ ZDOTDIR: '/home/jin/.zsh' },
{ ZDOTDIR: '/userdata/shell-ready/zsh', ORCA_SHELL_FEATURES: 'history' }
]
])('hands back the user’s own env when %s falls back', (shellPath, userEnv, launchEnv) => {
const preLaunchEnv = Object.fromEntries(
Object.keys(launchEnv).map((key) => [key, userEnv[key]])
)
const env: Record<string, string> = { HOME: '/home/jin', ...userEnv, ...launchEnv }
const ptySpawn = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('primary boom')
})
.mockReturnValue({ pid: 5 })
const result = spawnShellWithFallback({
shellPath,
shellArgs: ['-l'],
cols: 80,
rows: 24,
cwd: '/work',
env,
ptySpawn: ptySpawn as never,
preLaunchEnv,
getShellReadyConfig: () => ({ args: null, env: {} })
})
expect(env).toEqual({ HOME: '/home/jin', SHELL: result.shellPath, ...userEnv })
})
it('drops the first fallback’s launch env when a second fallback takes over', () => {
// Scrub the last attempt keys; a once-only primary scrub would leak the first fallback env.
const bashLaunchEnv = { ORCA_SHELL_FEATURES: 'markers', BASH_ENV: '/userdata/bash/rcfile' }
@@ -1,15 +1,8 @@
import type * as WslPathOwner from '../../../shared/wsl-paths'
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
import type * as ForegroundProcess from '../agent-foreground-process'
import type { WindowsProcessRow } from '../../windows/windows-process-table'
import {
ptyLastRecognizedForeground,
ptyProcesses,
ptyIncarnations
} from './local-pty-provider-state'
import { inspectionShowsShellAlone } from '../../runtime/run-terminal-shell-alone'
const {
fullTable,
@@ -206,364 +199,9 @@ describe('LocalPtyProvider', () => {
resolveScan({ available: true, processName: 'zsh' })
await expect(inspection).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' })
})
it('returns false when foreground process matches shell', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
expect(await provider.hasChildProcesses(id)).toBe(false)
})
it('returns true when foreground process differs from shell', async () => {
mockProc.process = 'node'
const { id } = await provider.spawn({ cols: 80, rows: 24 })
expect(await provider.hasChildProcesses(id)).toBe(true)
})
it('returns false for unknown PTY ids', async () => {
expect(await provider.hasChildProcesses('nonexistent')).toBe(false)
})
})
it.each([false, true])(
'reconciles real Windows decision evidence (static=%s)',
async (staticName) => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const actual = await vi.importActual<typeof ForegroundProcess>('../agent-foreground-process')
resolveAgentForegroundProcessMock.mockImplementation(
actual.resolveAgentForegroundProcessWithAvailability
)
mockProc.pid = 100
mockProc.process = 'powershell.exe'
Object.assign(mockProc, { processNameIsSpawnFile: staticName })
const { id, incarnationId } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
assert(incarnationId, 'fixture incarnation missing')
const nativeRow = (
pid: number,
ppid: number,
name: string,
command = name
): WindowsProcessRow => ({ pid, ppid, name, command, creationTimeMs: pid })
const shell = nativeRow(100, 1, 'powershell.exe')
const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation')
const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D')
type EvidenceOptions = {
age?: number
detailed?: WindowsProcessRow[]
identities?: WindowsProcessRow[]
scanFailed?: boolean
jobFailed?: boolean
consoleFailed?: boolean
childFailed?: boolean
}
const cases: [string, number | null, WindowsProcessRow[], boolean, EvidenceOptions?][] = [
['C1', null, [shell, daemon], true],
['live young anchor', 120, [shell, orphan], false],
['live old anchor', 120, [shell, orphan], false, { age: 40_000 }],
['anchor identity missing', 120, [shell, orphan], false, { detailed: [shell] }],
['anchor query denied', 120, [shell, nativeRow(120, 999, 'unknown.exe', '')], false],
['anchored scan failed', 120, [shell, orphan], false, { scanFailed: true }],
['job unavailable', 120, [shell, orphan], false, { jobFailed: true }],
['anchor absent', 120, [shell, daemon], true],
['anchor just exited', 120, [shell], true],
[
'foreign anchor',
120,
[shell, nativeRow(120, 999, 'ssh-agent.exe', 'ssh-agent.exe -D')],
true
],
['attached agent', null, [shell, { ...orphan, ppid: 100 }], false],
['ordinary background job', null, [shell, nativeRow(130, 100, 'sleep.exe')], false],
['incomplete identity', null, [shell, daemon], false, { identities: [shell] }],
['console unavailable', null, [shell, daemon], false, { consoleFailed: true }],
['child read failed', null, [shell, daemon], false, { childFailed: true }],
['unanchored degraded complete', null, [shell, daemon], true, { scanFailed: true }],
[
'unanchored degraded incomplete',
null,
[shell, daemon],
false,
{ scanFailed: true, identities: [shell] }
]
]
vi.useFakeTimers({ toFake: ['Date'] })
try {
const seed = (pid: number | null) =>
ptyLastRecognizedForeground.set(id, { name: 'claude', pid, at: 1000, steady: null })
for (const [name, anchor, rows, admits, input = {}] of cases) {
vi.setSystemTime(1000 + (input.age ?? 1000))
seed(anchor)
readWindowsPtyJobProcessIdsMock.mockReturnValue(
input.jobFailed ? null : new Set(rows.map((row) => row.pid))
)
fullTable.mockReset().mockResolvedValue(input.detailed ?? rows)
if (input.scanFailed) {
fullTable.mockRejectedValue(new Error('denied'))
}
identityTable.mockReset().mockResolvedValue(input.identities ?? rows)
if (input.childFailed) {
identityTable.mockRejectedValue(new Error('denied'))
}
consoleMembers.mockResolvedValue(input.consoleFailed ? null : new Set([100]))
const observed = await provider.inspectProcess(id, {
expectedIncarnationId: incarnationId,
scanChildProcesses: true,
ignoreDetached: true
})
expect(inspectionShowsShellAlone(observed), name).toBe(admits)
expect(fullTable, name).toHaveBeenCalledTimes(1)
expect(identityTable.mock.calls.length, name).toBe(
input.jobFailed || rows.length === 1 ? 0 : 1
)
}
fullTable.mockReset().mockResolvedValue([shell, daemon])
identityTable.mockReset().mockResolvedValue([shell, daemon])
consoleMembers.mockResolvedValue(new Set([100]))
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 130]))
for (const count of [2, 20]) {
fullTable.mockClear()
identityTable.mockClear()
for (let i = 0; i < count; i++) {
seed(null)
expect(
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: true }))
).toBe(true)
}
expect(fullTable).toHaveBeenCalledTimes(count)
expect(identityTable).toHaveBeenCalledTimes(count)
}
seed(null)
fullTable.mockClear()
expect(await provider.getForegroundProcess(id)).toBe('claude')
await expect(
provider.inspectProcess(id, { steadyState: true, ignoreDetached: true })
).resolves.toMatchObject({ foregroundProcess: 'claude' })
expect(fullTable).not.toHaveBeenCalled()
expect(
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: false }))
).toBe(false)
seed(120)
fullTable.mockResolvedValue([shell, orphan])
identityTable.mockResolvedValue([shell, orphan])
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 120]))
expect(await provider.confirmForegroundProcess(id)).not.toBe('claude')
for (const replacement of ['object', 'incarnation']) {
const original = ptyProcesses.get(id)
if (!original) {
throw new Error('fixture PTY missing')
}
fullTable.mockImplementationOnce(async () => {
if (replacement === 'object') {
ptyProcesses.set(id, { ...original })
} else {
ptyIncarnations.set(id, 'replacement')
}
return [shell]
})
await expect(
provider.inspectProcess(id, {
expectedIncarnationId: incarnationId,
ignoreDetached: true
})
).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' })
ptyProcesses.set(id, original)
ptyIncarnations.set(id, incarnationId)
}
} finally {
vi.useRealTimers()
}
}
)
describe('getForegroundProcess', () => {
it('returns the process name', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
expect(await provider.getForegroundProcess(id)).toBe('zsh')
})
it('uses the spawned Windows shell when node-pty reports only the terminal name', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'xterm-256color'
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
expect(await provider.getForegroundProcess(id)).toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
mockProc.pid,
'powershell.exe',
expect.any(Object)
)
})
it('returns null for unknown PTY ids', async () => {
expect(await provider.getForegroundProcess('nonexistent')).toBeNull()
})
it('keeps a recognized agent across an unavailable scan without adding probes', async () => {
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValueOnce({ available: false, processName: 'powershell.exe' })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
})
it('drops a delayed scan result after the PTY exits', async () => {
let resolveScan!: (resolution: { available: boolean; processName: string }) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise((resolve) => {
resolveScan = resolve
})
)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const foreground = provider.getForegroundProcess(id)
exitCb?.({ exitCode: 0 })
resolveScan({ available: true, processName: 'droid' })
await expect(foreground).resolves.toBeNull()
})
it('confirms a still-active agent from job membership without a whole-table scan', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
// A descendant beyond the shell is still in the pane's job.
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
// node-pty names only the shell; job membership revalidates the agent without another full scan.
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
})
it('stops trusting job membership once the cached agent goes stale', async () => {
// An unanchored job superset must age out; WSL detached plumbing can otherwise pin agent identity forever.
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
vi.setSystemTime(1_000_000 + 40_000)
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'powershell.exe'
})
await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('does not refresh the agent age after a degraded revalidation scan', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValueOnce({ available: false, processName: null })
.mockResolvedValue({ available: true, processName: 'powershell.exe' })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
vi.setSystemTime(1_000_000 + 41_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
} finally {
vi.useRealTimers()
}
})
it('keeps short-circuiting a live agent instead of scanning on every call', async () => {
// Confirmation must reset age; measuring total runtime would permanently disable cheap rechecks (#9258).
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
vi.setSystemTime(1_000_000 + 45_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('falls through to the scan when the job holds only the shell', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock.mockResolvedValue({
available: true,
processName: 'claude'
})
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345]))
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
})
it('keeps the cached agent when both the console probe and process snapshot are inconclusive', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValue({ available: true, processName: null })
readWindowsPtyJobProcessIdsMock.mockReturnValue(null)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
})
it('retires an anchored agent immediately when its pid leaves the job, despite a leftover', async () => {
// A missing anchor proves exit immediately; detached leftovers cannot preserve that identity.
Object.defineProperty(process, 'platform', {
@@ -587,72 +225,6 @@ describe('LocalPtyProvider', () => {
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
})
it('never expires an anchored agent the job still holds, even when scans miss it', async () => {
// A live job anchor outranks incomplete scans and refreshes the age bound.
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({
available: true,
processName: 'claude',
processId: 999
})
.mockResolvedValue({ available: true, processName: null })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
vi.setSystemTime(1_000_000 + 45_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
vi.setSystemTime(1_000_000 + 80_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
} finally {
vi.useRealTimers()
}
})
it('holds a restarting agent across a degraded scan instead of reporting an exit', async () => {
// A degraded scan during restart cannot prove exit; retain unanchored identity until a fresh scan re-anchors.
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 })
.mockResolvedValueOnce({ available: false, processName: null })
.mockResolvedValue({ available: true, processName: 'claude', processId: 1000 })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 999]))
vi.useFakeTimers({ toFake: ['Date'] })
try {
vi.setSystemTime(1_000_000)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345, 1000]))
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
vi.setSystemTime(1_000_000 + 40_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
vi.setSystemTime(1_000_000 + 45_000)
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(3)
} finally {
vi.useRealTimers()
}
})
it('drops an anchored agent when the drift recheck proves the pid was recycled', async () => {
// A recycled PID still in the job cannot preserve an identity contradicted by the scan.
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -679,40 +251,5 @@ describe('LocalPtyProvider', () => {
vi.useRealTimers()
}
})
it('retires the cached agent after verified shell-only membership and a no-agent scan', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
mockProc.process = 'powershell.exe'
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
.mockResolvedValue({ available: true, processName: null })
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([12345]))
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await expect(provider.getForegroundProcess(id)).resolves.toBe('claude')
await expect(provider.getForegroundProcess(id)).resolves.toBeNull()
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2)
})
})
describe('confirmForegroundProcess', () => {
it('drops a delayed result after the PTY exits', async () => {
let resolveScan!: (resolution: { available: boolean; processName: string }) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise((resolve) => {
resolveScan = resolve
})
)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const confirmation = provider.confirmForegroundProcess(id)
exitCb?.({ exitCode: 0 })
resolveScan({ available: true, processName: 'droid' })
await expect(confirmation).resolves.toBeNull()
})
})
})
@@ -1,392 +0,0 @@
import type * as WslPathOwner from '../../../shared/wsl-paths'
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
const {
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
spawnMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
prepareMacosTccLoginShellMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
readWindowsPtyJobProcessIdsMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
isWslAvailableAsyncMock: vi.fn(),
wslUncDirectoryExistsAsyncMock: vi.fn(),
createShellPromptReadinessProbeMock: vi.fn()
}))
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
...(await importOriginal<typeof WslPathOwner>()),
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
toWindowsWslPath: (path: string, distro: string) =>
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
statSync: statSyncMock,
accessSync: accessSyncMock,
mkdirSync: mkdirSyncMock,
writeFileSync: writeFileSyncMock,
chmodSync: vi.fn(),
renameSync: vi.fn(),
rmSync: vi.fn(),
constants: { X_OK: 1 }
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/orca-user-data')
}
}))
vi.mock('node-pty', () => ({
spawn: spawnMock
}))
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
...(await importOriginal<typeof MacosTccLoginShell>()),
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
}))
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
vi.mock('../windows-powershell-executable', () => ({
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe'
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('../agent-foreground-process', () => ({
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
resolveAgentForegroundProcessMock(...args)
}))
vi.mock('../windows-pty-job-membership', () => ({
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
isWindowsPtyJobReadable: () => true
}))
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
parseWslPath: (path: string) => {
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
if (!match) {
return null
}
return {
distro: match[1],
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
}
},
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
}))
vi.mock('../../wsl/discovery/distro-catalog', () => ({
getDefaultWslDistro: () => 'Ubuntu'
}))
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
...(await importOriginal<typeof WslAvailabilityTypes>()),
isWslAvailableAsync: () => isWslAvailableAsyncMock()
}))
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
}))
import { LocalPtyProvider } from './local-pty-provider'
import {
applyLocalPtyProviderMockDefaults,
createLocalPtyMockProcess,
installLocalPtyProviderEnvSandbox,
type LocalPtyMockProcess
} from './local-pty-provider-test-harness'
describe('LocalPtyProvider', () => {
let provider: LocalPtyProvider
let mockProc: LocalPtyMockProcess
let exitCb: ((info: { exitCode: number; signal?: number }) => void) | undefined
installLocalPtyProviderEnvSandbox()
beforeEach(() => {
applyLocalPtyProviderMockDefaults({
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
})
exitCb = undefined
mockProc = createLocalPtyMockProcess({
get: () => exitCb,
set: (cb) => {
exitCb = cb
}
})
spawnMock.mockReturnValue(mockProc)
provider = new LocalPtyProvider()
})
it('retains the fallback owner cause when output and exit precede the spawn reply', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockProc.onData.mockImplementation((cb) => {
cb('too many consoles in use, max consoles is 128')
return { dispose: vi.fn() }
})
mockProc.onExit.mockImplementation((cb) => {
cb({ exitCode: 256 })
return { dispose: vi.fn() }
})
const result = await provider.spawn({ cols: 80, rows: 24 })
expect(result).toMatchObject({
exitedBeforeSpawnReply: true,
exitCause: {
kind: 'exited',
exitCode: 256,
initialOutputDiagnostic: 'git-bash-console-capacity'
}
})
})
describe('write', () => {
it('writes data to the PTY process', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
expect(provider.write(id, 'hello')).toBe(true)
expect(mockProc.write).toHaveBeenCalledWith('hello')
})
it('is a no-op for unknown PTY ids', () => {
expect(provider.write('nonexistent', 'hello')).toBe(false)
expect(mockProc.write).not.toHaveBeenCalled()
})
})
describe('resize', () => {
it('resizes the PTY process', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
provider.resize(id, 120, 40)
expect(mockProc.resize).toHaveBeenCalledWith(120, 40)
})
})
describe('producer flow control', () => {
it('pauses and resumes the node-pty process directly', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
provider.pauseProducer(id)
expect(mockProc.pause).toHaveBeenCalledTimes(1)
provider.resumeProducer(id)
expect(mockProc.resume).toHaveBeenCalledTimes(1)
})
it('is a no-op for unknown PTY ids', () => {
expect(() => {
provider.pauseProducer('nonexistent')
provider.resumeProducer('nonexistent')
}).not.toThrow()
expect(mockProc.pause).not.toHaveBeenCalled()
expect(mockProc.resume).not.toHaveBeenCalled()
})
it('swallows node-pty throws from a torn-down PTY', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
mockProc.pause.mockImplementation(() => {
throw new Error('read EIO')
})
mockProc.resume.mockImplementation(() => {
throw new Error('read EIO')
})
expect(() => {
provider.pauseProducer(id)
provider.resumeProducer(id)
}).not.toThrow()
})
})
describe('event listeners', () => {
it('notifies data listeners when PTY produces output', async () => {
const dataHandler = vi.fn()
provider.onData(dataHandler)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const onDataCb = mockProc.onData.mock.calls[0][0]
onDataCb('hello world')
expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world' })
})
it('classifies startup queries before runtime and public data listeners', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const runtimeData = vi.fn()
const dataHandler = vi.fn()
provider.configure({ onData: runtimeData })
provider.onData(dataHandler)
const { id } = await provider.spawn({
cols: 80,
rows: 24,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
const onDataCb = mockProc.onData.mock.calls[0][0]
const query = '\x1b]10;?\x07'
const echo = ']10;rgb:2e2e/3434/3434\\'
onDataCb(query)
onDataCb(echo)
onDataCb('prompt')
expect(mockProc.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\')
expect(runtimeData.mock.calls.map((call) => call.slice(1))).toEqual([
['', expect.any(Number), query.length, true],
['', expect.any(Number), echo.length, true],
['prompt', expect.any(Number)]
])
expect(dataHandler.mock.calls.map(([payload]) => payload)).toEqual([
{
id,
data: '',
sequenceChars: query.length,
seq: query.length,
transformed: true,
runtimeFed: true
},
{
id,
data: '',
sequenceChars: echo.length,
seq: query.length + echo.length,
transformed: true,
runtimeFed: true
},
{ id, data: 'prompt', runtimeFed: true }
])
})
it('answers a native Windows OSC color query from pushed colours before renderer delivery', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const dataHandler = vi.fn()
provider.onData(dataHandler)
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
const onDataCb = mockProc.onData.mock.calls[0][0]
const query = '\x1b]10;?\x07'
onDataCb(query)
provider.setColorQueryReplyColors({ foreground: '#123456', background: '#000000' })
onDataCb(query)
expect(dataHandler).toHaveBeenCalledWith({
id,
data: '',
sequenceChars: query.length,
seq: query.length,
transformed: true
})
expect(mockProc.write.mock.calls).toEqual([
['\x1b]10;rgb:ffff/ffff/ffff\x1b\\'],
['\x1b]10;rgb:1212/3434/5656\x1b\\']
])
})
it('keeps forwarded OSC color replies for a Windows-owned WSL PTY', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const { id } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
const onDataCb = mockProc.onData.mock.calls[0][0]
const reply = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'
onDataCb('\x1b]11;?\x07')
provider.write(id, reply)
expect(mockProc.write).toHaveBeenCalledWith(reply)
})
it('notifies exit listeners when PTY exits', async () => {
const exitHandler = vi.fn()
provider.onExit(exitHandler)
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
exitCb?.({ exitCode: 0 })
expect(exitHandler).toHaveBeenCalledWith({
id,
code: 0,
incarnationId,
cause: { kind: 'exited', exitCode: 0 }
})
})
it('reports a signalled death as a signal, not as the zero node-pty pairs with it', async () => {
const exitHandler = vi.fn()
provider.onExit(exitHandler)
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
// node-pty can report SIGKILL as exitCode 0 with signal 9; dropping signal misclassifies crashes (STA-4536).
exitCb?.({ exitCode: 0, signal: 9 })
expect(exitHandler).toHaveBeenCalledWith({
id,
code: 0,
incarnationId,
cause: { kind: 'signaled', signal: 9 }
})
})
it('allows unsubscribing from events', async () => {
const dataHandler = vi.fn()
const unsub = provider.onData(dataHandler)
const { id: _id } = await provider.spawn({ cols: 80, rows: 24 })
unsub()
const onDataCb = mockProc.onData.mock.calls[0][0]
onDataCb('hello')
expect(dataHandler).not.toHaveBeenCalled()
})
})
})
@@ -1,375 +0,0 @@
import type * as WslPathOwner from '../../../shared/wsl-paths'
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
const {
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
spawnMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
prepareMacosTccLoginShellMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
readWindowsPtyJobProcessIdsMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
isWslAvailableAsyncMock: vi.fn(),
wslUncDirectoryExistsAsyncMock: vi.fn(),
createShellPromptReadinessProbeMock: vi.fn()
}))
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
...(await importOriginal<typeof WslPathOwner>()),
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
toWindowsWslPath: (path: string, distro: string) =>
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
statSync: statSyncMock,
accessSync: accessSyncMock,
mkdirSync: mkdirSyncMock,
writeFileSync: writeFileSyncMock,
chmodSync: vi.fn(),
renameSync: vi.fn(),
rmSync: vi.fn(),
constants: { X_OK: 1 }
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/orca-user-data')
}
}))
vi.mock('node-pty', () => ({
spawn: spawnMock
}))
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
...(await importOriginal<typeof MacosTccLoginShell>()),
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
}))
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
vi.mock('../windows-powershell-executable', () => ({
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe'
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('../agent-foreground-process', () => ({
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
resolveAgentForegroundProcessMock(...args)
}))
vi.mock('../windows-pty-job-membership', () => ({
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
isWindowsPtyJobReadable: () => true
}))
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
parseWslPath: (path: string) => {
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
if (!match) {
return null
}
return {
distro: match[1],
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
}
},
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
}))
vi.mock('../../wsl/discovery/distro-catalog', () => ({
getDefaultWslDistro: () => 'Ubuntu'
}))
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
...(await importOriginal<typeof WslAvailabilityTypes>()),
isWslAvailableAsync: () => isWslAvailableAsyncMock()
}))
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
}))
import { LocalPtyProvider } from './local-pty-provider'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../../pty/shell-startup/posix-shell-startup-command'
import {
applyLocalPtyProviderMockDefaults,
createLocalPtyMockProcess,
installLocalPtyProviderEnvSandbox,
type LocalPtyMockProcess
} from './local-pty-provider-test-harness'
describe('LocalPtyProvider', () => {
let provider: LocalPtyProvider
let mockProc: LocalPtyMockProcess
let exitCb: ((info: { exitCode: number }) => void) | undefined
installLocalPtyProviderEnvSandbox()
beforeEach(() => {
applyLocalPtyProviderMockDefaults({
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
})
exitCb = undefined
mockProc = createLocalPtyMockProcess({
get: () => exitCb,
set: (cb) => {
exitCb = cb
}
})
spawnMock.mockReturnValue(mockProc)
provider = new LocalPtyProvider()
})
describe('spawn', () => {
it('delegates markerless Codex startup to the shell wrapper without a PTY write', async () => {
process.env.SHELL = '/bin/zsh'
const command = "codex '--dangerously-bypass-approvals-and-sandbox'"
await provider.spawn({ cols: 80, rows: 24, command })
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(command)
expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('startup')
expect(spawnEnv.ORCA_SHELL_FEATURES).not.toContain('ready')
expect(mockProc.write).not.toHaveBeenCalled()
})
it('keeps payload-bearing Codex startup in the wrapper readiness path', async () => {
process.env.SHELL = '/bin/zsh'
const command = "codex '--dangerously-bypass-approvals-and-sandbox' 'review this branch'"
await provider.spawn({
cols: 80,
rows: 24,
command,
startupCommandDelivery: 'shell-ready'
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(command)
expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('startup')
expect(spawnEnv.ORCA_SHELL_FEATURES).toContain('ready')
expect(mockProc.write).not.toHaveBeenCalled()
})
it('scrubs an inherited wrapper startup command from ordinary panes', async () => {
process.env.SHELL = '/bin/zsh'
await provider.spawn({
cols: 80,
rows: 24,
env: { [POSIX_SHELL_STARTUP_COMMAND_ENV]: 'poisoned command' }
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined()
expect(mockProc.write).not.toHaveBeenCalled()
})
it('retains PTY delivery for unsupported local shells without leaking wrapper state', async () => {
vi.useFakeTimers()
try {
process.env.SHELL = '/bin/sh'
const command = "codex '--dangerously-bypass-approvals-and-sandbox'"
await provider.spawn({ cols: 80, rows: 24, command })
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env
expect(spawnEnv[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBeUndefined()
expect(mockProc.write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(200)
expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`)
} finally {
vi.useRealTimers()
}
})
it('stages a long startup command and types only the line that sources it', async () => {
vi.useFakeTimers()
try {
process.env.SHELL = '/bin/sh'
const command = `claude '${'x'.repeat(600)}'`
const result = await provider.spawn({ cols: 80, rows: 24, command })
expect(result).not.toHaveProperty('startupDelivery')
const staged = writeFileSyncMock.mock.calls.find(([path]) =>
String(path).includes('orca-launch-')
)
expect(staged?.[1]).toContain(`\n${command}\n`)
await vi.advanceTimersByTimeAsync(200)
expect(mockProc.write).toHaveBeenCalledWith(`. '${staged?.[0]}'\r`)
} finally {
vi.useRealTimers()
}
})
it('prints a notice in the terminal when it types a line it could not stage', async () => {
vi.useFakeTimers()
try {
process.env.SHELL = '/bin/sh'
const received: string[] = []
provider.configure({ onData: (_id, data) => received.push(data) })
writeFileSyncMock.mockImplementationOnce(() => {
throw new Error('ENOSPC: no space left on device')
})
const command = `claude '${'x'.repeat(600)}'`
await provider.spawn({ cols: 80, rows: 24, command })
expect(received.join('')).toContain(
'[orca] Could not stage the launch command (ENOSPC: no space left on device)'
)
await vi.advanceTimersByTimeAsync(200)
expect(mockProc.write).toHaveBeenCalledWith(`${command}\r`)
} finally {
vi.useRealTimers()
}
})
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' })
)
})
// This case consumes the preceding default-shell spawn in mock.calls[0]; the spy accumulates across tests.
it('uses fallback shell readiness when startup-command shell spawn falls back', async () => {
vi.useFakeTimers()
try {
process.env.SHELL = '/usr/bin/fish'
spawnMock.mockImplementationOnce(() => {
throw new Error('fish failed')
})
spawnMock.mockReturnValue(mockProc)
await provider.spawn({ cols: 80, rows: 24, command: "printf 'linked issue context'" })
expect(spawnMock.mock.calls[0]?.[0]).toBe('/bin/zsh')
await Promise.resolve()
vi.advanceTimersByTime(50)
await Promise.resolve()
expect(mockProc.write).not.toHaveBeenCalled()
const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void
dataCallback('\x1b]777;orca-shell-ready\x07user@host % ')
await Promise.resolve()
vi.advanceTimersByTime(29)
await Promise.resolve()
expect(mockProc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
await Promise.resolve()
expect(mockProc.write).toHaveBeenCalledWith("printf 'linked issue context'\r")
} finally {
vi.useRealTimers()
}
})
it('releases held marker-prefix bytes when local shell readiness times out', async () => {
vi.useFakeTimers()
const onData = vi.fn()
provider.configure({ onData })
try {
await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' })
const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void
dataCallback('\x1b]777;orca-shell-ready')
expect(onData).not.toHaveBeenCalled()
vi.advanceTimersByTime(1500)
await Promise.resolve()
expect(onData).toHaveBeenCalledWith(
expect.any(String),
'\x1b]777;orca-shell-ready',
expect.any(Number)
)
expect(mockProc.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(200)
await Promise.resolve()
expect(mockProc.write).toHaveBeenCalledWith('printf ready\r')
} finally {
vi.useRealTimers()
}
})
it('releases held marker-prefix bytes when local shell exits before readiness', async () => {
const onData = vi.fn()
provider.configure({ onData })
await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' })
const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void
dataCallback('\x1b]777;orca-shell-ready')
expect(onData).not.toHaveBeenCalled()
exitCb?.({ exitCode: 0 })
expect(onData).toHaveBeenCalledWith(
expect.any(String),
'\x1b]777;orca-shell-ready',
expect.any(Number)
)
})
})
})
@@ -130,9 +130,7 @@ vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
}))
import {
LOCAL_PTY_FORCE_KILL_RETRY_MS,
LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS,
LOCAL_PTY_PHYSICAL_EXIT_TIMEOUT_MS
} from './local-pty-termination'
@@ -182,45 +180,6 @@ describe('LocalPtyProvider', () => {
})
describe('shutdown', () => {
it('kills the PTY process', async () => {
// Cleanup replaces kill to prevent recycled-PID SIGHUP; capture the spy before shutdown (docs/fix-pty-fd-leak.md).
const killSpy = mockProc.kill
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await provider.shutdown(id, { immediate: true })
expect(killSpy).toHaveBeenCalled()
})
it('invokes onExit callback via the node-pty exit handler', async () => {
const onExit = vi.fn()
provider.configure({ onExit })
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
await provider.shutdown(id, { immediate: true })
expect(onExit).toHaveBeenCalledWith(id, -1, incarnationId, {
kind: 'operator_close'
})
})
it('does not destroy after an intentional Windows shutdown kill', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const killSpy = vi.fn()
const destroySpy = vi.fn(() => {
killSpy()
})
spawnMock.mockReturnValue({
...mockProc,
kill: killSpy,
destroy: destroySpy
})
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const shutdown = provider.shutdown(id, { immediate: true })
exitCb?.({ exitCode: -1 })
await shutdown
expect(killSpy).toHaveBeenCalledTimes(1)
expect(destroySpy).not.toHaveBeenCalled()
})
it('keeps shutdown and ownership pending until node-pty reports physical exit', async () => {
const killSpy = vi.fn()
mockProc.kill = killSpy
@@ -240,107 +199,6 @@ describe('LocalPtyProvider', () => {
expect(provider.hasPty(id)).toBe(false)
})
it('keeps physical-exit tracking when orphan cleanup races immediate shutdown', async () => {
const killSpy = vi.fn(() => {
queueMicrotask(() => exitCb?.({ exitCode: 137 }))
})
mockProc.kill = killSpy
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const shutdown = provider.shutdown(id, { immediate: true })
expect(provider.killOrphanedPtys(1)).toEqual([{ id }])
expect(provider.hasPty(id)).toBe(true)
await expect(shutdown).resolves.toBeUndefined()
expect(killSpy).toHaveBeenCalledTimes(1)
expect(provider.hasPty(id)).toBe(false)
})
it('escalates graceful shutdown before orphan cleanup disables the kill handle', async () => {
vi.useFakeTimers()
try {
const killSpy = vi.fn()
mockProc.kill = killSpy
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const graceful = provider.shutdown(id, { immediate: false })
expect(killSpy.mock.calls).toEqual([['SIGTERM']])
expect(provider.killOrphanedPtys(1)).toEqual([{ id }])
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
// Immediate escalation must cancel the graceful timer before it can invoke the neutralized kill.
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
expect(killSpy).toHaveBeenCalledTimes(2)
exitCb?.({ exitCode: 137 })
await graceful
expect(provider.hasPty(id)).toBe(false)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('escalates a graceful shutdown when destructive cleanup joins it', async () => {
const killSpy = vi.fn()
mockProc.kill = killSpy
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const graceful = provider.shutdown(id, { immediate: false })
const immediate = provider.shutdown(id, { immediate: true })
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
exitCb?.({ exitCode: 137 })
await Promise.all([graceful, immediate])
expect(provider.hasPty(id)).toBe(false)
})
it('force-kills a POSIX PTY that ignores graceful shutdown', async () => {
vi.useFakeTimers()
try {
const killSpy = vi.fn()
mockProc.kill = killSpy
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const graceful = provider.shutdown(id, { immediate: false })
expect(killSpy.mock.calls).toEqual([['SIGTERM']])
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
exitCb?.({ exitCode: 137 })
await graceful
expect(provider.hasPty(id)).toBe(false)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('keeps the kill handle until a graceful-deadline sweep settles when orphan cleanup joins', async () => {
vi.useFakeTimers()
try {
const killSpy = vi.fn()
const destroySpy = vi.fn()
spawnMock.mockReturnValue({ ...mockProc, kill: killSpy, destroy: destroySpy })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const table = Promise.withResolvers<string>()
readProcessTableMock.mockReturnValueOnce(table.promise)
const graceful = provider.shutdown(id, { immediate: false })
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
expect(provider.killOrphanedPtys(1)).toEqual([{ id }])
expect(destroySpy).not.toHaveBeenCalled()
table.resolve('')
await vi.waitFor(() => expect(destroySpy).toHaveBeenCalledOnce())
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
exitCb?.({ exitCode: 137 })
await graceful
} finally {
vi.useRealTimers()
}
})
it('escalates through orphan cleanup when the graceful-deadline sweep it joined is refused', async () => {
vi.useFakeTimers()
try {
@@ -374,50 +232,6 @@ describe('LocalPtyProvider', () => {
}
})
it('retries a rejected graceful-deadline SIGKILL before the physical timeout', async () => {
vi.useFakeTimers()
try {
let forceAttempts = 0
const killSpy = vi.fn((signal: string) => {
if (signal === 'SIGKILL' && forceAttempts++ === 0) {
throw new Error('transient force-kill failure')
}
})
mockProc.kill = killSpy
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const graceful = provider.shutdown(id, { immediate: false })
await vi.advanceTimersByTimeAsync(LOCAL_PTY_GRACEFUL_FORCE_TIMEOUT_MS)
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']])
expect(provider.hasPty(id)).toBe(true)
await vi.advanceTimersByTimeAsync(LOCAL_PTY_FORCE_KILL_RETRY_MS)
expect(killSpy.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGKILL']])
exitCb?.({ exitCode: 137 })
await graceful
expect(provider.hasPty(id)).toBe(false)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('does not double-kill ConPTY when destructive cleanup joins shutdown', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const killSpy = vi.fn()
mockProc.kill = killSpy
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const graceful = provider.shutdown(id, { immediate: false })
const immediate = provider.shutdown(id, { immediate: true })
expect(killSpy.mock.calls).toEqual([[]])
exitCb?.({ exitCode: 137 })
await Promise.all([graceful, immediate])
expect(killSpy).toHaveBeenCalledTimes(1)
expect(provider.hasPty(id)).toBe(false)
})
it('rejects a physical-exit timeout but retains the owner for a successful retry', async () => {
vi.useFakeTimers()
try {
@@ -454,231 +268,5 @@ describe('LocalPtyProvider', () => {
await expect(provider.shutdown(id, { immediate: true })).resolves.toBeUndefined()
expect(provider.hasPty(id)).toBe(false)
})
it('cancels pending shell-ready startup delivery on forced shutdown', async () => {
vi.useFakeTimers()
try {
const { id } = await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' })
await provider.shutdown(id, { immediate: true })
vi.advanceTimersByTime(2000)
await Promise.resolve()
expect(mockProc.write).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('is a no-op for unknown PTY ids', async () => {
await provider.shutdown('nonexistent', { immediate: true })
expect(mockProc.kill).not.toHaveBeenCalled()
})
it('waits for an in-flight agent shutdown before reusing the same session id', async () => {
let releaseSweep!: () => void
killWithDescendantSweepMock.mockImplementation(
(_rootPid: number, killRoot: () => void) =>
new Promise<void>((resolve) => {
releaseSweep = () => {
killRoot()
resolve()
}
})
)
const spawnArgs = {
cols: 80,
rows: 24,
sessionId: 'stable-agent-session',
launchAgent: 'claude' as const
}
const spawnCallsBefore = spawnMock.mock.calls.length
const { id } = await provider.spawn(spawnArgs)
const shutdown = provider.shutdown(id, { immediate: true })
const respawn = provider.spawn(spawnArgs)
await Promise.resolve()
expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 1)
releaseSweep()
await shutdown
await respawn
expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 2)
})
it('coalesces duplicate shutdown while descendant sweep is pending', async () => {
let releaseSweep!: () => void
killWithDescendantSweepMock.mockImplementation(
(_rootPid: number, killRoot: () => void) =>
new Promise<void>((resolve) => {
releaseSweep = () => {
killRoot()
resolve()
}
})
)
const { id } = await provider.spawn({
cols: 80,
rows: 24,
launchAgent: 'claude'
})
const first = provider.shutdown(id, { immediate: true })
const second = provider.shutdown(id, { immediate: true })
expect(killWithDescendantSweepMock).toHaveBeenCalledOnce()
releaseSweep()
await Promise.all([first, second])
expect(killWithDescendantSweepMock).toHaveBeenCalledOnce()
})
it('does not terminate descendants after the tracked root exits mid-sweep', async () => {
const terminateDescendants = vi.fn()
let releaseSweep!: () => void
killWithDescendantSweepMock.mockImplementation(
(_rootPid: number, killRoot: () => void, deps?: { ownsRoot?: () => boolean }) =>
new Promise<void>((resolve) => {
releaseSweep = () => {
// Production killWithDescendantSweep only signals descendants while ownsRoot.
if (deps?.ownsRoot?.() ?? true) {
terminateDescendants()
}
killRoot()
resolve()
}
})
)
const { id } = await provider.spawn({
cols: 80,
rows: 24,
launchAgent: 'claude'
})
const shutdown = provider.shutdown(id, { immediate: true })
exitCb?.({ exitCode: 0 })
releaseSweep()
await shutdown
expect(terminateDescendants).not.toHaveBeenCalled()
})
it('win32 immediate shutdown of a plain shell taskkills the descendant tree', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await provider.shutdown(id, { immediate: true })
// Windows orphan descendants hold ConPTY and cwd open; tree sweep allows worktree removal.
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
mockProc.pid,
expect.any(Function),
expect.objectContaining({ ownsRoot: expect.any(Function) })
)
})
it('win32 graceful shutdown of a plain shell does not taskkill the tree', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await provider.shutdown(id, { immediate: false })
expect(killWithDescendantSweepMock).not.toHaveBeenCalled()
})
it('POSIX immediate shutdown sweeps detached OMP tools without startup recognition', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
await provider.shutdown(id, { immediate: true })
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
mockProc.pid,
expect.any(Function),
expect.objectContaining({ ownsRoot: expect.any(Function) })
)
// Root group SIGKILL must wait for the descendant SIGTERM grace, or Claude skips SessionEnd.
expect(killWithDescendantSweepMock.mock.calls[0][2].awaitEscalation()).toBe(true)
})
})
describe('killAll', () => {
it('kills all PTY processes', async () => {
// Separate processes/callbacks prevent one exit cleanup from neutralizing or removing the other PTY.
let exit1: ((e: { exitCode: number }) => void) | undefined
let exit2: ((e: { exitCode: number }) => void) | undefined
const mock1Kill = vi.fn(() => exit1?.({ exitCode: -1 }))
const mock2Kill = vi.fn(() => exit2?.({ exitCode: -1 }))
spawnMock
.mockReturnValueOnce({
...mockProc,
kill: mock1Kill,
onExit: vi.fn((cb) => {
exit1 = cb
})
})
.mockReturnValueOnce({
...mockProc,
kill: mock2Kill,
onExit: vi.fn((cb) => {
exit2 = cb
})
})
await provider.spawn({ cols: 80, rows: 24 })
await provider.spawn({ cols: 80, rows: 24 })
provider.killAll()
expect(mock1Kill).toHaveBeenCalled()
expect(mock2Kill).toHaveBeenCalled()
const list = await provider.listProcesses()
expect(list).toHaveLength(0)
})
it('force-kills an OMP PTY root during app quit', async () => {
const killSpy = vi.fn()
spawnMock.mockReturnValue({
...mockProc,
kill: killSpy
})
await provider.spawn({ cols: 80, rows: 24, launchAgent: 'omp' })
provider.killAll()
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
})
it('does not destroy after intentional Windows orphan kills', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const destroySpy = vi.fn()
const killSpy = vi.fn()
spawnMock.mockReturnValue({
...mockProc,
kill: killSpy,
destroy: destroySpy
})
await provider.spawn({ cols: 80, rows: 24 })
provider.killAll()
expect(killSpy).toHaveBeenCalledTimes(1)
expect(destroySpy).not.toHaveBeenCalled()
})
it('settles an overlapping shutdown when app quit takes final ownership', async () => {
mockProc.kill.mockImplementation(() => undefined)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const shutdown = provider.shutdown(id, { immediate: true })
let settled = false
void shutdown.then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
provider.killAll()
await expect(shutdown).resolves.toBeUndefined()
})
})
})
@@ -1,261 +0,0 @@
import type * as WslPathOwner from '../../../shared/wsl-paths'
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
const {
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
spawnMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
prepareMacosTccLoginShellMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
readWindowsPtyJobProcessIdsMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
isWslAvailableAsyncMock: vi.fn(),
wslUncDirectoryExistsAsyncMock: vi.fn(),
createShellPromptReadinessProbeMock: vi.fn()
}))
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
...(await importOriginal<typeof WslPathOwner>()),
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
toWindowsWslPath: (path: string, distro: string) =>
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
statSync: statSyncMock,
accessSync: accessSyncMock,
mkdirSync: mkdirSyncMock,
writeFileSync: writeFileSyncMock,
chmodSync: vi.fn(),
renameSync: vi.fn(),
rmSync: vi.fn(),
constants: { X_OK: 1 }
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/orca-user-data')
}
}))
vi.mock('node-pty', () => ({
spawn: spawnMock
}))
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
...(await importOriginal<typeof MacosTccLoginShell>()),
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
}))
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
vi.mock('../windows-powershell-executable', () => ({
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe'
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('../agent-foreground-process', () => ({
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
resolveAgentForegroundProcessMock(...args)
}))
vi.mock('../windows-pty-job-membership', () => ({
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
isWindowsPtyJobReadable: () => true
}))
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
parseWslPath: (path: string) => {
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
if (!match) {
return null
}
return {
distro: match[1],
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
}
},
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
}))
vi.mock('../../wsl/discovery/distro-catalog', () => ({
getDefaultWslDistro: () => 'Ubuntu'
}))
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
...(await importOriginal<typeof WslAvailabilityTypes>()),
isWslAvailableAsync: () => isWslAvailableAsyncMock()
}))
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
}))
import { LocalPtyProvider } from './local-pty-provider'
import {
applyLocalPtyProviderMockDefaults,
createLocalPtyMockProcess,
installLocalPtyProviderEnvSandbox,
type LocalPtyMockProcess
} from './local-pty-provider-test-harness'
import { isRootLikePath } from '../pty-path-safety'
describe('LocalPtyProvider', () => {
let provider: LocalPtyProvider
let mockProc: LocalPtyMockProcess
let exitCb: ((info: { exitCode: number }) => void) | undefined
installLocalPtyProviderEnvSandbox()
beforeEach(() => {
applyLocalPtyProviderMockDefaults({
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
})
exitCb = undefined
mockProc = createLocalPtyMockProcess({
get: () => exitCb,
set: (cb) => {
exitCb = cb
}
})
spawnMock.mockReturnValue(mockProc)
provider = new LocalPtyProvider()
})
describe('spawn', () => {
it('throws when cwd does not exist', async () => {
existsSyncMock.mockImplementation((p: string) => p !== '/nonexistent')
await expect(provider.spawn({ cols: 80, rows: 24, cwd: '/nonexistent' })).rejects.toThrow(
'does not exist'
)
})
it('allows an explicitly requested plain shell at POSIX root', async () => {
await provider.spawn({ cols: 80, rows: 24, cwd: '/' })
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ cwd: '/' })
)
})
it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => {
spawnMock.mockClear()
const origHome = process.env.HOME
// Pin the exact default-home candidate to catch unintended cwd resolution.
process.env.HOME = '/home/testuser'
try {
// Omitted cwd resolves to safe home; the root-path guard must still allow startup.
await expect(
provider.spawn({ cols: 80, rows: 24, command: 'codex' })
).resolves.toBeDefined()
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].cwd).toBe('/home/testuser')
expect(isRootLikePath(spawnCall[2].cwd)).toBe(false)
} finally {
if (origHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = origHome
}
}
})
it('rejects automatic agent startup at POSIX root', async () => {
spawnMock.mockClear()
await expect(
provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' })
).rejects.toThrow(/requires a non-root workspace/)
expect(spawnMock).not.toHaveBeenCalled()
})
it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalUserProfile = process.env.USERPROFILE
const originalHomeDrive = process.env.HOMEDRIVE
const originalHomePath = process.env.HOMEPATH
Object.defineProperty(process, 'platform', { value: 'win32' })
delete process.env.USERPROFILE
process.env.HOMEDRIVE = 'D:'
process.env.HOMEPATH = '\\Users\\orca'
try {
await provider.spawn({ cols: 80, rows: 24 })
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
if (originalHomeDrive === undefined) {
delete process.env.HOMEDRIVE
} else {
process.env.HOMEDRIVE = originalHomeDrive
}
if (originalHomePath === undefined) {
delete process.env.HOMEPATH
} else {
process.env.HOMEPATH = originalHomePath
}
}
expect(spawnMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ cwd: 'D:\\Users\\orca' })
)
})
})
})
@@ -1,655 +0,0 @@
import type * as WslPathOwner from '../../../shared/wsl-paths'
import type * as WslAvailabilityTypes from '../../wsl/discovery/wsl-availability'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter } from 'node:path'
import type * as MacosTccLoginShell from '../macos-tcc-login-shell'
import { stripLegacyTerminalShimEnv } from '../../pty/shell-startup/legacy-terminal-shim-dir'
const {
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
spawnMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
prepareMacosTccLoginShellMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn(),
readWindowsPtyJobProcessIdsMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
isWslAvailableAsyncMock: vi.fn(),
wslUncDirectoryExistsAsyncMock: vi.fn(),
createShellPromptReadinessProbeMock: vi.fn()
}))
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
...(await importOriginal<typeof WslPathOwner>()),
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
toWindowsWslPath: (path: string, distro: string) =>
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
statSync: statSyncMock,
accessSync: accessSyncMock,
mkdirSync: mkdirSyncMock,
writeFileSync: writeFileSyncMock,
chmodSync: vi.fn(),
renameSync: vi.fn(),
rmSync: vi.fn(),
constants: { X_OK: 1 }
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/orca-user-data')
}
}))
vi.mock('node-pty', () => ({
spawn: spawnMock
}))
vi.mock('../macos-tcc-login-shell', async (importOriginal) => ({
...(await importOriginal<typeof MacosTccLoginShell>()),
prepareMacosTccLoginShell: prepareMacosTccLoginShellMock
}))
vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
// The fs mock misses executable probes; pin paths here and leave Store-alias coverage to the resolver tests.
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
vi.mock('../windows-powershell-executable', () => ({
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe'
? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS]
: [WINDOWS_POWERSHELL_ABS, CMD_ABS],
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('../agent-foreground-process', () => ({
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
resolveAgentForegroundProcessMock(...args)
}))
vi.mock('../windows-pty-job-membership', () => ({
readWindowsPtyJobProcessIds: (...args: unknown[]) => readWindowsPtyJobProcessIdsMock(...args),
isWindowsPtyJobReadable: () => true
}))
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
parseWslPath: (path: string) => {
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
if (!match) {
return null
}
return {
distro: match[1],
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
}
},
wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args)
}))
vi.mock('../../wsl/discovery/distro-catalog', () => ({
getDefaultWslDistro: () => 'Ubuntu'
}))
vi.mock('../../wsl/discovery/wsl-availability', async (importOriginal) => ({
...(await importOriginal<typeof WslAvailabilityTypes>()),
isWslAvailableAsync: () => isWslAvailableAsyncMock()
}))
vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: createShellPromptReadinessProbeMock
}))
import { LocalPtyProvider } from './local-pty-provider'
import {
applyLocalPtyProviderMockDefaults,
createLocalPtyMockProcess,
installLocalPtyProviderEnvSandbox,
type LocalPtyMockProcess
} from './local-pty-provider-test-harness'
describe('LocalPtyProvider', () => {
let provider: LocalPtyProvider
let mockProc: LocalPtyMockProcess
let exitCb: ((info: { exitCode: number }) => void) | undefined
installLocalPtyProviderEnvSandbox()
beforeEach(() => {
applyLocalPtyProviderMockDefaults({
existsSyncMock,
statSyncMock,
accessSyncMock,
mkdirSyncMock,
writeFileSyncMock,
prepareMacosTccLoginShellMock,
resolveAgentForegroundProcessMock,
readWindowsPtyJobProcessIdsMock,
killWithDescendantSweepMock,
isWslAvailableAsyncMock,
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
})
exitCb = undefined
mockProc = createLocalPtyMockProcess({
get: () => exitCb,
set: (cb) => {
exitCb = cb
}
})
spawnMock.mockReturnValue(mockProc)
provider = new LocalPtyProvider()
})
describe('spawn', () => {
it('expands variables in PATH before spawning a Windows shell', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\repo',
env: {
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local',
PATH: '%orca_path_root%\\agy\\bin;C:\\Windows'
}
})
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe(
'C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows'
)
})
it('passes explicit pane environment separately from inherited process values', async () => {
const buildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
provider.configure({ buildSpawnEnv })
const env = { XDG_DATA_HOME: '/pane/data' }
await provider.spawn({ cols: 80, rows: 24, env })
expect(buildSpawnEnv).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({ explicitEnv: env })
)
})
it('invokes buildSpawnEnv callback to customize environment', async () => {
const buildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => {
env.CUSTOM_VAR = 'custom-value'
return env
})
provider.configure({ buildSpawnEnv })
await provider.spawn({ cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
it('re-reads buildSpawnEnv after a reentrant configuration change', async () => {
const initialBuildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
const configuredBuildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
provider.configure({
get buildSpawnEnv() {
provider.configure({ buildSpawnEnv: configuredBuildSpawnEnv })
return initialBuildSpawnEnv
}
})
await provider.spawn({ cols: 80, rows: 24 })
expect(initialBuildSpawnEnv).not.toHaveBeenCalled()
expect(configuredBuildSpawnEnv).toHaveBeenCalledOnce()
})
it.each([
// Exported fish_history can leak the launching workspace session even when isolation is off (STA-4682).
['an inherited Orca session', 'orca_abc123', undefined],
['a user value', 'mine', 'mine']
])('history isolation off: %s', async (_kind, inherited, expected) => {
const previous = process.env.fish_history
process.env.fish_history = inherited
try {
// Missing workspace id exercises history-off cleanup.
await provider.spawn({ cols: 80, rows: 24 })
} finally {
if (previous === undefined) {
delete process.env.fish_history
} else {
process.env.fish_history = previous
}
}
expect(spawnMock.mock.calls.at(-1)![2].env.fish_history).toBe(expected)
})
it.each([
// Exported HISTFILE can leak the launching workspace history even with isolation off.
[
'an inherited Orca path',
'/fake/userData/terminal-history/aabbccddeeff0011/zsh_history',
undefined
],
['a user value', '/home/me/.zsh_history', '/home/me/.zsh_history']
])('history isolation off: %s HISTFILE', async (_kind, inherited, expected) => {
const previous = process.env.HISTFILE
process.env.HISTFILE = inherited
try {
// Missing workspace id exercises history-off cleanup.
await provider.spawn({ cols: 80, rows: 24 })
} finally {
if (previous === undefined) {
delete process.env.HISTFILE
} else {
process.env.HISTFILE = previous
}
}
expect(spawnMock.mock.calls.at(-1)![2].env.HISTFILE).toBe(expected)
})
it('does not inherit NODE_ENV from the Orca process env', async () => {
// Ambient NODE_ENV belongs to the Orca build and can break terminal next build/Vitest.
const previous = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
try {
await provider.spawn({ cols: 80, rows: 24 })
} finally {
if (previous === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previous
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.NODE_ENV).toBeUndefined()
const expectedEnv = { PATH: process.env.PATH ?? '' }
stripLegacyTerminalShimEnv(expectedEnv, process.platform)
expect(spawnCall[2].env.PATH).toBe(expectedEnv.PATH)
})
it('keeps an explicitly requested NODE_ENV for spawned terminals', async () => {
const previous = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
try {
await provider.spawn({ cols: 80, rows: 24, env: { NODE_ENV: 'production' } })
} finally {
if (previous === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previous
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.NODE_ENV).toBe('production')
})
it('suppresses the first-run Powerlevel10k wizard for spawned terminals', async () => {
await provider.spawn({ cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('true')
})
it('preserves an explicit Powerlevel10k wizard env value', async () => {
await provider.spawn({
cols: 80,
rows: 24,
env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'already-set' }
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBe('already-set')
})
it('honors requests to delete the Powerlevel10k wizard env value', async () => {
await provider.spawn({
cols: 80,
rows: 24,
envToDelete: ['POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD']
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD).toBeUndefined()
})
it('honors explicit terminal env overrides after deleting requested defaults', async () => {
provider.configure({
buildSpawnEnv: (_id, env) => {
env.TERM_PROGRAM = 'Orca'
env.ORCA_STALE_TEST_ENV = '/tmp/orca-stale'
env.PATH = `/tmp/orca-stale:${env.PATH ?? ''}`
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
env: {
TERM: 'screen-256color',
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].name).toBe('screen-256color')
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_STALE_TEST_ENV).toBeUndefined()
})
it('does not re-promote a legacy attribution path for Agent Teams', async () => {
await provider.spawn({
cols: 80,
rows: 24,
env: {
PATH: '/tmp/orca-terminal-attribution/posix:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
}
})
expect(spawnMock.mock.calls.at(-1)?.[2].env.PATH).toBe('/usr/bin')
})
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
const keys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1'
] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '2'
process.env.GIT_CONFIG_KEY_0 = 'base.zero'
process.env.GIT_CONFIG_VALUE_0 = 'zero'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
try {
await provider.spawn({
cols: 80,
rows: 24,
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'override.zero',
GIT_CONFIG_VALUE_0: 'override'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('1')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('override.zero')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBeUndefined()
expect(spawnEnv.GIT_CONFIG_VALUE_1).toBeUndefined()
} finally {
for (const key of keys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('does not inherit AppImage runtime env into Linux PTY shells', async () => {
const saved = {
APPIMAGE: process.env.APPIMAGE,
APPDIR: process.env.APPDIR,
ARGV0: process.env.ARGV0,
OWD: process.env.OWD,
APPIMAGE_LIBRARY_PATH: process.env.APPIMAGE_LIBRARY_PATH,
PATH: process.env.PATH,
LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH
}
process.env.APPIMAGE = '/data/apps/orca.appimage'
process.env.APPDIR = '/tmp/.mount_orca123'
process.env.ARGV0 = '/data/apps/orca.appimage'
process.env.OWD = '/home/user/project'
process.env.APPIMAGE_LIBRARY_PATH = '/tmp/.mount_orca123/usr/lib'
process.env.PATH = ['/tmp/.mount_orca123', '/tmp/.mount_orca123/usr/sbin', '/usr/bin'].join(
delimiter
)
process.env.LD_LIBRARY_PATH = ['/tmp/.mount_orca123/usr/lib', '/opt/audio/lib'].join(
delimiter
)
try {
await provider.spawn({ cols: 80, rows: 24 })
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.APPIMAGE).toBeUndefined()
expect(env.APPDIR).toBeUndefined()
expect(env.ARGV0).toBeUndefined()
expect(env.OWD).toBeUndefined()
expect(env.APPIMAGE_LIBRARY_PATH).toBeUndefined()
expect(env.PATH).toBe('/usr/bin')
expect(env.LD_LIBRARY_PATH).toBe('/opt/audio/lib')
})
it('does not forward a half-activated conda env into the shell', async () => {
// CONDA_SHLVL without CONDA_PREFIX breaks user conda hooks (#14195).
const saved = {
CONDA_SHLVL: process.env.CONDA_SHLVL,
CONDA_PREFIX: process.env.CONDA_PREFIX,
CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV,
CONDA_PROMPT_MODIFIER: process.env.CONDA_PROMPT_MODIFIER,
CONDA_EXE: process.env.CONDA_EXE
}
delete process.env.CONDA_PREFIX
process.env.CONDA_SHLVL = '1'
process.env.CONDA_DEFAULT_ENV = 'base'
process.env.CONDA_PROMPT_MODIFIER = '(base) '
process.env.CONDA_EXE = '/opt/miniconda3/bin/conda'
try {
await provider.spawn({ cols: 80, rows: 24 })
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.CONDA_SHLVL).toBeUndefined()
expect(env.CONDA_DEFAULT_ENV).toBeUndefined()
expect(env.CONDA_PROMPT_MODIFIER).toBeUndefined()
expect(env.CONDA_EXE).toBe('/opt/miniconda3/bin/conda')
})
it('drops the conda sentinel when the client asks to delete CONDA_PREFIX', async () => {
// envToDelete runs after the scrub, so coherence repair must run last.
const saved = {
CONDA_SHLVL: process.env.CONDA_SHLVL,
CONDA_PREFIX: process.env.CONDA_PREFIX,
CONDA_DEFAULT_ENV: process.env.CONDA_DEFAULT_ENV
}
process.env.CONDA_SHLVL = '1'
process.env.CONDA_PREFIX = '/opt/miniconda3'
process.env.CONDA_DEFAULT_ENV = 'base'
try {
await provider.spawn({ cols: 80, rows: 24, envToDelete: ['CONDA_PREFIX'] })
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.CONDA_PREFIX).toBeUndefined()
expect(env.CONDA_SHLVL).toBeUndefined()
expect(env.CONDA_DEFAULT_ENV).toBeUndefined()
})
it('uses shell wrapper when MiMo home must survive shell startup', async () => {
provider.configure({
buildSpawnEnv: (_id, env) => {
env.MIMOCODE_CONFIG_DIR = '/tmp/orca-mimocode-overlay'
env.ORCA_MIMOCODE_CONFIG_DIR = '/tmp/orca-mimocode-overlay'
return env
}
})
await provider.spawn({ cols: 80, rows: 24 })
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[1]).toEqual(['-l'])
expect(spawnCall[2].env.ZDOTDIR).toMatch(/shell-ready[\\/]zsh/)
expect(spawnCall[2].env.ORCA_SHELL_FEATURES).not.toContain('ready')
})
it('promotes the agent-teams shim onto the Windows `Path` spelling', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => {
// Why: host env collapses Windows PATH onto `Path` and prepends its own shim dir.
delete env.PATH
env.Path = `/tmp/orca-stale:${env.Path ?? ''}`
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
env: {
Path: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)![2].env
expect(Object.keys(spawnEnv).filter((key) => /^path$/i.test(key))).toEqual(['Path'])
expect(spawnEnv.Path.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
})
it('does not inherit parent Orca pane identity when caller omits pane env', async () => {
const saved = {
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID,
ORCA_ORCAD_MANAGED_ACTIVATION_ROOT: process.env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT,
ORCA_REMOTE_VIEWER_CLIPBOARD: process.env.ORCA_REMOTE_VIEWER_CLIPBOARD,
ORCA_ORCAD_IDLE_TIMEOUT_MS: process.env.ORCA_ORCAD_IDLE_TIMEOUT_MS,
ORCA_WSL_ORCAD_HOST: process.env.ORCA_WSL_ORCAD_HOST,
ORCA_WSL_DISTRO_GUID: process.env.ORCA_WSL_DISTRO_GUID
}
process.env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT = '/home/u/.orca-remote/.activation'
process.env.ORCA_ORCAD_IDLE_TIMEOUT_MS = '300000'
// A WSL host's own marker and distro id; a user's `orcad` in its shell is not that host.
process.env.ORCA_WSL_ORCAD_HOST = '1'
process.env.ORCA_WSL_DISTRO_GUID = '{0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f}'
process.env.ORCA_REMOTE_VIEWER_CLIPBOARD = '1'
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
process.env.ORCA_TAB_ID = 'parent-tab'
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
try {
await provider.spawn({ cols: 80, rows: 24 })
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.ORCA_PANE_KEY).toBeUndefined()
expect(spawnCall[2].env.ORCA_TAB_ID).toBeUndefined()
expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBeUndefined()
expect(spawnCall[2].env.ORCA_ORCAD_MANAGED_ACTIVATION_ROOT).toBeUndefined()
expect(spawnCall[2].env.ORCA_REMOTE_VIEWER_CLIPBOARD).toBeUndefined()
expect(spawnCall[2].env.ORCA_ORCAD_IDLE_TIMEOUT_MS).toBeUndefined()
expect(spawnCall[2].env.ORCA_WSL_ORCAD_HOST).toBeUndefined()
expect(spawnCall[2].env.ORCA_WSL_DISTRO_GUID).toBeUndefined()
})
it('preserves explicit child Orca pane identity over parent env', async () => {
const saved = {
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
ORCA_TAB_ID: process.env.ORCA_TAB_ID,
ORCA_WORKTREE_ID: process.env.ORCA_WORKTREE_ID
}
process.env.ORCA_PANE_KEY = 'parent-tab:parent-leaf'
process.env.ORCA_TAB_ID = 'parent-tab'
process.env.ORCA_WORKTREE_ID = 'parent-worktree'
try {
await provider.spawn({
cols: 80,
rows: 24,
env: {
ORCA_PANE_KEY: 'child-tab:child-leaf',
ORCA_TAB_ID: 'child-tab',
ORCA_WORKTREE_ID: 'child-worktree'
}
})
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.ORCA_PANE_KEY).toBe('child-tab:child-leaf')
expect(spawnCall[2].env.ORCA_TAB_ID).toBe('child-tab')
expect(spawnCall[2].env.ORCA_WORKTREE_ID).toBe('child-worktree')
})
})
})
@@ -133,7 +133,6 @@ vi.mock('../../pty/shell-startup/shell-prompt-readiness-probe', () => ({
import { LocalPtyProvider } from './local-pty-provider'
import {
pendingLocalPtySpawns,
ptyDisposables,
ptyExitDisposables,
ptyPhysicalExits,
@@ -218,180 +217,6 @@ describe('LocalPtyProvider', () => {
expect(mockProc.write).not.toHaveBeenCalled()
})
it('reattaches to an existing caller-supplied session id without spawning', async () => {
const first = await provider.spawn({ cols: 80, rows: 24, sessionId: 'serve-session-1' })
spawnMock.mockClear()
const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id })
expect(second).toEqual({
id: 'serve-session-1',
incarnationId: first.incarnationId,
pid: 12345,
isReattach: true,
// This local reattach resized the PTY; publish the new grid unlike daemon/relay attachments.
attachedGrid: { cols: 120, rows: 40 }
})
expect(mockProc.resize).toHaveBeenCalledWith(120, 40)
expect(spawnMock).not.toHaveBeenCalled()
})
it('attaches only to an existing stable session', async () => {
await provider.spawn({ cols: 80, rows: 24, sessionId: 'stable-pane-session' })
spawnMock.mockClear()
const result = await provider.spawn({
cols: 120,
rows: 40,
sessionId: 'stable-pane-session',
attachOnly: true
})
expect(result).toMatchObject({ id: 'stable-pane-session', isReattach: true })
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not create when an attach-only stable session is absent', async () => {
await expect(
provider.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-stable-pane-session',
attachOnly: true
})
).rejects.toThrow('Session not found: missing-stable-pane-session')
expect(spawnMock).not.toHaveBeenCalled()
})
it('attaches only to an existing numeric provider session', async () => {
const first = await provider.spawn({ cols: 80, rows: 24 })
spawnMock.mockClear()
const result = await provider.spawn({
cols: 120,
rows: 40,
sessionId: first.id,
attachOnly: true
})
expect(result).toMatchObject({
id: first.id,
incarnationId: first.incarnationId,
isReattach: true
})
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not create when a numeric attach-only provider session is absent', async () => {
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: '404', attachOnly: true })
).rejects.toThrow('Session not found: 404')
expect(spawnMock).not.toHaveBeenCalled()
})
it('keeps a native UNC session native on a conflicting WSL reattach', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const first = await provider.spawn({
cols: 80,
rows: 24,
sessionId: 'native-session',
cwd: '\\\\server\\share\\repo',
shellOverride: 'powershell.exe'
})
spawnMock.mockClear()
const second = await provider.spawn({
cols: 120,
rows: 40,
sessionId: first.id,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
expect(first.wslDistro).toBeNull()
expect(second.wslDistro).toBeNull()
expect(spawnMock).not.toHaveBeenCalled()
})
it('keeps the first WSL distro on a conflicting distro reattach', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const first = await provider.spawn({
cols: 80,
rows: 24,
sessionId: 'wsl-session',
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
spawnMock.mockClear()
const second = await provider.spawn({
cols: 120,
rows: 40,
sessionId: first.id,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
})
expect(first.wslDistro).toBe('Ubuntu')
expect(second.wslDistro).toBe('Ubuntu')
expect(spawnMock).not.toHaveBeenCalled()
})
it('does not reattach numeric caller session ids that can collide after restart', async () => {
const first = await provider.spawn({ cols: 80, rows: 24 })
spawnMock.mockClear()
const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id })
expect(second.id).not.toBe(first.id)
expect(second.isReattach).toBeUndefined()
expect(spawnMock).toHaveBeenCalledOnce()
})
it('does not spawn after shutdown cancels a pending stable session id', async () => {
let finishPreparation!: () => void
spawnMock.mockClear()
prepareMacosTccLoginShellMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishPreparation = resolve
})
)
const spawn = provider.spawn({
cols: 80,
rows: 24,
sessionId: 'pending-local-session'
})
const canceledSpawn = expect(spawn).rejects.toThrow(
'PTY spawn canceled: pending-local-session'
)
await vi.waitFor(() => expect(prepareMacosTccLoginShellMock).toHaveBeenCalledOnce())
await provider.shutdown('pending-local-session', { immediate: true })
finishPreparation()
await canceledSpawn
expect(spawnMock).not.toHaveBeenCalled()
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: 'pending-local-session' })
).resolves.toMatchObject({ id: 'pending-local-session' })
expect(spawnMock).toHaveBeenCalledOnce()
})
it('cancels an immediate stable-session shutdown before any PTY is spawned', async () => {
spawnMock.mockClear()
const spawn = provider.spawn({
cols: 80,
rows: 24,
sessionId: 'immediate-shutdown-session'
})
await provider.shutdown('immediate-shutdown-session', { immediate: true })
await expect(spawn).rejects.toThrow('PTY spawn canceled: immediate-shutdown-session')
expect(spawnMock).not.toHaveBeenCalled()
})
// Hook/trust setup makes env build a long pre-spawn await; shutdown must cancel during it (#16441).
it('does not spawn after shutdown cancels a pending spawn during the env build', async () => {
spawnMock.mockClear()
@@ -451,117 +276,6 @@ describe('LocalPtyProvider', () => {
}
)
it.each([1, 2])(
'settles final-preflight shutdown without a late PTY (%i microtasks)',
async (microtasks) => {
spawnMock.mockClear()
let finishPreparation!: () => void
prepareMacosTccLoginShellMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishPreparation = resolve
})
)
const id = 'preflight-resume-session'
const kill = mockProc.kill
let committed = false
const outcome = provider
.spawn({
cols: 80,
rows: 24,
sessionId: id,
onPtySpawnCommitted: () => {
committed = true
}
})
.then(
(result) => ({ ok: true as const, result }),
(error: unknown) => ({ ok: false as const, error })
)
await import('node-pty')
await vi.waitFor(() => expect(finishPreparation).toBeTypeOf('function'))
finishPreparation()
let committedAtShutdown = false
await new Promise<void>((resolve, reject) => {
const shutdown = () => {
committedAtShutdown = committed
provider.shutdown(id, { immediate: true }).then(resolve, reject)
}
queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown)))
})
const settled = await outcome
if (committedAtShutdown) {
expect(settled.ok).toBe(true)
expect(kill).toHaveBeenCalled()
} else {
expect(settled).toEqual({ ok: false, error: new Error(`PTY spawn canceled: ${id}`) })
expect(spawnMock.mock.calls.length).toBe(0)
}
expect(provider.getPtyProcess(id)).toBeUndefined()
expect(pendingLocalPtySpawns.has(id)).toBe(false)
}
)
it('cancels deferred shell availability before finalizing a launch plan', async () => {
spawnMock.mockClear()
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
let finishAvailability!: (available: boolean) => void
const buildSpawnEnv = vi.fn((_id: string, env: Record<string, string>) => env)
provider.configure({
getWindowsShell: () => 'powershell.exe',
getWindowsPowerShellImplementation: () => 'auto',
pwshAvailable: () =>
new Promise<boolean>((resolve) => {
finishAvailability = resolve
}),
buildSpawnEnv
})
const id = 'deferred-availability-session'
const spawn = provider.spawn({ cols: 80, rows: 24, sessionId: id })
const rejected = expect(spawn).rejects.toThrow(`PTY spawn canceled: ${id}`)
await provider.shutdown(id, { immediate: true })
finishAvailability(true)
await rejected
expect(buildSpawnEnv).not.toHaveBeenCalled()
expect(spawnMock.mock.calls.length).toBe(0)
expect(pendingLocalPtySpawns.has(id)).toBe(false)
})
it.each(['synchronous environment', 'async environment', 'preflight'])(
'releases cancellation state after failed %s and allows a fresh retry',
async (phase) => {
spawnMock.mockClear()
const id = 'failed-preparation-session'
let fail = true
provider.configure({
buildSpawnEnv: (_id, env) => {
if (fail && phase === 'synchronous environment') {
throw new Error('preparation failed')
}
if (fail && phase === 'async environment') {
return Promise.reject(new Error('preparation failed'))
}
return env
}
})
if (phase === 'preflight') {
prepareMacosTccLoginShellMock.mockRejectedValueOnce(new Error('preparation failed'))
}
await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).rejects.toThrow(
'preparation failed'
)
expect(pendingLocalPtySpawns.has(id)).toBe(false)
expect(spawnMock.mock.calls.length).toBe(0)
await provider.shutdown(id, { immediate: true })
fail = false
await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).resolves.toMatchObject({
id
})
expect(spawnMock.mock.calls.length).toBe(1)
expect(pendingLocalPtySpawns.has(id)).toBe(false)
}
)
it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => {
spawnMock.mockClear()
const procA = { ...mockProc, pid: 1001 }
@@ -588,28 +302,5 @@ describe('LocalPtyProvider', () => {
expect(spawnMock).toHaveBeenCalledOnce()
expect(procA.resize).toHaveBeenCalledWith(132, 44)
})
it('invokes onSpawned callback', async () => {
const onSpawned = vi.fn()
provider.configure({ onSpawned })
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
expect(onSpawned).toHaveBeenCalledWith(id, incarnationId)
})
it('reports physical commit before post-spawn publication can fail', async () => {
spawnMock.mockClear()
const committed = vi.fn()
provider.configure({
onSpawned: () => {
throw new Error('post-spawn publication failed')
}
})
await expect(
provider.spawn({ cols: 80, rows: 24, onPtySpawnCommitted: committed })
).rejects.toThrow('post-spawn publication failed')
expect(spawnMock).toHaveBeenCalledOnce()
expect(committed).toHaveBeenCalledOnce()
})
})
})
@@ -81,7 +81,6 @@ vi.mock('../../pty/process-retirement/pty-descendant-termination', () => ({
const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe'
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
vi.mock('../windows-powershell-executable', () => ({
resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') =>
family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS,
@@ -130,8 +129,6 @@ import {
installLocalPtyProviderEnvSandbox,
type LocalPtyMockProcess
} from './local-pty-provider-test-harness'
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../../pty/shell-startup/powerlevel10k-wizard-env'
import { wslDistroHoldMessage } from '../../../shared/wsl-distro-stopped-hold'
describe('LocalPtyProvider', () => {
let provider: LocalPtyProvider
@@ -169,242 +166,6 @@ describe('LocalPtyProvider', () => {
})
describe('spawn', () => {
it('passes the guest Claude pointer and injected-home marker through WSLENV', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => ({
...env,
CLAUDE_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home',
ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/home/fake/.local/share/orca/claude-profiles/a/home',
ORCA_CLAUDE_PROFILE_POINTER: '~/.local/share/orca/claude-profiles/selected-wsl-orca'
})
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo'
})
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.WSLENV.split(':')).toEqual(
expect.arrayContaining([
'CLAUDE_CONFIG_DIR',
'ORCA_CLAUDE_PROFILE_POINTER',
'ORCA_CLAUDE_INJECTED_CONFIG_DIR'
])
)
expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe(
'~/.local/share/orca/claude-profiles/selected-wsl-orca'
)
})
it('does not pass a Windows Codex home into WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => {
env.CODEX_HOME = 'C:\\Users\\jin\\.codex'
env.ORCA_CODEX_HOME = 'C:\\Users\\jin\\.codex'
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.CODEX_HOME).toBeUndefined()
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined()
})
it('does not pass a WSL managed Codex home into Windows terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => {
env.CODEX_HOME =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
env.ORCA_CODEX_HOME =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\Users\\jin\\repo'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].env.CODEX_HOME).toBeUndefined()
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined()
})
it('preserves an explicit Linux Codex home for WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => {
env.CODEX_HOME = '/home/jin/.codex-alt'
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.codex-alt')
expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME')
})
it('translates a WSL managed Codex home before launching a WSL terminal', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => {
env.CODEX_HOME =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
env.ORCA_CODEX_HOME =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.CODEX_HOME).toBe('/home/jin/.local/share/orca/codex-accounts/a/home')
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBe(
'/home/jin/.local/share/orca/codex-accounts/a/home'
)
expect(spawnCall[2].env.WSLENV).toContain('CODEX_HOME')
expect(spawnCall[2].env.WSLENV).toContain('ORCA_CODEX_HOME')
})
it('does not pass a WSL managed Codex home into a different WSL distro', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env) => {
env.CODEX_HOME =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
env.ORCA_CODEX_HOME =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-accounts\\a\\home'
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\repo'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.CODEX_HOME).toBeUndefined()
expect(spawnCall[2].env.ORCA_CODEX_HOME).toBeUndefined()
})
it('uses the preferred WSL distro for Windows cwd WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await provider.spawn({
cols: 80,
rows: 24,
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
cwd: 'C:\\Users\\jin\\repo',
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[1]).toEqual([
'-d',
'Debian',
'--exec',
'sh',
'-c',
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
])
expect(spawnCall[1][5]).toContain('exec "$_orca_wsl_shell" -l')
expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian')
})
it.each(['/home/jin/repo', '/a', '/c'])(
'preserves a POSIX cwd through the preferred WSL distro (%s)',
async (cwd) => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await provider.spawn({
cols: 80,
rows: 24,
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
cwd,
prevalidatedCwd: `\\\\wsl.localhost\\Debian${cwd.replaceAll('/', '\\')}`,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[1]).toEqual([
'-d',
'Debian',
'--exec',
'sh',
'-c',
expect.stringContaining(`cd '${cwd}'`)
])
expect(spawnCall[2].cwd).not.toBe(cwd)
expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled()
}
)
it('revalidates when cwd evidence does not exactly match the resolved WSL path', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await provider.spawn({
cols: 80,
rows: 24,
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
cwd: '/a',
prevalidatedCwd: '\\\\wsl.localhost\\Debian\\c',
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
})
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a')
})
it('rejects a WSL cwd the distro reports missing without spawning a shell', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
const spawnCallsBefore = spawnMock.mock.calls.length
await expect(
provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\gone'
})
).rejects.toThrow(/does not exist/)
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(
'\\\\wsl.localhost\\Debian\\home\\jin\\gone'
)
expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore)
})
it('rejects a canceled spawn while its WSL cwd probe is still pending', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const probeStarted = Promise.withResolvers<void>()
@@ -429,396 +190,5 @@ describe('LocalPtyProvider', () => {
await canceledSpawn
expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore)
})
it('resolves and persists the default distro for Windows cwd WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const buildSpawnEnv = vi.fn(
(
_id: string,
env: Record<string, string>,
_ctx?: { isWsl?: boolean; wslDistro?: string | null }
) => env
)
provider.configure({ buildSpawnEnv })
const result = await provider.spawn({
cols: 80,
rows: 24,
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
cwd: 'C:\\Users\\jin\\repo',
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: null
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[1]).toEqual([
'-d',
'Ubuntu',
'--exec',
'sh',
'-c',
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
])
expect(buildSpawnEnv.mock.calls[0]?.[2]).toMatchObject({
isWsl: true,
wslDistro: 'Ubuntu'
})
expect(result.wslDistro).toBe('Ubuntu')
expect(
(await provider.listProcesses()).find((entry) => entry.id === result.id)?.wslDistro
).toBe('Ubuntu')
})
it('repro: keeps explicit PowerShell 7 selection when the pwsh probe is cold-false', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const pwshAvailable = vi.fn(() => false)
provider.configure({
getWindowsShell: () => 'powershell.exe',
getWindowsPowerShellImplementation: () => 'pwsh.exe',
pwshAvailable
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\Users\\jin\\repo'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe(PWSH7_ABS)
expect(spawnCall[1]).toContain('-EncodedCommand')
expect(pwshAvailable).not.toHaveBeenCalled()
})
it('awaits PowerShell availability before resolving an automatic Windows shell', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
let resolveAvailability!: (available: boolean) => void
const pwshAvailable = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveAvailability = resolve
})
)
provider.configure({
getWindowsShell: () => 'powershell.exe',
getWindowsPowerShellImplementation: () => 'auto',
pwshAvailable
})
const callsBeforeSpawn = spawnMock.mock.calls.length
const spawn = provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
await Promise.resolve()
expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn)
resolveAvailability(true)
await spawn
expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn + 1)
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS)
})
it('re-reads Windows shell options after a reentrant configuration change', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const initialPowerShellImplementation = vi.fn(() => 'powershell.exe' as const)
const configuredPwshAvailable = vi.fn(() => true)
provider.configure({
getWindowsShell: () => {
provider.configure({
getWindowsPowerShellImplementation: () => 'auto',
pwshAvailable: configuredPwshAvailable
})
return 'powershell.exe'
},
getWindowsPowerShellImplementation: initialPowerShellImplementation
})
await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
expect(initialPowerShellImplementation).not.toHaveBeenCalled()
expect(configuredPwshAvailable).toHaveBeenCalledOnce()
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe(PWSH7_ABS)
})
it('registers a probe-complete spawn before a later microtask shutdown', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
getWindowsShell: () => 'powershell.exe',
getWindowsPowerShellImplementation: () => 'auto',
pwshAvailable: () => false
})
const callsBeforeSpawn = spawnMock.mock.calls.length
const spawn = provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\Users\\jin\\repo',
sessionId: 'probe-shutdown-session'
})
const canceledSpawn = expect(spawn).rejects.toThrow(
'PTY spawn canceled: probe-shutdown-session'
)
const shutdown = new Promise<void>((resolve, reject) => {
queueMicrotask(() => {
provider.shutdown('probe-shutdown-session', { immediate: true }).then(resolve, reject)
})
})
await shutdown
await canceledSpawn
expect(spawnMock).toHaveBeenCalledTimes(callsBeforeSpawn)
})
it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const savedCodexHome = process.env.CODEX_HOME
const savedOrcaCodexHome = process.env.ORCA_CODEX_HOME
delete process.env.CODEX_HOME
delete process.env.ORCA_CODEX_HOME
provider.configure({
buildSpawnEnv: (_id, env, ctx) => {
env.ORCA_TERMINAL_HANDLE = 'term_wsl'
if (ctx?.isWsl) {
env.WSLENV = 'ORCA_TERMINAL_HANDLE/u'
}
return env
}
})
try {
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' }
})
} finally {
if (savedCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = savedCodexHome
}
if (savedOrcaCodexHome === undefined) {
delete process.env.ORCA_CODEX_HOME
} else {
process.env.ORCA_CODEX_HOME = savedOrcaCodexHome
}
}
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
expect.arrayContaining([
'ORCA_TERMINAL_HANDLE/u',
'ORCA_HERMES_STARTUP_QUERY',
POWERLEVEL10K_WIZARD_DISABLE_ENV
])
)
})
it('does not mark deleted Powerlevel10k wizard env for WSL import', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env[POWERLEVEL10K_WIZARD_DISABLE_ENV]).toBeUndefined()
expect(spawnCall[2].env.WSLENV ?? '').not.toContain(POWERLEVEL10K_WIZARD_DISABLE_ENV)
})
it('launches POSIX cwd split panes through WSL when worktree context is WSL', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
await provider.spawn({
cols: 80,
rows: 24,
cwd: '/home/jin/repo/subdir',
shellOverride: 'powershell.exe',
worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
[
'-d',
'Ubuntu',
'--exec',
'sh',
'-c',
expect.stringContaining("cd '/home/jin/repo/subdir'")
],
expect.objectContaining({ cwd: expect.any(String) })
)
})
it('resolves the Git Bash default shell and preserves the requested cwd', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalProgramFiles = process.env.ProgramFiles
Object.defineProperty(process, 'platform', { value: 'win32' })
process.env.ProgramFiles = 'C:\\Program Files'
provider.configure({
getWindowsShell: () => 'git-bash',
buildSpawnEnv: (_id, env) => ({
...env,
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
})
})
try {
await provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\Users\\jin\\repo'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
if (originalProgramFiles === undefined) {
delete process.env.ProgramFiles
} else {
process.env.ProgramFiles = originalProgramFiles
}
}
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
[
'-c',
expect.stringMatching(
/^chcp\.com 65001 >\/dev\/null 2>&1; exec "\$BASH" --rcfile '.*shell-ready\/bash\/rcfile' -i$/
)
],
expect.objectContaining({
cwd: 'C:\\Users\\jin\\repo',
env: expect.objectContaining({
CHERE_INVOKING: '1',
PYTHONUTF8: '1',
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
})
})
)
})
it('runs the Codex preflight once in the cmd.exe startup chain', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
provider.configure({
getWindowsShell: () => 'cmd.exe',
buildSpawnEnv: (_id, env) => ({
...env,
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT
})
})
try {
await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' })
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'cmd.exe',
[
'/K',
'chcp 65001 > nul & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
],
expect.objectContaining({
env: expect.objectContaining({
ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT,
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE: '"'
})
})
)
})
it('holds a daemon-off passive restore of a stopped WSL distro without spawning wsl.exe', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
readWslDistroRunningStateMock.mockResolvedValue('stopped')
spawnMock.mockClear()
await expect(
provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo',
sessionId: 'restored-session',
wslPassiveRestore: true
})
).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped'))
expect(readWslDistroRunningStateMock).toHaveBeenCalledWith('Ubuntu', {
retryUnverifiable: true
})
expect(spawnMock).not.toHaveBeenCalled()
})
it('restores a daemon-off WSL pane once its distro reads running', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
readWslDistroRunningStateMock.mockResolvedValue('running')
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo',
sessionId: 'restored-session',
wslPassiveRestore: true
})
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe('wsl.exe')
})
// A UNC cwd runs in the guest whatever shell was picked, so the cwd alone names the distro.
describe('a non-WSL shell whose cwd is a WSL UNC path', () => {
const uncSpawn = (cwd: string, extra: { wslPassiveRestore?: true } = {}) =>
provider.spawn({
cols: 80,
rows: 24,
cwd,
sessionId: 'wt-1::C:/folder@@pane1',
shellOverride: 'powershell.exe',
...extra
})
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
readWslDistroRunningStateMock.mockReset()
readWslDistroRunningStateMock.mockResolvedValue('stopped')
spawnMock.mockClear()
})
it.each([
['\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo'],
['\\\\wsl$\\Ubuntu\\home\\fake\\repo']
])('holds its passive restore while the distro is stopped (%s)', async (cwd) => {
await expect(uncSpawn(cwd, { wslPassiveRestore: true })).rejects.toThrow(
wslDistroHoldMessage('Ubuntu', 'stopped')
)
expect(spawnMock).not.toHaveBeenCalled()
})
it("starts the user's own launch without reading the distro", async () => {
await uncSpawn('\\\\wsl.localhost\\Ubuntu\\home\\fake\\repo')
expect(spawnMock.mock.calls.at(-1)?.[0]).toBe('wsl.exe')
expect(readWslDistroRunningStateMock).not.toHaveBeenCalled()
})
it('leaves a host cwd alone', async () => {
await uncSpawn('C:\\folder', { wslPassiveRestore: true })
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(readWslDistroRunningStateMock).not.toHaveBeenCalled()
})
})
})
})
@@ -1,91 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
describePosix,
importFreshLocalPtyShellReady,
restoreUserDataPathAfterEach,
setTestUserDataPath
} from './local-pty-shell-ready-test-harness'
import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile'
import { getZshShellReadyWrapperFile } from './local-pty-shell-ready-wrapper-generation'
import { makeUserZdotdir } from '../../pty/shell-startup/zsh-user-config-dir-fixture'
// Wrapper roots are content-addressed; resolve them instead of assuming a fixed path.
import {
getRequiredShellReadyWrapperPaths,
getShellReadyWrapperRoot
} from './local-pty-shell-ready-wrapper-root'
import { buildLocalShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-fileset'
restoreUserDataPathAfterEach()
describe('ensureShellReadyWrappersAt', () => {
it('keeps required wrapper paths aligned with generated files', () => {
const root = '/tmp/orca-shell-ready'
expect(getRequiredShellReadyWrapperPaths(root)).toEqual(
buildLocalShellReadyWrapperFiles(root).map(([path]) => path)
)
})
// Rewriting content-addressed live wrappers adds Windows indexer/antivirus write collisions without changing bytes.
it('does not rewrite a tree that is already present', async () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-warm-tree-'))
try {
setTestUserDataPath(userData)
const generation = await import('./local-pty-shell-ready-wrapper-generation')
const { getShellReadyWrapperRoot: resolveRoot } =
await import('./local-pty-shell-ready-wrapper-root')
expect(generation.ensureShellReadyWrappersAt()).toBe(true)
const rcfile = join(resolveRoot(), 'bash', 'rcfile')
const firstWrite = statSync(rcfile).mtimeMs
generation.ensureShellReadyWrappersAt()
vi.resetModules()
const fresh = await import('./local-pty-shell-ready-wrapper-generation')
fresh.ensureShellReadyWrappersAt()
expect(statSync(rcfile).mtimeMs).toBe(firstWrite)
} finally {
rmSync(userData, { recursive: true, force: true })
}
})
it('regenerates a tree whose files went missing', async () => {
const userData = mkdtempSync(join(tmpdir(), 'orca-missing-tree-'))
try {
setTestUserDataPath(userData)
const generation = await import('./local-pty-shell-ready-wrapper-generation')
const { getShellReadyWrapperRoot: resolveRoot } =
await import('./local-pty-shell-ready-wrapper-root')
generation.ensureShellReadyWrappersAt()
const rcfile = join(resolveRoot(), 'bash', 'rcfile')
rmSync(rcfile)
expect(generation.ensureShellReadyWrappersAt()).toBe(true)
expect(existsSync(rcfile)).toBe(true)
} finally {
rmSync(userData, { recursive: true, force: true })
}
})
})
describe('shell-ready wrapper root resolution', () => {
// Plain-node daemon forks have no Electron app; use the shared user-data env path.
it('resolves the wrapper root from ORCA_USER_DATA_PATH', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-userdata-env-'))
try {
setTestUserDataPath(root)
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh'))
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
const itWithBash = hasBash ? it : it.skip
@@ -164,180 +90,6 @@ describePosix('local PTY shell-ready launch config', () => {
vi.restoreAllMocks()
})
it('wraps fish launches with a fish_prompt shell-ready marker init command', async () => {
// Fish must wait through its first prompt or agent launch can race Starship initialization (STA-3417).
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/opt/homebrew/bin/fish')
expect(config.supportsReadyMarker).toBe(true)
// Fish bakes features into init text without an exported feature variable.
expect(config.env).toEqual({})
expect(config.args?.slice(0, 2)).toEqual(['-l', '-C'])
const init = config.args?.[2] ?? ''
expect(init).toContain('--on-event fish_prompt')
// Why `builtin`: a user-defined printf function would swallow the marker.
expect(init).toContain('builtin printf "\\033]777;orca-shell-ready\\007"')
expect(init).toContain('functions -e __orca_shell_ready_marker')
})
it('keeps markerless fish argv untouched and points it at the vendor snippet', async () => {
const { getMarkerlessShellLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getMarkerlessShellLaunchConfig('/opt/homebrew/bin/fish')
expect(config.args).toBeNull()
expect(config.supportsReadyMarker).toBe(false)
const prefix = config.env.ORCA_FISH_XDG_DATA_DIRS_PREFIX
expect(prefix).toMatch(/\/fish-xdg-data:\/usr\/local\/share:\/usr\/share$/)
expect(config.env).toEqual({ XDG_DATA_DIRS: prefix, ORCA_FISH_XDG_DATA_DIRS_PREFIX: prefix })
const dataDir = prefix.split(':')[0]
expect(
readFileSync(`${dataDir}/fish/vendor_conf.d/orca-shell-integration.fish`, 'utf8')
).toContain('set argv --no-daemon $argv')
})
it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => {
// Nested Orca launches must not inherit wrapper ZDOTDIR and recurse.
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh'
process.env.HOME = '/Users/alice'
try {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => {
const previousZdotdir = process.env.ZDOTDIR
const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
const previousHome = process.env.HOME
delete process.env.ZDOTDIR
process.env.ORCA_ORIG_ZDOTDIR = '/some/other/orca/shell-ready/zsh'
process.env.HOME = '/Users/alice'
try {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousOrigZdotdir === undefined) {
delete process.env.ORCA_ORIG_ZDOTDIR
} else {
process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"')
expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"')
// Clear handback state before nested Orca or /etc/zshrc can mistake it for user configuration.
expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR')
expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan(
zshenv.indexOf('builtin source -- "$_orca_user_zshenv"')
)
// Restored user ZDOTDIR owns the remaining startup files.
for (const name of ['.zprofile', '.zshrc', '.zlogin']) {
expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false)
}
expect(zshenv).not.toContain('$(emulate')
})
it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
// .zshenv defines the deferred registration run once by first-prompt precmd.
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark')
expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"')
// Why: add-zle-hook-widget aborts its chain on a non-zero earlier hook (e.g. oh-my-zsh vi-mode); don't register the marker through it.
expect(zshenv).not.toContain('add-zle-hook-widget line-init')
// Re-sourcing must preserve the original widget chain instead of capturing Orca itself.
expect(zshenv).toContain('== "user:__orca_prompt_mark"')
expect(zshenv).toContain('__orca_deferred_init')
})
it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
// .zshrc and .zlogin both invoke the single restore epilogue defined in .zshenv.
const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
const bashRc = getBashShellReadyRcfileContent()
const restoreLine =
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
const mimoRestoreLine =
'[[ -n "${ORCA_MIMOCODE_CONFIG_DIR:-}" ]] && export MIMOCODE_CONFIG_DIR="${ORCA_MIMOCODE_CONFIG_DIR}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
expect(zshrc).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
expect(zshrc).toContain(mimoRestoreLine)
expect(bashRc).toContain(mimoRestoreLine)
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zshrc).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
expect(zshrc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(zshrc).toContain(ompWrapperLine)
expect(bashRc).toContain(ompWrapperLine)
for (const wrapperFile of [zshrc, bashRc]) {
expect(wrapperFile).not.toContain('prime-agent()')
expect(wrapperFile).not.toContain('__orca_prime_agent')
expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
expect(wrapperFile).not.toContain('command prime-agent --extension')
}
})
it('emits OSC 133 C/D markers in the zsh wrapper', () => {
const zshRc = getZshShellReadyWrapperFile()
expect(zshRc).toContain('printf "\\033]133;D;%s\\007"')
expect(zshRc).toContain('printf "\\033]133;C\\007"')
})
itWithBash('runs the bash wrapper without fake C/D markers before the first prompt', async () => {
const output = runInteractiveBashRcfile(getBashShellReadyRcfileContent(), userDataPath)
expectBashOsc133Lifecycle(output)
})
itWithBash('emits lifecycle for foreground text ending like an internal hook', () => {
const input = 'echo user:__orca_osc133_prompt_done\nfalse\nexit 0\n'
const output = runInteractiveBashRcfile(getBashShellReadyRcfileContent(), userDataPath, input)
@@ -398,27 +150,6 @@ describePosix('local PTY shell-ready launch config', () => {
expectBashOsc133Lifecycle(output)
})
itWithBash(
'starts the next command after its prompt when the DEBUG trap is removed at a legacy prompt',
() => {
// Why: force the bash <= 5.0 scalar PROMPT_COMMAND dispatch on any bash.
const gate = 'BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1)'
const rcfile = getBashShellReadyRcfileContent()
expect(rcfile).toContain(gate)
const legacyRcfile = rcfile.replaceAll(gate, '0')
writeFileSync(join(userDataPath, '.bash_profile'), "PS1='ORCA_PS1> '\n")
const input = 'trap - DEBUG\nfalse\nexit 0\n'
const output = runInteractiveBashRcfile(legacyRcfile, userDataPath, input)
const startBeforePrompt = new RegExp(
`${String.fromCharCode(27)}]133;A${String.fromCharCode(7)}(?:(?!ORCA_PS1> )[\\s\\S])*${String.fromCharCode(27)}]133;C`
)
expect(output).toContain('ORCA_PS1> ')
expect(output).not.toMatch(startBeforePrompt)
expectBashOsc133Lifecycle(output)
}
)
itWithBash('composes an inherited PROMPT_COMMAND ending in separators', () => {
writeFileSync(
join(userDataPath, '.bash_profile'),
@@ -431,88 +162,4 @@ describePosix('local PTY shell-ready launch config', () => {
expect(output).toContain('PROMPT_SEPARATOR')
expectBashOsc133Lifecycle(output)
})
it('preserves a real inherited ZDOTDIR as ORCA_ORIG_ZDOTDIR', async () => {
const previousZdotdir = process.env.ZDOTDIR
const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh')
process.env.ZDOTDIR = userZdotdir
try {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir)
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
}
})
it('falls back to HOME when ZDOTDIR is only slashes (e.g. "/")', async () => {
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = '/'
process.env.HOME = '/Users/alice'
try {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('preserves ZDOTDIR that contains /shell-ready/zsh as a substring but does not end with it', async () => {
const previousZdotdir = process.env.ZDOTDIR
const userZdotdir = makeUserZdotdir(userDataPath, 'shell-ready', 'zsh-custom')
process.env.ZDOTDIR = userZdotdir
try {
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir)
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
}
})
it('sources the user .zshenv at wrapper top level, not inside a function', async () => {
// Function-scoped .zshenv broke typeset -U path; user configuration must execute at top level (#1737).
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"')
// Define required functions before user emulate sh can change parsing semantics.
expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan(
zshenv.indexOf('builtin source -- "$_orca_user_zshenv"')
)
})
it('bakes no generation-time path into the zsh hook', async () => {
// Windows-generated wrappers may be sourced through /mnt/c in WSL; generation-time paths cannot be embedded (#8003).
const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
expect(zshenv).not.toContain(getShellReadyWrapperRoot())
expect(zshenv).not.toContain('%x')
})
})
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe'
const { existsSyncMock, userInfoMock, runProcessMock, ptyProbeMock } = vi.hoisted(() => ({
@@ -35,469 +34,9 @@ const ACCEPTED_RESULT = {
timedOut: false
}
const REJECTED_RESULT = { code: 1, signal: null, stdout: '', stderr: '', timedOut: false }
const TIMED_OUT_RESULT = {
code: null,
signal: 'SIGKILL' as const,
stdout: '',
stderr: '',
timedOut: true
}
const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const
const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const
const itOnPosixHost = process.platform === 'win32' ? it.skip : it
describe('wrapShellSpawnForMacosTccAttribution', () => {
let origPlatform: PropertyDescriptor | undefined
let origDisable: string | undefined
function setPlatform(value: string): void {
Object.defineProperty(process, 'platform', { configurable: true, value })
}
beforeEach(() => {
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
existsSyncMock.mockReturnValue(true)
userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' })
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: false
})
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
resetMacosLoginShellPreflightForTests()
})
afterEach(() => {
if (origPlatform) {
Object.defineProperty(process, 'platform', origPlatform)
}
if (origDisable === undefined) {
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
} else {
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable
}
vi.restoreAllMocks()
vi.clearAllMocks()
})
it('uses the exact positional login-shell trampoline argv on macOS', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/usr/bin/login',
args: [
'-flpq',
'ada',
'/bin/bash',
'--noprofile',
'--norc',
'-p',
'-c',
'export SHELL="$1"; shift; exec -l -- "$@"',
'orca-tcc-login',
'/bin/zsh',
'/bin/zsh',
'-l'
]
})
expect(runProcessMock).toHaveBeenCalledWith({
program: '/usr/bin/login',
args: ['-flpq', 'ada', '/usr/bin/printf', 'ORCA_LOGIN_PREFLIGHT_OK'],
// Detached daemons can outlive their worktree; probe from account home.
cwd: '/Users/ada',
maxOutputBytes: 1024,
signal: undefined,
timeoutMs: 500
})
})
it('spawns the shell directly when login PAM policy rejects the current user', async () => {
setPlatform('darwin')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'Login incorrect\nlogin: ',
stderr: '',
timedOut: false
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/bin/zsh',
args: ['-l']
})
expect(console.warn).toHaveBeenCalledWith(
'[pty] macOS login(1) preflight failed; spawning shells directly'
)
})
it('caches a conclusive PAM rejection for later terminal spawns', async () => {
setPlatform('darwin')
runProcessMock.mockImplementation(() => {
// login(1) exiting non-zero on its own is a deterministic PAM verdict.
return REJECTED_RESULT
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/bin/bash')
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(ptyProbeMock).toHaveBeenCalledTimes(1)
expect(console.warn).toHaveBeenCalledTimes(1)
})
it('uses the production-shaped PTY verdict when the pipe probe falsely rejects', async () => {
setPlatform('darwin')
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(ACCEPTED_OUTCOME)
await expect(prepareMacosTccLoginShell()).resolves.toEqual(ACCEPTED_OUTCOME)
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 500, 1_024)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('dedupes concurrent PTY confirmations of a rejected pipe verdict', async () => {
setPlatform('darwin')
runProcessMock.mockReturnValue(REJECTED_RESULT)
let finishPtyProbe!: (outcome: typeof REJECTED_OUTCOME) => void
ptyProbeMock.mockReturnValue(
new Promise((resolve) => {
finishPtyProbe = resolve
})
)
const first = prepareMacosTccLoginShell()
const second = prepareMacosTccLoginShell()
// Wait for probe sharing rather than coupling the assertion to an internal microtask count.
await vi.waitFor(() => expect(ptyProbeMock).toHaveBeenCalledTimes(1))
expect(runProcessMock).toHaveBeenCalledTimes(1)
finishPtyProbe(REJECTED_OUTCOME)
await expect(Promise.all([first, second])).resolves.toEqual([
REJECTED_OUTCOME,
REJECTED_OUTCOME
])
})
it('re-verifies a cached PAM rejection after the revalidation window (#9756)', async () => {
setPlatform('darwin')
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
let attempt = 0
runProcessMock.mockImplementation(() => {
attempt += 1
// A one-off PAM hiccup that login(1) reports as a deterministic rejection.
return attempt === 1 ? REJECTED_RESULT : ACCEPTED_RESULT
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
now += 29 * 60_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
now += 60_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(2)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('keeps an accepted PAM verdict cached across the rejection revalidation window', async () => {
setPlatform('darwin')
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
await prepareMacosTccLoginShell()
now += 24 * 60 * 60_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(1)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('backs off repeated transient timeouts instead of delaying every terminal spawn (F1)', async () => {
setPlatform('darwin')
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
// A probe our own SIGKILL cap killed proves nothing about PAM; it must retry.
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const first = await prepareMacosTccLoginShell()
expect(first).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledOnce()
now += 5_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(2)
// Backoff prevents repeated hung probes from adding a subprocess to every terminal.
now += 5_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(2)
now += 5_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(3)
})
it('self-heals to the login wrapper once a re-probe succeeds (F1)', async () => {
setPlatform('darwin')
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
let attempt = 0
runProcessMock.mockImplementation(() => {
attempt += 1
return attempt === 1 ? TIMED_OUT_RESULT : ACCEPTED_RESULT
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledOnce()
now += 5_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(2)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('returns a conclusive outcome the daemon can log structurally (F2)', async () => {
setPlatform('darwin')
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'Login incorrect\nlogin: ',
stderr: '',
timedOut: false
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
const outcome = await prepareMacosTccLoginShell()
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
expect(await prepareMacosTccLoginShell()).toBeNull()
})
it('rejects a marker emitted by a preflight that does not exit cleanly', async () => {
setPlatform('darwin')
// A marker printed by a probe that then hit the timeout cap proves nothing.
runProcessMock.mockReturnValue({
code: null,
signal: 'SIGKILL' as const,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: true
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/bin/zsh',
args: ['-l']
})
})
it('dedupes concurrent successful preflights and caches later terminal spawns', async () => {
setPlatform('darwin')
await Promise.all([prepareMacosTccLoginShell(), prepareMacosTccLoginShell()])
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
expect(wrapShellSpawnForMacosTccAttribution('/bin/bash', ['-l']).file).toBe('/usr/bin/login')
expect(runProcessMock).toHaveBeenCalledTimes(1)
})
it('preserves the non-login Bash shell-ready rcfile launch', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(
wrapShellSpawnForMacosTccAttribution('/bin/bash', ['--rcfile', '/orca/bash/rcfile'])
).toEqual({
file: '/usr/bin/login',
args: [
'-flpq',
'ada',
'/bin/bash',
'--noprofile',
'--norc',
'-p',
'-c',
'export SHELL="$1"; shift; exec -- "$@"',
'orca-tcc-login',
'/bin/bash',
'/bin/bash',
'--rcfile',
'/orca/bash/rcfile'
]
})
})
it('re-asserts the spawn env SHELL that login(1) would overwrite', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(
wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '/opt/homebrew/bin/fish' })
).toEqual({
file: '/usr/bin/login',
args: [
'-flpq',
'ada',
'/bin/bash',
'--noprofile',
'--norc',
'-p',
'-c',
'export SHELL="$1"; shift; exec -l -- "$@"',
'orca-tcc-login',
'/opt/homebrew/bin/fish',
'/bin/zsh',
'-l'
]
})
})
it('falls back to the spawned shell for SHELL when the env value is empty', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '' })).toEqual({
file: '/usr/bin/login',
args: [
'-flpq',
'ada',
'/bin/bash',
'--noprofile',
'--norc',
'-p',
'-c',
'export SHELL="$1"; shift; exec -l -- "$@"',
'orca-tcc-login',
'/bin/zsh',
'/bin/zsh',
'-l'
]
})
})
it('passes spaces and equals signs positionally without interpolation', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(
wrapShellSpawnForMacosTccAttribution(
'/Applications/Custom Shell/bin/fish=debug',
['--init-command', 'set label=a b'],
{ SHELL: '/Applications/Custom Shell/bin/fish=debug' }
)
).toEqual({
file: '/usr/bin/login',
args: [
'-flpq',
'ada',
'/bin/bash',
'--noprofile',
'--norc',
'-p',
'-c',
'export SHELL="$1"; shift; exec -l -- "$@"',
'orca-tcc-login',
'/Applications/Custom Shell/bin/fish=debug',
'/Applications/Custom Shell/bin/fish=debug',
'--init-command',
'set label=a b'
]
})
})
it('terminates exec option parsing before a dash-prefixed shell name', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
const wrapped = wrapShellSpawnForMacosTccAttribution('-custom-shell', ['-l'])
expect(wrapped.args).toContain('export SHELL="$1"; shift; exec -l -- "$@"')
expect(wrapped.args.slice(-2)).toEqual(['-custom-shell', '-l'])
})
itOnPosixHost('ignores inherited BASH_ENV before evaluating the trampoline', () => {
const result = spawnSync(
'/bin/bash',
['--noprofile', '--norc', '-p', '-c', 'printf "TRAMPOLINE_OK\\n"'],
{
encoding: 'utf8',
env: { ...process.env, BASH_ENV: '/dev/stdin' },
input: 'printf "BASH_ENV_EXECUTED\\n"\n'
}
)
expect(result.status).toBe(0)
expect(result.stdout).toBe('TRAMPOLINE_OK\n')
expect(result.stderr).toBe('')
})
it('is a no-op on non-macOS platforms', () => {
setPlatform('linux')
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/bin/zsh',
args: ['-l']
})
})
it('is idempotent when the file is already /usr/bin/login', () => {
setPlatform('darwin')
const args = ['-flpq', 'ada', '/bin/zsh', '-l']
expect(wrapShellSpawnForMacosTccAttribution('/usr/bin/login', args)).toEqual({
file: '/usr/bin/login',
args
})
})
it('falls back to the plain spawn when the login binary is missing', () => {
setPlatform('darwin')
existsSyncMock.mockReturnValue(false)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/bin/zsh',
args: ['-l']
})
})
it('falls back to the plain spawn when the username cannot be resolved', () => {
setPlatform('darwin')
userInfoMock.mockImplementation(() => {
throw new Error('no user')
})
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/bin/zsh',
args: ['-l']
})
})
it('falls back to the plain spawn when the username is empty', () => {
setPlatform('darwin')
userInfoMock.mockReturnValue({ username: '', homedir: '/Users/ada' })
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/bin/zsh',
args: ['-l']
})
})
it('falls back to the plain spawn when disabled via env', () => {
setPlatform('darwin')
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
file: '/bin/zsh',
args: ['-l']
})
expect(runProcessMock).not.toHaveBeenCalled()
})
})
void (process.platform === 'win32' ? it.skip : it)
describe('probeMacosLoginSessionAlive', () => {
let origPlatform: PropertyDescriptor | undefined
@@ -537,37 +76,6 @@ describe('probeMacosLoginSessionAlive', () => {
vi.clearAllMocks()
})
it('re-probes even after a cached acceptance', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(1)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
expect(runProcessMock).toHaveBeenCalledTimes(2)
})
it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => {
setPlatform('darwin')
let finishPreflight!: (result: typeof ACCEPTED_RESULT) => void
runProcessMock.mockImplementation(
() =>
new Promise((resolve) => {
finishPreflight = resolve
})
)
const warmup = prepareMacosTccLoginShell()
const freshProbe = probeMacosLoginSessionAlive()
expect(runProcessMock).toHaveBeenCalledOnce()
finishPreflight(ACCEPTED_RESULT)
await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([
ACCEPTED_OUTCOME,
ACCEPTED_OUTCOME
])
expect(runProcessMock).toHaveBeenCalledOnce()
})
it('does not let a spawn-path probe overwrite a newer death verdict', async () => {
setPlatform('darwin')
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
@@ -585,132 +93,4 @@ describe('probeMacosLoginSessionAlive', () => {
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
})
it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
// A dead login session must stop creating login(1) prompt zombies (#7936).
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
})
it('does not trust a pipe rejection when its PTY confirmation is inconclusive', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
await expect(probeMacosLoginSessionAlive()).resolves.toEqual({
ok: false,
conclusive: false,
reason: 'timeout'
})
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('does not add PTY probes to periodic checks on a host that never accepted login', async () => {
setPlatform('darwin')
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
await prepareMacosTccLoginShell()
await probeMacosLoginSessionAlive()
await probeMacosLoginSessionAlive()
expect(runProcessMock).toHaveBeenCalledTimes(3)
expect(ptyProbeMock).toHaveBeenCalledTimes(1)
})
it('does not let periodic rejected health probes postpone spawn revalidation', async () => {
setPlatform('darwin')
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
await prepareMacosTccLoginShell()
runProcessMock.mockReturnValue(REJECTED_RESULT)
ptyProbeMock.mockResolvedValue(REJECTED_OUTCOME)
await probeMacosLoginSessionAlive()
now += 29 * 60_000
await probeMacosLoginSessionAlive()
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: 'ORCA_LOGIN_PREFLIGHT_OK',
stderr: '',
timedOut: false
})
now += 60_000
await prepareMacosTccLoginShell()
expect(runProcessMock).toHaveBeenCalledTimes(4)
expect(ptyProbeMock).toHaveBeenCalledTimes(2)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('returns null off macOS and when disabled', async () => {
setPlatform('linux')
expect(await probeMacosLoginSessionAlive()).toBeNull()
setPlatform('darwin')
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
expect(await probeMacosLoginSessionAlive()).toBeNull()
expect(runProcessMock).not.toHaveBeenCalled()
})
it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => {
setPlatform('darwin')
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
expect(runProcessMock).toHaveBeenCalledOnce()
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined)
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
})
it('stays inconclusive when both pipe and PTY probes time out', async () => {
setPlatform('darwin')
await prepareMacosTccLoginShell()
runProcessMock.mockReturnValue(TIMED_OUT_RESULT)
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
const outcome = await probeMacosLoginSessionAlive()
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
// Inconclusive must not disturb the cached acceptance.
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
})
it('does not start a PTY fallback after the watch cancels its pipe probe', async () => {
setPlatform('darwin')
const abortController = new AbortController()
abortController.abort()
// Cancellation proves no PAM verdict.
runProcessMock.mockReturnValue({
code: null,
signal: 'SIGTERM' as const,
stdout: '',
stderr: '',
timedOut: false
})
await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({
ok: false,
conclusive: false,
reason: 'error'
})
expect(ptyProbeMock).not.toHaveBeenCalled()
})
})
@@ -1,542 +0,0 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { buildWslInteractiveLoginShellCommand } from '../../shared/wsl-login-shell-command'
import { WSL_GUEST_ROOT_MARKER_COMMAND } from '../../shared/wsl-guest-root-marker'
import { resolveSetupRunnerCommand } from '../../shared/setup-runner-command'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
// Resolve content-addressed wrapper paths instead of assuming a fixed directory.
import { getShellReadyWrapperRoot } from './local-pty/local-pty-shell-ready-wrapper-root'
const CODEX_LAUNCH_PREFLIGHT = 'C:\\Program Files\\Orca\\orca.exe'
const POWERSHELL_STARTUP_COMMAND_C_MARK = '[Console]::Write("$([char]27)]133;C$([char]7)")'
const CMD_CODEX_LAUNCH_PREFLIGHT =
'if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1'
function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${WSL_GUEST_ROOT_MARKER_COMMAND} && ${buildWslInteractiveLoginShellCommand()}`
// Spell out --exec so a regressed -- separator cannot change both implementation and expected args.
const shellArgs = ['--exec', 'sh', '-c', command]
return distro ? ['-d', distro, ...shellArgs] : shellArgs
}
function decodePowerShellCommand(result: ReturnType<typeof resolveWindowsShellLaunchArgs>): string {
expect(result.shellArgs.slice(0, 3)).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand'])
return Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le')
}
function expectedPowerShellRestoreCwdCommand(cwdLiteral: string): string {
return `try { Set-Location -LiteralPath ${cwdLiteral} -ErrorAction Stop } catch { Write-Warning "Failed to restore working directory: $_" }`
}
function getGitBashRcfilePath(command: string): string {
const rcfilePath = command.match(/--rcfile '([^']+)'/)?.[1]
if (!rcfilePath) {
throw new Error(`Git Bash launch command has no rcfile: ${command}`)
}
return rcfilePath
}
describe('resolveWindowsShellLaunchArgs', () => {
let previousUserDataPath: string | undefined
let userDataPath: string
beforeEach(() => {
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-'))
process.env.ORCA_USER_DATA_PATH = userDataPath
})
afterEach(() => {
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
rmSync(userDataPath, { recursive: true, force: true })
})
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
const result = resolveWindowsShellLaunchArgs(
'cmd.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
undefined,
CODEX_LAUNCH_PREFLIGHT
)
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('C:\\Users\\alice')
})
it('embeds short cmd.exe startup commands in shell args', () => {
const result = resolveWindowsShellLaunchArgs(
'cmd.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
'codex --no-alt-screen',
CODEX_LAUNCH_PREFLIGHT
)
expect(result.shellArgs).toEqual([
'/K',
`chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT} & codex --no-alt-screen`
])
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
})
it('keeps quoted cmd.exe startup commands on stdin delivery', () => {
// node-pty C-runtime escaping corrupts cmd /K quotes; stdin preserves normal interactive parsing.
const result = resolveWindowsShellLaunchArgs(
'cmd.exe',
'C:\\Users\\alice\\repo',
'C:\\Users\\alice',
undefined,
'cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"'
)
expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul'])
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
})
it('keeps large cmd.exe startup commands on stdin delivery', () => {
const result = resolveWindowsShellLaunchArgs(
'cmd.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
`codex ${'x'.repeat(7000)}`,
CODEX_LAUNCH_PREFLIGHT
)
expect(result.shellArgs).toEqual(['/K', `chcp 65001 > nul & ${CMD_CODEX_LAUNCH_PREFLIGHT}`])
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
})
it('returns PowerShell args that install OSC 133 bootstrap after normal profile loading', () => {
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
'C:\\Users\\alice',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
const command = decodePowerShellCommand(result)
const outputEncodingIndex = command.indexOf('[Console]::OutputEncoding')
const opencodeRestoreIndex = command.indexOf(
'$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR'
)
const mimocodeRestoreIndex = command.indexOf(
'$env:MIMOCODE_CONFIG_DIR = $env:ORCA_MIMOCODE_CONFIG_DIR'
)
const duplicateStateGuardIndex = command.indexOf('Test-Path variable:global:__OrcaOsc133State')
const languageModeGuardIndex = command.indexOf('LanguageMode -eq "FullLanguage"')
const ompWrapperIndex = command.indexOf('function Global:omp')
const ompExtensionIndex = command.indexOf('--extension $env:ORCA_OMP_STATUS_EXTENSION')
const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME')
const promptIndex = command.indexOf('function Global:prompt')
const cwdRestoreIndex = command.indexOf(
expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'")
)
expect(command).not.toContain('$PROFILE')
expect(command).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(command).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(command).not.toContain('$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_SOURCE_AGENT_DIR')
for (const restoreIndex of [opencodeRestoreIndex, mimocodeRestoreIndex, codexRestoreIndex]) {
expect(restoreIndex).toBeGreaterThanOrEqual(0)
expect(restoreIndex).toBeLessThan(duplicateStateGuardIndex)
expect(restoreIndex).toBeLessThan(languageModeGuardIndex)
}
expect(outputEncodingIndex).toBeGreaterThan(languageModeGuardIndex)
expect(outputEncodingIndex).toBeGreaterThan(duplicateStateGuardIndex)
expect(ompWrapperIndex).toBeGreaterThan(outputEncodingIndex)
expect(ompExtensionIndex).toBeGreaterThan(ompWrapperIndex)
expect(promptIndex).toBeGreaterThan(ompWrapperIndex)
expect(cwdRestoreIndex).toBeGreaterThan(promptIndex)
expect(command).toContain('Esc = [char]27')
expect(command).toContain('Bel = [char]7')
expect(command).toContain(')]133;D;$fakeExitCode$(')
expect(command).toContain(')]133;C$(')
expect(command).not.toContain('`e]133')
})
it('normalizes MSYS drive cwd before spawning native PowerShell', () => {
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
'/c/Users/alice/project',
'C:\\Users\\alice'
)
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project')
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
expect(decodePowerShellCommand(result)).toContain(
expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\project'")
)
})
it('quotes the PowerShell cwd restore command literally', () => {
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
"C:\\Users\\alice\\client's app",
'C:\\Users\\alice'
)
expect(decodePowerShellCommand(result)).toContain(
expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice\\client''s app'")
)
})
it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => {
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
"& 'codex' '--no-alt-screen'"
)
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
const command = decodePowerShellCommand(result)
expect(command).toContain('function Global:prompt')
expect(command).toContain(expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'"))
expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true)
// Without the mark, the first prompt after the agent exits withholds 133;D (#18180).
expect(command).toContain(
`${POWERSHELL_STARTUP_COMMAND_C_MARK}; $Global:__OrcaOsc133State.HasSeenPrompt = $true }\n& 'codex' '--no-alt-screen'`
)
})
it('preserves complex PowerShell startup command text through EncodedCommand', () => {
const startupCommand =
'& "C:\\Program Files\\Orca CLI\\orca.exe" "--label" "quoted value"; $env:ORCA_VALUE = "nested"'
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
startupCommand
)
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
const command = decodePowerShellCommand(result)
expect(command).toContain(`\n${startupCommand}`)
expect(command.trimEnd().endsWith(startupCommand)).toBe(true)
})
it('keeps large PowerShell startup commands on stdin delivery', () => {
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
`orca ${'x'.repeat(7000)}`
)
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
const command = decodePowerShellCommand(result)
expect(command).toContain(expectedPowerShellRestoreCwdCommand("'C:\\Users\\alice'"))
// Stdin-typed commands get readline's 133;C; an early latch would emit a false 133;D.
expect(command).not.toContain(POWERSHELL_STARTUP_COMMAND_C_MARK)
})
it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => {
const result = resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\', 'C:\\Users\\alice')
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
expect(decodePowerShellCommand(result)).toContain(expectedPowerShellRestoreCwdCommand("'C:\\'"))
})
it('does not change PowerShell args when the cmd/Git Bash preflight is provided', () => {
const baseline = resolveWindowsShellLaunchArgs(
'powershell.exe',
'C:\\Users\\alice',
'C:\\Users\\alice'
)
const withWindowsPreflight = resolveWindowsShellLaunchArgs(
'powershell.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
undefined,
CODEX_LAUNCH_PREFLIGHT
)
expect(withWindowsPreflight).toEqual(baseline)
})
it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => {
const result = resolveWindowsShellLaunchArgs(
'C:\\Program Files\\Git\\bin\\bash.exe',
'/c',
'C:\\Users\\alice',
undefined,
undefined,
CODEX_LAUNCH_PREFLIGHT
)
// Git Bash inherits OEM codepage; UTF-8 must precede exec of the interactive shell.
expect(result.shellArgs[0]).toBe('-c')
const bashCommand = result.shellArgs[1]
expect(bashCommand).toContain('chcp.com 65001')
expect(bashCommand).toContain('--rcfile')
expect(bashCommand).toMatch(/exec "\$BASH" .* -i$/)
expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec'))
// Missing chcp.com must not prevent exec; semicolon preserves startup.
expect(bashCommand).not.toContain('&&')
expect(result.effectiveCwd).toBe('C:\\')
expect(result.validationCwd).toBe('C:\\')
const bashRcfile = readFileSync(getGitBashRcfilePath(bashCommand), 'utf8')
expect(bashRcfile).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex')
expect(bashRcfile).not.toContain(CODEX_LAUNCH_PREFLIGHT)
})
it('keeps Git Bash startup commands on stdin delivery with the preflight wrapper', () => {
const result = resolveWindowsShellLaunchArgs(
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
'codex --no-alt-screen',
CODEX_LAUNCH_PREFLIGHT
)
expect(result.shellArgs[1]).toContain('chcp.com 65001')
expect(result.shellArgs[1]).toContain('--rcfile')
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
})
it('gives a plain Git Bash tab the same rcfile as one with a startup command', () => {
const plain = resolveWindowsShellLaunchArgs(
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Users\\alice',
'C:\\Users\\alice'
)
const launched = resolveWindowsShellLaunchArgs(
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
"codex 'fix the bug'"
)
expect(plain.shellArgs).toEqual(launched.shellArgs)
// The rcfile owns account-following Claude and Codex no-daemon wrappers.
const rcfile = readFileSync(getGitBashRcfilePath(plain.shellArgs[1]), 'utf8')
expect(rcfile).toContain('function claude {')
expect(rcfile).toContain('set -- --no-daemon "$@"')
expect(rcfile).toContain('source "$HOME/.bash_profile"')
expect(launched.startupCommandDeliveredInShellArgs).toBeUndefined()
})
it('quotes a spaced preflight path through each shell environment', () => {
const cmd = resolveWindowsShellLaunchArgs(
'cmd.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
undefined,
CODEX_LAUNCH_PREFLIGHT
)
const gitBash = resolveWindowsShellLaunchArgs(
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Users\\alice',
'C:\\Users\\alice',
undefined,
undefined,
CODEX_LAUNCH_PREFLIGHT
)
expect(cmd.shellArgs[1]).toContain(
'call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%'
)
expect(cmd.shellArgs[1]).not.toContain('"')
expect(cmd.shellArgs[1]).not.toContain(CODEX_LAUNCH_PREFLIGHT)
const rcfilePath = getGitBashRcfilePath(gitBash.shellArgs[1])
expect(readFileSync(rcfilePath, 'utf8')).toContain('"${ORCA_CODEX_LAUNCH_PREFLIGHT}"')
})
it('does not apply Git Bash launch args to unrelated bash.exe paths', () => {
const result = resolveWindowsShellLaunchArgs(
'C:\\msys64\\usr\\bin\\bash.exe',
'C:\\Users\\alice\\code',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual([])
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code')
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
})
it('translates Windows cwd to /mnt/<drive>/... for wsl.exe', () => {
const baseline = resolveWindowsShellLaunchArgs(
'wsl.exe',
'C:\\Users\\alice\\code',
'C:\\Users\\alice',
undefined,
'codex'
)
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'C:\\Users\\alice\\code',
'C:\\Users\\alice',
undefined,
'codex',
CODEX_LAUNCH_PREFLIGHT
)
expect(result).toEqual(baseline)
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
expect(result.startupCommandDeliveredInShellArgs).toBeUndefined()
// Launch wsl.exe from Windows home; shell argv performs the guest Linux cd.
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
})
it('materializes shell-ready wrappers before building WSL shell args', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'C:\\Users\\alice\\code',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
expect(existsSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'))).toBe(true)
expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'))).toBe(true)
// Typed OMP uses status integration; typed Prime must reach user argv unchanged.
const bashRcfile = readFileSync(join(getShellReadyWrapperRoot(), 'bash', 'rcfile'), 'utf8')
const zshEnv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8')
for (const wrapperFile of [bashRcfile, zshEnv]) {
expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"')
expect(wrapperFile).toContain('function omp { __orca_omp "$@"; }')
expect(wrapperFile).not.toContain('prime-agent()')
expect(wrapperFile).not.toContain('__orca_prime_agent')
expect(wrapperFile).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
expect(wrapperFile).not.toContain('command prime-agent --extension')
}
})
it('translates MSYS drive cwd to /mnt/<drive>/... for wsl.exe', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'/c/Users/alice/project',
'C:\\Users\\alice',
undefined,
'codex'
)
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
})
it.each(['/a', '/c'])('keeps a selected WSL runtime single-letter cwd exact (%s)', (cwd) => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', cwd, 'C:\\Users\\alice', {
distro: 'Ubuntu',
treatPosixCwdAsWsl: true
})
expect(result.shellArgs).toEqual(expectedWslArgs(cwd, 'Ubuntu'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe(`\\\\wsl.localhost\\Ubuntu\\${cwd.slice(1)}`)
})
it('escapes single quotes when translating a WSL cwd', () => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
// Single quotes in paths must remain within the generated sh argument.
expect(result.shellArgs[3]).toContain("cd '/mnt/c/weird'\\''path'")
expect(result.shellArgs[3]).toContain('exec "$_orca_wsl_shell" -l')
})
it('falls back to /mnt/c when cwd is not a drive-letter path', () => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice')
expect(result.shellArgs[3]).toContain('cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH"')
})
it('keeps WSL UNC worktree cwd inside the matching distro', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo', 'Ubuntu'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo')
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('keeps POSIX cwd inside the worktree distro when WSL context is provided', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'/home/alice/repo/subdir',
'C:\\Users\\alice',
{ distro: 'Ubuntu', treatPosixCwdAsWsl: true }
)
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo/subdir', 'Ubuntu'))
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir')
})
it('falls back to empty args + same cwd for unknown shells', () => {
const result = resolveWindowsShellLaunchArgs(
'C:\\tools\\fish.exe',
'C:\\Users\\alice',
'C:\\Users\\alice'
)
expect(result.shellArgs).toEqual([])
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('C:\\Users\\alice')
})
it('is case-insensitive on the shell basename', () => {
const result = resolveWindowsShellLaunchArgs('PowerShell.EXE', 'C:\\', 'C:\\')
expect(result.shellArgs).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand', expect.any(String)])
})
})
// #7236: UTF-16 base64 delivers setup-runner quotes intact instead of risking partial typed-stdin parsing.
describe('issue #7236: PowerShell setup-runner command delivery', () => {
// git rev-parse hands back a forward-slash Windows-absolute path for the runner.
const runnerPath = 'C:/Users/alice/repo/.git/orca/setup-runner.cmd'
it('wraps the setup runner in balanced double quotes', () => {
const { command } = resolveSetupRunnerCommand(runnerPath, 'windows')
expect(command).toBe(`cmd.exe /c "${runnerPath}"`)
expect((command.match(/"/g) ?? []).length % 2).toBe(0)
})
it('delivers the setup-runner command through -EncodedCommand, never raw stdin', () => {
const { command } = resolveSetupRunnerCommand(runnerPath, 'windows')
const result = resolveWindowsShellLaunchArgs(
'powershell.exe',
'C:\\Users\\alice\\repo',
'C:\\Users\\alice',
undefined,
command
)
// The argv-delivery flag prevents a second unencoded stdin write (#7236).
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
expect(result.shellArgs.slice(0, 3)).toEqual(['-NoLogo', '-NoExit', '-EncodedCommand'])
const decoded = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le')
expect(decoded).toContain(`\n${command}`)
expect(decoded.trimEnd().endsWith(command)).toBe(true)
expect((decoded.slice(decoded.lastIndexOf(command)).match(/"/g) ?? []).length % 2).toBe(0)
})
})
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -17,12 +17,6 @@ import {
let tempDir: string
/** Let the UNC lane-acquire microtask run. */
const flushLaneAcquire = async (): Promise<void> => {
await Promise.resolve()
await Promise.resolve()
}
beforeEach(async () => {
wslUncDirectoryExistsAsyncMock.mockReset()
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
@@ -35,36 +29,6 @@ afterEach(async () => {
})
describe('validateWorkingDirectoryAsync', () => {
it('accepts an existing directory', async () => {
await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined()
})
it('rejects a missing directory with the actionable unmounted-volume message', async () => {
await expect(validateWorkingDirectoryAsync(path.join(tempDir, 'gone'))).rejects.toThrow(
/does not exist.*unmounted volume/s
)
})
it('rejects a path that exists but is a file', async () => {
const filePath = path.join(tempDir, 'not-a-dir.txt')
await writeFile(filePath, 'x')
await expect(validateWorkingDirectoryAsync(filePath)).rejects.toThrow('is not a directory')
})
it('never blocks the event loop while the filesystem answers', async () => {
// A dead UNC share must not freeze unrelated daemon RPCs (STA-4470).
let ticked = false
const pending = validateWorkingDirectoryAsync(tempDir)
setImmediate(() => {
ticked = true
})
await pending
expect(ticked).toBe(true)
})
describe('cancellation', () => {
// Unsettled probes persist in the module-level dedupe map; use a distinct path per test.
const deadShare = (name: string): string => `\\\\wsl.localhost\\Ubuntu\\dead-${name}`
@@ -77,25 +41,6 @@ describe('validateWorkingDirectoryAsync', () => {
vi.restoreAllMocks()
})
it('lets an aborted caller give up on a probe that never answers', async () => {
// fs.stat cannot abort; only its caller stops waiting.
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
const abort = new AbortController()
const pending = validateWorkingDirectoryAsync(deadShare('abort'), { signal: abort.signal })
abort.abort()
await expect(pending).rejects.toThrow('was canceled')
})
it('rejects immediately when the signal is already aborted', async () => {
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
await expect(
validateWorkingDirectoryAsync(deadShare('pre-aborted'), { signal: AbortSignal.abort() })
).rejects.toThrow('was canceled')
})
it('leaves the shared probe intact for callers that are still waiting', async () => {
let releaseProbe: () => void = () => {}
wslUncDirectoryExistsAsyncMock.mockReturnValue(
@@ -115,33 +60,6 @@ describe('validateWorkingDirectoryAsync', () => {
await expect(staying).resolves.toBeUndefined()
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
})
it('never pins a second probe on a path whose probe is still hung', async () => {
vi.useFakeTimers()
try {
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
const hung = deadShare('still-hung')
// Expiring an uninterruptible stat would let each retry pin another libuv thread.
for (let retry = 0; retry < 4; retry += 1) {
void validateWorkingDirectoryAsync(hung).catch(() => {})
await vi.advanceTimersByTimeAsync(60_000)
}
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('re-probes once the previous probe settles, so a recovered mount is seen', async () => {
wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const recovering = deadShare('recovers')
await expect(validateWorkingDirectoryAsync(recovering)).rejects.toThrow(/does not exist/)
await expect(validateWorkingDirectoryAsync(recovering)).resolves.toBeUndefined()
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2)
})
})
describe('UNC route concurrency', () => {
@@ -153,28 +71,6 @@ describe('validateWorkingDirectoryAsync', () => {
vi.restoreAllMocks()
})
it('caps concurrent probes against one unreachable distro', async () => {
// Four dead paths could occupy the default libuv fs pool.
let inFlight = 0
let peak = 0
wslUncDirectoryExistsAsyncMock.mockImplementation(
() =>
new Promise<boolean>(() => {
inFlight += 1
peak = Math.max(peak, inFlight)
})
)
for (let index = 0; index < 4; index += 1) {
void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\Ubuntu\\capped-${index}`).catch(
() => {}
)
}
await flushLaneAcquire()
expect(peak).toBe(2)
})
it('keeps a healthy local path out of a stalled share queue', async () => {
wslUncDirectoryExistsAsyncMock.mockReturnValue(new Promise<boolean>(() => {}))
for (let index = 0; index < 4; index += 1) {
@@ -185,89 +81,5 @@ describe('validateWorkingDirectoryAsync', () => {
await expect(validateWorkingDirectoryAsync(tempDir)).resolves.toBeUndefined()
})
it('gives separate servers separate lanes', async () => {
let inFlight = 0
wslUncDirectoryExistsAsyncMock.mockImplementation(
() =>
new Promise<boolean>(() => {
inFlight += 1
})
)
for (const distro of ['AlphaDistro', 'BetaDistro']) {
for (let index = 0; index < 2; index += 1) {
void validateWorkingDirectoryAsync(`\\\\wsl.localhost\\${distro}\\lane-${index}`).catch(
() => {}
)
}
}
await flushLaneAcquire()
expect(inFlight).toBe(4)
})
})
describe('WSL UNC paths', () => {
const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
beforeEach(() => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('asks the distro asynchronously and accepts its yes', async () => {
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
await expect(validateWorkingDirectoryAsync(wslPath)).resolves.toBeUndefined()
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(wslPath)
})
it('shares one in-flight probe for the same working directory', async () => {
let releaseProbe: () => void = () => {}
wslUncDirectoryExistsAsyncMock.mockReturnValue(
new Promise<boolean>((resolve) => {
releaseProbe = () => resolve(true)
})
)
const first = validateWorkingDirectoryAsync(wslPath)
const second = validateWorkingDirectoryAsync(wslPath)
await flushLaneAcquire()
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
releaseProbe()
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined])
})
it('keeps byte-distinct working directories in separate probes', async () => {
wslUncDirectoryExistsAsyncMock.mockResolvedValue(true)
const decomposedPath = `${wslPath}-e\u0301`
const composedPath = `${wslPath}-\u00e9`
await Promise.all([
validateWorkingDirectoryAsync(decomposedPath),
validateWorkingDirectoryAsync(composedPath)
])
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2)
})
it(`rejects on the distro no without falling back to a Win32 stat`, async () => {
wslUncDirectoryExistsAsyncMock.mockResolvedValue(false)
await expect(validateWorkingDirectoryAsync(wslPath)).rejects.toThrow(/does not exist/)
})
it('falls back to the filesystem when the distro probe is inconclusive', async () => {
// An inconclusive distro probe must fall back to filesystem validation.
wslUncDirectoryExistsAsyncMock.mockResolvedValue(null)
await expect(validateWorkingDirectoryAsync(wslPath)).rejects.toThrow(/does not exist/)
expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledOnce()
})
})
})
@@ -18,7 +18,6 @@ import {
readPersistedWindowsPathSegments,
readPersistedWindowsPathSegmentsAsync
} from './windows-environment-path'
import { resolvePathEnvKey } from './windows-path-segment-merge'
import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader'
const registryGetKeyMock = vi.fn()
@@ -41,164 +40,6 @@ afterEach(() => {
})
describe('readPersistedWindowsPathSegments', () => {
it('reads machine and user Path values from the Windows registry', () => {
const execFileSync = vi
.fn()
.mockReturnValueOnce(
[
'',
'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment',
' Path REG_EXPAND_SZ %SystemRoot%\\System32;C:\\Tools',
''
].join('\r\n')
)
.mockReturnValueOnce(
['', 'HKEY_CURRENT_USER\\Environment', ' Path REG_SZ C:\\Users\\me\\bin', ''].join(
'\r\n'
)
)
const segments = readPersistedWindowsPathSegments({
platform: 'win32',
execFileSync,
env: { SystemRoot: 'C:\\Windows' }
})
expect(segments).toEqual(['C:\\Windows\\System32', 'C:\\Tools', 'C:\\Users\\me\\bin'])
expect(execFileSync).toHaveBeenCalledTimes(2)
expect(
execFileSync.mock.calls.every(([command]) => command === 'C:\\Windows\\System32\\reg.exe')
).toBe(true)
})
it('returns an empty list outside Windows', () => {
const execFileSync = vi.fn()
expect(readPersistedWindowsPathSegments({ platform: 'linux', execFileSync })).toEqual([])
expect(execFileSync).not.toHaveBeenCalled()
})
it('does not start asynchronous registry reads outside Windows', async () => {
await expect(readPersistedWindowsPathSegmentsAsync({ platform: 'linux' })).resolves.toEqual([])
expect(defaultExecFileMock).not.toHaveBeenCalled()
})
it('caches production registry reads briefly', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockReturnValueOnce(registryPath('C:\\User'))
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('force-refreshes the production registry cache', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockReturnValueOnce(registryPath('C:\\User'))
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI'))
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([
'C:\\Machine',
'C:\\User',
'C:\\Program Files\\GitHub CLI'
])
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('invalidates the cache after Windows reports an environment change', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\OldMachine'))
.mockReturnValueOnce(registryPath('C:\\OldUser'))
.mockReturnValueOnce(registryPath('C:\\NewMachine'))
.mockReturnValueOnce(registryPath('C:\\NewUser'))
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
invalidatePersistedWindowsPathCache()
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\NewUser'])
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('combines a fresh hive with the other hive last known good after invalidation', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\OldMachine'))
.mockReturnValueOnce(registryPath('C:\\OldUser'))
.mockReturnValueOnce(registryPath('C:\\NewMachine'))
.mockImplementationOnce(() => {
throw new Error('user registry unavailable')
})
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
invalidatePersistedWindowsPathCache()
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\NewMachine', 'C:\\OldUser'])
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('combines a fresh user hive with the machine hive last known good', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\OldMachine'))
.mockReturnValueOnce(registryPath('C:\\OldUser'))
.mockImplementationOnce(() => {
throw new Error('machine registry unavailable')
})
.mockReturnValueOnce(registryPath('C:\\NewUser'))
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\OldUser'])
invalidatePersistedWindowsPathCache()
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\OldMachine', 'C:\\NewUser'])
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('withholds a partial first registry snapshot instead of changing PATH precedence', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -223,115 +64,6 @@ describe('readPersistedWindowsPathSegments', () => {
}
})
it('withholds the reverse partial first snapshot', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockImplementationOnce(() => {
throw new Error('machine registry unavailable')
})
.mockReturnValueOnce(registryPath('C:\\User'))
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual([])
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('does not duplicate an unresolved asynchronous refresh for a synchronous caller', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockImplementationOnce(() => {
throw new Error('user registry unavailable')
})
__resetPersistedWindowsPathCacheForTests()
try {
const refresh = readPersistedWindowsPathSegmentsAsync()
expect(readPersistedWindowsPathSegments()).toEqual([])
await expect(refresh).resolves.toEqual([])
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('does not let injected test reads populate production fallback state', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_SZ C:\\InjectedMachine\r\n')
.mockReturnValueOnce(' Path REG_SZ C:\\InjectedUser\r\n')
registryGetKeyMock.mockImplementation(() => {
throw new Error('registry unavailable')
})
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments({ platform: 'win32', execFileSync })).toEqual([
'C:\\InjectedMachine',
'C:\\InjectedUser'
])
expect(readPersistedWindowsPathSegments()).toEqual([])
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('keeps the last good segments when a forced read hits a blocked registry', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockReturnValueOnce(registryPath('C:\\User'))
.mockImplementation(() => {
throw new Error('ERROR: Access is denied.')
})
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([
'C:\\Machine',
'C:\\User'
])
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('still clears the cache when the registry reports an empty Path', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockReturnValueOnce(registryPath('C:\\User'))
.mockReturnValue(registryPath(''))
__resetPersistedWindowsPathCacheForTests()
try {
expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User'])
// An empty persisted PATH is successful, not a reason to serve stale cache.
expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([])
expect(readPersistedWindowsPathSegments()).toEqual([])
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('does not resurrect a successfully emptied hive when the other hive fails', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -359,66 +91,6 @@ describe('readPersistedWindowsPathSegments', () => {
}
})
it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockReturnValueOnce(registryPath('C:\\User'))
__resetPersistedWindowsPathCacheForTests()
try {
const firstEnv = { Path: 'C:\\First' }
const secondEnv = { Path: 'C:\\Second' }
const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true })
const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true })
await Promise.all([first, second])
expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User')
expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User')
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('keeps the last good cache when native registry reads fail', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
registryGetKeyMock
.mockReturnValueOnce(registryPath('C:\\Machine'))
.mockReturnValueOnce(registryPath('C:\\User'))
.mockImplementation(() => {
throw new Error('registry unavailable')
})
__resetPersistedWindowsPathCacheForTests()
try {
await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([
'C:\\Machine',
'C:\\User'
])
await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([
'C:\\Machine',
'C:\\User'
])
expect(registryGetKeyMock).toHaveBeenCalledTimes(2)
await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([
'C:\\Machine',
'C:\\User'
])
await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([
'C:\\Machine',
'C:\\User'
])
expect(registryGetKeyMock).toHaveBeenCalledTimes(4)
} finally {
__resetPersistedWindowsPathCacheForTests()
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('does not let an older async read replace a newer synchronous refresh', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -484,188 +156,3 @@ describe('readPersistedWindowsPathSegments', () => {
}
})
})
describe('mergePersistedWindowsPath', () => {
it('expands variables already present in the inherited PATH', () => {
const execFileSync = vi.fn().mockReturnValue(' Path REG_SZ \r\n')
const env = {
ORCA_PATH_ROOT: 'C:\\Users\\orca\\AppData\\Local',
Path: '%orca_path_root%\\agy\\bin;C:\\Windows'
}
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync, env })
expect(env.Path).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;C:\\Windows')
})
it('adopts persisted machine and user PATH ordering', () => {
const execFileSync = vi
.fn()
.mockReturnValueOnce(
[
'',
'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment',
' Path REG_EXPAND_SZ C:\\Windows\\System32;C:\\Existing',
''
].join('\r\n')
)
.mockReturnValueOnce(
[
'',
'HKEY_CURRENT_USER\\Environment',
' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\Orca\\bin;C:\\Existing',
''
].join('\r\n')
)
const env = { Path: 'C:\\Existing' }
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
expect(env.Path).toBe(
'C:\\Windows\\System32;C:\\Existing;C:\\Users\\me\\AppData\\Local\\Orca\\bin'
)
})
it('uses PATH when that is the existing path key', () => {
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n')
.mockReturnValueOnce(' Path REG_SZ C:\\User\r\n')
const env = { PATH: 'C:\\Current' }
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
expect(env).toEqual({ PATH: 'C:\\Current;C:\\Machine;C:\\User' })
})
it('keeps the inherited process PATH when the target env has no path key', () => {
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n')
.mockReturnValueOnce(' Path REG_SZ C:\\User\r\n')
const env: Record<string, string> = {}
mergePersistedWindowsPath(env, {
platform: 'win32',
execFileSync,
env: { Path: 'C:\\Inherited' }
})
expect(env).toEqual({ Path: 'C:\\Inherited;C:\\Machine;C:\\User' })
})
it('reads the live host spelling when a path-less env inherits duplicate keys', () => {
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n')
.mockReturnValueOnce(' Path REG_SZ C:\\User\r\n')
const env: Record<string, string> = {}
mergePersistedWindowsPath(env, {
platform: 'win32',
execFileSync,
env: { ROOT: 'C:\\Root', Path: '%ROOT%\\Live', PATH: 'C:\\Shadowed' }
})
expect(env).toEqual({ Path: 'C:\\Root\\Live;C:\\Machine;C:\\User' })
})
it('stops an inherited WindowsApps alias from shadowing a newly installed Python', () => {
const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\'
const scripts = `${python}Scripts\\`
const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps'
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32;C:\\Windows\r\n')
.mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${scripts};${windowsApps}\r\n`)
const env = { Path: `C:\\Windows\\system32;C:\\Windows;${windowsApps}` }
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
const segments = env.Path.split(';')
expect(segments.indexOf(python)).toBeLessThan(segments.indexOf(windowsApps))
expect(env.Path).toBe(`C:\\Windows\\system32;C:\\Windows;${python};${scripts};${windowsApps}`)
})
it('keeps injected entries ahead of the persisted PATH', () => {
const injected = 'C:\\Users\\me\\AppData\\Local\\Orca\\resources\\bin'
const python = 'C:\\Users\\me\\AppData\\Local\\Programs\\Python\\Python314\\'
const windowsApps = 'C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps'
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_EXPAND_SZ C:\\Windows\\system32\r\n')
.mockReturnValueOnce(` Path REG_EXPAND_SZ ${python};${windowsApps}\r\n`)
const env = { Path: `${injected};C:\\Windows\\system32;${windowsApps}` }
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
expect(env.Path).toBe(`${injected};C:\\Windows\\system32;${python};${windowsApps}`)
})
it('leaves the inherited PATH untouched when both registry reads are blocked', () => {
const execFileSync = vi.fn().mockImplementation(() => {
throw new Error('ERROR: Access is denied.')
})
const env = { Path: 'C:\\Windows\\system32;C:\\Existing' }
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
expect(env.Path).toBe('C:\\Windows\\system32;C:\\Existing')
})
it('deduplicates segments that differ only by trailing separators', () => {
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_SZ C:\\Tools\r\n')
.mockReturnValueOnce(' Path REG_SZ \r\n')
const env = { Path: 'C:\\Tools\\' }
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
expect(env.Path.split(';')).toEqual(['C:\\Tools'])
})
it('canonicalizes drive-root slash style and repetition for comparison', () => {
const execFileSync = vi
.fn()
.mockReturnValueOnce(' Path REG_SZ C:\\\\\r\n')
.mockReturnValueOnce(' Path REG_SZ \r\n')
const env = { Path: 'C:/' }
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
expect(env.Path.split(';')).toEqual(['C:\\\\'])
})
})
describe('resolvePathEnvKey', () => {
it('uses whichever Windows spelling is present on the env', () => {
expect(resolvePathEnvKey({ Path: 'C:\\Windows' }, 'win32')).toBe('Path')
expect(resolvePathEnvKey({ PATH: 'C:\\Windows' }, 'win32')).toBe('PATH')
expect(resolvePathEnvKey({ path: 'C:\\Windows' }, 'win32')).toBe('path')
expect(resolvePathEnvKey({ PaTh: 'C:\\Windows' }, 'win32')).toBe('PaTh')
})
// Win32 resolves the first case-insensitive block entry; casing alone cannot select the live key.
it('resolves a dual-cased Windows env by block order, not casing', () => {
expect(resolvePathEnvKey({ PATH: 'C:\\Live', Path: 'C:\\Shadowed' }, 'win32')).toBe('PATH')
expect(resolvePathEnvKey({ Path: 'C:\\Live', PATH: 'C:\\Shadowed' }, 'win32')).toBe('Path')
})
it('skips a path key explicitly set to undefined', () => {
expect(resolvePathEnvKey({ PATH: undefined, Path: 'C:\\Windows' }, 'win32')).toBe('Path')
})
it('falls back to the host block spelling for a Windows env with no path key', () => {
// Match the daemon merge key to avoid creating a shadow PATH entry.
expect(resolvePathEnvKey({}, 'win32', { PATH: 'C:\\Windows' })).toBe('PATH')
expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Windows' })).toBe('Path')
expect(resolvePathEnvKey({}, 'win32', { Path: 'C:\\Live', PATH: 'C:\\Shadowed' })).toBe('Path')
expect(resolvePathEnvKey({}, 'win32', {})).toBe('Path')
})
it('always resolves `PATH` off Windows so a POSIX `Path` variable is untouched', () => {
expect(resolvePathEnvKey({ Path: '/decoy' }, 'linux')).toBe('PATH')
expect(resolvePathEnvKey({ Path: '/decoy', PATH: '/usr/bin' }, 'darwin')).toBe('PATH')
})
})
@@ -5,9 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
deleteFishHistoryFile,
fishHistorySessionName,
isSafeFishHistorySession,
relayFishHistorySessionName,
resolveFishHistoryDir,
sweepOrphanedFishHistoryFiles
} from './fish-history-session'
@@ -15,47 +13,6 @@ const HASH_A = 'a1b2c3d4e5f60718'
const HASH_B = '00112233445566ff'
const SESSION_A = fishHistorySessionName(HASH_A)
describe('fish history session naming', () => {
it('mints a session name that is a valid fish variable value', () => {
// Invalid fish variable names silently select shared history.
expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718')
expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/)
})
it.each([
['orca_deadbeef', true],
['orca_', false],
['fish_history', false],
['orca_../../etc/passwd', false],
['orca_DEADBEEF', false],
['', false],
[undefined, false],
[42, false]
])('accepts %s as a safe session name: %s', (value, expected) => {
expect(isSafeFishHistorySession(value)).toBe(expected)
})
})
describe('fish history directory resolution', () => {
it('follows XDG_DATA_HOME when it is absolute', () => {
expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe(
join('/data', 'fish')
)
})
it('ignores a relative XDG_DATA_HOME the way fish does', () => {
expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe(
join('/home/me', '.local', 'share', 'fish')
)
})
it('falls back to HOME when XDG_DATA_HOME is unset', () => {
expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe(
join('/home/me', '.local', 'share', 'fish')
)
})
})
describe('fish history deletion', () => {
let root: string
let fishDir: string
@@ -72,30 +29,6 @@ describe('fish history deletion', () => {
const historyFile = (session: string): string => join(fishDir, `${session}_history`)
it('deletes the session file rather than truncating it', () => {
writeFileSync(historyFile(SESSION_A), '- cmd: ls\n')
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true)
expect(existsSync(historyFile(SESSION_A))).toBe(false)
})
it('tries every candidate directory, since the PTY may not share this env', () => {
const other = join(root, 'other-xdg', 'fish')
mkdirSync(other, { recursive: true })
writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n')
expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true)
expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false)
})
it('refuses a session name it did not mint', () => {
const foreign = join(fishDir, 'fish_history')
writeFileSync(foreign, 'the user’s real history\n')
expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false)
expect(existsSync(foreign)).toBe(true)
})
// Windows symlinks require Developer Mode or elevation.
it.skipIf(process.platform === 'win32')(
'refuses to follow a symlink out of the fish data dir',
@@ -108,10 +41,6 @@ describe('fish history deletion', () => {
expect(existsSync(outside)).toBe(true)
}
)
it('reports false when there is nothing to delete', () => {
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
})
})
describe('orphaned fish history sweep', () => {
@@ -136,59 +65,6 @@ describe('orphaned fish history sweep', () => {
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true)
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false)
})
it("never touches the user's own history files", () => {
const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history']
for (const name of untouched) {
writeFileSync(join(fishDir, name), 'mine\n')
}
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0)
for (const name of untouched) {
expect(existsSync(join(fishDir, name))).toBe(true)
}
})
it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => {
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0)
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true)
})
it('spares a file younger than the age guard, since the live set is a snapshot', () => {
// A workspace created after the live-set snapshot must keep its actively written history.
const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
writeFileSync(fresh, 'just created\n')
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0)
expect(existsSync(fresh)).toBe(true)
})
it('sweeps a file older than the age guard', () => {
const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
writeFileSync(stale, 'long dead\n')
const wellPast = Date.now() + 60 * 60 * 1000
expect(
sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast)
).toBe(1)
expect(existsSync(stale)).toBe(false)
})
it('tolerates a directory that does not exist', () => {
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0)
})
it('sweeps every candidate directory once', () => {
const other = join(root, 'other', 'fish')
mkdirSync(other, { recursive: true })
for (const dir of [fishDir, other]) {
writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
}
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2)
})
})
// Desktop live sets cannot account for remote clients’ history; prefixes keep their sweeps separate.
@@ -203,14 +79,6 @@ describe('relay fish history naming', () => {
rmSync(relayRoot, { recursive: true, force: true })
})
it('is namespaced apart from desktop session names', () => {
expect(relayFishHistorySessionName(HASH_A)).not.toBe(fishHistorySessionName(HASH_A))
})
it('is still a safe session name, so the relay can delete it by name', () => {
expect(isSafeFishHistorySession(relayFishHistorySessionName(HASH_A))).toBe(true)
})
it('survives a desktop sweep that knows none of the relay worktree ids', () => {
const dir = relayRoot
const relayFile = join(dir, `${relayFishHistorySessionName(HASH_A)}_history`)
@@ -8,7 +8,7 @@ import {
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup'
@@ -22,7 +22,6 @@ vi.mock('../../filesystem/host-tree-removal', () => ({
}))
import * as eventLoopYield from '../../../shared/event-loop-yield'
import { readHistoryMeta } from './terminal-history'
import {
cancelPendingHistoryTreeRemovalRetries,
flushPendingWorktreeHistoryDeletions
@@ -38,50 +37,6 @@ let userDataDir: string
let historyRoot: string
let originalXdgDataHome: string | undefined
// Pre-change pruning oracle; report decisions without deleting history.
function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set<string>): string[] {
const decisions: string[] = []
if (!existsSync(root)) {
return decisions
}
const now = Date.now()
for (const entry of readdirSync(root)) {
if (entry === PENDING_DELETE_DIR_NAME) {
continue
}
const entryPath = join(root, entry)
try {
const stats = statSync(entryPath)
if (!stats.isDirectory()) {
continue
}
try {
for (const file of readdirSync(entryPath)) {
statSync(join(entryPath, file))
}
} catch {
// Match the original walk's tolerance for failed size estimates.
}
if (!existsSync(join(entryPath, 'meta.json'))) {
continue
}
const meta = readHistoryMeta(entryPath)
if (!meta?.worktreeId) {
continue
}
if (!liveWorktreeIds.has(meta.worktreeId)) {
if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) {
continue
}
decisions.push(entry)
}
} catch {
// Match the original walk's tolerance for unreadable entries.
}
}
return decisions
}
function seedDir(name: string, files: Record<string, string>): string {
const dir = join(historyRoot, name)
mkdirSync(dir, { recursive: true })
@@ -164,13 +119,6 @@ beforeEach(() => {
})
})
// Strip the tombstone timestamp/random suffix to compare original entry names.
function tombstonedNames(): Set<string> {
return new Set(
removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.'))
)
}
afterEach(async () => {
cancelHistoryGc()
vi.useRealTimers()
@@ -185,23 +133,6 @@ afterEach(async () => {
})
describe('history GC prune decisions', () => {
it('prunes exactly the set the synchronous walk chose', async () => {
seedDecisionMatrix()
seedBulk(200, 7)
const live = new Set([LIVE_WORKTREE_ID])
const before = survivingDirs()
const expected = new Set(referenceSyncPruneDecisions(historyRoot, live))
await runHistoryGc(live)
const after = survivingDirs()
const actual = new Set([...before].filter((entry) => !after.has(entry)))
expect(expected.size).toBeGreaterThan(0)
expect([...actual].sort()).toEqual([...expected].sort())
})
it('keeps every directory whose ownership cannot be established', async () => {
seedDecisionMatrix()
@@ -230,51 +161,6 @@ describe('history GC prune decisions', () => {
expect(after.has('orphan-unparseable-createdat')).toBe(false)
expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true)
})
it('refuses to prune anything when the live set is empty', async () => {
seedDecisionMatrix()
await runHistoryGc(new Set())
expect(survivingDirs().has('orphan-old')).toBe(true)
expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([])
})
it('does not throw when the history root does not exist', async () => {
rmSync(historyRoot, { recursive: true, force: true })
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
})
it('tombstones orphans instead of removing them on the calling thread', async () => {
seedDecisionMatrix()
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
for (const [dir] of removeHostTreeMock.mock.calls) {
expect(dir).toContain(PENDING_DELETE_DIR_NAME)
}
expect(tombstonedNames().has('orphan-old')).toBe(true)
})
it('drains pre-existing tombstones without scanning them as worktrees', async () => {
seedDecisionMatrix()
const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef')
mkdirSync(leftover, { recursive: true })
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000'))
})
it('continues the pass after one orphan tombstone fails', async () => {
seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) })
seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) })
writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory')
await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined()
expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b']))
})
})
describe('history GC concurrency behaviour', () => {
@@ -312,26 +198,6 @@ describe('history GC concurrency behaviour', () => {
}
})
it('starts a fresh pass once the previous one has settled', async () => {
seedDecisionMatrix()
const live = new Set([LIVE_WORKTREE_ID])
await runHistoryGc(live)
const second = runHistoryGc(live)
await expect(second).resolves.toBeUndefined()
})
it('stops an in-flight walk on cancel without pruning', async () => {
seedDecisionMatrix()
seedBulk(300, 3)
const before = survivingDirs()
const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID]))
cancelHistoryGc()
await pass
expect(survivingDirs()).toEqual(before)
})
it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => {
seedDecisionMatrix()
vi.useFakeTimers()
@@ -350,124 +216,4 @@ describe('history GC concurrency behaviour', () => {
expect(survivingDirs().has('orphan-old')).toBe(true)
})
it('coalesces duplicate scheduled startup GC calls', async () => {
vi.useFakeTimers()
const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set<string>())
scheduleHistoryGc(getLiveWorktreeIds)
scheduleHistoryGc(getLiveWorktreeIds)
await vi.advanceTimersByTimeAsync(10_000)
expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1)
})
})
describe('history GC races an async walk introduces', () => {
it('survives a directory removed while the walk is in flight', async () => {
seedDecisionMatrix()
seedBulk(300, 5)
const live = new Set([LIVE_WORKTREE_ID])
const vanishing = ['bulk-11', 'bulk-77', 'bulk-201']
const pass = runHistoryGc(live)
for (const name of vanishing) {
rmSync(join(historyRoot, name), { recursive: true, force: true })
}
await expect(pass).resolves.toBeUndefined()
expect(survivingDirs().has('live-old')).toBe(true)
expect(survivingDirs().has('bulk-1')).toBe(true)
for (const name of vanishing) {
expect(existsSync(join(historyRoot, name))).toBe(false)
}
})
it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => {
seedDir('being-written', {})
seedBulk(200, 5)
const live = new Set([LIVE_WORKTREE_ID])
const pass = runHistoryGc(live)
writeFileSync(
join(historyRoot, 'being-written', 'meta.json'),
`{"worktreeId":"${DEAD_WORKTREE_ID}","created`
)
await pass
expect(survivingDirs().has('being-written')).toBe(true)
})
it('prunes a directory whose meta.json arrived after the directory did', async () => {
seedDir('late-meta', {})
writeFileSync(
join(historyRoot, 'late-meta', 'meta.json'),
meta(`${DEAD_WORKTREE_ID}-late`, OLD)
)
await runHistoryGc(new Set([LIVE_WORKTREE_ID]))
expect(survivingDirs().has('late-meta')).toBe(false)
})
})
describe('history GC main-thread occupancy', () => {
it('yields to timers throughout the walk instead of blocking on it', async () => {
seedBulk(1_200, 40)
const live = new Set([LIVE_WORKTREE_ID])
const ticks = { sync: 0, async: 0 }
let maxAsyncGapMs = 0
// The synchronous reference walk cannot service timers.
const syncTimer = setInterval(() => {
ticks.sync += 1
}, 4)
try {
referenceSyncPruneDecisions(historyRoot, live)
} finally {
clearInterval(syncTimer)
}
const originalYield = eventLoopYield.yieldToEventLoop
const servicedYields: boolean[] = []
const yieldObserver = vi
.spyOn(eventLoopYield, 'yieldToEventLoop')
.mockImplementation(async () => {
let serviced = false
const probe = setTimeout(() => {
serviced = true
}, 0)
try {
await originalYield()
servicedYields.push(serviced)
if (serviced) {
ticks.async += 1
}
} finally {
clearTimeout(probe)
}
})
let asyncTimer: ReturnType<typeof setInterval> | undefined
try {
let last = performance.now()
asyncTimer = setInterval(() => {
const now = performance.now()
maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4)
last = now
}, 4)
await runHistoryGc(live)
maxAsyncGapMs = Math.max(maxAsyncGapMs, performance.now() - last - 4)
clearInterval(asyncTimer)
expect(ticks.sync).toBe(0)
expect(ticks.async).toBeGreaterThan(5)
expect(servicedYields).not.toContain(false)
// CI scheduling delays idle timers; detect a whole-walk block rather than small stalls.
expect(maxAsyncGapMs).toBeLessThan(2_000)
} finally {
clearInterval(asyncTimer)
yieldObserver.mockRestore()
}
})
})
@@ -25,8 +25,6 @@ import {
cancelPendingHistoryTreeRemovalRetries,
deleteWorktreeHistoryDir,
flushPendingWorktreeHistoryDeletions,
MAX_PENDING_HISTORY_TREE_REMOVALS,
HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS,
schedulePendingHistoryTreeRemovals
} from './terminal-history-deletion'
import {
@@ -63,118 +61,6 @@ describe('tombstoned history removal retries', () => {
rmSync(userDataDir, { recursive: true, force: true })
})
it('admits a bounded tombstone batch and drains the rest from disk', async () => {
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
mkdirSync(distroRoot, { recursive: true })
const releases: (() => void)[] = []
removeHostTreeMock.mockImplementation(
(dir) =>
new Promise<void>((resolve) =>
releases.push(() => {
rmSync(dir, { recursive: true, force: true })
resolve()
})
)
)
for (let index = 0; index < 1_000; index++) {
const worktreeId = `repo-1::/path/wsl-${index}`
const dir = join(distroRoot, hashWorktreeId(worktreeId))
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'meta.json'),
JSON.stringify({
worktreeId,
fishSession: fishHistorySessionName(hashWorktreeId(worktreeId))
})
)
}
for (let index = 0; index < 1_000; index++) {
deleteWorktreeHistoryDir(`repo-1::/path/wsl-${index}`)
}
await vi.advanceTimersByTimeAsync(0)
expect(removeHostTreeMock).toHaveBeenCalledTimes(64)
expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(64)
expect(releases).toHaveLength(64)
for (let pass = 0; removeHostTreeMock.mock.calls.length < 1_000; pass++) {
expect(pass).toBeLessThan(200)
await vi.waitFor(() => expect(releases.length).toBeGreaterThan(0))
releases.splice(0).forEach((release) => release())
await vi.advanceTimersByTimeAsync(0)
await settleTombstoneDirectoryReads()
}
expect(removeHostTreeMock).toHaveBeenCalledTimes(1_000)
expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(1_000)
})
it('caps persistent failures and leaves excess tombstones for a later disk batch', async () => {
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
mkdirSync(distroRoot, { recursive: true })
// Fish cleanup failures are tolerated; fail tree removal itself to exercise the cap.
removeHostTreeMock.mockRejectedValue(new Error('EBUSY'))
for (let index = 0; index < 1_000; index++) {
const worktreeId = `repo-1::/path/fail-${index}`
const dir = join(distroRoot, hashWorktreeId(worktreeId))
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'meta.json'),
JSON.stringify({
worktreeId,
fishSession: fishHistorySessionName(hashWorktreeId(worktreeId))
})
)
deleteWorktreeHistoryDir(worktreeId)
}
await vi.advanceTimersByTimeAsync(0)
expect(vi.getTimerCount()).toBe(64)
expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(1_000)
for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) {
await vi.advanceTimersByTimeAsync(delay)
expect(vi.getTimerCount()).toBeLessThanOrEqual(64)
}
await vi.advanceTimersByTimeAsync(0)
removeHostTreeMock.mockReset()
removeHostTreeMock.mockImplementation(async (dir) => {
rmSync(dir, { recursive: true, force: true })
})
schedulePendingHistoryTreeRemovals(distroRoot)
await flushPendingWorktreeHistoryDeletions()
expect(removeHostTreeMock.mock.calls.length).toBeGreaterThan(
MAX_PENDING_HISTORY_TREE_REMOVALS * (HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1)
)
expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0)
})
it('still removes the history tree when WSL fish cleanup fails', async () => {
// WSL terminals can record fish sessions even in distros without fish.
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
mkdirSync(distroRoot, { recursive: true })
deleteWslFishHistoryFileMock.mockRejectedValue(new Error('fish: command not found'))
removeHostTreeMock.mockImplementation(async (dir) => {
rmSync(dir, { recursive: true, force: true })
})
const worktreeId = 'repo-1::/path/no-fish'
const dir = join(distroRoot, hashWorktreeId(worktreeId))
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'meta.json'),
JSON.stringify({
worktreeId,
fishSession: fishHistorySessionName(hashWorktreeId(worktreeId))
})
)
deleteWorktreeHistoryDir(worktreeId)
await flushPendingWorktreeHistoryDeletions()
expect(deleteWslFishHistoryFileMock).toHaveBeenCalled()
expect(removeHostTreeMock).toHaveBeenCalled()
expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0)
})
it('keeps the tombstone while its distro is stopped and finishes once the distro runs', async () => {
const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
mkdirSync(distroRoot, { recursive: true })
@@ -1,270 +0,0 @@
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup'
const reads = vi.hoisted(() => ({ count: 0, names: 0, failNext: 0 }))
const { removeHostTreeMock } = vi.hoisted(() => ({
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const fsp = await importOriginal<typeof NodeFsPromises>()
return {
...fsp,
readdir: async (...args: Parameters<typeof fsp.readdir>) => {
if (!String(args[0]).endsWith('.pending-delete')) {
return fsp.readdir(...args)
}
reads.count++
if (reads.failNext > 0) {
reads.failNext--
throw new Error('EACCES: permission denied, scandir')
}
const result = await fsp.readdir(...args)
reads.names += Array.isArray(result) ? result.length : 0
return result
}
}
})
vi.mock('../../filesystem/host-tree-removal', () => ({ removeHostTree: removeHostTreeMock }))
import {
cancelPendingHistoryTreeRemovalRetries,
flushPendingWorktreeHistoryDeletions,
HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS,
MAX_PENDING_HISTORY_TREE_REMOVALS,
schedulePendingHistoryTreeRemovals
} from './terminal-history-deletion'
describe('terminal history tombstone scan cost', () => {
let userDataDir: string
beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-scan-cost-'))
installFakeAppEnvironment({ getPath: () => userDataDir })
reads.count = 0
reads.names = 0
reads.failNext = 0
removeHostTreeMock.mockReset()
})
afterEach(() => {
cancelPendingHistoryTreeRemovalRetries()
vi.useRealTimers()
rmSync(userDataDir, { recursive: true, force: true })
})
function seedTombstones(root: string, count: number): void {
for (let index = 0; index < count; index++) {
mkdirSync(join(root, '.pending-delete', `old-session-${index}`), {
recursive: true
})
}
}
function holdRemovals(): (() => void)[] {
const releases: (() => void)[] = []
removeHostTreeMock.mockImplementation(
(dir) =>
new Promise<void>((resolve) =>
releases.push(() => {
rmSync(dir, { recursive: true, force: true })
resolve()
})
)
)
return releases
}
// Do not let real directory reads land while settling removal promises.
async function settleRemovalPromises(): Promise<void> {
for (let index = 0; index < 8; index++) {
await Promise.resolve()
}
}
async function waitFor(label: string, condition: () => boolean): Promise<void> {
for (let index = 0; index < 500; index++) {
if (condition()) {
return
}
await new Promise((resolve) => setTimeout(resolve, 2))
}
throw new Error(`Timed out waiting for ${label}`)
}
it('drains 1024 tombstones without re-reading the directory per completion', async () => {
const root = join(userDataDir, 'terminal-history')
const pendingRoot = join(root, '.pending-delete')
seedTombstones(root, 1024)
const releases = holdRemovals()
await schedulePendingHistoryTreeRemovals(root)
expect(reads.count).toBe(1)
expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS)
while (releases.length > 0) {
expect(releases.length).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS)
releases.splice(0).forEach((release) => release())
await settleRemovalPromises()
}
expect(removeHostTreeMock).toHaveBeenCalledTimes(1024)
expect(readdirSync(pendingRoot)).toEqual([])
expect(reads.count).toBeLessThanOrEqual(8)
expect(reads.names).toBeLessThanOrEqual(1024 + MAX_PENDING_HISTORY_TREE_REMOVALS)
})
it('picks up a tombstone created after the queued names were read', async () => {
const root = join(userDataDir, 'terminal-history')
const pendingRoot = join(root, '.pending-delete')
seedTombstones(root, 1)
const releases = holdRemovals()
await schedulePendingHistoryTreeRemovals(root)
mkdirSync(join(pendingRoot, 'late-session'))
releases.splice(0).forEach((release) => release())
await waitFor('late tombstone admission', () => removeHostTreeMock.mock.calls.length === 2)
releases.splice(0).forEach((release) => release())
await flushPendingWorktreeHistoryDeletions()
expect(readdirSync(pendingRoot)).toEqual([])
})
it('drains both roots within the shared admission cap without per-completion reads', async () => {
const nativeRoot = join(userDataDir, 'terminal-history')
const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
seedTombstones(nativeRoot, 160)
seedTombstones(wslRoot, 160)
let inFlight = 0
let peakInFlight = 0
removeHostTreeMock.mockImplementation(async (dir) => {
inFlight++
peakInFlight = Math.max(peakInFlight, inFlight)
await new Promise((resolve) => setTimeout(resolve, 0))
rmSync(dir, { recursive: true, force: true })
inFlight--
})
await flushPendingWorktreeHistoryDeletions()
expect(removeHostTreeMock).toHaveBeenCalledTimes(320)
expect(peakInFlight).toBeLessThanOrEqual(MAX_PENDING_HISTORY_TREE_REMOVALS)
expect(readdirSync(join(nativeRoot, '.pending-delete'))).toEqual([])
expect(readdirSync(join(wslRoot, '.pending-delete'))).toEqual([])
expect(reads.count).toBeLessThanOrEqual(12)
})
it('keeps draining after a directory read fails mid-drain', async () => {
const root = join(userDataDir, 'terminal-history')
const pendingRoot = join(root, '.pending-delete')
seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1)
const releases = holdRemovals()
await schedulePendingHistoryTreeRemovals(root)
expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS)
reads.failNext = 1
releases.splice(0).forEach((release) => release())
await settleRemovalPromises()
// The queued 65th removal comes from memory despite the failed refill read.
expect(removeHostTreeMock).toHaveBeenCalledTimes(MAX_PENDING_HISTORY_TREE_REMOVALS + 1)
mkdirSync(join(pendingRoot, 'late-after-failed-read'))
releases.splice(0).forEach((release) => release())
await waitFor(
'recovery after failed read',
() => removeHostTreeMock.mock.calls.length === MAX_PENDING_HISTORY_TREE_REMOVALS + 2
)
releases.splice(0).forEach((release) => release())
await flushPendingWorktreeHistoryDeletions()
expect(readdirSync(pendingRoot)).toEqual([])
})
it('drops an in-flight refill during fixture cleanup', async () => {
const root = join(userDataDir, 'terminal-history')
seedTombstones(root, MAX_PENDING_HISTORY_TREE_REMOVALS + 1)
const releases = holdRemovals()
await schedulePendingHistoryTreeRemovals(root)
mkdirSync(join(root, '.pending-delete', 'late-session'))
releases.splice(0).forEach((release) => release())
await settleRemovalPromises()
const admittedBeforeCleanup = removeHostTreeMock.mock.calls.length
cancelPendingHistoryTreeRemovalRetries()
await waitFor('the dropped refill to land', () => reads.count >= 2)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(removeHostTreeMock).toHaveBeenCalledTimes(admittedBeforeCleanup)
})
it('preserves delayed failure retries while successful removals replenish the queue', async () => {
vi.useFakeTimers()
const root = join(userDataDir, 'terminal-history')
seedTombstones(root, 130)
const failedDir = join(root, '.pending-delete', 'old-session-0')
removeHostTreeMock.mockImplementation(async (dir) => {
if (dir === failedDir) {
throw new Error('EBUSY')
}
rmSync(dir, { recursive: true, force: true })
})
await flushPendingWorktreeHistoryDeletions()
expect(removeHostTreeMock).toHaveBeenCalledTimes(130)
expect(readdirSync(join(root, '.pending-delete'))).toEqual(['old-session-0'])
await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[0] - 1)
expect(removeHostTreeMock).toHaveBeenCalledTimes(130)
await vi.advanceTimersByTimeAsync(1)
expect(removeHostTreeMock).toHaveBeenCalledTimes(131)
await vi.advanceTimersByTimeAsync(HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS[1])
expect(removeHostTreeMock).toHaveBeenCalledTimes(132)
expect(vi.getTimerCount()).toBe(0)
})
it('does not retry a tombstone past its attempt budget when another root frees slots', async () => {
vi.useFakeTimers()
const nativeRoot = join(userDataDir, 'terminal-history')
const wslRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu')
seedTombstones(nativeRoot, 32)
seedTombstones(wslRoot, 130)
const releases: (() => void)[] = []
const attemptsByDir = new Map<string, number>()
removeHostTreeMock.mockImplementation((dir) => {
attemptsByDir.set(dir, (attemptsByDir.get(dir) ?? 0) + 1)
if (dir.startsWith(wslRoot)) {
return Promise.reject(new Error('EBUSY'))
}
return new Promise<void>((resolve) =>
releases.push(() => {
rmSync(dir, { recursive: true, force: true })
resolve()
})
)
})
await schedulePendingHistoryTreeRemovals(nativeRoot)
await schedulePendingHistoryTreeRemovals(wslRoot)
for (const delay of HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS) {
await vi.advanceTimersByTimeAsync(delay)
}
expect(vi.getTimerCount()).toBe(0)
const maxAttempts = 1 + HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS.length
const exhausted = [...attemptsByDir.entries()].filter(([, count]) => count === maxAttempts)
expect(exhausted.length).toBeGreaterThan(0)
releases.splice(0).forEach((release) => release())
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(0)
// Freed slots may admit untried tombstones, but must not restart exhausted ones.
for (const count of attemptsByDir.values()) {
expect(count).toBeLessThanOrEqual(maxAttempts)
}
for (const [dir] of exhausted) {
expect(attemptsByDir.get(dir)).toBe(maxAttempts)
}
})
})
@@ -1,731 +0,0 @@
import type * as WslPathOwner from '../../../shared/wsl-paths'
import type * as FsPromises from 'node:fs/promises'
import { sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installFakeAppEnvironment } from '../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup'
const {
existsSyncMock,
mkdirSyncMock,
writeFileSyncMock,
readFileSyncMock,
lstatSyncMock,
rmSyncMock,
renameSyncMock,
readdirSyncMock,
statSyncMock,
openSyncMock,
fstatSyncMock,
closeSyncMock,
getPathMock,
rmAsyncMock,
deleteWslFishHistoryFileMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
mkdirSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
lstatSyncMock: vi.fn(),
rmSyncMock: vi.fn(),
renameSyncMock: vi.fn(),
readdirSyncMock: vi.fn(),
statSyncMock: vi.fn(),
openSyncMock: vi.fn((_path?: string | Buffer) => 1),
fstatSyncMock: vi.fn(() => ({
dev: 1n,
ino: 2n,
birthtimeNs: 3n,
isFile: () => true,
isDirectory: () => true
})),
closeSyncMock: vi.fn(),
getPathMock: vi.fn(),
rmAsyncMock: vi.fn(async () => undefined),
deleteWslFishHistoryFileMock: vi.fn(async () => undefined)
}))
vi.mock('../../../shared/wsl-paths', async (importOriginal) => ({
...(await importOriginal<typeof WslPathOwner>()),
toLinuxPath: toLinuxPathMock
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
mkdirSync: mkdirSyncMock,
writeFileSync: writeFileSyncMock,
readFileSync: readFileSyncMock,
lstatSync: lstatSyncMock,
rmSync: rmSyncMock,
renameSync: renameSyncMock,
readdirSync: readdirSyncMock,
statSync: statSyncMock,
openSync: openSyncMock,
fstatSync: fstatSyncMock,
closeSync: closeSyncMock
}))
// Preserve transitive fs/promises imports while replacing the removal and listing entrypoints.
vi.mock('node:fs/promises', async () => ({
...(await vi.importActual<typeof FsPromises>('node:fs/promises')),
rm: rmAsyncMock,
readdir: async (path: string) => readdirSyncMock(path) ?? []
}))
const { parseWslPathMock, toLinuxPathMock } = vi.hoisted(() => ({
parseWslPathMock: vi.fn((_path: string) => null as { distro: string; linuxPath: string } | null),
toLinuxPathMock: vi.fn((p: string) => p)
}))
vi.mock('../../wsl/filesystem/unc-path-access', () => ({
parseWslPath: parseWslPathMock
}))
vi.mock('../../wsl/history/wsl-fish-history-cleanup', () => ({
deleteWslFishHistoryFile: deleteWslFishHistoryFileMock
}))
import {
resolveShellKind,
ensureHistoryDir,
injectHistoryEnv,
injectWslFishHistoryEnv,
MAX_HISTORY_META_BYTES,
updateHistoryEnvForFallback,
type HistoryInjectionResult
} from './terminal-history'
import { fishHistorySessionName, relayFishHistorySessionName } from './fish-history-session'
import { hashWorktreeId } from './terminal-history-id'
import {
cancelPendingHistoryTreeRemovalRetries,
deleteWorktreeHistoryDir,
flushPendingWorktreeHistoryDeletions
} from './terminal-history-deletion'
const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt')
describe('terminal-history', () => {
afterEach(() => {
cancelPendingHistoryTreeRemovalRetries()
vi.useRealTimers()
})
beforeEach(() => {
vi.clearAllMocks()
mkdirSyncMock.mockReset()
// clearAllMocks retains throwing implementations; reset them between tests.
renameSyncMock.mockReset()
readFileSyncMock.mockReset()
readdirSyncMock.mockReset()
rmAsyncMock.mockReset()
rmAsyncMock.mockResolvedValue(undefined)
getPathMock.mockReturnValue('/fake/userData')
installFakeAppEnvironment({ getPath: getPathMock })
existsSyncMock.mockReturnValue(true)
statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 })
openSyncMock.mockImplementation(() => 1)
fstatSyncMock.mockReturnValue({
dev: 1n,
ino: 2n,
birthtimeNs: 3n,
isFile: () => true,
isDirectory: () => true
})
lstatSyncMock.mockReturnValue({
dev: 1n,
ino: 2n,
birthtimeNs: 3n,
size: 100,
isDirectory: () => true,
isFile: () => true,
isSymbolicLink: () => false
})
})
describe('resolveShellKind', () => {
it('detects versioned bash (bash-5.2)', () => {
expect(resolveShellKind('/usr/local/bin/bash-5.2')).toBe('bash')
})
it('detects versioned zsh (zsh-5.9)', () => {
expect(resolveShellKind('/usr/local/bin/zsh-5.9')).toBe('zsh')
})
it('detects nix-store zsh path', () => {
expect(resolveShellKind('/nix/store/abc123/bin/zsh')).toBe('zsh')
})
it('detects pwsh', () => {
expect(resolveShellKind('pwsh')).toBe('pwsh')
expect(resolveShellKind('pwsh.exe')).toBe('pwsh')
})
})
describe('hashWorktreeId', () => {
it('produces deterministic output for the same input', () => {
const a = hashWorktreeId('repo-1::/Users/foo/worktree-a')
const b = hashWorktreeId('repo-1::/Users/foo/worktree-a')
expect(a).toBe(b)
})
it('produces different output for different inputs', () => {
const a = hashWorktreeId('repo-1::/Users/foo/worktree-a')
const b = hashWorktreeId('repo-1::/Users/foo/worktree-b')
expect(a).not.toBe(b)
})
it('produces a 16-character hex string', () => {
const hash = hashWorktreeId('repo-1::/Users/foo/worktree-a')
expect(hash).toMatch(/^[0-9a-f]{16}$/)
})
})
describe('ensureHistoryDir', () => {
it('creates directory with mode 0o700', () => {
ensureHistoryDir('abcdef0123456789')
expect(mkdirSyncMock).toHaveBeenCalledWith(
expect.stringMatching(/[\\/]fake[\\/]userData[\\/]terminal-history[\\/]abcdef0123456789$/),
{ recursive: true, mode: 0o700 }
)
})
it('returns null on mkdir failure', () => {
mkdirSyncMock.mockImplementation(() => {
throw new Error('permission denied')
})
const result = ensureHistoryDir('abcdef0123456789')
expect(result).toBeNull()
})
})
describe('injectHistoryEnv', () => {
it('injects HISTFILE for zsh', () => {
mkdirSyncMock.mockReturnValue(undefined)
const env: Record<string, string> = {}
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(result.shell).toBe('zsh')
expect(result.histFile).toContain('terminal-history')
expect(result.histFile).toContain('zsh_history')
expect(env.HISTFILE).toBe(result.histFile)
})
it('injects HISTFILE for bash', () => {
mkdirSyncMock.mockReturnValue(undefined)
const env: Record<string, string> = {}
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/bash', '/path/wt')
expect(result.shell).toBe('bash')
expect(result.histFile).toContain('bash_history')
expect(env.HISTFILE).toBe(result.histFile)
})
it('produces different HISTFILE for different worktreeIds', () => {
mkdirSyncMock.mockReturnValue(undefined)
existsSyncMock.mockReturnValue(true)
const envA: Record<string, string> = {}
injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/bin/zsh', '/path/wt-a')
const envB: Record<string, string> = {}
injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/bin/zsh', '/path/wt-b')
expect(envA.HISTFILE).not.toBe(envB.HISTFILE)
})
it('preserves caller-provided HISTFILE (check-before-set)', () => {
const env: Record<string, string> = { HISTFILE: '/my/custom/histfile' }
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(env.HISTFILE).toBe('/my/custom/histfile')
expect(result.histFile).toBeNull()
})
it('does not inject HISTFILE for unknown shells', () => {
const env: Record<string, string> = {}
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/tcsh', '/path/wt')
expect(env.HISTFILE).toBeUndefined()
expect(result.shell).toBe('unknown')
expect(result.histFile).toBeNull()
})
it('does not inject HISTFILE for cmd.exe', () => {
const env: Record<string, string> = {}
const result = injectHistoryEnv(env, 'repo-1::/path/wt', 'cmd.exe', '/path/wt')
expect(env.HISTFILE).toBeUndefined()
expect(result.shell).toBe('cmd')
expect(result.histFile).toBeNull()
})
it('scopes fish by session name instead of HISTFILE, which fish ignores', () => {
const env: Record<string, string> = {}
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
expect(env.HISTFILE).toBeUndefined()
expect(result.shell).toBe('fish')
expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
expect(env.fish_history).toBe(result.fishSession)
expect(writeFileSyncMock).toHaveBeenCalledWith(
expect.stringContaining('meta.json'),
expect.stringContaining(`"fishSession":"${result.fishSession}"`),
expect.anything()
)
})
it('records the history DIRECTORY from the SPAWN env, not this process env', () => {
const originalDataHome = process.env.XDG_DATA_HOME
process.env.XDG_DATA_HOME = ['', 'main', 'process', 'data'].join(sep)
try {
const env: Record<string, string> = { XDG_DATA_HOME: ['', 'spawn', 'data'].join(sep) }
injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record<
string,
string
>
expect(meta.fishHistoryDir).toBe(['', 'spawn', 'data', 'fish'].join(sep))
} finally {
if (originalDataHome === undefined) {
delete process.env.XDG_DATA_HOME
} else {
process.env.XDG_DATA_HOME = originalDataHome
}
}
})
it('replaces oversized metadata without parsing it', () => {
statSyncMock.mockReturnValue({ isDirectory: () => true, size: MAX_HISTORY_META_BYTES + 1 })
const env = { XDG_DATA_HOME: ['', 'active'].join(sep) }
injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
expect(readFileSyncMock).not.toHaveBeenCalled()
const meta = JSON.parse(writeFileSyncMock.mock.calls.at(-1)?.[1] as string) as Record<
string,
string
>
expect(meta.fishHistoryDir).toBe(['', 'active', 'fish'].join(sep))
})
it('gives two fish worktrees different history sessions', () => {
const envA: Record<string, string> = {}
injectHistoryEnv(envA, 'repo-1::/path/wt-a', '/usr/bin/fish', '/path/wt-a')
const envB: Record<string, string> = {}
injectHistoryEnv(envB, 'repo-1::/path/wt-b', '/usr/bin/fish', '/path/wt-b')
expect(envA.fish_history).not.toBe(envB.fish_history)
})
it('drops an ORCA_HISTFILE inherited from a parent Orca PTY', () => {
const env: Record<string, string> = {
ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep)
}
injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(env.ORCA_HISTFILE).toBe(env.HISTFILE)
expect(env.ORCA_HISTFILE).not.toContain('other')
})
it('drops an inherited ORCA_HISTFILE even when it injects nothing', () => {
const env: Record<string, string> = {
HISTFILE: ['', 'mine', 'zsh_history'].join(sep),
ORCA_HISTFILE: ['', 'other', 'wt', 'zsh_history'].join(sep)
}
injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(env.HISTFILE).toBe(['', 'mine', 'zsh_history'].join(sep))
expect(env.ORCA_HISTFILE).toBeUndefined()
})
it.each([
[
'desktop',
['', 'fake', 'userData', 'terminal-history', OTHER_WORKTREE_HASH, 'zsh_history'].join(sep)
],
[
'relay',
[
'',
'home',
'me',
'.orca-remote',
'terminal-history',
`${OTHER_WORKTREE_HASH}-zsh_history`
].join(sep)
]
])('drops a %s HISTFILE inherited from a parent Orca pane', (_kind, inherited) => {
const env: Record<string, string> = { HISTFILE: inherited }
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(result.histFile).toBe(env.HISTFILE)
expect(env.HISTFILE).toContain(hashWorktreeId('repo-1::/path/wt'))
expect(env.HISTFILE).not.toContain(OTHER_WORKTREE_HASH)
})
it.each([
['an ordinary path', ['', 'home', 'me', '.zsh_history'].join(sep)],
// Orca mints absolute paths; a matching relative path belongs to the user.
[
'a relative path of Orca’s shape',
['terminal-history', OTHER_WORKTREE_HASH, 'zsh_history'].join(sep)
]
])('preserves %s the user set as HISTFILE', (_kind, histFile) => {
const env: Record<string, string> = { HISTFILE: histFile }
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(env.HISTFILE).toBe(histFile)
expect(result.histFile).toBeNull()
})
it('preserves a caller-supplied fish_history', () => {
const env: Record<string, string> = { fish_history: 'mine' }
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
expect(env.fish_history).toBe('mine')
expect(result.fishSession).toBeNull()
})
it.each([
['desktop', fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))],
['relay', relayFishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))]
])('replaces a %s fish_history inherited from a parent Orca', (_kind, inherited) => {
const env: Record<string, string> = { fish_history: inherited }
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
expect(env.fish_history).toBe(result.fishSession)
})
it('drops an inherited fish_history even when the shell is not fish', () => {
const env: Record<string, string> = {
fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))
}
injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(env.fish_history).toBeUndefined()
})
it('degrades gracefully when directory creation fails', () => {
mkdirSyncMock.mockImplementation(() => {
throw new Error('disk full')
})
const env: Record<string, string> = {}
const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
expect(env.HISTFILE).toBeUndefined()
expect(result.histFile).toBeNull()
})
})
describe('injectWslFishHistoryEnv', () => {
it('replaces an inherited Orca fish_history with this worktree session', () => {
const env: Record<string, string> = {
fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))
}
const session = injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu')
expect(session).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
expect(env.fish_history).toBe(session)
})
it('preserves a caller-supplied fish_history', () => {
const env: Record<string, string> = { fish_history: 'mine' }
expect(injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu')).toBeNull()
expect(env.fish_history).toBe('mine')
})
})
describe('updateHistoryEnvForFallback', () => {
const zshInjection = (): HistoryInjectionResult => ({
shell: 'zsh',
histFile: '/fake/userData/terminal-history/abc123/zsh_history',
fishSession: null,
historyDir: '/fake/userData/terminal-history/abc123'
})
it('updates HISTFILE to match fallback shell', () => {
const env: Record<string, string> = {
HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history'
}
updateHistoryEnvForFallback(env, '/bin/bash', zshInjection())
expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history')
})
it('removes HISTFILE for unknown fallback shell', () => {
const env: Record<string, string> = {
HISTFILE: '/fake/userData/terminal-history/abc123/zsh_history'
}
updateHistoryEnvForFallback(env, '/bin/sh', zshInjection())
expect(env.HISTFILE).toBeUndefined()
})
it('is a no-op when nothing was injected', () => {
const env: Record<string, string> = {}
updateHistoryEnvForFallback(env, '/bin/bash', {
shell: 'unknown',
histFile: null,
fishSession: null,
historyDir: null
})
expect(env.HISTFILE).toBeUndefined()
})
it('swaps an injected fish session for the fallback shell HISTFILE', () => {
const env: Record<string, string> = { fish_history: 'orca_abc123' }
updateHistoryEnvForFallback(env, '/bin/bash', {
shell: 'fish',
histFile: null,
fishSession: 'orca_abc123',
historyDir: '/fake/userData/terminal-history/abc123'
})
expect(env.fish_history).toBeUndefined()
expect(env.HISTFILE).toBe('/fake/userData/terminal-history/abc123/bash_history')
})
it('keeps a caller-supplied fish_history the injection never claimed', () => {
const env: Record<string, string> = { fish_history: 'mine' }
updateHistoryEnvForFallback(env, '/bin/bash', zshInjection())
expect(env.fish_history).toBe('mine')
})
})
describe('deleteWorktreeHistoryDir', () => {
const worktreeId = 'repo-1::/path/wt'
const session = fishHistorySessionName(hashWorktreeId(worktreeId))
const historyFilename = `${session}_history`
it('tombstones then async-removes the history directory without recursive rmSync', async () => {
existsSyncMock.mockReturnValue(true)
deleteWorktreeHistoryDir('repo-1::/path/wt')
expect(renameSyncMock).toHaveBeenCalled()
expect(rmSyncMock).not.toHaveBeenCalled()
expect(rmAsyncMock).toHaveBeenCalledWith(
expect.stringContaining('.pending-delete'),
expect.objectContaining({ recursive: true, force: true })
)
await flushPendingWorktreeHistoryDeletions()
})
it('deletes the fish history file in the directory meta.json recorded', async () => {
const recordedDir = ['', 'spawn', 'data', 'fish'].join(sep)
existsSyncMock.mockReturnValue(true)
lstatSyncMock.mockReturnValue({ isFile: () => true })
readFileSyncMock.mockReturnValue(
JSON.stringify({ worktreeId, fishSession: session, fishHistoryDir: recordedDir })
)
deleteWorktreeHistoryDir('repo-1::/path/wt')
expect(rmSyncMock).toHaveBeenCalledWith([recordedDir, historyFilename].join(sep))
await flushPendingWorktreeHistoryDeletions()
})
it('also tries this process fish dir, for meta written before that field', async () => {
const originalDataHome = process.env.XDG_DATA_HOME
process.env.XDG_DATA_HOME = ['', 'main', 'data'].join(sep)
try {
existsSyncMock.mockReturnValue(true)
lstatSyncMock.mockReturnValue({ isFile: () => true })
readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session }))
deleteWorktreeHistoryDir('repo-1::/path/wt')
expect(rmSyncMock).toHaveBeenCalledWith(
['', 'main', 'data', 'fish', historyFilename].join(sep)
)
} finally {
if (originalDataHome === undefined) {
delete process.env.XDG_DATA_HOME
} else {
process.env.XDG_DATA_HOME = originalDataHome
}
}
await flushPendingWorktreeHistoryDeletions()
})
it('ignores a fishSession that does not belong to this history directory', async () => {
existsSyncMock.mockReturnValue(true)
lstatSyncMock.mockReturnValue({ isFile: () => true })
readFileSyncMock.mockReturnValue(
JSON.stringify({ worktreeId, fishSession: 'orca_deadbeefdeadbeef' })
)
deleteWorktreeHistoryDir('repo-1::/path/wt')
expect(rmSyncMock).not.toHaveBeenCalledWith(expect.stringContaining('orca_deadbeefdeadbeef'))
await flushPendingWorktreeHistoryDeletions()
})
it('refuses to unlink a fish history path that is not a regular file', async () => {
existsSyncMock.mockReturnValue(true)
lstatSyncMock.mockReturnValue({ isFile: () => false })
readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session }))
deleteWorktreeHistoryDir('repo-1::/path/wt')
expect(rmSyncMock).not.toHaveBeenCalled()
await flushPendingWorktreeHistoryDeletions()
})
it('cleans WSL Fish history through the owning distro before removing metadata', async () => {
readFileSyncMock.mockReturnValue(JSON.stringify({ worktreeId, fishSession: session }))
readdirSyncMock.mockImplementation((path: string) => {
if (path.endsWith('terminal-history-wsl')) {
return ['Ubuntu']
}
return []
})
deleteWorktreeHistoryDir(worktreeId)
await flushPendingWorktreeHistoryDeletions()
expect(deleteWslFishHistoryFileMock).toHaveBeenCalledWith('Ubuntu', session)
})
it('leaves the live directory alone when the tombstone rename fails', async () => {
existsSyncMock.mockReturnValue(true)
renameSyncMock.mockImplementation(() => {
throw new Error('permission denied')
})
expect(() => deleteWorktreeHistoryDir('repo-1::/path/wt')).not.toThrow()
// A recreated worktree can own the live path before async rm lands; leave it for GC.
expect(rmAsyncMock).not.toHaveBeenCalled()
expect(rmSyncMock).not.toHaveBeenCalled()
await flushPendingWorktreeHistoryDeletions()
})
it('does not throw when the async removal itself fails', async () => {
existsSyncMock.mockReturnValue(true)
rmAsyncMock.mockRejectedValueOnce(new Error('async fail'))
expect(() => deleteWorktreeHistoryDir('repo-1::/path/wt')).not.toThrow()
await expect(flushPendingWorktreeHistoryDeletions()).resolves.toBeUndefined()
})
it('retries pending tombstones on flush after a failed async rm (app-quit durability)', async () => {
let leftoverTombstonePresent = true
existsSyncMock.mockImplementation((p: string) => {
const path = String(p)
if (path.includes('.pending-delete') && !path.endsWith('.pending-delete')) {
return true
}
if (path.endsWith('terminal-history') || path.endsWith('.pending-delete')) {
return true
}
return path.includes(hashWorktreeId('repo-1::/path/wt'))
})
readdirSyncMock.mockImplementation((p: string) => {
if (String(p).endsWith('.pending-delete')) {
return leftoverTombstonePresent ? ['leftover-tombstone'] : []
}
return []
})
rmAsyncMock.mockImplementation(async () => {
leftoverTombstonePresent = false
})
await flushPendingWorktreeHistoryDeletions()
expect(rmAsyncMock).toHaveBeenCalledWith(
expect.stringContaining('leftover-tombstone'),
expect.objectContaining({ recursive: true, force: true })
)
})
})
describe('WSL path conversion', () => {
it('converts HISTFILE to Linux path for WSL cwd', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/user/project' })
toLinuxPathMock.mockReturnValue(
'/mnt/c/Users/user/AppData/Roaming/Orca/terminal-history-wsl/Ubuntu/abc123/bash_history'
)
mkdirSyncMock.mockReturnValue(undefined)
existsSyncMock.mockReturnValue(true)
const env: Record<string, string> = {}
const result = injectHistoryEnv(
env,
'repo-1::/wsl/path',
'/bin/bash',
'\\\\wsl.localhost\\Ubuntu\\home\\user\\project'
)
expect(toLinuxPathMock).toHaveBeenCalled()
expect(result.histFile).toContain('/mnt/')
expect(env.HISTFILE).toBe(result.histFile)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('stores WSL history under terminal-history-wsl/<distro>/', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/user' })
toLinuxPathMock.mockImplementation((p: string) => p)
mkdirSyncMock.mockReturnValue(undefined)
existsSyncMock.mockReturnValue(true)
const env: Record<string, string> = {}
injectHistoryEnv(
env,
'repo-1::/wsl/path',
'/bin/bash',
'\\\\wsl.localhost\\Ubuntu\\home\\user'
)
expect(mkdirSyncMock).toHaveBeenCalledWith(
expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/),
expect.any(Object)
)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('uses the project WSL distro hint when cwd is a Windows path', () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
parseWslPathMock.mockReturnValue(null)
toLinuxPathMock.mockImplementation((p: string) => p.replace(/^C:\\/i, '/mnt/c/'))
mkdirSyncMock.mockReturnValue(undefined)
existsSyncMock.mockReturnValue(true)
getPathMock.mockReturnValue('C:\\Users\\alice\\AppData\\Roaming\\Orca')
const env: Record<string, string> = {}
const result = injectHistoryEnv(env, 'repo-1::C:\\repo', '/bin/bash', 'C:\\repo', {
wslDistro: 'Ubuntu'
})
expect(mkdirSyncMock).toHaveBeenCalledWith(
expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/),
expect.any(Object)
)
expect(toLinuxPathMock).toHaveBeenCalled()
expect(result.histFile).toMatch(/^\/mnt\/c\//)
expect(env.HISTFILE).toBe(result.histFile)
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
})
})
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec'
import {
getPosixPtyForegroundGroup,
@@ -9,10 +9,6 @@ import {
const { runProcessMock } = vi.hoisted(() => ({
runProcessMock: vi.fn<(spec: ProcessSpec) => Promise<ProcessResult>>()
}))
const pidArg = (spec: ProcessSpec): string => {
const args = spec.args ?? []
return args[args.indexOf('-p') + 1]
}
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
const psResult = (stdout: string): ProcessResult => ({
@@ -26,10 +22,6 @@ const psResult = (stdout: string): ProcessResult => ({
const macosTable = ['84644 84985 ttys318', ' 4242 4242 ttys002'].join('\n')
describe('getPosixPtyForegroundGroup', () => {
it('accepts a Linux pts device name', () => {
expect(getPosixPtyForegroundGroup('900 931 pts/3', 900, '/dev/pts/3', 4242)).toBe(931)
})
it('refuses when the pid now answers for a different tty', () => {
// A recycled root PID must not signal another terminal's group.
expect(getPosixPtyForegroundGroup(macosTable, 84644, '/dev/ttys999', 4242)).toBeNull()
@@ -40,16 +32,6 @@ describe('getPosixPtyForegroundGroup', () => {
const shared = ['84644 84985 ttys318', '4242 84985 ttys318'].join('\n')
expect(getPosixPtyForegroundGroup(shared, 84644, '/dev/ttys318', 4242)).toBeNull()
})
it('refuses a detached tty or an unowned foreground group', () => {
expect(getPosixPtyForegroundGroup('84644 84985 ??', 84644, '/dev/ttys318', 4242)).toBeNull()
expect(getPosixPtyForegroundGroup('84644 -1 ttys318', 84644, '/dev/ttys318', 4242)).toBeNull()
expect(getPosixPtyForegroundGroup('84644 1 ttys318', 84644, '/dev/ttys318', 4242)).toBeNull()
})
it('refuses when the root pid is absent from the table', () => {
expect(getPosixPtyForegroundGroup('4242 4242 ttys002', 84644, '/dev/ttys318', 4242)).toBeNull()
})
})
describe('signalPosixPtyForegroundGroup', () => {
@@ -70,157 +52,6 @@ describe('signalPosixPtyForegroundGroup', () => {
kill.mockRestore()
}
})
it('falls back to the root pid on Windows', async () => {
// Windows rejects negative PIDs and treats SIGTERM/SIGINT as termination.
const fallback = vi.fn()
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
platform: 'win32',
currentPid: 4242,
readProcessTable: table
})
expect(fallback).toHaveBeenCalledOnce()
})
it('falls back when the slave device name is unknown', async () => {
const fallback = vi.fn()
await signalPosixPtyForegroundGroup(84644, undefined, 'SIGWINCH', fallback, {
platform: 'darwin',
currentPid: 4242,
readProcessTable: table
})
expect(fallback).toHaveBeenCalledOnce()
})
it('falls back when the process table cannot be read', async () => {
const fallback = vi.fn()
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
platform: 'darwin',
currentPid: 4242,
readProcessTable: async () => {
throw new Error('ps timed out')
}
})
expect(fallback).toHaveBeenCalledOnce()
})
it('treats a vanished foreground group as done rather than falling back', async () => {
// ESRCH means the captured target vanished; root fallback would be stale too.
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
const error = new Error('no such process') as NodeJS.ErrnoException
error.code = 'ESRCH'
throw error
})
const fallback = vi.fn()
try {
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
platform: 'darwin',
currentPid: 4242,
readProcessTable: table
})
expect(fallback).not.toHaveBeenCalled()
} finally {
kill.mockRestore()
}
})
it('falls back when foreground group signalling fails', async () => {
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
const error = new Error('operation not permitted') as NodeJS.ErrnoException
error.code = 'EPERM'
throw error
})
const fallback = vi.fn()
try {
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', fallback, {
platform: 'darwin',
currentPid: 4242,
readProcessTable: table
})
expect(fallback).toHaveBeenCalledOnce()
} finally {
kill.mockRestore()
}
})
})
describe('process table lookup', () => {
beforeEach(() => {
resetPosixPtyForegroundGroupOwnRowCache()
})
it('asks ps for one pid at a time', async () => {
// macOS PID lists scan the whole table (~3.6s vs ~3ms), silently timing out into root-only delivery.
runProcessMock.mockReset()
runProcessMock.mockResolvedValue(psResult('84644 84985 ttys318'))
// Mock group signalling so fixture PGIDs cannot target real host processes.
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await signalPosixPtyForegroundGroup(84644, '/dev/ttys318', 'SIGWINCH', vi.fn(), {
platform: 'darwin',
currentPid: 4242
})
expect(runProcessMock).toHaveBeenCalled()
let timeoutBudget = 0
for (const [spec] of runProcessMock.mock.calls) {
expect(pidArg(spec)).toMatch(/^\d+$/)
expect(spec.args?.filter((arg) => arg === '-p')).toHaveLength(1)
timeoutBudget += spec.timeoutMs ?? Infinity
}
expect(timeoutBudget).toBeLessThanOrEqual(250)
} finally {
kill.mockRestore()
}
})
it('forks ps once per pane after the first SIGWINCH of the process', async () => {
// Reuse Orca's own TTY row: repeated ps forks doubled the per-pane resize cost.
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
const panes = [
{ rootPid: 900, tty: 'ttys301' },
{ rootPid: 901, tty: 'ttys302' },
{ rootPid: 902, tty: 'ttys303' },
{ rootPid: 903, tty: 'ttys304' }
]
try {
runProcessMock.mockReset()
runProcessMock.mockImplementation(async (spec) => {
const pid = Number(pidArg(spec))
if (pid === 4242) {
return psResult('4242 4242 ttys002')
}
const pane = panes.find((entry) => entry.rootPid === pid)
return psResult(pane ? `${pane.rootPid} ${pane.rootPid + 50} ${pane.tty}` : '')
})
for (const pane of panes) {
// Hidden restore and reattach each signal once per pane.
for (let signalIndex = 0; signalIndex < 2; signalIndex += 1) {
await signalPosixPtyForegroundGroup(
pane.rootPid,
`/dev/${pane.tty}`,
'SIGWINCH',
vi.fn(),
{
platform: 'darwin',
currentPid: 4242
}
)
}
}
const pidArgs = runProcessMock.mock.calls.map(([spec]) => Number(pidArg(spec)))
expect(pidArgs.filter((pid) => pid === 4242)).toHaveLength(1)
expect(pidArgs).toHaveLength(9)
expect(kill).toHaveBeenCalledTimes(8)
} finally {
runProcessMock.mockReset()
kill.mockRestore()
}
})
})
it('shares the native master group on resize with own-TTY and lifetime protection', async () => {
@@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import type { ProcessResult } from '@orca/process-host/process-spec'
const {
readFileMock,
readFileSyncMock,
@@ -33,19 +31,12 @@ vi.mock('@orca/process-host', () => ({
runProcess: runProcessMock,
runProcessSync: runProcessSyncMock
}))
import {
forceKillPosixPtyProcessGroups,
forceKillPosixPtyProcessGroupsSync,
getPosixPtyProcessGroups,
isPosixPtyRootStopped,
signalPosixPtyProcessGroups
} from './posix-pty-process-groups'
import {
parseLinuxProcStatSessionId,
readPosixPtyProcessTable,
resetPosixPtyProcessTableDialectForTests
} from './posix-pty-process-table'
import { resetPosixPtyProcessTableDialectForTests } from './posix-pty-process-table'
// `/proc/<pid>/stat` fixture: fields after comm are state, ppid, pgrp, session, tty_nr, ...
const procStat = (pid: number, session: number, comm = 'bash'): string =>
@@ -79,12 +70,6 @@ const TABLE = `
200 200 ttys002
300 300 ??
`
const ALL_PROCESS_ARGS = [
'-e',
'-o',
'pid=PROCESS_ID,pgid=PROCESS_GID,tty=TERMINAL_DEVICE_NUMBER,stat=PROCESS_STATE'
]
const BUSYBOX_TABLE = `
PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER
100 100 136,100
@@ -93,204 +78,8 @@ PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER
201 201 136,10
999 999 ?
`
const unsupportedSelection = (stderr = 'ps: unrecognized option: p\n'): ProcessResult => ({
code: 1,
signal: null,
stdout: '',
stderr,
timedOut: false
})
describe('ps selection compatibility', () => {
it.each([
['p', 'ps: unrecognized option: p\nBusyBox v1.37\nUsage: ps'],
['p', "ps: invalid option -- 'p'\n"],
['p', 'ps: illegal option -- p\n'],
['t', 'ps: unrecognized option: t\n']
])(
'falls back after a rejected %s selector and caches only the dialect (%s)',
async (option, stderr) => {
if (option === 't') {
runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 pts/100' })
}
runProcessMock
.mockResolvedValueOnce(unsupportedSelection(stderr))
.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
.mockResolvedValueOnce({
code: 0,
stdout: BUSYBOX_TABLE.replace('201 201 136,10', '202 202 136,10')
})
const first = await readPosixPtyProcessTable(100)
const second = await readPosixPtyProcessTable(200)
expect(first).toBe(BUSYBOX_TABLE)
expect(getPosixPtyProcessGroups(first, 100, 999)).toEqual([101, 100])
expect(getPosixPtyProcessGroups(second, 200, 999)).toEqual([202, 200])
expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([
['-p', '100', '-o', 'pid=,pgid=,tty=,stat='],
...(option === 't' ? [['-t', 'pts/100', '-o', 'pid=,pgid=,tty=,stat=']] : []),
ALL_PROCESS_ARGS,
ALL_PROCESS_ARGS
])
expect(
runProcessMock.mock.calls.every(
([spec]) => spec.maxOutputBytes === 1048576 && spec.timeoutMs === 1000
)
).toBe(true)
}
)
it('shares the initial unsupported probe across concurrent callers and cancels waiting independently', async () => {
let resolveProbe: (result: ProcessResult) => void = () => {}
runProcessMock
.mockImplementationOnce(
() =>
new Promise<ProcessResult>((resolve) => {
resolveProbe = resolve
})
)
.mockResolvedValue({ code: 0, stdout: BUSYBOX_TABLE })
const first = readPosixPtyProcessTable(100)
const second = readPosixPtyProcessTable(200)
const controller = new AbortController()
const cancelled = readPosixPtyProcessTable(300, controller.signal)
expect(runProcessMock).toHaveBeenCalledOnce()
controller.abort()
await expect(cancelled).rejects.toThrow()
resolveProbe(unsupportedSelection())
expect(getPosixPtyProcessGroups(await first, 100, 999)).toEqual([101, 100])
expect(getPosixPtyProcessGroups(await second, 200, 999)).toEqual([201, 200])
expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([
['-p', '100', '-o', 'pid=,pgid=,tty=,stat='],
ALL_PROCESS_ARGS,
ALL_PROCESS_ARGS
])
})
it('releases every waiter after a failed probe instead of chaining another probe per caller', async () => {
// A wedged ps would otherwise stack one timeout per concurrent force-kill.
const probe = Promise.withResolvers<ProcessResult>()
const queries: ((result: ProcessResult) => void)[] = []
runProcessMock
.mockImplementationOnce(() => probe.promise)
.mockImplementation(() => new Promise((resolve) => queries.push(resolve)))
const first = readPosixPtyProcessTable(100)
const second = readPosixPtyProcessTable(200)
const third = readPosixPtyProcessTable(300)
probe.resolve({ code: null, signal: 'SIGTERM', stdout: '', stderr: '', timedOut: true })
await expect(first).rejects.toThrow('unavailable')
await vi.waitFor(() => expect(runProcessMock).toHaveBeenCalledTimes(3))
for (const resolve of queries) {
resolve({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false })
}
await expect(Promise.all([second, third])).resolves.toEqual(['', ''])
})
it('uses the cached async dialect for synchronous teardown with fresh membership', async () => {
runProcessMock
.mockResolvedValueOnce(unsupportedSelection())
.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
await readPosixPtyProcessTable(100)
runProcessSyncMock.mockReturnValue({
code: 0,
stdout: BUSYBOX_TABLE.replace('101 101 136,100', '102 102 136,100')
})
const signalProcessGroup = vi.fn()
const fallback = vi.fn()
forceKillPosixPtyProcessGroupsSync(100, fallback, {
platform: 'linux',
currentPid: 999,
signalProcessGroup
})
expect(runProcessSyncMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS])
expect(signalProcessGroup.mock.calls).toEqual([[102], [100]])
expect(fallback).not.toHaveBeenCalled()
})
it('discovers unsupported selection during teardown and shares it with async readers', async () => {
runProcessSyncMock
.mockReturnValueOnce(unsupportedSelection())
.mockReturnValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
const signalProcessGroup = vi.fn()
const fallback = vi.fn()
forceKillPosixPtyProcessGroupsSync(100, fallback, {
platform: 'linux',
currentPid: 999,
signalProcessGroup
})
expect(signalProcessGroup.mock.calls).toEqual([[101], [100]])
expect(fallback).not.toHaveBeenCalled()
runProcessMock.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE })
await readPosixPtyProcessTable(200)
expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS])
})
it.each([
{ stderr: 'ps: permission denied' },
{ stderr: 'ps: unrecognized option: o' },
{ stderr: 'ps: unrecognized option: t' },
{ timedOut: true },
{ outputTruncated: true },
{ code: null, signal: 'SIGTERM' }
])('does not turn an unrelated failure into a full-host scan: %j', async (failure) => {
runProcessMock.mockResolvedValueOnce({ ...unsupportedSelection(), ...failure })
await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable')
expect(runProcessMock).toHaveBeenCalledOnce()
runProcessMock
.mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' })
.mockResolvedValueOnce({ code: 0, stdout: TABLE })
await readPosixPtyProcessTable(100)
expect(runProcessMock.mock.calls[1][0].args).toEqual([
'-p',
'100',
'-o',
'pid=,pgid=,tty=,stat='
])
})
it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])(
'rejects incomplete fallback snapshots for async discovery and sync teardown: %j',
async (failure) => {
runProcessMock
.mockResolvedValueOnce(unsupportedSelection())
.mockResolvedValueOnce({ stdout: BUSYBOX_TABLE, ...failure })
await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable')
runProcessSyncMock.mockReturnValue({ stdout: BUSYBOX_TABLE, ...failure })
const signalProcessGroup = vi.fn()
const fallback = vi.fn()
forceKillPosixPtyProcessGroupsSync(100, fallback, {
platform: 'linux',
currentPid: 999,
signalProcessGroup
})
expect(fallback).toHaveBeenCalledOnce()
expect(signalProcessGroup).not.toHaveBeenCalled()
}
)
it('does not cache a rejected selector if the caller has already cancelled', async () => {
const controller = new AbortController()
runProcessMock.mockImplementationOnce(async () => {
controller.abort()
return unsupportedSelection()
})
await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow()
expect(runProcessMock).toHaveBeenCalledOnce()
runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 ?' })
await readPosixPtyProcessTable(100)
expect(runProcessMock.mock.calls[1][0].args[0]).toBe('-p')
})
it.each(['?', '??', '-', '0', '0,0'])(
'refuses to group processes without a controlling terminal (%s)',
(tty) => {
expect(getPosixPtyProcessGroups(`100 100 ${tty}\n101 101 ${tty}`, 100, 999)).toBeNull()
}
)
it('preserves full numeric terminal identity and the daemon terminal guard', () => {
expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 100, 999)).toEqual([101, 100])
expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 200, 999)).toEqual([201, 200])
@@ -298,94 +87,7 @@ describe('ps selection compatibility', () => {
})
})
describe('asynchronous PTY process discovery', () => {
it('requires a stopped state for the exact shell process', () => {
expect(isPosixPtyRootStopped('100 100 pts/test Ts\n101 101 pts/test R+', 100)).toBe(true)
expect(isPosixPtyRootStopped('100 100 pts/test S\n101 101 pts/test T', 100)).toBe(false)
expect(isPosixPtyRootStopped('101 101 pts/test T', 100)).toBe(false)
expect(isPosixPtyRootStopped('100 100 pts/test\n101 101 pts/test T', 100)).toBe(false)
})
it('bounds each lookup and selects the root terminal without synchronous subprocesses', async () => {
const controller = new AbortController()
runProcessMock
.mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' })
.mockResolvedValueOnce({ code: 0, stdout: TABLE })
expect(await readPosixPtyProcessTable(100, controller.signal)).toBe(`100 100 ttys001\n${TABLE}`)
expect(
runProcessMock.mock.calls.map(([spec]) => [{ ...spec, env: { LC_ALL: spec.env.LC_ALL } }])
).toEqual([
[
{
program: 'ps',
env: { LC_ALL: 'C' },
args: ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='],
timeoutMs: 1000,
maxOutputBytes: 1048576,
signal: controller.signal
}
],
[
{
program: 'ps',
env: { LC_ALL: 'C' },
args: ['-t', 'ttys001', '-o', 'pid=,pgid=,tty=,stat='],
timeoutMs: 1000,
maxOutputBytes: 1048576,
signal: controller.signal
}
]
])
expect(runProcessSyncMock).not.toHaveBeenCalled()
})
it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])(
'rejects incomplete process evidence: %j',
async (result) => {
runProcessMock.mockResolvedValue({ stdout: TABLE, ...result })
await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable')
expect(runProcessMock).toHaveBeenCalledOnce()
}
)
it('does not start the second lookup after cancellation', async () => {
const controller = new AbortController()
runProcessMock.mockImplementation(async () => {
controller.abort()
return { code: 0, stdout: '100 100 ttys001' }
})
await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow()
expect(runProcessMock).toHaveBeenCalledOnce()
})
})
describe('POSIX PTY process-group termination', () => {
it('returns every group attached to the root PTY with the root group last', () => {
expect(getPosixPtyProcessGroups(TABLE, 100, 999)).toEqual([101, 103, 100])
})
it('refuses an unbound root or a PTY shared with Orca itself', () => {
expect(getPosixPtyProcessGroups(TABLE, 300, 999)).toBeNull()
expect(getPosixPtyProcessGroups(TABLE, 100, 102)).toBeNull()
expect(getPosixPtyProcessGroups(TABLE, 999, 998)).toBeNull()
})
it('kills foreground and background groups before the PTY leader', async () => {
const fallback = vi.fn()
const signalProcessGroup = vi.fn()
await forceKillPosixPtyProcessGroups(100, fallback, {
platform: 'darwin',
currentPid: 999,
readProcessTable: async () => TABLE,
signalProcessGroup
})
expect(signalProcessGroup.mock.calls.map(([pgid]) => pgid)).toEqual([101, 103, 100])
expect(fallback).not.toHaveBeenCalled()
})
it.each([
['SIGSTOP', [99, 101, 103]],
['SIGCONT', [101, 103, 99]]
@@ -420,42 +122,6 @@ describe('POSIX PTY process-group termination', () => {
}
)
it('falls back when the process table cannot prove PTY ownership', async () => {
const fallback = vi.fn()
await forceKillPosixPtyProcessGroups(100, fallback, {
platform: 'linux',
currentPid: 102,
readProcessTable: async () => TABLE,
signalProcessGroup: vi.fn()
})
expect(fallback).toHaveBeenCalledOnce()
})
it('ignores groups that exited after the snapshot but preserves real signal errors', async () => {
const gone = Object.assign(new Error('gone'), { code: 'ESRCH' })
const denied = Object.assign(new Error('denied'), { code: 'EPERM' })
const signalProcessGroup = vi
.fn<(pgid: number) => void>()
.mockImplementationOnce(() => {
throw gone
})
.mockImplementationOnce(() => {
throw denied
})
await expect(
forceKillPosixPtyProcessGroups(100, vi.fn(), {
platform: 'darwin',
currentPid: 999,
readProcessTable: async () => TABLE,
signalProcessGroup
})
).rejects.toThrow('denied')
expect(signalProcessGroup).toHaveBeenCalledTimes(3)
})
it('signals nothing when the root exits while the table is read', async () => {
// A reaped root frees its pid and TTY, so the table may now describe another terminal.
let rootExited = false
@@ -476,19 +142,6 @@ describe('POSIX PTY process-group termination', () => {
expect(signalProcessGroup).not.toHaveBeenCalled()
expect(fallback).not.toHaveBeenCalled()
})
it('uses the existing fallback on Windows without reading ps', async () => {
const fallback = vi.fn()
const readProcessTable = vi.fn(async () => TABLE)
await forceKillPosixPtyProcessGroups(100, fallback, {
platform: 'win32',
readProcessTable
})
expect(fallback).toHaveBeenCalledOnce()
expect(readProcessTable).not.toHaveBeenCalled()
})
})
describe('Linux terminal session identity (#27337)', () => {
@@ -501,13 +154,6 @@ describe('Linux terminal session identity (#27337)', () => {
600 600 pts/6 Ss
`
it('parses the session id past a comm containing spaces and parentheses', () => {
expect(parseLinuxProcStatSessionId(procStat(42, 7, 'evil) S 1 2 3 (x'))).toBe(7)
expect(parseLinuxProcStatSessionId('42 (bash')).toBeNull()
expect(parseLinuxProcStatSessionId('42 (bash) S 1 42')).toBeNull()
expect(parseLinuxProcStatSessionId('42 (bash) S 1 42 0 0')).toBeNull()
})
it('force-kills only groups in the shell session, not container processes on the same pts', async () => {
procSessions.set(500, 500).set(501, 500).set(600, 600)
const signalProcessGroup = vi.fn()
@@ -522,85 +168,4 @@ describe('Linux terminal session identity (#27337)', () => {
expect(fallback).not.toHaveBeenCalled()
expect(readFileMock).toHaveBeenCalledWith('/proc/500/stat', 'utf8')
})
it('applies the same session filter to the stop/continue sweep', async () => {
procSessions.set(500, 500).set(501, 500).set(600, 600)
const signalProcessGroup = vi.fn()
await signalPosixPtyProcessGroups(100, 'SIGSTOP', vi.fn(), {
platform: 'linux',
currentPid: 999,
readProcessTable: async () => COLLIDING_TABLE,
signalProcessGroup
})
expect(signalProcessGroup.mock.calls).toEqual([[100], [101]])
})
it('excludes candidates whose session can no longer be read', () => {
expect(
getPosixPtyProcessGroups(COLLIDING_TABLE, 100, 999, (pid) =>
pid === 101 ? null : pid < 200 ? 100 : pid
)
).toEqual([100])
})
it.each([
['unreadable', async () => null],
['not a session leader', async () => 50]
])('falls back to the root-only kill when the shell session is %s', async (_label, sessionOf) => {
const signalProcessGroup = vi.fn()
const fallback = vi.fn()
await forceKillPosixPtyProcessGroups(100, fallback, {
platform: 'linux',
currentPid: 999,
readProcessTable: async () => COLLIDING_TABLE,
readProcessSessionId: sessionOf,
signalProcessGroup
})
expect(fallback).toHaveBeenCalledOnce()
expect(signalProcessGroup).not.toHaveBeenCalled()
})
it('keeps macOS selection by terminal alone without reading /proc', async () => {
const signalProcessGroup = vi.fn()
await forceKillPosixPtyProcessGroups(100, vi.fn(), {
platform: 'darwin',
currentPid: 999,
readProcessTable: async () => COLLIDING_TABLE,
signalProcessGroup
})
expect(signalProcessGroup.mock.calls).toEqual([[101], [500], [600], [100]])
expect(readFileMock).not.toHaveBeenCalled()
})
})
describe('POSIX PTY group-sweep breadcrumbs', () => {
it('records every group it actually signalled', async () => {
await forceKillPosixPtyProcessGroups(100, vi.fn(), {
platform: 'darwin',
currentPid: 999,
readProcessTable: async () => TABLE,
signalProcessGroup: vi.fn()
})
expect(recordSelfInitiatedTreeKillMock.mock.calls.map(([kill]) => kill)).toEqual([
{ pid: 101, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' },
{ pid: 103, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' },
{ pid: 100, site: 'posix-pty-process-group-sweep', scope: 'posix-process-group' }
])
})
it('does not claim a group that was already gone', async () => {
await forceKillPosixPtyProcessGroups(100, vi.fn(), {
platform: 'darwin',
currentPid: 999,
readProcessTable: async () => TABLE,
signalProcessGroup: (pgid: number) => {
if (pgid === 103) {
throw Object.assign(new Error('no such process'), { code: 'ESRCH' })
}
}
})
expect(recordSelfInitiatedTreeKillMock.mock.calls.map(([kill]) => kill.pid)).toEqual([101, 100])
})
})
@@ -11,15 +11,9 @@ import {
killWithDescendantSweep,
terminateDescendantSnapshot
} from './pty-descendant-termination'
import {
DESCENDANT_SNAPSHOT_TIMEOUT_MS,
type ProcessTableCapture
} from './pty-descendant-fresh-table'
import { parseProcessTable, type ProcessTableRow } from './pty-process-table-parser'
import {
terminateDescendantSnapshotAndWait,
terminateDescendantSnapshotWithVerdict
} from './pty-descendant-exit-verification'
import type { ProcessTableCapture } from './pty-descendant-fresh-table'
import type { ProcessTableRow } from './pty-process-table-parser'
import { terminateDescendantSnapshotWithVerdict } from './pty-descendant-exit-verification'
import { excludeShellBackgroundJobs } from './pty-shell-job-preservation'
const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026')
@@ -67,40 +61,7 @@ function snapshot(
}
}
describe('parseProcessTable', () => {
it('parses pid/ppid/pgid and keeps the space-containing lstart verbatim', () => {
const rows = parseProcessTable(
[
' 101 1 101 -1 Mon Jul 13 12:54:47 2026',
'42017 101 42017 42017 Tue Jul 14 01:02:03 2026 ',
'',
'not a process line'
].join('\n')
)
expect(rows).toEqual([
{ pid: 101, ppid: 1, pgid: 101, tpgid: -1, startedAt: 'Mon Jul 13 12:54:47 2026' },
{ pid: 42017, ppid: 101, pgid: 42017, tpgid: 42017, startedAt: 'Tue Jul 14 01:02:03 2026' }
])
})
})
describe('collectDescendantRows', () => {
it('walks detached-pgid descendants once even when a non-atomic table looks cyclic', () => {
// Detached process groups still share ancestry: 10 → 20 → 30 → 31; 99 is unrelated.
const table = [
row(10, 1, 10),
row(20, 10, 20),
row(30, 20, 30),
row(31, 30, 30),
row(20, 31, 20), // PID reuse can make a non-atomic ps read look cyclic.
row(99, 1, 99)
]
const snapshot = collectDescendantRows(10, table, CAPTURED_AT_MS)
expect(snapshot.rootPgid).toBe(10)
expect(snapshot.descendants.map((r) => r.pid)).toEqual([20, 30, 31])
expect(snapshot.capturedAtMs).toBe(CAPTURED_AT_MS)
})
it("excludes only the shell's own background jobs; agent-made process groups stay swept", () => {
const tty = (pid: number, ppid: number, pgid: number, tpgid: number) => ({
...row(pid, ppid, pgid),
@@ -156,36 +117,6 @@ describe('captureDescendantSnapshot', () => {
vi.useRealTimers()
})
it('captures no descendants and signals nothing when the root PID was already recycled', async () => {
const readTable = vi
.fn()
.mockResolvedValue(tableCapture([row(501, 500, 500), row(502, 500, 500), row(503, 500, 500)]))
const result = await captureDescendantSnapshot(500, { readTable, platform: 'darwin' })
expect(result?.descendants).toEqual([])
const sendSignal = vi.fn()
terminateDescendantSnapshot(result!, { sendSignal })
expect(sendSignal).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('degrades to null when a custom process-table reader throws synchronously', async () => {
const readTable = vi.fn(() => {
throw new Error('reader exploded')
})
expect(await captureDescendantSnapshot(10, { readTable, platform: 'linux' })).toBeNull()
})
it('degrades to null when ps hangs past the timeout instead of blocking teardown', async () => {
const readTable = vi.fn().mockReturnValue(new Promise<ProcessTableCapture>(() => {}))
const pending = captureDescendantSnapshot(10, {
readTable,
platform: 'darwin',
timeoutMs: 1_000
})
await vi.advanceTimersByTimeAsync(1_000)
expect(await pending).toBeNull()
})
it('records the identity boundary before ps starts even when it crosses a second', async () => {
vi.setSystemTime(CAPTURED_AT_MS + 900)
execFileMock.mockImplementation((...args: unknown[]) => {
@@ -240,18 +171,6 @@ describe('terminateDescendantSnapshot', () => {
expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']])
})
it('bounds a wedged escalation read and releases its deadline timer', async () => {
const sendSignal = vi.fn()
terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), {
sendSignal,
readTable: vi.fn().mockReturnValue(new Promise<ProcessTableCapture>(() => {}))
})
sendSignal.mockClear()
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS + DESCENDANT_SNAPSHOT_TIMEOUT_MS)
expect(sendSignal).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it("uses each row's capture boundary when escalating a merged snapshot", async () => {
const oldBoundary = CAPTURED_AT_MS + 900
const refreshBoundary = CAPTURED_AT_MS + 2_100
@@ -285,77 +204,6 @@ describe('terminateDescendantSnapshotAndWait', () => {
vi.useRealTimers()
})
it('escalates an identity-matched survivor and verifies its exit', async () => {
const survivor = row(20, 10, 20)
const sendSignal = vi.fn()
const readTable = vi
.fn()
.mockResolvedValueOnce(tableCapture([survivor]))
.mockResolvedValueOnce(tableCapture([]))
const pending = terminateDescendantSnapshotAndWait(snapshot([survivor]), {
sendSignal,
readTable,
graceMs: 0,
verifyMs: 200
})
await vi.advanceTimersByTimeAsync(50)
await expect(pending).resolves.toBe(true)
expect(sendSignal.mock.calls).toEqual([
[20, 'SIGTERM'],
[20, 'SIGKILL']
])
})
it('keeps polling past a read that missed its deadline rather than surrendering', async () => {
const survivor = row(20, 10, 20)
const readTable = vi
.fn()
.mockRejectedValueOnce(new Error('ps timed out'))
.mockResolvedValueOnce(tableCapture([survivor]))
.mockResolvedValueOnce(tableCapture([]))
const sendSignal = vi.fn()
const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), {
sendSignal,
readTable,
graceMs: 0,
verifyMs: 2_000
})
await vi.advanceTimersByTimeAsync(500)
await expect(pending).resolves.toBe('exited')
expect(sendSignal.mock.calls).toEqual([
[20, 'SIGTERM'],
[20, 'SIGKILL']
])
})
it('names a survivor seen at the deadline live, never unverifiable', async () => {
const survivor = row(20, 10, 20)
const pending = terminateDescendantSnapshotWithVerdict(snapshot([survivor]), {
sendSignal: vi.fn(),
readTable: vi.fn().mockResolvedValue(tableCapture([survivor])),
graceMs: 0,
verifyMs: 100
})
await vi.advanceTimersByTimeAsync(200)
await expect(pending).resolves.toBe('live')
})
it('names an unreadable verification table unverifiable', async () => {
const pending = terminateDescendantSnapshotWithVerdict(snapshot([row(20, 10, 20)]), {
sendSignal: vi.fn(),
readTable: vi.fn().mockRejectedValue(new Error('ps exploded')),
verifyMs: 200
})
await vi.advanceTimersByTimeAsync(400)
await expect(pending).resolves.toBe('unverifiable')
})
it('never escalates a duplicate PID observation during shutdown verification', async () => {
const survivor = row(20, 10, 20)
const sendSignal = vi.fn()
@@ -373,24 +221,6 @@ describe('terminateDescendantSnapshotAndWait', () => {
expect(sendSignal).not.toHaveBeenCalled()
})
it('does not signal a recycled descendant when identity validation is required', async () => {
const sendSignal = vi.fn()
const recycled = row(20, 10, 20, 'Tue Jul 14 13:00:00 2026')
const pending = terminateDescendantSnapshotWithVerdict(
snapshot([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')]),
{
sendSignal,
readTable: vi.fn().mockResolvedValue(tableCapture([recycled], CAPTURED_AT_MS + 1_000)),
requireIdentityBeforeSignal: true,
verifyMs: 100
}
)
await vi.advanceTimersByTimeAsync(200)
await expect(pending).resolves.toBe('exited')
expect(sendSignal).not.toHaveBeenCalled()
})
it('uses row-scoped boundaries for forced cleanup in the exit verifier', async () => {
const oldBoundary = CAPTURED_AT_MS + 900
const refreshBoundary = CAPTURED_AT_MS + 2_100
@@ -424,19 +254,6 @@ describe('terminateDescendantSnapshotAndWait', () => {
})
describe('createProcessTableSnapshotReader', () => {
it('coalesces same-turn teardown requests onto one fresh scan', async () => {
const capture = tableCapture([row(10, 1, 10)])
const readFresh = vi.fn().mockResolvedValue(capture)
const readTable = createProcessTableSnapshotReader(readFresh)
const results = await Promise.all(Array.from({ length: 20 }, () => readTable(1_000)))
expect(results.every((result) => result === capture)).toBe(true)
expect(readFresh).toHaveBeenCalledOnce()
await readTable(1_000)
expect(readFresh).toHaveBeenCalledTimes(2)
})
it('queues a post-request scan when another scan has already started', async () => {
const firstGate = deferred<ProcessTableCapture>()
const secondGate = deferred<ProcessTableCapture>()
@@ -462,17 +279,6 @@ describe('createProcessTableSnapshotReader', () => {
secondGate.resolve(newer)
await expect(Promise.all([laterA, laterB])).resolves.toEqual([newer, newer])
})
it('retries after failed process-table reads', async () => {
const readFresh = vi
.fn()
.mockRejectedValueOnce(new Error('ps failed'))
.mockResolvedValueOnce(tableCapture([]))
const readTable = createProcessTableSnapshotReader(readFresh)
await expect(readTable()).rejects.toThrow('ps failed')
await expect(readTable()).resolves.toEqual(tableCapture([]))
expect(readFresh).toHaveBeenCalledTimes(2)
})
})
describe('killWithDescendantSweep', () => {
@@ -506,39 +312,6 @@ describe('killWithDescendantSweep', () => {
expect(events).toEqual(['descendants', 'root', 'finished'])
})
it('signals descendants after snapshot resolution, then kills the root', async () => {
const events: string[] = []
const sendSignal = vi.fn(() => events.push('descendant-term'))
const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)]))
const killRoot = vi.fn(() => events.push('root-kill'))
const pending = killWithDescendantSweep(10, killRoot, {
readTable,
sendSignal,
platform: 'darwin'
})
expect(killRoot).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
await pending
expect(killRoot).toHaveBeenCalledOnce()
expect(sendSignal.mock.calls).toEqual([[20, 'SIGTERM']])
expect(events).toEqual(['descendant-term', 'root-kill'])
})
it('still kills the root when the snapshot is unavailable', async () => {
const sendSignal = vi.fn()
const readTable = vi.fn().mockRejectedValue(new Error('ps exploded'))
const killRoot = vi.fn()
const pending = killWithDescendantSweep(10, killRoot, {
readTable,
sendSignal,
platform: 'darwin'
})
await vi.advanceTimersByTimeAsync(0)
await pending
expect(killRoot).toHaveBeenCalledOnce()
expect(sendSignal).not.toHaveBeenCalled()
})
it('on Windows terminates the owning job instead of probing and taskkilling', async () => {
const events: string[] = []
const terminateOwnedTree = vi.fn(() => {
@@ -563,84 +336,6 @@ describe('killWithDescendantSweep', () => {
expect(events).toEqual(['job-kill', 'root-kill'])
})
it('on Windows falls back to the probe when the pty has no job', async () => {
// Older or unassigned PTYs lack jobs; unavailable cannot prove their processes exited.
const terminateOwnedTree = vi.fn(() => 'unavailable' as const)
const killWindowsTree = vi.fn(async () => {})
const killRoot = vi.fn()
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
terminateOwnedTree,
killWindowsTree,
verifyTreeKillTarget: async () => 'own'
})
expect(killWindowsTree).toHaveBeenCalledWith(4242)
expect(killRoot).toHaveBeenCalledOnce()
})
it('on Windows taskkills the process tree before killRoot (#10004)', async () => {
const events: string[] = []
const killWindowsTree = vi.fn(async () => {
events.push('tree-kill')
})
const killRoot = vi.fn(() => events.push('root-kill'))
const sendSignal = vi.fn()
const readTable = vi.fn()
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
killWindowsTree,
sendSignal,
readTable,
// Avoid querying the host’s real process table for this synthetic PID.
verifyTreeKillTarget: async () => 'own'
})
expect(killWindowsTree).toHaveBeenCalledWith(4242)
expect(killRoot).toHaveBeenCalledOnce()
expect(sendSignal).not.toHaveBeenCalled()
expect(readTable).not.toHaveBeenCalled()
expect(events).toEqual(['tree-kill', 'root-kill'])
})
it('on Windows skips taskkill when the root pid was recycled by a stranger', async () => {
const killWindowsTree = vi.fn(async () => {})
const killRoot = vi.fn()
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
killWindowsTree,
verifyTreeKillTarget: async () => 'foreign'
})
expect(killWindowsTree).not.toHaveBeenCalled()
expect(killRoot).toHaveBeenCalledOnce()
})
it('on Windows skips taskkill when identity is unknown', async () => {
const killWindowsTree = vi.fn(async () => {})
const killRoot = vi.fn()
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
killWindowsTree,
verifyTreeKillTarget: async () => 'unknown'
})
expect(killWindowsTree).not.toHaveBeenCalled()
expect(killRoot).toHaveBeenCalledOnce()
})
it('on Windows skips taskkill when the identity probe throws', async () => {
const killWindowsTree = vi.fn(async () => {})
const killRoot = vi.fn()
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
killWindowsTree,
verifyTreeKillTarget: async () => {
throw new Error('probe exploded')
}
})
expect(killWindowsTree).not.toHaveBeenCalled()
expect(killRoot).toHaveBeenCalledOnce()
})
it('on Windows re-checks ownership lost while the identity probe ran', async () => {
const killWindowsTree = vi.fn(async () => {})
const killRoot = vi.fn()
@@ -657,50 +352,4 @@ describe('killWithDescendantSweep', () => {
expect(killWindowsTree).not.toHaveBeenCalled()
expect(killRoot).toHaveBeenCalledOnce()
})
it('on Windows does not probe identity once ownership is already lost', async () => {
const verifyTreeKillTarget = vi.fn(async () => 'own' as const)
const killRoot = vi.fn()
await killWithDescendantSweep(4242, killRoot, {
platform: 'win32',
killWindowsTree: vi.fn(async () => {}),
ownsRoot: () => false,
verifyTreeKillTarget
})
expect(verifyTreeKillTarget).not.toHaveBeenCalled()
expect(killRoot).toHaveBeenCalledOnce()
})
it('on Windows still kills the root when taskkill fails', async () => {
const killWindowsTree = vi.fn(async () => {
throw new Error('taskkill failed')
})
const killRoot = vi.fn()
await expect(
killWithDescendantSweep(99, killRoot, {
platform: 'win32',
killWindowsTree,
verifyTreeKillTarget: async () => 'own'
})
).resolves.toBeUndefined()
expect(killRoot).toHaveBeenCalledOnce()
})
it('does not signal a captured tree after the caller loses root ownership', async () => {
const sendSignal = vi.fn()
const killRoot = vi.fn()
const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)]))
const ownsRoot = vi.fn(() => false)
await killWithDescendantSweep(10, killRoot, {
readTable,
sendSignal,
platform: 'darwin',
ownsRoot
})
expect(ownsRoot).toHaveBeenCalledOnce()
expect(sendSignal).not.toHaveBeenCalled()
expect(killRoot).toHaveBeenCalledOnce()
})
})
@@ -1,14 +1,6 @@
import type * as ForegroundModule from './terminal-process-rows'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
observePtyForeground,
readPtyForegroundGroup,
resolvePtyForegroundMembers
} from './pty-master-foreground-observation'
import { isTerminalForegroundObservation } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation'
import { judgeTerminalForeground } from '../../runtime/terminal-foreground-group'
import type { ProcessTableRow } from '../../../shared/process-table-snapshot'
import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent'
import { observePtyForeground, readPtyForegroundGroup } from './pty-master-foreground-observation'
const mocks = vi.hoisted(() => ({ rows: vi.fn(), tty: vi.fn(), ps: vi.fn() }))
vi.mock('./terminal-process-rows', async (importOriginal) => ({
...(await importOriginal<typeof ForegroundModule>()),
@@ -37,54 +29,6 @@ beforeEach(() => {
mocks.ps.mockResolvedValue('200 pts/5')
})
describe('physical PTY observations', () => {
it('survives client rebind and a missing group leader/root row', async () => {
const owner = proc()
const first = await observePtyForeground(owner, () => true)
const afterRebind = await observePtyForeground(owner, () => true)
expect(first).toMatchObject({
verdict: 'observed',
source: 'pty-master',
foregroundKind: 'non-shell',
processName: 'claude',
foregroundPgid: 200
})
expect(afterRebind).toEqual(first)
expect(mocks.tty).not.toHaveBeenCalled()
})
it('uses all members rather than naming only a wrapper leader', async () => {
mocks.rows.mockResolvedValue([
row('sh ./wrapper', 200),
{ ...row('node /opt/node_modules/@openai/codex/bin/codex.js'), ppid: 200 }
])
expect(await observePtyForeground(proc(), () => true)).toMatchObject({
verdict: 'observed',
foregroundKind: 'non-shell',
processName: 'codex'
})
})
it.each([
['node /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js --resume abc', 'claude'],
['/home/user/.local/share/claude/versions/2.1.26', '2.1.26'],
['python3 /opt/work/unknown_agent.py', 'python3']
])(
'resolves real installation argv without rewriting the user command: %s',
(command, processName) => {
const sample = resolvePtyForegroundMembers([row(command)], 200)
expect(sample).toMatchObject({
verdict: 'observed',
foregroundKind: 'non-shell',
processName
})
}
)
it('keeps unreadable non-shell identity separate from local non-shell admission', () => {
expect(resolvePtyForegroundMembers([row('(node)')], 200)).toMatchObject({
verdict: 'observed',
foregroundKind: 'non-shell',
processName: null
})
})
it('never queries a retired/reused fd or rescues a supported failure with a cached name', async () => {
const owner = proc()
owner.fd = -1
@@ -109,21 +53,7 @@ describe('physical PTY observations', () => {
reason: 'session_changed'
})
})
it('rereads at most once when the group changes and obeys its original deadline', async () => {
const owner = proc()
owner.getForegroundProcessGroup
.mockReturnValueOnce(200)
.mockReturnValueOnce(300)
.mockReturnValueOnce(200)
.mockReturnValueOnce(300)
expect(await observePtyForeground(owner, () => true)).toMatchObject({ verdict: 'unverifiable' })
expect(mocks.rows).toHaveBeenCalledTimes(2)
mocks.rows.mockClear()
expect(await observePtyForeground(proc(), () => true, 0)).toMatchObject({
verdict: 'unverifiable'
})
expect(mocks.rows).not.toHaveBeenCalled()
})
it('supports older native images with fresh PTY-matched root proof', async () => {
const owner = proc()
owner.getForegroundProcessGroup.mockReturnValue(undefined)
@@ -136,166 +66,4 @@ describe('physical PTY observations', () => {
mocks.tty.mockResolvedValue('pts/6')
expect(await observePtyForeground(owner, () => true)).toMatchObject({ verdict: 'unverifiable' })
})
it('validates optional host observations without treating a future/malformed branch as proof', () => {
const metadata = {
ptyId: 'p',
ptyIncarnationId: 'i',
authorityGeneration: 'a',
observationEpoch: 1,
capturedAgeMs: 0
}
expect(
isTerminalForegroundObservation({
...metadata,
verdict: 'observed',
source: 'pty-master',
foregroundKind: 'non-shell',
processName: 'claude',
foregroundPgid: 200
})
).toBe(true)
expect(
isTerminalForegroundObservation({
...metadata,
verdict: 'observed',
source: 'pty-master',
foregroundKind: 'agent',
processName: 'claude'
})
).toBe(false)
expect(
isTerminalForegroundObservation({
...metadata,
verdict: 'unverifiable',
reason: 'new_host_reason'
})
).toBe(true)
})
})
/** `ps -t` rows on one terminal: the shell (root 100) and whatever holds foreground group `tpgid`. */
function terminal(
tpgid: number,
members: [pid: number, ppid: number, pgid: number, stat: string, command: string][]
): ProcessTableRow[] {
return members.map(([pid, ppid, pgid, stat, command]) => ({
pid,
ppid,
pgid,
tpgid,
stat,
command
}))
}
const CLAUDE_NODE = 'node /usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'
const CODEX_NODE = 'node /usr/local/lib/node_modules/@openai/codex/bin/codex.js'
const CODEX_NATIVE =
'/usr/local/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex/codex'
describe('local admission parity with the root-pid judge', () => {
it.each<[string, TuiAgent, ProcessTableRow[]]>([
['plain shell', 'claude', terminal(100, [[100, 1, 100, 'Ss+', '-zsh']])],
[
'nested shell',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', 'bash']
])
],
[
'native claude',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', 'claude --resume abc']
])
],
[
'node claude',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', CLAUDE_NODE]
])
],
[
'codex node with its native child',
'codex',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', CODEX_NODE],
[201, 200, 200, 'S+', CODEX_NATIVE]
])
],
[
'non-exec wrapper script',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', 'sh /usr/local/bin/claude'],
[201, 200, 200, 'S+', CLAUDE_NODE]
])
],
[
'interpreter running an agent script',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', 'bash /opt/bin/claude']
])
],
[
'agent stopped with Ctrl-Z',
'claude',
terminal(100, [
[100, 1, 100, 'Ss+', '-zsh'],
[200, 100, 200, 'T', 'claude']
])
],
[
'tmux client',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', 'tmux attach']
])
],
[
'sudo with withheld argv',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', '(sudo)']
])
],
[
'unreadable root shell',
'claude',
terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, 'S+', '(bash)']
])
]
])('%s', (_name, agent, rows) => {
const sample = resolvePtyForegroundMembers(rows, rows[0].tpgid ?? 0)
const local =
sample.verdict !== 'observed'
? 'unknown'
: sample.foregroundKind === 'shell'
? 'shell'
: 'agent'
expect(local).toBe(judgeTerminalForeground(rows, 100, agent))
})
it('refuses a zombie or stopped-only foreground the root-pid judge admitted', () => {
for (const stat of ['Z+', 'T+']) {
const rows = terminal(200, [
[100, 1, 100, 'Ss', '-zsh'],
[200, 100, 200, stat, 'claude']
])
expect(judgeTerminalForeground(rows, 100, 'claude')).toBe('agent')
expect(resolvePtyForegroundMembers(rows, 200).verdict).toBe('unverifiable')
}
})
})
@@ -13,17 +13,12 @@ import { tmpdir } from 'node:os'
import { delimiter, isAbsolute, join } from 'node:path'
import { execFileSync, spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight'
import {
CODEX_COMMAND_IDENTITY_ENV_KEYS,
getFishCodexShellLaunchPreflight,
getPosixCodexShellLaunchPreflight,
getPowerShellCodexShellLaunchPreflight
getPosixCodexShellLaunchPreflight
} from '../../../shared/codex-shell-function'
import {
fishRequirementViolation,
resolveFishBinary
} from '../../../shared/fish-binary-requirement'
import { resolveFishBinary } from '../../../shared/fish-binary-requirement'
import { getPowerShellAgentWrappers } from '../../../shared/powershell-agent-wrappers'
const roots: string[] = []
@@ -199,13 +194,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
expect(runAliasLaunch(afterRoot, getPosixCodexShellLaunchPreflight())).toBe('normal')
})
it.skipIf(!existsSync('/bin/zsh'))('repairs a zsh cx alias before Codex starts', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-zsh-alias-'))
roots.push(root)
expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/zsh')).toBe('normal')
})
it.skipIf(!zshAvailable)('keeps a user alias named codex working in zsh', () => {
expectNamedAliasSurvives('/bin/zsh', 'setopt aliases')
})
@@ -214,39 +202,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
expectNamedAliasSurvives('/bin/bash', 'shopt -s expand_aliases')
})
it('still launches Codex when the best-effort preflight fails', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-failure-'))
roots.push(root)
expect(runAliasLaunch(root, getPosixCodexShellLaunchPreflight(), '/bin/bash', false)).toBe(
'hooks-review'
)
})
it('does not trigger a preflight outside an Orca terminal', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-'))
roots.push(root)
const bin = join(root, 'bin')
const marker = join(root, 'preflight-ran')
mkdirSync(bin)
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n')
writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`)
const output = execFileSync(
'/bin/bash',
['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`],
{
encoding: 'utf-8',
env: {
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`
}
}
)
expect(output.trim()).toBe('launched')
expect(existsSync(marker)).toBe(false)
})
// skipIf exposes missing shells; an early return would report an unexercised test as passing.
for (const [shell, strict] of [
['/bin/bash', 'set -e'],
@@ -276,29 +231,7 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
)
}
it.skipIf(!fishAvailable)('preserves a user-defined fish function', () => {
const output = execFileSync(
'fish',
[
'--no-config',
'-c',
[
'set -gx ORCA_CODEX_LAUNCH_PREFLIGHT missing-preflight',
'function codex; echo custom-codex; end',
getFishCodexShellLaunchPreflight(),
'codex'
].join('\n')
],
{ encoding: 'utf-8' }
)
expect(output.trim()).toBe('custom-codex')
})
// Fail explicitly when CI requires fish; skipped regression cases cannot establish coverage.
it('has fish when CI demanded it', () => {
expect(fishRequirementViolation(fishLookup)).toBeNull()
})
// #16893: absent Codex made unquoted (type -t codex) vanish and test complain; exercise real -l -C startup.
it.skipIf(!fishAvailable)('stays silent and installs no wrapper when codex is absent', () => {
@@ -314,174 +247,6 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
expect(result.status).toBe(0)
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
})
it.skipIf(!fishAvailable)('wraps codex and runs the preflight when codex is a real file', () => {
const { bin, preflight, marker } = createFishSandbox('orca-codex-fish-present-')
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex $*"\n')
const output = execFileSync(
join(bin, 'fish'),
['--no-config', '-l', '-C', getFishCodexShellLaunchPreflight(), '-c', 'codex hi'],
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
)
expect(output.trim()).toBe('real codex hi')
expect(existsSync(marker)).toBe(true)
})
it.skipIf(!fishAvailable)('leaves a codex alias unwrapped', () => {
const { bin, preflight } = createFishSandbox('orca-codex-fish-alias-')
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf "real codex"\n')
const result = spawnSync(
join(bin, 'fish'),
[
'--no-config',
'-l',
'-C',
`alias codex='echo aliased'\n${getFishCodexShellLaunchPreflight()}`,
'-c',
FISH_STATE_PROBE
],
{ encoding: 'utf-8', env: { PATH: bin, ORCA_CODEX_LAUNCH_PREFLIGHT: preflight } }
)
expect(result.stderr).not.toContain('Missing argument')
expect(result.stdout.trim()).toBe('wrapper=NO var=[]')
})
})
describe('PowerShell Codex shell launch preflight', () => {
it('preserves a user-defined command', () => {
expect(getPowerShellCodexShellLaunchPreflight()).toContain(
'$orcaCodexCommand.CommandType -in @("Application", "ExternalScript")'
)
})
it.skipIf(!pwshAvailable)('fails open when native errors are promoted', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-pwsh-failure-'))
const bin = join(root, 'bin')
roots.push(root)
mkdirSync(bin)
const executableSuffix = process.platform === 'win32' ? '.cmd' : ''
writeExecutable(
join(bin, `orca-test${executableSuffix}`),
process.platform === 'win32' ? '@exit /b 7\r\n' : '#!/bin/sh\nexit 7\n'
)
writeExecutable(
join(bin, `codex${executableSuffix}`),
process.platform === 'win32' ? '@echo launched\r\n' : '#!/bin/sh\nprintf "launched\\n"\n'
)
const result = spawnSync(
'pwsh',
[
'-NoLogo',
'-NoProfile',
'-Command',
[
'$ErrorActionPreference = "Stop"',
'$PSNativeCommandUseErrorActionPreference = $true',
getPowerShellAgentWrappers(),
'codex'
].join('\n')
],
{
encoding: 'utf-8',
env: {
...process.env,
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`,
ORCA_CODEX_LAUNCH_PREFLIGHT: join(bin, `orca-test${executableSuffix}`)
}
}
)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout.trim()).toBe('launched')
})
})
describe('Codex shell launch preflight command', () => {
function makeCliRoot(): { resourcesPath: string } {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-'))
roots.push(root)
const resourcesPath = join(root, 'resources')
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
return { resourcesPath }
}
function wslOptions(resourcesPath: string | null) {
return {
hooksEnabled: true,
isPackaged: true,
isWsl: true,
managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home',
resourcesPath,
platform: 'win32' as const
}
}
it.each([
{ platform: 'darwin' as const, bundled: 'orca', isPackaged: true },
{ platform: 'linux' as const, bundled: 'orca-ide', isPackaged: true },
{ platform: 'win32' as const, bundled: 'orca.exe', isPackaged: true },
{ platform: 'darwin' as const, bundled: 'orca', isPackaged: false }
])(
'gives a native $platform pane no preflight: the app prepares its Codex home (packaged $isPackaged)',
(config) => {
const { resourcesPath } = makeCliRoot()
writeExecutable(join(resourcesPath, 'bin', config.bundled), '#!/bin/sh\nexit 0\n')
expect(
resolveCodexShellLaunchPreflightCommand({
hooksEnabled: true,
isPackaged: config.isPackaged,
managedHomePath: '/managed/home',
resourcesPath,
platform: config.platform
})
).toBeNull()
}
)
it('carries the packaged Windows launcher, as an absolute path, for WSLENV path translation', () => {
const { resourcesPath } = makeCliRoot()
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
const command = resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath))
expect(command).toBe(launcherPath)
expect(isAbsolute(command as string)).toBe(true)
})
it.each([
{ label: 'the launcher file is missing', create: null },
{ label: 'the launcher path is a directory', create: 'directory' as const }
])('skips the preflight when $label', (config) => {
const { resourcesPath } = makeCliRoot()
if (config.create === 'directory') {
mkdirSync(join(resourcesPath, 'bin', 'orca.exe'))
}
expect(resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath))).toBeNull()
})
it('skips the preflight when the packaged build exposes no resources root', () => {
expect(resolveCodexShellLaunchPreflightCommand(wslOptions(null))).toBeNull()
})
it.each([{ hooksEnabled: false }, { isPackaged: false }, { managedHomePath: null }])(
'does not enable an unsupported WSL preflight for %o',
(override) => {
const { resourcesPath } = makeCliRoot()
writeExecutable(join(resourcesPath, 'bin', 'orca.exe'), '#!/bin/sh\nexit 0\n')
expect(
resolveCodexShellLaunchPreflightCommand({ ...wslOptions(resourcesPath), ...override })
).toBeNull()
}
)
})
// Verified launcher paths may contain spaces in app bundles or Program Files.
@@ -293,24 +293,6 @@ describe('codex wrapper --no-daemon rule', () => {
expect(lines(result.stdout)).toEqual(ADDED.map((argv) => expectedLine(argv, false)))
})
it('adds nothing with ORCA_CODEX_ISOLATE=0, read on every call', () => {
const sandbox = makeSandbox(HELP_WITH_FLAG)
const setIsolate = (value: string): string =>
isPowerShell(shell)
? `$env:ORCA_CODEX_ISOLATE = '${value}'`
: shell === 'fish'
? `set -gx ORCA_CODEX_ISOLATE ${value}`
: `export ORCA_CODEX_ISOLATE=${value}`
const result = run(
shell,
['codex a', setIsolate('1'), 'codex b', setIsolate('0'), 'codex c'].join('\n'),
sandbox,
{ ORCA_CODEX_ISOLATE: '0' }
)
expect(lines(result.stdout)).toEqual(['ARGV|a', 'ARGV|--no-daemon|b', 'ARGV|c'])
})
it('re-probes --help when Codex changes version mid-shell', () => {
const sandbox = makeSandbox(HELP_WITH_FLAG)
const swap = (help: string): string =>
@@ -395,15 +377,6 @@ describe('codex wrapper --no-daemon rule', () => {
}
)
it.skipIf(!available)(`${shell} forwards identity to Codex 0.155 and older too`, () => {
const sandbox = makeSandbox(HELP_WITHOUT_FLAG)
const result = run(shell, codexCall(shell, ['x']), sandbox, identity)
expect(lines(result.stdout).map(splitOverrides)).toEqual([
{ overrides: identityOverrides, rest: ['x'] }
])
})
it.skipIf(!available)(`${shell} skips a value holding an apostrophe`, () => {
const sandbox = makeSandbox(HELP_WITHOUT_FLAG)
const result = run(shell, codexCall(shell, ['x']), sandbox, {
@@ -415,52 +388,9 @@ describe('codex wrapper --no-daemon rule', () => {
{ overrides: [identityOverrides[0]], rest: ['x'] }
])
})
it.skipIf(!available)(`${shell} forwards nothing with ORCA_CODEX_ISOLATE=0`, () => {
const sandbox = makeSandbox(HELP_WITH_FLAG)
const result = run(shell, codexCall(shell, ['x']), sandbox, {
...identity,
ORCA_CODEX_ISOLATE: '0'
})
expect(lines(result.stdout)).toEqual(['ARGV|x'])
})
}
})
for (const [shell, enableAliases] of [
['bash', 'shopt -s expand_aliases'],
['zsh', 'setopt aliases']
] as const) {
it.skipIf(isWindows || !existsSync(`/bin/${shell}`))(
`applies a user alias named codex defined before the wrapper in ${shell}`,
() => {
const sandbox = makeSandbox(HELP_WITH_FLAG)
const result = run(
shell,
'codex x',
sandbox,
{},
`${enableAliases}\nalias codex='codex --alias-flag'`
)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout.trim()).toBe('ARGV|--no-daemon|--alias-flag|x')
}
)
}
it.skipIf(isWindows || !existsSync('/bin/zsh'))(
'creates no globals under warn_create_global',
() => {
const sandbox = makeSandbox(HELP_WITH_FLAG)
const result = run('zsh', 'setopt warn_create_global no_unset\ncodex x', sandbox)
expect(result.stderr).toBe('')
expect(result.stdout.trim()).toBe('ARGV|--no-daemon|x')
}
)
for (const [shell, available] of shells.filter(([name]) => isPowerShell(name))) {
it.skipIf(!available)(
`${shell} runs under StrictMode and Stop with a failing, noisy hook prep`,
-229
View File
@@ -1,229 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
getGitBashCandidatePaths,
isGitForWindowsBashLauncherPath,
isWindowsGitBashShellPath,
resolveGitBashPath,
resolveWindowsGitBashShellPath
} from './git-bash'
describe('Git Bash path discovery', () => {
it('includes common Git for Windows install locations and PATH-derived fallbacks', () => {
const candidates = getGitBashCandidatePaths({
ProgramFiles: 'C:\\Program Files',
LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local',
Path: '"C:\\Program Files\\Git\\cmd";C:\\tools;C:\\PortableGit\\bin'
})
expect(candidates).toContain('C:\\Program Files\\Git\\bin\\bash.exe')
expect(candidates).toContain('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe')
expect(candidates).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe')
expect(candidates).toContain('C:\\PortableGit\\bin\\bash.exe')
expect(candidates).not.toContain('C:\\tools\\bash.exe')
})
it('resolves the first existing common bash.exe path on Windows', () => {
const exists = vi.fn((path: string) => path === 'C:\\Program Files\\Git\\bin\\bash.exe')
expect(
resolveGitBashPath({
platform: 'win32',
env: { ProgramFiles: 'C:\\Program Files' },
exists
})
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
})
it('does not expose Git Bash discovery on non-Windows hosts', () => {
expect(
resolveGitBashPath({
platform: 'darwin',
env: { ProgramFiles: 'C:\\Program Files' },
exists: () => true
})
).toBeNull()
})
it('maps the persisted Git Bash sentinel to a discovered bash.exe path', () => {
expect(
resolveWindowsGitBashShellPath('git-bash', {
platform: 'win32',
env: { LOCALAPPDATA: 'C:\\Users\\alice\\AppData\\Local' },
exists: (path) => path === 'C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'
})
).toBe('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe')
})
// Inject platform options so these assertions also exercise Windows behavior on other hosts.
it('honors an explicit bash.exe path for future user-configurable launch paths', () => {
expect(
resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', {
platform: 'win32',
env: {},
exists: (path) => path === 'D:\\PortableGit\\bin\\bash.exe'
})
).toBe('D:\\PortableGit\\bin\\bash.exe')
})
it('rejects an explicit Git Bash path that is no longer installed', () => {
expect(
resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', {
platform: 'win32',
env: {},
exists: () => false
})
).toBeNull()
})
it('resolves a bare bash entry through Git Bash discovery', () => {
expect(
resolveWindowsGitBashShellPath('bash', {
platform: 'win32',
env: { ProgramFiles: 'C:\\Program Files' },
exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe'
})
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
})
it('returns null for a bare bash entry when Git Bash is not installed', () => {
expect(
resolveWindowsGitBashShellPath('bash', {
platform: 'win32',
env: { ProgramFiles: 'C:\\Program Files' },
exists: () => false
})
).toBeNull()
})
it('resolves an extension-less Git Bash path to the bash.exe it names', () => {
expect(
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', {
platform: 'win32',
env: {},
exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe'
})
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
})
it('returns null for an extension-less Git Bash path with no bash.exe beside it', () => {
expect(
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', {
platform: 'win32',
env: {},
exists: () => false
})
).toBeNull()
})
it('does not treat an extension-less non-Git bash path as Git Bash', () => {
expect(
resolveWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash', {
platform: 'win32',
env: {},
exists: () => true
})
).toBeNull()
})
it('does not probe a bash-prefixed path that is not bash itself', () => {
expect(
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash.old', {
platform: 'win32',
env: {},
exists: () => true
})
).toBeNull()
})
it('recognizes Git Bash executable paths case-insensitively', () => {
expect(isWindowsGitBashShellPath('D:\\PortableGit\\BIN\\BASH.EXE')).toBe(true)
})
it('does not classify arbitrary bash.exe paths as Git Bash', () => {
expect(
resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe', {
platform: 'win32',
env: {},
exists: () => true
})
).toBeNull()
expect(isWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash.exe')).toBe(false)
})
it('ignores non-Git bash.exe candidates discovered through PATH', () => {
expect(
resolveGitBashPath({
platform: 'win32',
env: { Path: 'C:\\msys64\\usr\\bin' },
exists: (path) => path === 'C:\\msys64\\usr\\bin\\bash.exe'
})
).toBeNull()
})
it('identifies only the bin\\bash.exe launcher, not the MSYS bash it runs', () => {
expect(isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\bin\\bash.exe')).toBe(true)
expect(isGitForWindowsBashLauncherPath('D:\\PortableGit\\bin\\bash.exe')).toBe(true)
expect(isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\usr\\bin\\bash.exe')).toBe(
false
)
expect(isGitForWindowsBashLauncherPath('bash.exe')).toBe(false)
})
it('identifies a launcher in an install folder named anything else', () => {
const renamedInstall = (path: string): boolean =>
[
'C:\\Tools\\Git-2.55\\bin\\bash.exe',
'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe',
'C:\\Tools\\Git-2.55\\cmd\\git.exe',
'C:\\Tools\\Git-2.55\\git-bash.exe'
].includes(path)
expect(
isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', {
exists: renamedInstall
})
).toBe(true)
expect(
isGitForWindowsBashLauncherPath('C:\\Users\\a\\scoop\\apps\\git\\current\\bin\\bash.exe', {
exists: (path) => path.startsWith('C:\\Users\\a\\scoop\\apps\\git\\current\\')
})
).toBe(true)
})
it.each([
['usr\\bin\\bash.exe', 'C:\\Tools\\Git-2.55\\usr\\bin\\bash.exe'],
['cmd\\git.exe', 'C:\\Tools\\Git-2.55\\cmd\\git.exe'],
['git-bash.exe', 'C:\\Tools\\Git-2.55\\git-bash.exe']
])('refuses a renamed install root missing only %s', (_label, missingMarker) => {
expect(
isGitForWindowsBashLauncherPath('C:\\Tools\\Git-2.55\\bin\\bash.exe', {
exists: (path) => path !== missingMarker
})
).toBe(false)
})
it('refuses a bash that is not a Git for Windows launcher, even where every path exists', () => {
const everythingExists = (): boolean => true
// Direct usr/bin/bash.exe has no nested Git install root.
expect(
isGitForWindowsBashLauncherPath('C:\\Program Files\\Git\\usr\\bin\\bash.exe', {
exists: (path) => !path.includes('\\usr\\usr\\') && !path.includes('\\usr\\cmd\\')
})
).toBe(false)
const observedCygwinRoot = ['C:\\cygwin64\\etc', 'C:\\cygwin64\\var']
expect(
isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', {
exists: (path) => observedCygwinRoot.includes(path)
})
).toBe(false)
// Even Git’s cmd/git.exe and usr/bin/bash.exe paths do not replace the git-bash.exe marker.
expect(
isGitForWindowsBashLauncherPath('C:\\cygwin64\\bin\\bash.exe', {
exists: (path) => path.endsWith('bin\\bash.exe') || path.endsWith('cmd\\git.exe')
})
).toBe(false)
expect(
isGitForWindowsBashLauncherPath('C:\\Tools\\sh\\bash.exe', { exists: everythingExists })
).toBe(false)
expect(isGitForWindowsBashLauncherPath('bash.exe', { exists: everythingExists })).toBe(false)
})
})
@@ -1,40 +1,15 @@
import { spawn, spawnSync } from 'node:child_process'
import {
chmodSync,
symlinkSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { symlinkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as PosixTombstoneModule from './legacy-terminal-posix-tombstone'
import {
readVerifiedShebangInterpreter,
resolvePosixTombstoneInterpreter
} from './legacy-terminal-posix-tombstone'
import {
__resetLegacyTerminalShimNeutralizationForTests,
isLegacyTerminalShimPathEntry,
neutralizeLegacyTerminalShimDir,
stripLegacyTerminalShimEnv
neutralizeLegacyTerminalShimDir
} from './legacy-terminal-shim-dir'
const itOnPosix = process.platform === 'win32' ? it.skip : it
// Windows ignores these permissions and root bypasses them.
const itOnPosixNonRoot = process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it
// indexOf returns -1 for missing text; assert presence before ordering.
function expectOrdered(text: string, first: string, second: string): void {
expect(text, `missing: ${first}`).toContain(first)
expect(text, `missing: ${second}`).toContain(second)
expect(text.indexOf(first)).toBeLessThan(text.indexOf(second))
}
void (process.platform === 'win32' || process.getuid?.() === 0 ? it.skip : it)
describe('legacy terminal shim neutralization', () => {
const tempRoots: string[] = []
@@ -56,73 +31,6 @@ describe('legacy terminal shim neutralization', () => {
}
})
it('atomically replaces the legacy command paths with executable tombstones', () => {
const userData = makeUserDataDir()
const legacyRoot = join(userData, 'orca-terminal-attribution')
const posixDir = join(legacyRoot, 'posix')
const win32Dir = join(legacyRoot, 'win32')
mkdirSync(posixDir, { recursive: true })
mkdirSync(win32Dir, { recursive: true })
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
writeFileSync(join(win32Dir, 'gh.cmd'), 'legacy attribution wrapper')
neutralizeLegacyTerminalShimDir(userData)
for (const path of [
join(posixDir, 'git'),
join(posixDir, 'gh'),
join(win32Dir, 'git.cmd'),
join(win32Dir, 'gh.cmd')
]) {
expect(existsSync(path)).toBe(true)
expect(readFileSync(path, 'utf8')).not.toContain('Co-authored-by: Orca')
if (process.platform !== 'win32') {
expect(statSync(path).mode & 0o111).not.toBe(0)
}
}
// Matching retired version 7 would make rollback mistake tombstones for active wrappers.
const version = readFileSync(join(legacyRoot, 'VERSION'), 'utf8')
expect(version).toBe('7-neutralized\n')
expect(version.trim()).not.toBe('7')
})
itOnPosix('rejects interpreter candidates that are unusable in a shebang', () => {
const userData = makeUserDataDir()
const spaced = join(userData, 'a b')
const dirNamedBash = join(userData, 'dirbin')
mkdirSync(spaced, { recursive: true })
mkdirSync(join(dirNamedBash, 'bash'), { recursive: true })
writeFileSync(join(spaced, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
expect(resolvePosixTombstoneInterpreter(spaced, [], 'linux')).toBeNull()
expect(resolvePosixTombstoneInterpreter(dirNamedBash, [], 'linux')).toBeNull()
})
itOnPosix('resolves the interpreter from absolute PATH entries only', () => {
// Force the NixOS/Guix fallback while excluding cwd-based interpreter lookup.
const userData = makeUserDataDir()
const absDir = join(userData, 'absbin')
const relDir = join(userData, 'relbin')
mkdirSync(absDir, { recursive: true })
mkdirSync(relDir, { recursive: true })
for (const dir of [absDir, relDir]) {
writeFileSync(join(dir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
}
expect(resolvePosixTombstoneInterpreter(`${absDir}:/usr/bin`, [])).toBe(join(absDir, 'bash'))
expect(resolvePosixTombstoneInterpreter(`:relbin:${absDir}`, [])).toBe(join(absDir, 'bash'))
// Relative PATH resolves from process cwd; a tmpdir-only fixture would pass without the guard.
const cwdRelName = `.orca-interp-${process.pid}`
const cwdRelDir = join(process.cwd(), cwdRelName)
mkdirSync(cwdRelDir, { recursive: true })
try {
writeFileSync(join(cwdRelDir, 'bash'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
expect(resolvePosixTombstoneInterpreter(`.:${cwdRelName}`, [])).toBeNull()
} finally {
rmSync(cwdRelDir, { recursive: true, force: true })
}
})
itOnPosix('resolves its own directory even when CDPATH is set', () => {
// CDPATH can redirect relative cd and echo into wrapper_dir, breaking lookup.
const userData = makeUserDataDir()
@@ -184,36 +92,6 @@ describe('legacy terminal shim neutralization', () => {
expect(wrapper).toContain('CDPATH= cd -P --')
})
itOnPosix('rejects a distinct legacy shim directory named by the environment', () => {
// An inherited marker can name an older install, distinct from the current wrapper directory.
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
const legacyDir = join(userData, 'legacy-shim')
const realBin = join(userData, 'real-bin')
for (const dir of [posixDir, legacyDir, realBin]) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
neutralizeLegacyTerminalShimDir(userData)
writeFileSync(join(legacyDir, 'git'), "#!/bin/bash\nprintf 'HOSTILE\\n'\n", { mode: 0o755 })
writeFileSync(join(realBin, 'git'), "#!/bin/bash\nprintf 'REAL\\n'\n", { mode: 0o755 })
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
env: {
...process.env,
ORCA_ATTRIBUTION_SHIM_DIR: legacyDir,
PATH: `${legacyDir}:${realBin}:/usr/bin:/bin`
},
encoding: 'utf8',
timeout: 20_000
})
expect(run.stdout).toContain('REAL')
expect(run.stdout).not.toContain('HOSTILE')
})
itOnPosix('rejects the legacy shim directory reached by a different spelling', () => {
// Equivalent inode spellings must exclude the still-live legacy wrapper too.
const userData = makeUserDataDir()
@@ -247,144 +125,6 @@ describe('legacy terminal shim neutralization', () => {
}
})
itOnPosix('reuses the replaced wrapper shebang when no absolute bash is verifiable', () => {
// Deleting a hashed wrapper strands existing shells at 127; preserve its verified interpreter.
const userData = makeUserDataDir()
const wrapper = join(userData, 'git')
writeFileSync(wrapper, '#!/bin/bash\nexit 0\n', { mode: 0o755 })
expect(readVerifiedShebangInterpreter(wrapper)).toBe('/bin/bash')
// env bash defers interpreter lookup to PATH and reopens cwd hijacking.
writeFileSync(wrapper, '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 })
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
// /bin/sh reliably exists but cannot run this Bash-specific body.
writeFileSync(wrapper, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
writeFileSync(wrapper, '#!bash\nexit 0\n', { mode: 0o755 })
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
writeFileSync(wrapper, '#!/nonexistent/bash\nexit 0\n', { mode: 0o755 })
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
writeFileSync(wrapper, 'no shebang at all\n', { mode: 0o755 })
expect(readVerifiedShebangInterpreter(wrapper)).toBeNull()
expect(readVerifiedShebangInterpreter(join(userData, 'absent'))).toBeNull()
})
itOnPosix('keeps the POSIX wrapper on a host where no absolute bash resolves', async () => {
// Force resolver failure: ordinary CI bash paths otherwise hide missing fallback wiring.
vi.resetModules()
vi.doMock('./legacy-terminal-posix-tombstone', async () => {
const actual = await vi.importActual<typeof PosixTombstoneModule>(
'./legacy-terminal-posix-tombstone'
)
return { ...actual, resolvePosixTombstoneInterpreter: () => null }
})
try {
const shimDir = await import('./legacy-terminal-shim-dir')
shimDir.__resetLegacyTerminalShimNeutralizationForTests()
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
mkdirSync(posixDir, { recursive: true })
writeFileSync(join(posixDir, 'git'), '#!/bin/bash\nlegacy attribution wrapper\n', {
mode: 0o755
})
// An unverifiable env shebang must be deleted rather than preserve retired attribution.
writeFileSync(join(posixDir, 'gh'), '#!/usr/bin/env bash\nlegacy\n', { mode: 0o755 })
shimDir.neutralizeLegacyTerminalShimDir(userData)
const git = readFileSync(join(posixDir, 'git'), 'utf8')
expect(git.split('\n')[0]).toBe('#!/bin/bash')
expect(git).toContain('Orca compatibility wrapper could not locate')
expect(existsSync(join(posixDir, 'gh'))).toBe(false)
} finally {
vi.doUnmock('./legacy-terminal-posix-tombstone')
vi.resetModules()
}
})
it('does not collapse .. when classifying a shim PATH entry', () => {
// Collapsing .. through a symlink can erase a legitimate PATH entry; remote/WSL paths cannot be resolved locally.
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix/../posix')).toBe(
false
)
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/posix')).toBe(true)
expect(isLegacyTerminalShimPathEntry('/tmp/old/orca-terminal-attribution/win32//')).toBe(true)
expect(isLegacyTerminalShimPathEntry('/usr/local/bin')).toBe(false)
})
it('strips a captured shim directory spelled with repeated separators', () => {
// Repeated separators must not defeat captured-directory removal.
const posixEnv = {
PATH: '/custom/elsewhere///:/usr/bin',
ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere'
}
stripLegacyTerminalShimEnv(posixEnv, 'linux')
expect(posixEnv.PATH).toBe('/usr/bin')
const windowsEnv = {
Path: 'C:\\Custom\\Else\\\\;C:\\Windows',
ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else'
}
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
expect(windowsEnv.Path).toBe('C:\\Windows')
})
itOnPosix(
'ignores a relative legacy shim directory instead of resolving it against the cwd',
() => {
// Relative -ef operands depend on cwd and could select different real git binaries.
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
const safeBin = join(userData, 'safe-bin')
const laterBin = join(userData, 'later-bin')
for (const dir of [posixDir, safeBin, laterBin]) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
neutralizeLegacyTerminalShimDir(userData)
writeFileSync(join(safeBin, 'git'), "#!/bin/bash\nprintf 'SAFE\\n'\n", { mode: 0o755 })
writeFileSync(join(laterBin, 'git'), "#!/bin/bash\nprintf 'LATER\\n'\n", { mode: 0o755 })
const outputs = [userData, join(userData, 'orca-terminal-attribution')].map((cwd) => {
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
cwd,
env: {
...process.env,
ORCA_ATTRIBUTION_SHIM_DIR: 'safe-bin',
PATH: `${safeBin}:${laterBin}:/usr/bin:/bin`
},
encoding: 'utf8',
timeout: 20_000
})
return run.stdout.trim()
})
expect(outputs[0]).toBe(outputs[1])
expect(outputs[0]).toBe('SAFE')
}
)
it('keeps a POSIX directory whose name ends in a backslash', () => {
// POSIX backslashes belong to filenames; stripping them can delete a real PATH entry.
const env = {
PATH: '/tmp/captured\\',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/captured'
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe('/tmp/captured\\')
const windowsEnv = { Path: 'C:\\captured\\', ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\captured' }
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
expect(windowsEnv.Path).toBeUndefined()
})
itOnPosix('does not fall back to the cwd when every PATH entry is filtered out', () => {
// An emptied PATH must not fall through to cwd-local git.
const userData = makeUserDataDir()
@@ -409,32 +149,6 @@ describe('legacy terminal shim neutralization', () => {
expect(run.status).toBe(127)
})
itOnPosix('hands the child the cleaned PATH, not the inherited one', () => {
// Export the cleaned PATH so git hooks and credential helpers cannot rediscover legacy or cwd entries.
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
const realBin = join(userData, 'real-bin')
mkdirSync(posixDir, { recursive: true })
mkdirSync(realBin, { recursive: true })
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
neutralizeLegacyTerminalShimDir(userData)
writeFileSync(join(realBin, 'git'), '#!/bin/bash\nprintf \'PATH=%s\\n\' "$PATH"\n', {
mode: 0o755
})
const run = spawnSync(join(posixDir, 'git'), ['--version'], {
env: { PATH: `${posixDir}:.:${realBin}:/usr/bin:/bin` },
encoding: 'utf8',
timeout: 20_000
})
expect(run.stdout).toContain('PATH=')
expect(run.stdout).not.toContain('orca-terminal-attribution')
expect(run.stdout.split('PATH=')[1]?.split(':')).not.toContain('.')
})
itOnPosix('does not let the cwd supply the script interpreter', async () => {
// Interpreter lookup precedes script PATH hygiene; env bash permits checkout-local interception.
const userData = makeUserDataDir()
@@ -528,109 +242,6 @@ describe('legacy terminal shim neutralization', () => {
expect(noSlash.stdout).toContain('REAL')
})
it('removes every Windows PATH occurrence of both captured wrapper directories', () => {
const userData = makeUserDataDir()
const win32Dir = join(userData, 'orca-terminal-attribution', 'win32')
mkdirSync(win32Dir, { recursive: true })
neutralizeLegacyTerminalShimDir(userData)
const cmd = readFileSync(join(win32Dir, 'git.cmd'), 'utf8')
const cmdCapture = 'set "orca_legacy_wrapper_dir=%ORCA_ATTRIBUTION_SHIM_DIR%"'
expectOrdered(cmd, cmdCapture, 'set "ORCA_ATTRIBUTION_SHIM_DIR="')
expect(cmd).toContain('for %%P in ("%PATH:;=" "%") do (')
expect(cmd).toContain('if /I "%orca_path_entry_dir%"=="%orca_wrapper_dir%" exit /b')
expect(cmd).toContain('if defined orca_legacy_wrapper_dir call :orca_reject_legacy_dir')
// cmd cannot use call :label && ...; preserve the explicit flag.
expect(cmd).toContain('if defined orca_skip_entry exit /b')
expect(cmd).not.toContain('call :orca_reject_legacy_dir &&')
expect(cmd).toContain('if "%orca_path_entry_dir:~-1%."=="%orca_sep%."')
const powershell = readFileSync(join(win32Dir, 'git-wrapper.ps1'), 'utf8')
expectOrdered(
powershell,
'$legacyWrapperDir = $env:ORCA_ATTRIBUTION_SHIM_DIR',
'Remove-Item "Env:$_"'
)
expect(powershell).toContain('$wrapperDirs = @($wrapperDir, $legacyWrapperDir)')
expect(powershell).toContain("$env:PATH = (($env:PATH -split ';') | Where-Object {")
expect(powershell).toContain('[StringComparison]::OrdinalIgnoreCase')
})
it('leaves an install that never ran the shim untouched', () => {
// Fresh installs need no hashed-path compatibility wrappers.
const userData = makeUserDataDir()
expect(() => neutralizeLegacyTerminalShimDir(userData)).not.toThrow()
expect(existsSync(join(userData, 'orca-terminal-attribution'))).toBe(false)
})
itOnPosixNonRoot('retries a startup failure in-process and latches after success', async () => {
vi.useFakeTimers()
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
const gitWrapper = join(posixDir, 'git')
mkdirSync(posixDir, { recursive: true })
writeFileSync(gitWrapper, 'legacy attribution wrapper')
chmodSync(posixDir, 0o500)
try {
neutralizeLegacyTerminalShimDir(userData)
expect(readFileSync(gitWrapper, 'utf8')).toBe('legacy attribution wrapper')
} finally {
chmodSync(posixDir, 0o700)
}
try {
await vi.advanceTimersByTimeAsync(1_000)
expect(readFileSync(gitWrapper, 'utf8')).not.toContain('legacy attribution wrapper')
writeFileSync(gitWrapper, 'recreated after success')
neutralizeLegacyTerminalShimDir(userData)
expect(readFileSync(gitWrapper, 'utf8')).toBe('recreated after success')
} finally {
vi.useRealTimers()
}
})
itOnPosixNonRoot('warns and stops retrying once the ladder is exhausted', async () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const userData = makeUserDataDir()
const posixDir = join(userData, 'orca-terminal-attribution', 'posix')
mkdirSync(posixDir, { recursive: true })
writeFileSync(join(posixDir, 'git'), 'legacy attribution wrapper')
chmodSync(posixDir, 0o500)
try {
neutralizeLegacyTerminalShimDir(userData)
// Cover all four retry delays plus enough slack to expose an unwanted fifth.
await vi.advanceTimersByTimeAsync(120_000)
expect(readFileSync(join(posixDir, 'git'), 'utf8')).toBe('legacy attribution wrapper')
const messages = warn.mock.calls.map((call) => String(call[0]))
expect(messages.filter((message) => message.includes('neutralization attempt'))).toHaveLength(
5
)
// Counts alone cannot catch incorrect attempt ordinals.
expect(messages.some((message) => message.includes('neutralization attempt 1 failed'))).toBe(
true
)
expect(messages.some((message) => message.includes('neutralization attempt 5 failed'))).toBe(
true
)
expect(
messages.some((message) => message.includes('gave up neutralizing after 5 attempts'))
).toBe(true)
warn.mockClear()
await vi.advanceTimersByTimeAsync(120_000)
expect(warn).not.toHaveBeenCalled()
} finally {
chmodSync(posixDir, 0o700)
warn.mockRestore()
vi.useRealTimers()
}
})
itOnPosix('keeps a real Bash command hash working with trailing PATH separators', async () => {
const userData = makeUserDataDir()
const shimDir = join(userData, 'orca-terminal-attribution', 'posix')
@@ -688,138 +299,6 @@ describe('legacy terminal shim neutralization', () => {
expect(stdout).not.toContain('Co-authored-by: Orca')
expect(stderr).toBe('fixture stderr\n')
})
it('drops inherited shim env and its PATH entry without touching real entries', () => {
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).toEqual({ PATH: '/usr/local/bin:/usr/bin', HOME: '/home/u' })
})
it('strips the captured shim directory when PATH spells it with a trailing separator', () => {
// Separator variants must not keep the captured shim directory reachable.
const posix: Record<string, string> = {
PATH: '/custom/elsewhere/:/usr/bin',
ORCA_ATTRIBUTION_SHIM_DIR: '/custom/elsewhere'
}
stripLegacyTerminalShimEnv(posix, 'linux')
expect(posix.PATH).toBe('/usr/bin')
const win: Record<string, string> = {
Path: 'C:\\Custom\\Else;C:\\Windows',
ORCA_ATTRIBUTION_SHIM_DIR: 'C:\\Custom\\Else\\'
}
stripLegacyTerminalShimEnv(win, 'win32')
expect(win.Path).toBe('C:\\Windows')
})
it('uses the captured POSIX shim directory literally when it contains a colon', () => {
const shimDir = '/tmp/orca:user/orca-terminal-attribution/posix'
const env: Record<string, string> = {
PATH: `/usr/local/bin:${shimDir}:/usr/bin`,
ORCA_ATTRIBUTION_SHIM_DIR: shimDir
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env).toEqual({ PATH: '/usr/local/bin:/usr/bin' })
})
it('treats legacy Windows environment keys case-insensitively', () => {
const shimDir = 'C:\\Users\\orca;user\\orca-terminal-attribution\\win32'
const env: Record<string, string> = {
Path: `${shimDir};C:\\Windows\\System32`,
orca_attribution_shim_dir: shimDir,
Orca_Enable_Git_Attribution: '1',
orca_real_git: 'C:\\Git\\git.exe'
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env).toEqual({ Path: 'C:\\Windows\\System32' })
})
it('strips legacy entries from every Windows PATH spelling', () => {
const env: Record<string, string> = {
PATH: 'C:\\Orca\\orca-terminal-attribution\\win32',
Path: 'C:\\Orca\\orca-terminal-attribution\\win32;C:\\Windows\\System32'
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env.PATH).toBeUndefined()
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('matches a re-cased Windows shim path', () => {
const env: Record<string, string> = {
Path: 'C:\\Orca\\Orca-Terminal-Attribution\\Win32;C:\\Windows\\System32'
}
stripLegacyTerminalShimEnv(env, 'win32')
expect(env.Path).toBe('C:\\Windows\\System32')
})
it('preserves explicit empty PATH values', () => {
const windowsEnv: Record<string, string> = { PATH: '', Path: 'C:\\Windows' }
const posixEnv: Record<string, string> = { PATH: '' }
stripLegacyTerminalShimEnv(windowsEnv, 'win32')
stripLegacyTerminalShimEnv(posixEnv, 'linux')
expect(windowsEnv).toEqual({ PATH: '', Path: 'C:\\Windows' })
expect(posixEnv).toEqual({ PATH: '' })
})
it('strips legacy shim entries that carry a trailing separator', () => {
const posix: Record<string, string> = {
PATH: '/home/u/.orca/orca-terminal-attribution/posix/:/usr/bin'
}
stripLegacyTerminalShimEnv(posix, 'linux')
expect(posix.PATH).toBe('/usr/bin')
const many: Record<string, string> = {
PATH: '/home/u/.orca/orca-terminal-attribution/posix///:/usr/bin'
}
stripLegacyTerminalShimEnv(many, 'linux')
expect(many.PATH).toBe('/usr/bin')
const win: Record<string, string> = {
Path: 'C:\\Users\\u\\orca-terminal-attribution\\win32\\;C:\\Windows'
}
stripLegacyTerminalShimEnv(win, 'win32')
expect(win.Path).toBe('C:\\Windows')
})
it('keeps neighbouring directories that merely share the name prefix', () => {
const env: Record<string, string> = {
PATH: '/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/home/u/orca-terminal-attribution-notes/bin:/usr/bin'
}
stripLegacyTerminalShimEnv(env, 'linux')
expect(env.PATH).toBe(
'/opt/orca-terminal-attribution:/opt/orca-terminal-attribution/custom-tools:/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')
})
})
function quoteBash(value: string): string {
@@ -121,74 +121,6 @@ describePosix('OMP shell wrapper node-pty reproduction', () => {
}
})
itWithBash('reproduces why restored shells miss OMP status without the wrapper', async () => {
const tempDir = makeTempDir()
const binDir = join(tempDir, 'bin')
const piDir = join(tempDir, 'pi-agent')
const ompDir = join(tempDir, 'omp-agent')
const extensionDir = join(ompDir, 'extensions')
mkdirSync(binDir)
mkdirSync(piDir)
mkdirSync(extensionDir, { recursive: true })
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
writeFileSync(statusExtension, 'export default {}')
writeFakeOmp(binDir)
const makeEnv = (captureFile: string, afterPiFile: string): Record<string, string> => ({
...process.env,
HOME: tempDir,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
PI_CODING_AGENT_DIR: '',
ORCA_PI_CODING_AGENT_DIR: '',
ORCA_OMP_CODING_AGENT_DIR: '',
ORCA_OMP_STATUS_EXTENSION: statusExtension,
ORCA_FAKE_OMP_DEFAULT_DIR: ompDir,
ORCA_CAPTURE_FILE: captureFile,
ORCA_AFTER_PI_FILE: afterPiFile,
TERM: process.env.TERM || 'xterm-256color'
})
const unwrappedCapture = join(tempDir, 'unwrapped-capture')
const unwrappedAfterPi = join(tempDir, 'unwrapped-after-pi')
await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: '',
env: makeEnv(unwrappedCapture, unwrappedAfterPi),
input: `omp ask
printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE"
exit 0
`
})
const unwrapped = readFileSync(unwrappedCapture, 'utf8')
expect(unwrapped).toContain('PI=\n')
expect(unwrapped).toContain(`EFFECTIVE=${ompDir}`)
expect(unwrapped).toContain('ARG1=ask')
expect(unwrapped).not.toContain('ARG1=--extension')
const wrappedCapture = join(tempDir, 'wrapped-capture')
const wrappedAfterPi = join(tempDir, 'wrapped-after-pi')
const wrappedOutput = await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: getPosixOmpShellWrapper(),
env: makeEnv(wrappedCapture, wrappedAfterPi),
input: `type omp
omp ask
printf '%s' "$PI_CODING_AGENT_DIR" > "$ORCA_AFTER_PI_FILE"
exit 0
`
})
const wrapped = readFileSync(wrappedCapture, 'utf8')
expect(wrappedOutput).toContain('omp is a function')
expect(wrapped).toContain('PI=\n')
expect(wrapped).toContain(`EFFECTIVE=${ompDir}`)
expect(wrapped).toContain('ARG1=--extension')
expect(wrapped).toContain(`ARG2=${statusExtension}`)
expect(wrapped).toContain('ARG3=ask')
expect(readFileSync(wrappedAfterPi, 'utf8')).toBe('')
})
itWithBash('runs OMP config subcommands without redirecting the home', async () => {
const tempDir = makeTempDir()
const binDir = join(tempDir, 'bin')
@@ -230,105 +162,6 @@ exit 0
expect(readFileSync(join(sourceDir, 'config.yml'), 'utf8')).toBe('updated-by-omp-config\n')
})
itWithBash.each([
'__complete',
'bench',
'completions',
'dry-balance',
'gallery',
'install',
'join',
'models',
'say',
'tiny-models',
'token',
'ttsr',
'usage'
])('runs OMP %s subcommands without injecting the status extension', async (subcommand) => {
const tempDir = makeTempDir()
const binDir = join(tempDir, 'bin')
const sourceDir = join(tempDir, 'source-omp-agent')
const extensionDir = join(sourceDir, 'extensions')
mkdirSync(binDir)
mkdirSync(sourceDir, { recursive: true })
mkdirSync(extensionDir, { recursive: true })
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
writeFileSync(statusExtension, 'export default {}')
writeFakeOmp(binDir)
const captureFile = join(tempDir, `${subcommand}-capture`)
await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: getPosixOmpShellWrapper(),
env: {
...process.env,
HOME: tempDir,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
PI_CODING_AGENT_DIR: '',
ORCA_PI_CODING_AGENT_DIR: '',
ORCA_OMP_CODING_AGENT_DIR: '',
ORCA_OMP_SOURCE_AGENT_DIR: sourceDir,
ORCA_OMP_STATUS_EXTENSION: statusExtension,
ORCA_FAKE_OMP_DEFAULT_DIR: sourceDir,
ORCA_CAPTURE_FILE: captureFile,
TERM: process.env.TERM || 'xterm-256color'
},
input: `omp ${subcommand}
exit 0
`
})
const capture = readFileSync(captureFile, 'utf8')
expect(capture).toContain('PI=\n')
expect(capture).toContain(`EFFECTIVE=${sourceDir}`)
expect(capture).toContain(`ARG1=${subcommand}`)
expect(capture).not.toContain('ARG1=--extension')
})
itWithBash(
'lets OMP config subcommands fall back to the default home without a source shadow',
async () => {
const tempDir = makeTempDir()
const binDir = join(tempDir, 'bin')
const defaultOmpDir = join(tempDir, '.omp', 'agent')
const extensionDir = join(defaultOmpDir, 'extensions')
mkdirSync(binDir)
mkdirSync(defaultOmpDir, { recursive: true })
mkdirSync(extensionDir, { recursive: true })
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
writeFileSync(statusExtension, 'export default {}')
writeFakeOmp(binDir)
const captureFile = join(tempDir, 'default-config-capture')
await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: getPosixOmpShellWrapper(),
env: {
...process.env,
HOME: tempDir,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
PI_CODING_AGENT_DIR: '',
ORCA_PI_CODING_AGENT_DIR: '',
ORCA_OMP_CODING_AGENT_DIR: '',
ORCA_OMP_STATUS_EXTENSION: statusExtension,
ORCA_FAKE_OMP_DEFAULT_DIR: defaultOmpDir,
ORCA_CAPTURE_FILE: captureFile,
TERM: process.env.TERM || 'xterm-256color'
},
input: `omp config
exit 0
`
})
const capture = readFileSync(captureFile, 'utf8')
expect(capture).toContain('PI=\n')
expect(capture).toContain(`EFFECTIVE=${defaultOmpDir}`)
expect(readFileSync(join(defaultOmpDir, 'config.yml'), 'utf8')).toBe(
'updated-by-omp-config\n'
)
}
)
async function expectStaleCwdRecovery(shell: PosixShell): Promise<void> {
const tempDir = makeTempDir()
const workspaceDir = join(tempDir, 'workspace')
@@ -1,364 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const lineEditorProbe = vi.hoisted(() => vi.fn())
const relayedLineEditorProbe = vi.hoisted(() => vi.fn())
const listChildPids = vi.hoisted(() => vi.fn())
const processReadinessProbe = vi.hoisted(() => vi.fn())
const resolveExecutablePath = vi.hoisted(() => vi.fn((value: string) => Promise.resolve(value)))
const resolveInstalledExecutablePaths = vi.hoisted(() =>
vi.fn((): Promise<string[]> => Promise.resolve([]))
)
vi.mock('../../../shared/terminal/pty/native-process/pty-slave-line-discipline-echo', () => ({
createPtySlaveLineEditorProbe: (ttyPath: string) =>
ttyPath === '/dev/ttys099' ? relayedLineEditorProbe : lineEditorProbe
}))
vi.mock('../../../shared/terminal/pty/native-process/shell-process-readiness', () => ({
readShellProcessReadiness: processReadinessProbe,
listChildProcessIds: listChildPids,
resolveShellExecutablePath: resolveExecutablePath,
resolveInstalledShellExecutablePaths: resolveInstalledExecutablePaths
}))
import { createShellPromptReadinessProbe } from './shell-prompt-readiness-probe'
describe('shell prompt readiness probe', () => {
beforeEach(() => {
vi.useFakeTimers()
lineEditorProbe.mockReset()
relayedLineEditorProbe.mockReset()
listChildPids.mockReset().mockResolvedValue([])
processReadinessProbe.mockReset()
resolveExecutablePath.mockClear()
resolveInstalledExecutablePaths.mockClear()
resolveInstalledExecutablePaths.mockResolvedValue([])
})
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(listChildPids).not.toHaveBeenCalled()
}
})
it.each([
['accepts', 'line-editor', true],
['rejects', 'other', false]
])(
'%s a relayed shell whose inner pty is in %s mode (#25586)',
async (_verb, innerState, expected) => {
lineEditorProbe.mockResolvedValue('other')
relayedLineEditorProbe.mockResolvedValue(innerState)
listChildPids.mockResolvedValue([43])
processReadinessProbe.mockImplementation((pid: number) =>
Promise.resolve(
pid === 42
? { executablePath: '/usr/local/bin/kiro-cli-term', foreground: true, ttyPath: null }
: { executablePath: '/bin/zsh', foreground: true, ttyPath: '/dev/ttys099' }
)
)
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).toHaveBeenCalledTimes(expected ? 1 : 0)
}
)
it('accepts a second installation of the same shell that the pane PATH resolves', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({
executablePath: '/opt/homebrew/bin/bash',
foreground: true
})
resolveInstalledExecutablePaths.mockResolvedValue(['/bin/bash', '/opt/homebrew/bin/bash'])
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/bash',
shellCwd: '/work',
shellPathEnv: '/opt/homebrew/bin:/usr/bin:/bin',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(resolveInstalledExecutablePaths).toHaveBeenCalledWith(
'bash',
'/work',
'/opt/homebrew/bin:/usr/bin:/bin'
)
expect(onPromptReady).toHaveBeenCalledOnce()
})
it('rejects a replacement with the shell basename that the pane PATH cannot reach', async () => {
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({ executablePath: '/tmp/bash', foreground: true })
resolveInstalledExecutablePaths.mockResolvedValue(['/bin/bash', '/opt/homebrew/bin/bash'])
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/bash',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
expect(onPromptReady).not.toHaveBeenCalled()
})
it('does not widen identity when the launched shell path resolves exactly', 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(resolveInstalledExecutablePaths).not.toHaveBeenCalled()
})
it('invalidates an alternate-installation result that resolves after disposal', async () => {
const pending: { resolve?: (value: string[]) => void } = {}
lineEditorProbe.mockResolvedValue('line-editor')
processReadinessProbe.mockResolvedValue({
executablePath: '/opt/homebrew/bin/bash',
foreground: true
})
resolveInstalledExecutablePaths.mockImplementation(
() => new Promise((resolve) => (pending.resolve = resolve))
)
const onPromptReady = vi.fn()
const probe = createShellPromptReadinessProbe({
slavePath: '/dev/ttys048',
shellPath: '/bin/bash',
getShellPid: () => 42,
onPromptReady,
settleMs: 10
})
probe?.notifyOutput('\x1b[?2004h')
await vi.advanceTimersByTimeAsync(10)
probe?.dispose()
pending.resolve?.(['/opt/homebrew/bin/bash'])
await vi.advanceTimersByTimeAsync(0)
expect(onPromptReady).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)
})
})
@@ -1,547 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { existsSyncMock, readFileSyncMock, readdirSyncMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
readdirSyncMock: vi.fn()
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
readdirSync: readdirSyncMock
}))
import {
__resetShellStartupEnvCache,
readBashStartupEnvVar,
readSessionShellStartupEnvVar,
readShellStartupEnvVar,
SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES
} from './shell-startup-env'
describe('readShellStartupEnvVar', () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
// CI may export XDG_CONFIG_HOME; pin it so fish mock paths do not depend on the host.
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
beforeEach(() => {
delete process.env.XDG_CONFIG_HOME
existsSyncMock.mockReset()
readFileSyncMock.mockReset()
readdirSyncMock.mockReset()
readdirSyncMock.mockImplementation(() => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
process.env.SHELL = '/bin/zsh'
__resetShellStartupEnvCache()
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
if (originalShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = originalShell
}
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
}
})
function mockStartupFiles(files: Record<string, string>) {
const hasAbsoluteKeys = Object.keys(files).some((path) => path.startsWith('/'))
existsSyncMock.mockImplementation((p: string) => {
const file = p.split('/').pop() ?? ''
return p in files || (!hasAbsoluteKeys && file in files)
})
readFileSyncMock.mockImplementation((p: string) => {
const file = p.split('/').pop() ?? ''
if (p in files) {
return files[p]
}
if (!hasAbsoluteKeys && file in files) {
return files[file]
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
}
// #1534: GUI env lacks rc-only exports; static discovery must preserve the user overlay source.
it('mirrors the user scenario: GUI-launched Orca discovers .zshrc-only export', () => {
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.config/opencode"\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
'/home/alice/.config/opencode'
)
})
it('returns undefined when HOME is unset', () => {
const savedHome = process.env.HOME
delete process.env.HOME
try {
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR')).toBeUndefined()
expect(existsSyncMock).not.toHaveBeenCalled()
} finally {
if (savedHome !== undefined) {
process.env.HOME = savedHome
}
}
})
it('returns undefined on Windows', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/win\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
})
it('reads Git Bash login files on Windows only when asked for bash', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
process.env.SHELL = '/usr/bin/bash'
mockStartupFiles({ '.bash_profile': 'export OPENCODE_CONFIG_DIR=/git-bash\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
expect(readBashStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/git-bash')
})
it('returns undefined when no startup file matches', () => {
mockStartupFiles({ '.zshrc': 'export FOO=bar\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
})
it('returns the LAST assignment when multiple files re-export', () => {
mockStartupFiles({
'.zshenv': 'export OPENCODE_CONFIG_DIR="/old/zshenv"\n',
'.zprofile': 'export OPENCODE_CONFIG_DIR="/middle/zprofile"\n',
'.zshrc': 'export OPENCODE_CONFIG_DIR="/new/zshrc"\n',
'.zlogin': 'export OPENCODE_CONFIG_DIR="/newest/zlogin"\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin')
})
it('uses ZDOTDIR exported from .zshenv for later zsh startup files', () => {
mockStartupFiles({
'/home/alice/.zshenv': 'export ZDOTDIR="$HOME/.config/zsh"\n',
'/home/alice/.config/zsh/.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/zsh')).toBe(
'/home/alice/company/opencode'
)
})
it('handles double-quoted values', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path')
})
it('handles single-quoted values', () => {
mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/quoted/path'\n" })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path')
})
it('handles unquoted values', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/unquoted/path\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/unquoted/path')
})
it('expands $HOME in values', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode"\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
'/home/alice/.opencode'
)
})
it('expands ${HOME} in values', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="${HOME}/.opencode"\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
'/home/alice/.opencode'
)
})
it('expands leading ~ in values', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=~/.opencode\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
'/home/alice/.opencode'
)
})
it('ignores bare assignments without the export keyword', () => {
// Unexported assignments never reach child processes and cannot be overlay sources.
mockStartupFiles({ '.zshrc': 'OPENCODE_CONFIG_DIR=/no/export\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
})
it('preserves a # inside a double-quoted value', () => {
// Unquoted # begins a comment only at a word boundary.
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/path/with#hash"\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash')
})
it('preserves a # inside a single-quoted value', () => {
mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='/path/with#hash'\n" })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/path/with#hash')
})
it('strips a trailing # comment from an unquoted value', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/bare/path # trailing comment\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/bare/path')
})
it('strips a trailing # comment after a double-quoted value', () => {
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/.opencode" # trailing comment\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe(
'/home/alice/.opencode'
)
})
it('strips a trailing # comment after a single-quoted value', () => {
mockStartupFiles({
'.zshrc': "export OPENCODE_CONFIG_DIR='/literal/path' # trailing comment\n"
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/literal/path')
})
it('does not expand $HOME inside single quotes', () => {
mockStartupFiles({ '.zshrc': "export OPENCODE_CONFIG_DIR='$HOME/.opencode'\n" })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOME/.opencode')
})
it('does not partially expand $HOMER / $HOMEPATH', () => {
// HOME expansion must not partially rewrite a different variable name.
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="$HOMER/agent"\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('$HOMER/agent')
})
it('only scans zsh startup files when SHELL is zsh', () => {
// Startup discovery must follow only the active shell family.
process.env.SHELL = '/bin/zsh'
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc')
})
it('only scans bash startup files when SHELL is bash', () => {
process.env.SHELL = '/bin/bash'
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/bash_profile')
})
it('defaults to zsh startup files when SHELL is unset', () => {
delete process.env.SHELL
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/from/zshrc')
})
it('does not scan .bashrc for bash shells', () => {
// Orca's login Bash wrapper does not force .bashrc; discovery must match.
process.env.SHELL = '/bin/bash'
mockStartupFiles({ '.bashrc': 'export OPENCODE_CONFIG_DIR=/from/bashrc\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
})
it('does not scan zsh or bash files for an explicit unsupported shell', () => {
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
})
expect(
readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/opt/bin/fish')
).toBeUndefined()
})
it('honors an explicit shell argument over process.env.SHELL', () => {
// Per-spawn SHELL can differ from main's inherited SHELL.
process.env.SHELL = '/bin/zsh'
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR=/from/zshrc\n',
'.bash_profile': 'export OPENCODE_CONFIG_DIR=/from/bash_profile\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/bash')).toBe(
'/from/bash_profile'
)
})
it('memoizes results across calls within the same process', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached')
const callsAfterFirst = readFileSyncMock.mock.calls.length
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/cached')
expect(readFileSyncMock.mock.calls.length).toBe(callsAfterFirst)
})
it('bounds cache keys from long-lived home and host churn', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/cached\n' })
for (let index = 0; index < SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40; index += 1) {
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', `/home/user-${index}`)).toBe('/cached')
}
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/user-0')).toBe('/cached')
expect(readFileSyncMock.mock.calls.length).toBeGreaterThan(
SHELL_STARTUP_ENV_CACHE_MAX_ENTRIES + 40
)
})
it('rejects names with regex metacharacters', () => {
mockStartupFiles({ '.zshrc': 'export FOO=/x\n' })
expect(readShellStartupEnvVar('FOO.*', '/home/alice')).toBeUndefined()
})
it('does not match other variable names', () => {
mockStartupFiles({
'.zshrc': 'export OPENCODE_CONFIG_DIR_BACKUP=/backup\nexport NOT_OPENCODE_CONFIG_DIR=/x\n'
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
})
it('survives a readFileSync error on one file and continues', () => {
existsSyncMock.mockReturnValue(true)
readFileSyncMock.mockImplementation((p: string) => {
if (p.endsWith('.zshenv')) {
throw new Error('EACCES')
}
if (p.endsWith('.zshrc')) {
return 'export OPENCODE_CONFIG_DIR=/found\n'
}
return ''
})
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/found')
})
it('handles CRLF line endings', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/crlf\r\nexport OTHER=x\r\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/crlf')
})
it('does not match an OPENCODE_CONFIG_DIR mention in a comment', () => {
mockStartupFiles({ '.zshrc': '# export OPENCODE_CONFIG_DIR=/from-comment\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
})
describe('fish', () => {
const FISH = '/opt/homebrew/bin/fish'
function mockFishFiles(files: Record<string, string>, snippets: string[] = []) {
readdirSyncMock.mockImplementation((dir: string) => {
if (dir === '/home/alice/.config/fish/conf.d' || dir === '/cfg/fish/conf.d') {
return snippets
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
mockStartupFiles(files)
}
it('reads an exported set from config.fish', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /home/alice/.codex\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex')
})
it('lets config.fish win over a conf.d snippet, matching fish source order', () => {
mockFishFiles(
{
'/home/alice/.config/fish/conf.d/10-agents.fish': 'set -gx CODEX_HOME /from/confd\n',
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/config\n'
},
['10-agents.fish']
)
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/config')
})
it('sources conf.d snippets in filename order', () => {
mockFishFiles(
{
'/home/alice/.config/fish/conf.d/aaa.fish': 'set -gx CODEX_HOME /from/aaa\n',
'/home/alice/.config/fish/conf.d/zzz.fish': 'set -gx CODEX_HOME /from/zzz\n'
},
['zzz.fish', 'aaa.fish', 'notes.txt']
)
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/zzz')
})
it('honors XDG_CONFIG_HOME', () => {
mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/xdg\n' })
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg')).toBe('/from/xdg')
})
// Cover the env-default path too so fish cases cannot silently depend on host configuration.
it('defaults configHome to XDG_CONFIG_HOME from the environment', () => {
process.env.XDG_CONFIG_HOME = '/cfg'
__resetShellStartupEnvCache()
mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/env-xdg\n' })
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/env-xdg')
})
it('ignores sets that are not exported', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish':
'set -g CODEX_HOME /global\nset -l CODEX_HOME /local\nset CODEX_HOME /plain\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
it('accepts every fish export spelling', () => {
for (const line of [
'set -x CODEX_HOME /a',
'set -xg CODEX_HOME /a',
'set -gx CODEX_HOME /a',
'set -Ux CODEX_HOME /a',
'set --export --global CODEX_HOME /a'
]) {
__resetShellStartupEnvCache()
mockFishFiles({ '/home/alice/.config/fish/config.fish': `${line}\n` })
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/a')
}
})
it('expands $HOME in double quotes and keeps single quotes literal', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex" # note\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex')
__resetShellStartupEnvCache()
mockFishFiles({
'/home/alice/.config/fish/config.fish': "set -gx CODEX_HOME '$HOME/.codex'\n"
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('$HOME/.codex')
})
it('does not read zsh or bash startup files for a fish user', () => {
mockFishFiles({
'/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n',
'/home/alice/.bash_profile': 'export CODEX_HOME=/from/bash\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
it('ignores a commented assignment', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': '# set -gx CODEX_HOME /from-comment\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
it('does not match a different variable with the same prefix', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME_BACKUP /backup\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
})
// Session XDG_CONFIG_HOME must match the fish config path used locally and over relay.
describe('readSessionShellStartupEnvVar', () => {
const FISH = '/opt/homebrew/bin/fish'
const savedConfigHome = process.env.XDG_CONFIG_HOME
const savedHome = process.env.HOME
afterEach(() => {
restoreEnv('XDG_CONFIG_HOME', savedConfigHome)
restoreEnv('HOME', savedHome)
})
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
it("prefers the session env's XDG_CONFIG_HOME over the main process's", () => {
process.env.XDG_CONFIG_HOME = '/main-process-cfg'
mockStartupFiles({
'/session-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/session\n',
'/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', {
HOME: '/home/alice',
SHELL: FISH,
XDG_CONFIG_HOME: '/session-cfg'
})
).toBe('/from/session')
})
it('falls back to the main process XDG_CONFIG_HOME when the session env lacks one', () => {
process.env.XDG_CONFIG_HOME = '/main-process-cfg'
mockStartupFiles({
'/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
).toBe('/from/main-process')
})
it("falls back to fish's own ~/.config default when neither env has one", () => {
delete process.env.XDG_CONFIG_HOME
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/default\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
).toBe('/from/default')
})
it('resolves against the session HOME, not the main process HOME', () => {
delete process.env.XDG_CONFIG_HOME
process.env.HOME = '/home/root-user'
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex"\n',
'/home/root-user/.config/fish/config.fish': 'set -gx CODEX_HOME /from/wrong-home\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
).toBe('/home/alice/.codex')
})
it('lets an explicit shell override beat the session SHELL', () => {
delete process.env.XDG_CONFIG_HOME
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/fish\n',
'/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n'
})
expect(
readSessionShellStartupEnvVar(
'CODEX_HOME',
{ HOME: '/home/alice', SHELL: '/bin/zsh' },
FISH
)
).toBe('/from/fish')
})
it('falls back to the main process HOME and SHELL with no session env at all', () => {
delete process.env.XDG_CONFIG_HOME
process.env.HOME = '/home/alice'
process.env.SHELL = FISH
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/process-env\n'
})
expect(readSessionShellStartupEnvVar('CODEX_HOME', undefined)).toBe('/from/process-env')
})
})
})
@@ -1,395 +0,0 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './posix-shell-startup-command'
import { selectShellStartupFeatures } from './shell-startup-features'
import { prependOrcaCliDirToChildPath } from '../../cli/orca-cli-child-path'
import { runZshPty } from './zsh-startup-hook-pty-harness'
import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates'
import {
importFreshLocalPtyShellReady,
restoreUserDataPathAfterEach,
setTestUserDataPath
} from '../../providers/local-pty/local-pty-shell-ready-test-harness'
restoreUserDataPathAfterEach()
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
const ZSH_PATH = hasZsh
? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim()
: ''
const itWithZsh = hasZsh ? it : it.skip
const describePosix = process.platform === 'win32' ? describe.skip : describe
const PLAIN_PANE = {
hasStartupCommand: false,
waitsForShellReady: false,
emitsStartupIdentity: false
}
describe('shell startup feature selection', () => {
it('ignores an inherited ORCA_SHELL_FEATURES in the spawn env', () => {
expect(
selectShellStartupFeatures({
shellPath: '/bin/zsh',
env: { ORCA_SHELL_FEATURES: 'overlay,markers,ready,identity,history' },
...PLAIN_PANE
})
).toEqual([])
})
it('cannot be turned off for a pane that needs a feature', () => {
expect(
selectShellStartupFeatures({
shellPath: '/bin/zsh',
env: { ORCA_SHELL_FEATURES: '', ORCA_HISTFILE: '/tmp/wt/zsh_history' },
...PLAIN_PANE
})
).toEqual(['history'])
})
it('gives a history-only pane no command markers', () => {
expect(
selectShellStartupFeatures({
shellPath: '/bin/zsh',
env: { ORCA_HISTFILE: '/tmp/wt/zsh_history' },
...PLAIN_PANE
})
).not.toContain('markers')
})
it('keeps markers on a pane that already had them', () => {
expect(
selectShellStartupFeatures({
shellPath: '/bin/zsh',
env: { ORCA_CODEX_HOME: '/tmp/codex' },
...PLAIN_PANE
})
).toContain('markers')
})
it('does not wrap bash for history alone', () => {
expect(
selectShellStartupFeatures({
shellPath: '/bin/bash',
env: { ORCA_HISTFILE: '/tmp/wt/bash_history' },
...PLAIN_PANE
})
).toEqual([])
})
})
describePosix('zsh launch config', () => {
let userDataPath = ''
let previousFeatures: string | undefined
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-'))
setTestUserDataPath(userDataPath)
previousFeatures = process.env.ORCA_SHELL_FEATURES
})
afterEach(() => {
if (previousFeatures === undefined) {
delete process.env.ORCA_SHELL_FEATURES
} else {
process.env.ORCA_SHELL_FEATURES = previousFeatures
}
chmodSync(userDataPath, 0o755)
rmSync(userDataPath, { recursive: true, force: true })
})
itWithZsh.each([false, true])(
'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s',
async (replacePromptHooks) => {
const cliBin = join(userDataPath, 'cli', 'bin')
const ambientBin = join(userDataPath, 'ambient-bin')
mkdirSync(cliBin, { recursive: true })
mkdirSync(ambientBin)
for (const bin of [cliBin, ambientBin]) {
const launcher = join(bin, 'orca-dev')
writeFileSync(launcher, '#!/bin/sh\nexit 0\n')
chmodSync(launcher, 0o755)
}
writeFileSync(
join(userDataPath, '.zshrc'),
`export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}`
)
const env: Record<string, string> = {
...process.env,
HOME: userDataPath,
USERPROFILE: userDataPath,
PATH: `${ambientBin}:/usr/bin:/bin`
}
const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath })
const features = selectShellStartupFeatures({
shellPath: ZSH_PATH,
env,
...PLAIN_PANE,
hasStartupCommand: true
})
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellLaunchConfig(ZSH_PATH, features)
const result = await runZshPty({
env: { ...env, ...config.env },
commands: ['ORCA_LOOKUP=$(command -v orca-dev)'],
report: ['ORCA_LOOKUP']
})
expect(result.values.ORCA_LOOKUP).toBe(launcher)
}
)
it('publishes exactly the selected features, whatever process.env holds', async () => {
process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity'
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellLaunchConfig('/bin/zsh', ['history'])
expect(config.env.ORCA_SHELL_FEATURES).toBe('history')
})
it('wraps a startup-only command without enabling shell readiness', async () => {
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellLaunchConfig('/bin/zsh', [], 'codex')
expect(config.env.ORCA_SHELL_FEATURES).toBe('startup')
expect(config.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe('codex')
expect(config.supportsReadyMarker).toBe(false)
})
it('falls back to plain login zsh when the wrapper tree cannot be written', async () => {
chmodSync(userDataPath, 0o500)
if (
spawnSync('sh', ['-c', `touch ${JSON.stringify(join(userDataPath, 'probe'))}`]).status === 0
) {
return // running with write access regardless of mode (e.g. root)
}
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
const config = getShellLaunchConfig('/bin/zsh', ['history'])
expect(config).toEqual({
args: ['-l'],
env: {},
supportsReadyMarker: false
})
})
it('does not treat another terminal’s hijacked ZDOTDIR as the user config dir', async () => {
// Other terminals may own ZDOTDIR; reject only positively identified Orca or empty directories.
const home = mkdtempSync(join(tmpdir(), 'orca-stacked-zdotdir-'))
const foreignWrapper = join(home, 'other-terminal', 'zsh')
mkdirSync(foreignWrapper, { recursive: true })
const previousZdotdir = process.env.ZDOTDIR
const previousHome = process.env.HOME
process.env.ZDOTDIR = foreignWrapper
process.env.HOME = home
try {
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
// An absent original ZDOTDIR must stay absent, rather than becoming HOME.
expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBeUndefined()
// The marker identifies ownership even without an Orca-shaped path.
writeFileSync(join(foreignWrapper, '.zshrc'), '')
writeFileSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE), '')
const stamped = await importFreshLocalPtyShellReady()
expect(
stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR
).toBeUndefined()
rmSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE))
const real = await importFreshLocalPtyShellReady()
expect(real.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(
foreignWrapper
)
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
rmSync(home, { recursive: true, force: true })
}
})
})
describePosix('epilogue under hostile user shell options', () => {
let userDataPath = ''
let home = ''
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-options-'))
setTestUserDataPath(userDataPath)
home = mkdtempSync(join(tmpdir(), 'orca-feature-options-home-'))
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
rmSync(home, { recursive: true, force: true })
})
// User options affect the deferred hook: NO_UNSET rejects absent variables; KSH_ARRAYS is zero-based.
it.each(['no_unset', 'ksh_arrays'])(
'still runs every feature when the user set %s',
async (option) => {
if (!hasZsh) {
return
}
const scoped = join(home, 'orca-history', 'zsh_history')
const opencodeDir = join(home, 'opencode-overlay')
writeFileSync(join(home, '.zshrc'), `setopt ${option}\n`)
// overlay is the first token, exposing KSH_ARRAYS subscript errors.
const spawnEnv: Record<string, string> = {
HOME: home,
ORCA_HISTFILE: scoped,
ORCA_OPENCODE_CONFIG_DIR: opencodeDir
}
const features = selectShellStartupFeatures({
shellPath: ZSH_PATH,
env: spawnEnv,
hasStartupCommand: true,
waitsForShellReady: true,
emitsStartupIdentity: true
})
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
const launch = getShellLaunchConfig(ZSH_PATH, features)
// A -c shell never reaches precmd; exercise the hook through a PTY.
const { values } = await runZshPty({
env: {
PATH: '/usr/bin:/bin',
...spawnEnv,
...launch.env,
ORCA_ORIG_ZDOTDIR: home
},
report: ['LINEINIT', 'PRECMD', 'OPENCODE_CONFIG_DIR', 'ZDOTDIR', 'HISTFILE'],
commands: [
'LINEINIT="${widgets[zle-line-init]:-none}"; PRECMD="${precmd_functions[*]:-none}"'
]
})
expect(values.LINEINIT).toBe('user:__orca_prompt_mark')
expect(values.PRECMD).toContain('__orca_osc133_precmd')
expect(values.OPENCODE_CONFIG_DIR).toBe(opencodeDir)
expect(values.ZDOTDIR).toBe(home)
expect(values.HISTFILE).toBe(scoped)
}
)
})
function makeUserHome(): string {
const home = mkdtempSync(join(tmpdir(), 'orca-feature-home-'))
for (const file of ['.zshenv', '.zprofile', '.zshrc', '.zlogin']) {
writeFileSync(join(home, file), `print -r -- "RAN=${file}"\n`)
}
return home
}
describePosix('history-only pane in a real zsh', () => {
let userDataPath = ''
let home = ''
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-feature-channel-live-'))
setTestUserDataPath(userDataPath)
home = makeUserHome()
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
rmSync(home, { recursive: true, force: true })
})
function withoutInheritedZdotdir(env: Record<string, string>): Record<string, string> {
const { ORCA_ORIG_ZDOTDIR: _inherited, ...rest } = env
return rest
}
async function launchHistoryOnly(): Promise<{
args: string[]
env: Record<string, string>
zdotdir: string
}> {
const scoped = join(home, 'orca-history', 'zsh_history')
const spawnEnv: Record<string, string> = {
HOME: home,
HISTFILE: scoped,
ORCA_HISTFILE: scoped
}
const features = selectShellStartupFeatures({
shellPath: ZSH_PATH,
env: spawnEnv,
...PLAIN_PANE
})
expect(features).toEqual(['history'])
const { getShellLaunchConfig } = await importFreshLocalPtyShellReady()
const launch = getShellLaunchConfig(ZSH_PATH, features)
return {
args: launch.args ?? ['-l'],
env: {
PATH: '/usr/bin:/bin',
...spawnEnv,
// This test home has no ZDOTDIR; do not inherit the developer's config.
...withoutInheritedZdotdir(launch.env)
},
zdotdir: launch.env.ZDOTDIR
}
}
itWithZsh('unsets the feature channel for the shell and for a grandchild', async () => {
const { args, env } = await launchHistoryOnly()
const output = execFileSync(
ZSH_PATH,
[
...args,
'-i',
'-c',
`print -r -- "SELF=[\${ORCA_SHELL_FEATURES:-}]"; zsh -c 'printenv ORCA_SHELL_FEATURES; print -r -- CHILD_CLEAN'`
],
{ encoding: 'utf8', timeout: 20_000, env }
)
expect(output).toContain('SELF=[]')
expect(output).toContain('CHILD_CLEAN')
expect(output).not.toContain('history')
})
itWithZsh(
'emits no OSC 133 and leaves a pane observably identical to an unwrapped one',
async () => {
// The deferred hook runs at the first prompt, so -c would inspect unfinished setup.
const { env } = await launchHistoryOnly()
const capture = [
'PRECMD="${precmd_functions[*]}"; PREEXEC="${preexec_functions[*]}"',
'LINEINIT="${widgets[zle-line-init]:-none}"'
]
const report = ['PRECMD', 'PREEXEC', 'LINEINIT', 'ZDOTDIR', 'ORCA_SHELL_FEATURES']
const wrapped = await runZshPty({ env, commands: capture, report })
const unwrapped = await runZshPty({
env: { PATH: '/usr/bin:/bin', HOME: home },
commands: capture,
report
})
expect(wrapped.output).not.toContain('\x1b]133;')
expect(wrapped.values.PRECMD).toBe(unwrapped.values.PRECMD)
expect(wrapped.values.PREEXEC).toBe(unwrapped.values.PREEXEC)
// Global zsh config may install a widget; compare against the same host's unwrapped shell.
expect(wrapped.values.LINEINIT).toBe(unwrapped.values.LINEINIT)
expect(wrapped.values.PRECMD).not.toContain('orca')
expect(wrapped.values.ZDOTDIR).toBe(unwrapped.values.ZDOTDIR)
expect(wrapped.values.ORCA_SHELL_FEATURES).toBe('UNSET')
}
)
})
@@ -1,305 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isAbsolute } from 'node:path'
import { getShellReadyWrapperRoot } from '../../providers/local-pty/local-pty-shell-ready-wrapper-root'
import {
SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV,
SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV
} from '../../../shared/setup-agent-sequencing'
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from './wsl-orca-env'
import { CODEX_REMOTE_VIEWER_SSH_CONNECTION } from '../../../shared/remote-viewer-clipboard-env'
describe('addOrcaWslInteropEnv', () => {
it.each(['1', '0'])(
'forwards Claude image override %s to WSL without path translation',
(value) => {
const env: Record<string, string> = { CLAUDE_CODE_FORCE_TERMINAL_IMAGES: value }
addOrcaWslInteropEnv(env)
addOrcaWslInteropEnv(env)
expect(env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES).toBe(value)
expect(
env.WSLENV.split(':').filter((entry) =>
entry.startsWith('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')
)
).toEqual(['CLAUDE_CODE_FORCE_TERMINAL_IMAGES/u'])
}
)
it('marks the Orca terminal handle for Windows to WSL env import', () => {
const env: Record<string, string> = { ORCA_TERMINAL_HANDLE: 'term_wsl' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p')
})
// Guests cannot derive the wrapper content hash from userData; publish the resolved root to avoid unwrapped launches.
it('publishes the resolved wrapper root path-translated for the guest', () => {
const env: Record<string, string> = {}
addOrcaWslInteropEnv(env)
expect(env.ORCA_SHELL_READY_ROOT).toBe(getShellReadyWrapperRoot())
expect(isAbsolute(env.ORCA_SHELL_READY_ROOT as string)).toBe(true)
// Translate the host wrapper path for guest access.
expect(env.WSLENV?.split(':')).toContain('ORCA_SHELL_READY_ROOT/p')
})
it('imports setup-gated startup and setup env into WSL without path translation', () => {
const env: Record<string, string> = {
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'codex',
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: 'while :; do sleep 1; done',
[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]: '( bash /repo/setup-runner.sh )'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV?.split(':')).toEqual([
'ORCA_SHELL_READY_ROOT/p',
`${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`,
`${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u`,
`${SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV}/u`
])
})
it('preserves existing WSLENV entries and does not duplicate the handle entry', () => {
const env: Record<string, string> = {
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p:ORCA_SHELL_READY_ROOT/p')
})
it('marks OMP status and hook env for Windows to WSL import', () => {
const env: Record<string, string> = {
ORCA_TERMINAL_HANDLE: 'term_wsl',
ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca',
ORCA_CLI_COMMAND: 'orca-ide',
ORCA_WSL_CLI_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\wsl-managed-cli\\hash',
ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe',
ORCA_CODEX_ISOLATE: '0',
ORCA_OMP_FRESH_CONFIG: 'C:\\Orca\\fresh-session.yml',
ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts',
ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts',
ORCA_PANE_KEY: 'tab-1:leaf-1',
ORCA_AGENT_PANE: 'tab-1:leaf-1',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret',
ORCA_AGENT_LAUNCH: 'launch-secret',
ORCA_OPENCODE_AGENT: 'opencode2',
ORCA_AGENT_HOOK_PORT: '4567',
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_AGENT_HOOK_ENV: 'dev',
ORCA_AGENT_HOOK_VERSION: '1',
ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1',
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu',
ORCA_RUNTIME_SOURCE_ID: '11111111-1111-4111-8111-111111111111',
ORCA_RUNTIME_SOURCE_INCARNATION: 'runtime-a',
ORCA_RUNTIME_SOURCE_PROFILE_PATH: 'C:\\Users\\a\\AppData\\Roaming\\orca'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
expect(env.WSLENV).toContain('ORCA_WSL_CLI_DIR/p')
expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p')
expect(env.WSLENV).toContain('ORCA_CODEX_ISOLATE/u')
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
expect(env.WSLENV).toContain('ORCA_OMP_FRESH_CONFIG/p')
expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
expect(env.WSLENV).toContain('ORCA_AGENT_PANE/u')
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u')
expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH_TOKEN/u')
expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH/u')
expect(env.WSLENV).toContain('ORCA_OPENCODE_AGENT/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_PORT/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u')
expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u')
expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_ID/u')
expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_INCARNATION/u')
expect(env.WSLENV).toContain('ORCA_RUNTIME_SOURCE_PROFILE_PATH/u')
})
it('overwrites caller host evidence with native runtime WSL authority', () => {
const env = {
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
}
stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu')
expect(env).toEqual({
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
})
})
it('clears inherited host evidence outside a runtime-owned WSL scope', () => {
const env = {
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
}
stampWslOrchestrationCompatibilityHost(env, 'local', null)
expect(env).toEqual({})
})
it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => {
const windowsEnv: Record<string, string> = {
ORCA_AGENT_HOOK_ENDPOINT: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\agent-hooks\\endpoint.cmd'
}
addOrcaWslInteropEnv(windowsEnv)
expect(windowsEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
const guestEnv: Record<string, string> = {
ORCA_AGENT_HOOK_ENDPOINT: '/home/jin/.orca-wsl/agent-hooks/port-4567/endpoint.env'
}
addOrcaWslInteropEnv(guestEnv)
expect(guestEnv.WSLENV).toContain('ORCA_AGENT_HOOK_ENDPOINT/u')
expect(guestEnv.WSLENV).not.toContain('ORCA_AGENT_HOOK_ENDPOINT/p')
})
it('tags pre-translated Linux setup paths /u so WSLENV does not translate them again (#9206)', () => {
const env: Record<string, string> = {
ORCA_ROOT_PATH: '/home/jin/repo',
ORCA_WORKTREE_PATH: '/home/jin/repo-worktrees/fix-1',
ORCA_WORKSPACE_NAME: 'fix-1',
CONDUCTOR_ROOT_PATH: '/home/jin/repo',
GHOSTX_ROOT_PATH: '/home/jin/repo'
}
addOrcaWslInteropEnv(env)
// Already-Linux hook paths must cross untranslated to avoid a second conversion.
expect(env.WSLENV).toContain('ORCA_ROOT_PATH/u')
expect(env.WSLENV).toContain('ORCA_WORKTREE_PATH/u')
expect(env.WSLENV).toContain('CONDUCTOR_ROOT_PATH/u')
expect(env.WSLENV).toContain('GHOSTX_ROOT_PATH/u')
expect(env.WSLENV).not.toContain('ORCA_ROOT_PATH/p')
expect(env.WSLENV).not.toContain('ORCA_WORKTREE_PATH/p')
expect(env.ORCA_ROOT_PATH).toBe('/home/jin/repo')
expect(env.ORCA_WORKTREE_PATH).toBe('/home/jin/repo-worktrees/fix-1')
})
it('tags untranslated Windows setup paths /p so WSLENV translates them (wsl.exe shell over a Windows worktree)', () => {
const env: Record<string, string> = {
ORCA_ROOT_PATH: 'C:\\Users\\jin\\repo',
ORCA_WORKTREE_PATH: 'C:\\Users\\jin\\repo-worktrees\\fix-1',
CONDUCTOR_ROOT_PATH: 'C:\\Users\\jin\\repo',
GHOSTX_ROOT_PATH: 'C:\\Users\\jin\\repo'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toContain('ORCA_ROOT_PATH/p')
expect(env.WSLENV).toContain('ORCA_WORKTREE_PATH/p')
expect(env.WSLENV).toContain('CONDUCTOR_ROOT_PATH/p')
expect(env.WSLENV).toContain('GHOSTX_ROOT_PATH/p')
expect(env.WSLENV).not.toContain('ORCA_ROOT_PATH/u')
expect(env.WSLENV).not.toContain('ORCA_WORKTREE_PATH/u')
})
it('always tags ORCA_WORKSPACE_NAME /u because it is a name, not a path', () => {
const env: Record<string, string> = { ORCA_WORKSPACE_NAME: 'fix-1' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WORKSPACE_NAME/u')
})
it.each(['repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', 'folder:f1'])(
'crosses the workspace id %s into the guest untranslated (/u), as the window and main stamp it',
(workspaceId) => {
const env: Record<string, string> = { ORCA_WORKSPACE_ID: workspaceId }
addOrcaWslInteropEnv(env)
expect(env.ORCA_WORKSPACE_ID).toBe(workspaceId)
expect(env.WSLENV?.split(':')).toContain('ORCA_WORKSPACE_ID/u')
}
)
// #10861: wsl.exe drops SSH_CONNECTION, so WSL Codex copied only to the Windows clipboard.
it('crosses the Codex remote-viewer clipboard marker into the guest untranslated (/u)', () => {
const env: Record<string, string> = {
SSH_CONNECTION: CODEX_REMOTE_VIEWER_SSH_CONNECTION,
ORCA_REMOTE_VIEWER_CLIPBOARD: '1'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV?.split(':')).toContain('SSH_CONNECTION/u')
expect(env.WSLENV?.split(':')).toContain('ORCA_REMOTE_VIEWER_CLIPBOARD/u')
})
it("leaves the Windows host's own SSH session out of the guest", () => {
const env: Record<string, string> = { SSH_CONNECTION: '10.0.0.1 5000 10.0.0.2 22' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV?.split(':')).not.toContain('SSH_CONNECTION/u')
})
it('crosses the inline-image protocol hint into the guest untranslated (/u)', () => {
const env: Record<string, string> = { ORCA_IMAGE_PROTOCOL: 'kitty' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toContain('ORCA_IMAGE_PROTOCOL/u')
})
it('marks the WSL hook relay version for import on relay spawn envs', () => {
const env: Record<string, string> = {
ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WSL_HOOK_RELAY_VERSION/u')
})
it('crosses a guest-side OpenCode config overlay untranslated (/u)', () => {
const env: Record<string, string> = {
OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc',
ORCA_OPENCODE_CONFIG_DIR: '/home/jin/.orca-relay/opencode-overlays/abc'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toContain('OPENCODE_CONFIG_DIR/u')
expect(env.WSLENV).toContain('ORCA_OPENCODE_CONFIG_DIR/u')
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR/p')
})
it('never crosses a Windows OpenCode config dir into the guest', () => {
// Windows overlays can leak through inherited relay/daemon env; never translate them into guest OpenCode config roots.
const env: Record<string, string> = {
OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc',
ORCA_OPENCODE_CONFIG_DIR: 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\opencode-overlays\\abc'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR')
expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR')
})
it('does not register the OpenCode config vars when they are absent', () => {
const env: Record<string, string> = { ORCA_TERMINAL_HANDLE: 'term_wsl' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).not.toContain('OPENCODE_CONFIG_DIR')
expect(env.WSLENV).not.toContain('ORCA_OPENCODE_CONFIG_DIR')
})
})
@@ -1,344 +0,0 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { prependOrcaCliDirToChildPath } from '../../cli/orca-cli-child-path'
import { getDaemonZshWrapperSpec } from '../../daemon/shell-readiness/daemon-zsh-shell-ready-wrapper-spec'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './posix-shell-startup-command'
import { getZshShellReadyWrapperFile } from '../../providers/local-pty/local-pty-shell-ready-wrapper-generation'
import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features'
import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates'
import { buildZshStartupHook } from './zsh-startup-wrapper-builder'
import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
const itWithZsh = hasZsh ? it : it.skip
const USER_WIDGET = `orca_test_line_init() {
O_UC=$((\${O_UC:-0}+1))
O_UN="$WIDGET"
builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n'
return 1
}
zle -N zle-line-init orca_test_line_init
`
const USER_REDRAW = `orca_test_redraw() {
O_RC=$((\${O_RC:-0}+1))
O_RN="$WIDGET"
return 1
}
zle -N zle-line-pre-redraw orca_test_redraw
`
const USER_PRECMD = `precmd() {
O_PCALLS=$((\${O_PCALLS:-0}+1))
O_PN="$0"
O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}"
return 1
}
`
describe('zsh deferred startup after prompt-hook replacement', () => {
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
itWithZsh.each([
'history',
'startup',
'chained-startup',
'stock-history',
'stock-startup',
'stock-chained-redraw',
'stock-nonzero-precmd',
'ordered-startup',
'replaced-precmd-startup',
'status-startup',
'sticky-startup',
'scheduled-startup',
'unavailable-startup',
'repeat-history',
'repeat-startup'
] as const)(
'restores CLI precedence and preserves the user widget in a %s pane',
async (intent) => {
const historyOnly = intent.endsWith('history')
const repeatSource = intent.startsWith('repeat-')
const stockBinding = intent.startsWith('stock-')
const chainedLineInit = intent === 'chained-startup'
const chainedRedraw = intent === 'stock-chained-redraw'
const nonzeroPrecmd = intent === 'stock-nonzero-precmd'
const orderedPrecmd = intent === 'ordered-startup'
const replacedPrecmd = intent === 'replaced-precmd-startup'
const statusPrecmd = intent === 'status-startup'
const stickyPrecmd = intent === 'sticky-startup'
const scheduledPrecmd = intent === 'scheduled-startup'
const unavailableSched = intent === 'unavailable-startup'
const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd
const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-'))
roots.push(home)
const cliBin = join(home, 'cli', 'bin')
const ambientBin = join(home, 'ambient-bin')
const wrapperDir = join(home, 'wrapper')
for (const bin of [cliBin, ambientBin, wrapperDir]) {
mkdirSync(bin, { recursive: true })
}
for (const bin of [cliBin, ambientBin]) {
writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n')
chmodSync(join(bin, 'orca-dev'), 0o755)
}
writeFileSync(
join(home, '.zshenv'),
(chainedLineInit || chainedRedraw || repeatSource
? ''
: stockBinding
? USER_REDRAW
: USER_WIDGET) +
(statusPrecmd
? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n'
: stickyPrecmd
? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n`
: scheduledPrecmd
? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n'
: unavailableSched
? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n'
: nonzeroPrecmd || replacedPrecmd
? USER_PRECMD
: orderedPrecmd
? USER_PRECMD.replace('return 1', 'return 0')
: '')
)
if (statusPrecmd || stickyPrecmd) {
writeFileSync(
join(home, '.zlogin'),
'orca_test_status() { return 42; }; orca_test_status\n'
)
}
const stockWidget =
stockBinding || stickyPrecmd || scheduledPrecmd
? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init')
: ''
writeFileSync(
join(home, '.zshrc'),
`${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${
chainedLineInit
? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n`
: chainedRedraw
? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n`
: ''
}${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}`
)
writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile())
writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '')
const env: Record<string, string> = {
...process.env,
HOME: home,
USERPROFILE: home,
PATH: `${ambientBin}:/usr/bin:/bin`,
DEBIAN_PREVENT_KEYBOARD_CHANGES: '1',
ZDOTDIR: wrapperDir,
ORCA_HISTFILE: join(home, 'scoped-history')
}
const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home })
const features = selectShellStartupFeatures({
shellPath: ZSH_PATH,
env,
hasStartupCommand: !historyOnly,
waitsForShellReady: !historyOnly,
emitsStartupIdentity: false
})
env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features)
if (!historyOnly) {
env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))'
}
if (statusPrecmd || stickyPrecmd) {
const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] })
expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42')
}
const result = await runZshPty({
env,
commands: [
...(repeatSource
? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"']
: []),
'O_LK=$(command -v orca-dev)',
'O_SR=${+widgets[__orca_saved_line_init]}',
...(scheduleCleanup
? [
'O_SC=${+functions[__orca_deferred_sched_init]}',
'O_SE=${zsh_scheduled_events[*]:-UNSET}'
]
: []),
...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []),
...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []),
'O_LI=${widgets[zle-line-init]:-none}',
'O_PC="${precmd_functions[*]}"',
...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : [])
],
report: [
'O_LK',
'O_UC',
'O_UN',
'O_SR',
...(scheduleCleanup ? ['O_SC', 'O_SE'] : []),
...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []),
...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []),
...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []),
...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []),
...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []),
...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []),
'O_LI',
'O_PC',
'O_SU',
'HISTFILE'
]
})
expect(result.values.O_LK).toBe(launcher)
expect(Number(result.values.O_UC)).toBeGreaterThan(0)
if (!chainedLineInit) {
expect(result.values.O_UN).toBe('zle-line-init')
}
expect(result.values.O_SR).toBe('0')
if (repeatSource) {
expect(result.output).not.toContain('job table full or recursion limit exceeded')
expect(result.values.O_PC).not.toContain('__orca_deferred_init')
}
if (scheduleCleanup) {
expect(result.values.O_SC).toBe('0')
expect(result.values.O_SE).not.toContain('orca')
}
if (unavailableSched) {
// Stock completion modules can fail before the fixture restores module_path.
expect(result.output).not.toContain('zsh/sched')
expect(result.output).not.toContain('__orca_arm_deferred_line_init:')
expect(result.values.O_SE).toBe('UNSET')
}
if (scheduledPrecmd) {
expect(result.values.O_EV).toBe('1')
expect(result.values.O_EO).toBe('0')
expect(result.values.O_SE).toContain('O_EVENT')
}
if (stockBinding) {
expect(Number(result.values.O_RC)).toBeGreaterThan(0)
expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw')
if (!chainedRedraw) {
expect(result.values.O_RW).toBe('user:orca_test_redraw')
}
}
expect(result.values.HISTFILE).toBe(join(home, 'scoped-history'))
if (nonzeroPrecmd) {
expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0)
expect(result.values.O_PN).toBe('precmd')
expect(result.values.O_PO).toBe('on:on')
expect(result.values.O_AC).toBe('called')
expect(result.values.O_PS).toBe('1')
}
if (orderedPrecmd) {
expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0)
expect(result.values.O_PN).toBe('precmd')
expect(result.values.O_AO).toBe('0')
}
if (replacedPrecmd) {
expect(result.values.O_PCALLS).toBe('UNSET')
expect(Number(result.values.O_NPC)).toBeGreaterThan(0)
expect(result.values.O_NPN).toBe('precmd')
}
if (statusPrecmd || stickyPrecmd) {
expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42')
expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ')
}
if (historyOnly) {
expect(result.output).not.toContain('\x1b]133;')
expect(result.values.O_LI).toBe(
stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init'
)
expect(result.values.O_PC).not.toContain('orca')
expect(result.values.O_SU).toBe('UNSET')
} else {
expect(result.output).toContain(MARKERS.ready)
expect(result.values.O_LI).toBe('user:__orca_prompt_mark')
expect(result.values.O_PC).toBe(
nonzeroPrecmd || orderedPrecmd
? 'orca_test_array __orca_osc133_precmd'
: '__orca_osc133_precmd'
)
expect(result.values.O_SU).toBe('1')
expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe(
result.output.split(MARKERS.ready).length - 1
)
}
}
)
})
describe('zsh plugins that call deferred line-init by name', () => {
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
itWithZsh.each(['local', 'daemon'] as const)(
'keeps the plugin call working across prompts in a %s wrapper',
async (transport) => {
const home = mkdtempSync(join(tmpdir(), 'orca-zsh-named-wrapper-'))
roots.push(home)
const wrapperDir = join(home, 'wrapper')
mkdirSync(wrapperDir)
writeFileSync(
join(home, '.zshrc'),
`functions[orca_test_app_mode]=\${widgets[zle-line-init]#user:}'
O_AM=$((\${O_AM:-0}+1))'
zle -N zle-line-init orca_test_app_mode
`
)
const wrapper =
transport === 'local'
? getZshShellReadyWrapperFile()
: buildZshStartupHook(getDaemonZshWrapperSpec())
writeFileSync(join(wrapperDir, '.zshenv'), wrapper)
writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '')
const env: Record<string, string> = {
HOME: home,
USERPROFILE: home,
PATH: '/usr/bin:/bin',
DEBIAN_PREVENT_KEYBOARD_CHANGES: '1',
ZDOTDIR: wrapperDir
}
env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(
selectShellStartupFeatures({
shellPath: ZSH_PATH,
env,
hasStartupCommand: false,
waitsForShellReady: true,
emitsStartupIdentity: false
})
)
const result = await runZshPty({
env,
commands: [
'true',
'false',
'[[ ${functions[orca_test_app_mode]} == *__orca_deferred_line_init* ]]; O_CALL_REF=$?',
'__orca_deferred_line_init; O_NOOP=$?',
'O_INIT=${+functions[__orca_deferred_init]}'
],
report: ['O_AM', 'O_CALL_REF', 'O_NOOP', 'O_INIT']
})
expect(result.output, result.output).not.toContain('command not found')
expect(result.output).toContain(MARKERS.ready)
expect(Number(result.values.O_AM)).toBeGreaterThan(2)
expect(result.values.O_CALL_REF).toBe('0')
expect(result.values.O_NOOP).toBe('0')
expect(result.values.O_INIT).toBe('0')
}
)
})
@@ -1,12 +1,12 @@
/** Exercise real launch selection and a PTY prompt so system zshrc and deferred hooks can affect HISTFILE. */
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { getShellLaunchConfig } from '../../providers/local-pty/local-pty-shell-ready'
import { selectShellStartupFeatures } from './shell-startup-features'
import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness'
const itWithZsh = hasZsh ? it : it.skip
@@ -78,17 +78,6 @@ function withHome(files: Record<string, string>, run: (home: string) => Promise<
describe.skipIf(process.platform === 'win32')(
'worktree-scoped HISTFILE survives zsh startup',
() => {
itWithZsh(
'wraps a plain pane once Orca injected a worktree HISTFILE',
withHome(USER_FILES, async (home) => {
const { features, launch } = launchPane(home, join(home, 'zsh_history'))
expect(features).toEqual(['history'])
expect(launch.env.ZDOTDIR).toBeTruthy()
expect(launch.env.ORCA_SHELL_FEATURES).toBe('history')
})
)
itWithClobber(
'keeps the injected path that the system zshrc would otherwise clobber',
withHome(USER_FILES, async (home) => {
@@ -100,262 +89,5 @@ describe.skipIf(process.platform === 'win32')(
expect(values.HISTFILE).toBe(scoped)
})
)
itWithZsh(
'hands ZDOTDIR back before the user’s own startup files load',
withHome(USER_FILES, async (home) => {
const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history'))
const { values } = await runZshPty({
env,
report: [
'ZDOTDIR',
'ORCA_TEST_USER_ZSHENV',
'ORCA_TEST_USER_ZPROFILE',
'ORCA_TEST_USER_ZSHRC'
]
})
expect(values.ORCA_TEST_USER_ZSHENV).toBe('1')
expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1')
expect(values.ORCA_TEST_USER_ZSHRC).toBe('1')
expect(values.ZDOTDIR).toBe(home)
expect(values.ZDOTDIR).not.toBe(launch.env.ZDOTDIR)
})
)
itWithZsh(
'never leaves history inside Orca’s own wrapper directory',
withHome(USER_FILES, async (home) => {
const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history'))
const { values } = await runZshPty({ env, report: ['HISTFILE'] })
expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR)
})
)
itWithZsh(
'consumes ORCA_HISTFILE so nothing the shell spawns can inherit it',
withHome(USER_FILES, async (home) => {
const scoped = join(home, 'orca-history', 'zsh_history')
const { env } = launchPane(home, scoped)
const { values } = await runZshPty({
env,
report: ['ORCA_HISTFILE', 'ORCA_SHELL_FEATURES', 'HISTFILE']
})
expect(values.ORCA_HISTFILE).toBe('UNSET')
expect(values.ORCA_SHELL_FEATURES).toBe('UNSET')
expect(values.HISTFILE).toBe(scoped)
})
)
itWithZsh(
'leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing',
withHome(USER_FILES, async (home) => {
// Defaults vary by host; compare with its unwrapped shell rather than a hardcoded HISTFILE.
const overlayEnv = { ORCA_CODEX_HOME: join(home, 'codex') }
const features = selectShellStartupFeatures({
shellPath: ZSH_PATH,
env: { HOME: home, ...overlayEnv },
hasStartupCommand: false,
waitsForShellReady: false,
emitsStartupIdentity: false
})
const launch = getShellLaunchConfig(ZSH_PATH, features)
const wrapped = await runZshPty({
env: {
PATH: '/usr/bin:/bin',
HOME: home,
...overlayEnv,
...launch.env,
ORCA_ORIG_ZDOTDIR: home
},
report: ['HISTFILE']
})
const unwrapped = await runZshPty({
env: { PATH: '/usr/bin:/bin', HOME: home },
report: ['HISTFILE']
})
expect(wrapped.values.HISTFILE).toBe(unwrapped.values.HISTFILE)
})
)
}
)
describe.skipIf(process.platform === 'win32')('the deferred hook delivers every feature', () => {
itWithZsh(
'emits the identity, readiness and OSC 133 markers a startup command waits on',
withHome(USER_FILES, async (home) => {
const scoped = join(home, 'orca-history', 'zsh_history')
const { env, features } = launchPane(home, scoped, {
hasStartupCommand: true,
waitsForShellReady: true,
emitsStartupIdentity: true
})
expect(features).toEqual(expect.arrayContaining(['markers', 'ready', 'identity']))
const { output, values } = await runZshPty({
env,
commands: ['true'],
report: ['HISTFILE']
})
expect(output).toMatch(MARKERS.identity)
expect(output).toContain(MARKERS.ready)
expect(output).toContain(MARKERS.promptStart)
expect(output).toContain(MARKERS.commandStart)
expect(output).toMatch(MARKERS.commandDone)
expect(values.HISTFILE).toBe(scoped)
})
)
itWithZsh(
'restores Orca’s overlay values after the user’s config overwrites them',
withHome(
{
...USER_FILES,
'.zshrc': 'export CODEX_HOME=/user/codex\nexport PATH=/user/bin:$PATH\n'
},
async (home) => {
const overlayEnv = {
ORCA_CODEX_HOME: '/orca/codex',
ORCA_AGENT_TEAMS_SHIM_DIR: '/orca/shim'
}
const features = selectShellStartupFeatures({
shellPath: ZSH_PATH,
env: { HOME: home, ...overlayEnv },
hasStartupCommand: false,
waitsForShellReady: false,
emitsStartupIdentity: false
})
const launch = getShellLaunchConfig(ZSH_PATH, features)
const { values } = await runZshPty({
env: {
PATH: '/usr/bin:/bin',
HOME: home,
...overlayEnv,
...launch.env,
ORCA_ORIG_ZDOTDIR: home
},
report: ['CODEX_HOME', 'PATH']
})
expect(values.CODEX_HOME).toBe('/orca/codex')
expect(values.PATH.startsWith('/orca/shim:')).toBe(true)
}
)
)
})
describe.skipIf(process.platform === 'win32')(
'the wrapper survives a hostile user zsh config',
() => {
it.each([
['.zshenv', 'emulate sh'],
['.zshenv', 'emulate ksh'],
['.zprofile', 'emulate sh'],
['.zshrc', 'emulate sh'],
['.zshrc', 'setopt no_unset'],
['.zshrc', 'setopt ksharrays']
])('still scopes history when the user %s runs `%s`', async (file, statement) => {
if (!hasZsh) {
return
}
const home = makeZshHome({
...USER_FILES,
[file]: `${USER_FILES[file as keyof typeof USER_FILES]}${statement}\n`
})
try {
const scoped = join(home, 'orca-history', 'zsh_history')
const { env } = launchPane(home, scoped)
const { values } = await runZshPty({ env, report: ['HISTFILE', 'ORCA_HISTFILE'] })
expect(values.HISTFILE).toBe(scoped)
expect(values.ORCA_HISTFILE).toBe('UNSET')
} finally {
rmSync(home, { recursive: true, force: true })
}
})
itWithZsh(
'still scopes history without leaking it when a config drops precmd_functions',
withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => {
const scoped = join(home, 'orca-history', 'zsh_history')
const { env, launch } = launchPane(home, scoped)
const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR']
const { values } = await runZshPty({ env, report })
expect(values.HISTFILE).toBe(scoped)
expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR)
expect(values.ORCA_HISTFILE).toBe('UNSET')
expect(values.ZDOTDIR).toBe(home)
})
)
itWithZsh(
'loads the user config even when a startup file writes an unrelated ZDOTDIR',
withHome(
{ ...USER_FILES, '.zshenv': `${USER_FILES['.zshenv']}export ZDOTDIR="$HOME"\n` },
async (home) => {
const { env } = launchPane(home, join(home, 'orca-history', 'zsh_history'))
const { values } = await runZshPty({
env,
report: ['ZDOTDIR', 'ORCA_TEST_USER_ZSHRC']
})
expect(values.ZDOTDIR).toBe(home)
expect(values.ORCA_TEST_USER_ZSHRC).toBe('1')
}
)
)
}
)
/** Relay uses the same builder with different feature flags; exercise that variant in a real shell too. */
describe.skipIf(process.platform === 'win32')('the real-zsh harness', () => {
itWithZsh(
'waits for delayed first output before answering compinit and scoping history',
withHome(
{
'.zshenv': 'sleep 0.6\nfpath=("$HOME/insecure-completions" $fpath)\n',
'.zshrc':
'if (( ! $+_comps )); then\n' +
' autoload -Uz compinit\n' +
' compinit -D\n' +
'fi\n' +
'export ORCA_TEST_COMPINIT_READY=$+_comps\n'
},
async (home) => {
const completions = join(home, 'insecure-completions')
mkdirSync(completions)
chmodSync(completions, 0o777)
writeFileSync(
join(completions, '_orca_compinit_fixture'),
'#compdef orca_compinit_fixture\n'
)
const scoped = join(home, 'scoped_history')
const { env } = launchPane(home, scoped)
const { output, values } = await runZshPty({
env,
report: ['HISTFILE', 'ORCA_TEST_COMPINIT_READY']
})
expect(output).toContain('Ignore insecure directories')
expect(output).not.toContain('initialization aborted')
expect(output).not.toContain('command not found: y')
expect(values.ORCA_TEST_COMPINIT_READY).toBe('1')
expect(values.HISTFILE).toBe(scoped)
}
)
)
})
@@ -10,214 +10,6 @@ import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty
const itWithZsh = hasZsh ? it : it.skip
const REPORTED = ['ZDOTDIR', 'ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'PATH'] as const
type ConfigCase = {
name: string
/** Builds `$HOME/.zshenv` and any extra files. Returns the dir holding .zshrc. */
setup: (home: string) => string
}
const CASES: ConfigCase[] = [
{
name: 'no ZDOTDIR at all',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=plain\n')
return home
}
},
{
name: 'ZDOTDIR exported to an XDG dir',
setup: (home) => {
const dir = join(home, '.config', 'zsh')
mkdirSync(dir, { recursive: true })
writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=xdg\nexport ZDOTDIR="${dir}"\n`)
return dir
}
},
{
name: 'ZDOTDIR set by a file the .zshenv sources',
setup: (home) => {
const dir = join(home, '.config', 'zsh')
mkdirSync(dir, { recursive: true })
const common = join(home, '.config', 'shell', 'common.sh')
mkdirSync(dirname(common), { recursive: true })
writeFileSync(common, `export ZDOTDIR="${dir}"\n`)
writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=sourced\nsource "${common}"\n`)
return dir
}
},
{
name: 'ZDOTDIR with spaces in the path',
setup: (home) => {
const dir = join(home, 'My Config', 'zsh')
mkdirSync(dir, { recursive: true })
writeFileSync(
join(home, '.zshenv'),
`export ORCA_TEST_MARK=spaces\nexport ZDOTDIR="${dir}"\n`
)
return dir
}
},
{
name: 'ZDOTDIR set more than once',
setup: (home) => {
const first = join(home, 'first')
const dir = join(home, 'second')
mkdirSync(first, { recursive: true })
mkdirSync(dir, { recursive: true })
writeFileSync(
join(home, '.zshenv'),
`export ORCA_TEST_MARK=twice\nexport ZDOTDIR="${first}"\nexport ZDOTDIR="${dir}"\n`
)
return dir
}
},
{
name: 'ZDOTDIR written with a trailing slash',
setup: (home) => {
const dir = join(home, 'trailing')
mkdirSync(dir, { recursive: true })
writeFileSync(
join(home, '.zshenv'),
`export ORCA_TEST_MARK=trailing\nexport ZDOTDIR="${dir}/"\n`
)
return dir
}
},
{
name: 'ZDOTDIR pointing at a directory that does not exist',
setup: (home) => {
writeFileSync(
join(home, '.zshenv'),
`export ORCA_TEST_MARK=missing\nexport ZDOTDIR="${join(home, 'nope')}"\n`
)
return home
}
},
{
name: 'ZDOTDIR set to the empty string',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=empty\nexport ZDOTDIR=""\n')
return home
}
},
{
name: 'ZDOTDIR explicitly set to $HOME',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=home\nexport ZDOTDIR="$HOME"\n')
return home
}
},
{
name: 'a .zshenv with a syntax error',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=broken\nif [ ; then\n')
return home
}
},
{
name: 'a .zshenv running set -u before anything else',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'set -u\nexport ORCA_TEST_MARK=nounset\n')
return home
}
},
{
name: 'a .zshenv running set -e with a failing command',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'set -e\nexport ORCA_TEST_MARK=errexit\nfalse\n')
return home
}
},
{
name: 'a .zshenv setting extendedglob and nullglob',
setup: (home) => {
writeFileSync(
join(home, '.zshenv'),
'setopt extendedglob nullglob\nexport ORCA_TEST_MARK=globs\n'
)
return home
}
},
{
name: 'a .zshenv that unsets HOME',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=nohome\nunset HOME\n')
return home
}
},
{
name: 'ZDOTDIR containing only slashes',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=slashes\nexport ZDOTDIR="///"\n')
return home
}
},
{
name: 'a whitespace-only ZDOTDIR',
setup: (home) => {
writeFileSync(
join(home, '.zshenv'),
'export ORCA_TEST_MARK=blank\nexport ZDOTDIR="$(printf \'\\t\\n\')"\n'
)
return home
}
},
{
name: 'ZDOTDIR with a single quote in the path',
setup: (home) => {
const dir = join(home, "it's zsh")
mkdirSync(dir, { recursive: true })
writeFileSync(
join(home, '.zshenv'),
`export ORCA_TEST_MARK=quote\nexport ZDOTDIR=${JSON.stringify(dir)}\n`
)
return dir
}
},
{
name: 'a .zshenv that conditionally unsets ZDOTDIR',
setup: (home) => {
writeFileSync(
join(home, '.zshenv'),
'export ORCA_TEST_MARK=conditional\nexport ZDOTDIR="$HOME/x"\nunset ZDOTDIR\n'
)
return home
}
},
{
name: 'a .zshenv using typeset -U path at top level',
setup: (home) => {
// A top-level-only path declaration proves .zshenv was not sourced inside a function or subshell.
writeFileSync(
join(home, '.zshenv'),
'typeset -U path\npath=(/usr/bin /bin /usr/bin)\nexport ORCA_TEST_MARK=uniqpath\n'
)
return home
}
},
{
name: 'a .zshenv defining a function and extending fpath',
setup: (home) => {
const fns = join(home, 'fns')
mkdirSync(fns, { recursive: true })
writeFileSync(
join(home, '.zshenv'),
`fpath=(${JSON.stringify(fns)} $fpath)\norca_test_fn() { : }\nexport ORCA_TEST_MARK=fnscope\n`
)
return home
}
},
{
name: 'a .zshenv that calls exit',
setup: (home) => {
writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=exiting\nexit 0\n')
return home
}
}
]
function wrappedEnv(home: string): Record<string, string> {
const features = selectShellStartupFeatures({
shellPath: ZSH_PATH,
@@ -237,42 +29,6 @@ function wrappedEnv(home: string): Record<string, string> {
}
}
describe.skipIf(process.platform === 'win32')(
'a wrapped pane resolves the user config exactly as an unwrapped one does',
() => {
it.each(CASES.map((testCase) => [testCase.name, testCase] as const))(
'matches unwrapped zsh for %s',
async (_name, testCase) => {
if (!hasZsh) {
return
}
const home = makeZshHome({})
try {
const zshrcDir = testCase.setup(home)
mkdirSync(zshrcDir, { recursive: true })
writeFileSync(join(zshrcDir, '.zshrc'), 'export ORCA_TEST_FROM_ZSHRC=1\n')
const wrapped = await runZshPty({ env: wrappedEnv(home), report: REPORTED })
const unwrapped = await runZshPty({
env: { PATH: '/usr/bin:/bin', HOME: home },
report: REPORTED
})
expect(wrapped.exitedBeforePrompt).toBe(unwrapped.exitedBeforePrompt)
for (const key of REPORTED) {
expect(
wrapped.values[key],
`${key} differs between a wrapped and an unwrapped pane`
).toBe(unwrapped.values[key])
}
} finally {
rmSync(home, { recursive: true, force: true })
}
}
)
}
)
describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was built for', () => {
let userDataPath = ''
let previousUserDataPath: string | undefined
@@ -347,26 +103,6 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui
}
})
itWithZsh('gives the user’s startup files their own ZDOTDIR while they run (#4667)', async () => {
const home = makeZshHome({})
const xdg = join(home, '.config', 'zsh')
mkdirSync(xdg, { recursive: true })
writeFileSync(join(home, '.zshenv'), `export ZDOTDIR=${JSON.stringify(xdg)}\n`)
writeFileSync(join(xdg, '.zshrc'), 'export ORCA_TEST_IN_ZSHRC="$ZDOTDIR"\n')
writeFileSync(join(xdg, '.zprofile'), 'export ORCA_TEST_IN_ZPROFILE="$ZDOTDIR"\n')
try {
const report = ['ORCA_TEST_IN_ZSHRC', 'ORCA_TEST_IN_ZPROFILE']
const wrapped = await runZshPty({ env: wrappedEnv(home), report })
const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report })
expect(wrapped.values.ORCA_TEST_IN_ZSHRC).toBe(xdg)
expect(wrapped.values.ORCA_TEST_IN_ZPROFILE).toBe(xdg)
expect(wrapped.values).toEqual(unwrapped.values)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
itWithZsh('refuses an inherited ZDOTDIR that is an Orca wrapper dir (#15258)', async () => {
// Older parents can supply stale ORCA_ORIG_ZDOTDIR without passing through Node's ownership check.
const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' })
@@ -387,40 +123,4 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui
rmSync(home, { recursive: true, force: true })
}
})
itWithZsh('leaves a nested Orca nothing of its own to inherit (#11044, #11146)', async () => {
const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' })
try {
const { values } = await runZshPty({
env: wrappedEnv(home),
commands: [
'ORCA_CHILD_ENV="$(env | grep -cE \'^(ORCA_SHELL_FEATURES|ORCA_HISTFILE)=\' || true)"',
'ORCA_CHILD_ZDOTDIR="$(env | sed -n \'s/^ZDOTDIR=//p\')"'
],
report: ['ORCA_CHILD_ENV', 'ORCA_CHILD_ZDOTDIR']
})
expect(values.ORCA_CHILD_ENV).toBe('0')
expect(values.ORCA_CHILD_ZDOTDIR).toBe('UNSET')
} finally {
rmSync(home, { recursive: true, force: true })
}
})
itWithZsh('survives a user .zshenv that returns early (#1947)', async () => {
const home = makeZshHome({
'.zshenv': 'export ORCA_TEST_MARK=early\nreturn 0\nexport ORCA_TEST_MARK=late\n',
'.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n'
})
try {
const report = ['ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'ZDOTDIR']
const wrapped = await runZshPty({ env: wrappedEnv(home), report })
const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report })
expect(wrapped.values).toEqual(unwrapped.values)
expect(wrapped.values.ORCA_TEST_FROM_ZSHRC).toBe('1')
} finally {
rmSync(home, { recursive: true, force: true })
}
})
})
@@ -3,7 +3,6 @@ import {
fetchAntigravityRateLimits,
resetAntigravityUsageSupportForTests
} from './antigravity-usage-fetcher'
import { ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_VERSION_ARGS } from './antigravity-usage-command'
import type { ProcessResult } from '@orca/process-host/process-spec'
const { hostShellEnvironmentMock } = vi.hoisted(() => ({
@@ -13,8 +12,7 @@ const { hostShellEnvironmentMock } = vi.hoisted(() => ({
vi.mock('../runtime/agent-session/environment/structured-agent-shell-environment', () => ({
hostShellEnvironment: hostShellEnvironmentMock
}))
const USAGE_ENVELOPE = JSON.stringify({
void JSON.stringify({
conversation_id: '',
status: 'SUCCESS',
command: {
@@ -85,227 +83,6 @@ function harness(
}
}
describe('fetchAntigravityRateLimits', () => {
beforeEach(() => {
resetAntigravityUsageSupportForTests()
})
it('publishes the CLI reading as Antigravity usage', async () => {
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
expect(result.status).toBe('ok')
expect(result.provider).toBe('antigravity')
expect(result.error).toBeNull()
expect(result.weekly).toMatchObject({ usedPercent: 60, windowMinutes: 10_080 })
expect(result.buckets).toEqual([
{
name: 'Gemini Models',
group: 'Gemini Models',
windowName: 'Weekly Limit Remaining',
usedPercent: 60,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
}
])
expect(result.usageMetadata).toMatchObject({
source: 'cli',
credentialSource: 'antigravity-cli'
})
})
it('never publishes the agy bucket id to the renderer', async () => {
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
for (const bucket of result.buckets ?? []) {
expect(bucket).not.toHaveProperty('id')
}
})
it('probes the version, then runs the quota read with the resolved path and login-shell env', async () => {
const loginShellEnv = { PATH: '/Users/x/.local/bin:/usr/bin' }
const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }), env: loginShellEnv })
await h.fetch()
// Login PATH finds ~/.local/bin; disabling updates prevents Windows console flashes.
const probeEnv = { PATH: '/Users/x/.local/bin:/usr/bin', AGY_CLI_DISABLE_AUTO_UPDATE: 'true' }
expect(h.runCommand).toHaveBeenCalledTimes(2)
const [versionSpec, usageSpec] = h.runCommand.mock.calls.map(([spec]) => spec)
expect(versionSpec.args).toEqual(ANTIGRAVITY_VERSION_ARGS)
expect(versionSpec.env).toEqual(probeEnv)
expect(usageSpec.program).toBe('/Users/x/.local/bin/agy')
expect(usageSpec.args).toEqual(ANTIGRAVITY_USAGE_ARGS)
expect(usageSpec.env).toEqual(probeEnv)
expect(usageSpec.timeoutMs).toBeGreaterThan(0)
expect(h.resolveCommand).toHaveBeenCalledWith('agy', { platform: 'darwin', env: probeEnv })
// The login-shell env is a shared cache entry; the probe must not write into it.
expect(loginShellEnv).toEqual({ PATH: '/Users/x/.local/bin:/usr/bin' })
})
it('looks agy up in the env agent launches use, so a Git Bash login PATH cannot hide it', async () => {
// Why: Git Bash's login PATH is /c/...:/usr/bin, which win32 lookup cannot read (#31459).
const launchEnv = { PATH: '/c/Users/x/bin:/usr/bin;C:\\Users\\x\\AppData\\Local\\agy\\bin' }
hostShellEnvironmentMock.mockResolvedValueOnce(launchEnv)
const resolveCommand = vi.fn().mockResolvedValue(null)
await fetchAntigravityRateLimits({ resolveCommand, platform: 'win32' })
expect(resolveCommand).toHaveBeenCalledWith('agy', {
platform: 'win32',
env: { ...launchEnv, AGY_CLI_DISABLE_AUTO_UPDATE: 'true' }
})
})
it('reports an absent CLI as unavailable and never spawns', async () => {
const h = harness({ program: null })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
expect(result.error).toContain('was not found on this machine')
expect(h.runCommand).not.toHaveBeenCalled()
})
it('reports a signed-out account as unavailable, not as a failed refresh', async () => {
const result = await harness({
// agy may exit zero for signed-out diagnostics.
result: processResult({ stderr: 'You are not logged into Antigravity.' })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.error).toContain('Sign in with `agy`')
})
it('reports an auth failure on stderr as sign-in guidance, not as a broken read', async () => {
// Nonzero auth failures still need sign-in guidance.
const result = await harness({
result: processResult({ code: 1, stderr: 'Not authenticated. Run agy login.' })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.error).toContain('Sign in with `agy`')
})
it('reports a bare "Not authenticated." failure on stderr as sign-in guidance', async () => {
const result = await harness({
result: processResult({ code: 1, stderr: 'Not authenticated.\n' })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.error).toContain('Sign in with `agy`')
})
it('reports a login with no quota groups as unavailable, not a failed refresh', async () => {
const stdout = JSON.stringify({
status: 'SUCCESS',
num_turns: 0,
command: { name: 'usage', data: { groups: [] } }
})
const result = await harness({ result: processResult({ stdout }) }).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('no quota for this account')
})
it('does not classify non-auth stderr as signed-out on non-zero exit', async () => {
// Incidental auth-like words must not trigger credential guidance.
const result = await harness({
result: processResult({
code: 1,
stderr: 'Failed to contact https://oauth2.googleapis.com/token: connection refused'
})
}).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
expect(result.error).toContain('did not report a quota (exit 1)')
})
it('reports a timeout as its own failure kind', async () => {
const result = await harness({ result: processResult({ timedOut: true }) }).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('did not answer in time')
})
it('holds automated reads after agy\u2019s browser sign-in', async () => {
// Why pinned: agy 1.4.2 opens this OAuth page and waits 60 s, so Orca's 30 s kill lands first (#26484).
const result = await harness({
result: processResult({
timedOut: true,
stderr:
'Authentication required. Please visit the URL to log in:\n https://accounts.google.com/o/oauth2/auth?...\n\nWaiting for authentication (timeout 60s)...\n'
})
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.usageMetadata?.retryAtMs).toBe(Number.MAX_SAFE_INTEGER)
})
it('keeps the reading when the browser sign-in completes within the read', async () => {
const result = await harness({
result: processResult({
stdout: USAGE_ENVELOPE,
stderr: 'Authentication required. Please visit the URL to log in:\n'
})
}).fetch()
expect(result.status).toBe('ok')
expect(result.usageMetadata?.retryAtMs).toBeUndefined()
})
it('does not cool down a signed-out read that fails fast without a browser', async () => {
const result = await harness({
result: processResult({
code: 1,
stderr: "Error: authentication required. Run 'agy' to log in, then retry.\n"
})
}).fetch()
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.usageMetadata?.retryAtMs).toBeUndefined()
})
it('reports an unreadable payload as a parse failure carrying the exit code', async () => {
const result = await harness({
result: processResult({ code: 2, stdout: 'unknown command /usage' })
}).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
expect(result.error).toContain('exit 2')
})
it('does not blame the exit code when agy exited cleanly with no payload', async () => {
const result = await harness({ result: processResult({ code: 0, stdout: '' }) }).fetch()
expect(result.status).toBe('error')
expect(result.error).not.toContain('exit')
})
it('reports a spawn failure instead of rejecting the cycle', async () => {
const runCommand = vi.fn().mockRejectedValue(new Error('EACCES'))
const result = await harness({ runCommand }).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('cli-unavailable')
expect(result.error).toContain('EACCES')
})
it('never reports quota from a successful read as stale session data', async () => {
const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch()
// This tier meters weekly pools, not the Gemini mirror's 60-minute per-model window (#22511).
expect(result.session).toBeNull()
expect(result.weekly).not.toBeNull()
})
})
/** Captured billed-turn response when /usage was treated as a prompt; must latch future probes off. */
const MODEL_TURN_ENVELOPE = JSON.stringify({
conversation_id: '28a5ca91-301f-4050-8efc-9c82c4e64df3',
@@ -320,16 +97,6 @@ describe('agy versions that answer /usage as a prompt', () => {
resetAntigravityUsageSupportForTests()
})
it('reports the quota read as unavailable instead of as a parse failure', async () => {
const result = await harness({
result: processResult({ stdout: MODEL_TURN_ENVELOPE })
}).fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('answers `/usage` as a prompt')
})
it('never spawns agy again once a turn was spent', async () => {
const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) })
await h.fetch()
@@ -340,198 +107,4 @@ describe('agy versions that answer /usage as a prompt', () => {
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
it('does not latch when the usage payload parsed, whatever else the envelope says', async () => {
const h = harness({
result: processResult({ stdout: `${USAGE_ENVELOPE}\n${MODEL_TURN_ENVELOPE}` })
})
const first = await h.fetch()
const second = await h.fetch()
expect(first.status).toBe('ok')
expect(second.status).toBe('ok')
expect(h.runCommand).toHaveBeenCalledTimes(4)
})
it('does not latch on an empty or unparsable answer', async () => {
const h = harness({ result: processResult({ code: 2, stdout: 'unknown flag' }) })
const first = await h.fetch()
const second = await h.fetch()
expect(first.status).toBe('error')
expect(second.status).toBe('error')
expect(h.runCommand).toHaveBeenCalledTimes(4)
})
})
describe('the agy version gate', () => {
beforeEach(() => {
resetAntigravityUsageSupportForTests()
})
it('never runs the quota read below the floor', async () => {
const h = harness({
versionResult: processResult({ stdout: 'agy version 1.1.10\n' }),
result: processResult({ stdout: USAGE_ENVELOPE })
})
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('needs agy 1.1.11 or newer (found 1.1.10)')
// Below the version floor /usage spends a model turn; never run it.
expect(h.runCommand).toHaveBeenCalledTimes(1)
expect(h.runCommand.mock.calls[0]![0].args).toEqual(ANTIGRAVITY_VERSION_ARGS)
})
it('holds a prerelease below the floor', async () => {
// Release candidates below the stable floor must not reach a billable read.
const h = harness({ versionResult: processResult({ stdout: 'agy version 1.1.11-rc.1\n' }) })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.error).toContain('found 1.1.11-rc.1')
expect(h.runCommand).toHaveBeenCalledTimes(1)
})
it.each([
['a garbled version line', processResult({ stdout: 'agy version unknown\n' })],
['a failed version probe', processResult({ code: 1, stderr: 'boom' })]
])('reports %s instead of guessing', async (_label, versionResult) => {
const h = harness({ versionResult })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
expect(result.error).toContain('version could not be read')
expect(h.runCommand).toHaveBeenCalledTimes(1)
})
it('runs the quota read at exactly the floor', async () => {
const h = harness({
versionResult: processResult({ stdout: 'agy version 1.1.11\n' }),
result: processResult({ stdout: USAGE_ENVELOPE })
})
const result = await h.fetch()
expect(result.status).toBe('ok')
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
it('reads the version from stderr if stdout is empty on exit 0', async () => {
const h = harness({
versionResult: processResult({ stdout: '', stderr: 'agy version 1.1.11\n' }),
result: processResult({ stdout: USAGE_ENVELOPE })
})
const result = await h.fetch()
expect(result.status).toBe('ok')
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
})
describe('quota read safety and diagnostic precedence', () => {
beforeEach(() => resetAntigravityUsageSupportForTests())
it.each([
processResult({ stdout: '1.2.14', timedOut: true }),
processResult({ stdout: '1.2.14', signal: 'SIGTERM' }),
processResult({ stdout: '1.2.14', code: 1 })
])('does not read quota after an unsuccessful version probe: %j', async (versionResult) => {
const h = harness({ versionResult })
const result = await h.fetch()
expect(result.status).toBe('unavailable')
expect(h.runCommand).toHaveBeenCalledTimes(1)
})
it('accepts a quota reading ahead of unrelated auth diagnostics', async () => {
const result = await harness({
result: processResult({
stdout: USAGE_ENVELOPE,
stderr: 'Not authenticated. Run agy login.'
})
}).fetch()
expect(result.status).toBe('ok')
})
it('latches a model turn ahead of authentication guidance', async () => {
const h = harness({
result: processResult({ stdout: MODEL_TURN_ENVELOPE, stderr: 'not logged into antigravity' })
})
expect((await h.fetch()).error).toContain('answers `/usage` as a prompt')
await h.fetch()
expect(h.runCommand).toHaveBeenCalledTimes(2)
})
it('prefers a structured quota failure over conflicting sign-in wording', async () => {
const result = await harness({
result: processResult({
stderr: 'not logged in\nAGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":429}'
})
}).fetch()
expect(result.usageMetadata?.failureKind).toBe('rate-limited')
expect(result.error).not.toContain('Sign in')
})
it('rechecks the version after an in-session CLI upgrade', async () => {
const runCommand = vi
.fn()
.mockResolvedValueOnce(processResult({ stdout: '1.1.10' }))
.mockResolvedValueOnce(processResult({ stdout: '1.2.14' }))
.mockResolvedValueOnce(processResult({ stdout: USAGE_ENVELOPE }))
const h = harness({ runCommand })
expect((await h.fetch()).status).toBe('unavailable')
expect((await h.fetch()).status).toBe('ok')
expect(runCommand).toHaveBeenCalledTimes(3)
})
})
describe("agy's own error envelope", () => {
beforeEach(() => resetAntigravityUsageSupportForTests())
const errorEnvelope = (error: string): string =>
`${JSON.stringify({ conversation_id: '', status: 'ERROR', response: '', error, num_turns: 0 })}\n`
const LOCATION_ERROR =
'Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location.'
it('shows agy’s text instead of the generic parse failure', async () => {
const result = await harness({
result: processResult({
code: 1,
stdout: errorEnvelope(LOCATION_ERROR),
stderr: `error: ${LOCATION_ERROR}\n`
})
}).fetch()
expect(result.status).toBe('error')
expect(result.usageMetadata?.failureKind).toBe('parse')
expect(result.error).toBe(`Antigravity usage is not available. agy reported: ${LOCATION_ERROR}`)
})
it('redacts identities and bounds agy’s text to one line', async () => {
const token = 'ya29.a0AfB_byC1234567890abcdefghijklmnopqrstuvwxyz'
const result = await harness({
result: processResult({
code: 1,
stdout: errorEnvelope(
`\u001b[31mDenied\u001b[0m for person@example.com\nAGY_SESSION=shortsecret\nwith ${token} ${'x '.repeat(200)}`
)
})
}).fetch()
const shown = result.error?.replace('Antigravity usage is not available. agy reported: ', '')
expect(shown).toMatch(/^Denied for \[email\] AGY_SESSION=\S+ with ya29\.\[redacted\] x x/)
expect(shown).not.toContain('shortsecret')
expect(Array.from(shown ?? '').length).toBeLessThanOrEqual(160)
expect(shown?.endsWith('…')).toBe(true)
})
it('keeps sign-in guidance ahead of agy’s text', async () => {
const result = await harness({
result: processResult({ code: 1, stdout: errorEnvelope('Not authenticated. Run agy login.') })
}).fetch()
expect(result.usageMetadata?.failureKind).toBe('missing-credentials')
expect(result.error).toContain('Sign in with `agy`')
})
})
@@ -1,294 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
parseAntigravityUsageEnvelope,
parseAntigravityUsageStdout
} from './antigravity-usage-response'
/** Verbatim agy 1.2.11/macOS ARM64 /usage envelope; this tier exposes weekly quota without a 5h pool. */
const REAL_AGY_1_2_11_STDOUT = `{"conversation_id":"","status":"SUCCESS","response":"Gemini Models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\nClaude and GPT models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0,"output_tokens":0,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":0},"command":{"name":"usage","data":{"description":"Within each group, models share a weekly limit.","groups":[{"name":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[{"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]},{"name":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[{"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]}]}}}`
function envelope(groups: unknown, description = 'pool help'): unknown {
return {
status: 'SUCCESS',
command: { name: 'usage', data: { description, groups } }
}
}
describe('parseAntigravityUsageStdout', () => {
it('reads the real agy 1.2.11 payload as two weekly group pools', () => {
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
expect(reading).not.toBeNull()
expect(reading?.buckets).toEqual([
{
id: 'gemini-weekly',
name: 'Gemini Models',
group: 'Gemini Models',
windowName: 'Weekly Limit Remaining',
usedPercent: 0,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
},
{
id: '3p-weekly',
name: 'Claude and GPT models',
group: 'Claude and GPT models',
windowName: 'Weekly Limit Remaining',
usedPercent: 0,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
}
])
})
it('reports the weekly window the Gemini mirror always left null', () => {
const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT)
expect(reading?.weekly).toEqual({
usedPercent: 0,
windowMinutes: 10_080,
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
resetDescription: null
})
expect(reading?.session).toBeNull()
})
it('ignores log noise printed around the envelope', () => {
const reading = parseAntigravityUsageStdout(
`I0926 16:22:51.157090 quota_manager.go:36] doRefreshQuota\n${REAL_AGY_1_2_11_STDOUT}\nBye.`
)
expect(reading?.buckets).toHaveLength(2)
})
it('returns null for stdout with no envelope at all', () => {
expect(parseAntigravityUsageStdout('You are not logged into Antigravity.')).toBeNull()
expect(parseAntigravityUsageStdout('')).toBeNull()
expect(parseAntigravityUsageStdout('{ not json')).toBeNull()
})
})
describe('parseAntigravityUsageEnvelope', () => {
it('maps a 5h bucket onto the session window and weekly onto the weekly window', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
{
id: 'gemini-5h',
name: '5h Limit Remaining',
window: '5h',
remaining_fraction: 0.25,
reset_time: '2026-09-30T12:00:00Z'
},
{
id: 'gemini-weekly',
name: 'Weekly Limit Remaining',
window: 'weekly',
remaining_fraction: 0.5,
reset_time: '2026-10-07T00:00:00Z'
}
]
}
])
)
expect(reading?.session).toMatchObject({ usedPercent: 75, windowMinutes: 300 })
expect(reading?.weekly).toMatchObject({ usedPercent: 50, windowMinutes: 10_080 })
})
it('names both windows of one group apart', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1 },
{ id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 }
]
}
])
)
expect(reading?.buckets.map((bucket) => bucket.name)).toEqual([
'Gemini Models · 5h',
'Gemini Models · Weekly'
])
})
it('fills the structured group and window name the renderer labels pools from', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
{ id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 },
{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 1 }
]
}
])
)
expect(reading?.buckets[0]).toMatchObject({
group: 'Gemini Models',
windowName: '5h Limit Remaining'
})
expect(reading?.buckets[1]).toMatchObject({ group: 'Gemini Models' })
expect(reading?.buckets[1]).not.toHaveProperty('windowName')
})
it('keeps only the four window fields on the session and weekly summaries', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
{ id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 },
{ id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 }
]
}
])
)
const window = { usedPercent: 0, resetsAt: null, resetDescription: null }
expect(reading?.session).toEqual({ ...window, windowMinutes: 300 })
expect(reading?.weekly).toEqual({ ...window, windowMinutes: 10_080 })
})
it('drops a disabled bucket instead of drawing it as unused', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
// #22511: unmetered 5h pool alongside exhausted weekly quota.
{ id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1, disabled: true },
{
id: 'gemini-weekly',
name: 'Weekly',
window: 'weekly',
remaining_fraction: 0,
reset_time: '2026-10-01T00:00:00Z'
}
]
}
])
)
expect(reading?.buckets).toHaveLength(1)
expect(reading?.buckets[0]).toMatchObject({ id: 'gemini-weekly', usedPercent: 100 })
expect(reading?.session).toBeNull()
expect(reading?.weekly?.usedPercent).toBe(100)
expect(reading?.buckets[0]?.name).toBe('Gemini Models')
})
it('summarises each window by its most constrained group', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.9 }]
},
{
name: 'Claude and GPT models',
buckets: [{ id: '3p-weekly', window: 'weekly', remaining_fraction: 0.1 }]
}
])
)
// Either exhausted group exhausts this tier.
expect(reading?.weekly?.usedPercent).toBe(90)
})
it('keeps an unrecognised window as a named bucket without claiming a duration', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'Gemini Models',
buckets: [
{
id: 'gemini-monthly',
name: 'Monthly Limit Remaining',
window: 'monthly',
remaining_fraction: 0.4
}
]
}
])
)
expect(reading?.buckets[0]).toMatchObject({
usedPercent: 60,
windowMinutes: 0,
windowName: 'Monthly Limit Remaining'
})
expect(reading?.session).toBeNull()
expect(reading?.weekly).toBeNull()
})
it('carries agy’s own pool explanation through', () => {
const reading = parseAntigravityUsageEnvelope(
envelope(
[
{ name: 'Gemini Models', buckets: [{ id: 'g', window: 'weekly', remaining_fraction: 1 }] }
],
'Quota is consumed proportionally to the cost of the tokens.'
)
)
expect(reading?.description).toBe('Quota is consumed proportionally to the cost of the tokens.')
})
it('clamps a fraction outside 0..1', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'G',
buckets: [
{ id: 'a', window: 'weekly', remaining_fraction: 1.4 },
{ id: 'b', window: '5h', remaining_fraction: -0.2 }
]
}
])
)
expect(reading?.buckets.map((bucket) => bucket.usedPercent)).toEqual([0, 100])
})
it('reads a missing or unparsable reset time as unknown', () => {
const reading = parseAntigravityUsageEnvelope(
envelope([
{
name: 'G',
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, reset_time: 'soon' }]
}
])
)
expect(reading?.buckets[0]?.resetsAt).toBeNull()
})
it.each([
['a non-SUCCESS status', { status: 'ERROR', command: { name: 'usage', data: { groups: [] } } }],
['another command’s payload', { status: 'SUCCESS', command: { name: 'models', data: {} } }],
['a missing command', { status: 'SUCCESS' }],
['no groups', envelope(undefined)],
['a group with no usable bucket', envelope([{ name: 'G', buckets: [{ id: 'a' }] }])],
[
'a group with every bucket disabled',
envelope([
{
name: 'G',
buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, disabled: true }]
}
])
],
['a non-object', 'nope'],
['null', null]
])('returns null for %s', (_label, value) => {
expect(parseAntigravityUsageEnvelope(value)).toBeNull()
})
})
@@ -1,302 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setMainHttpClient } from '../network/http-client'
import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request'
import { classifyClaudeOAuthUsageError } from './claude-usage-error-classification'
const fetchMock = vi.fn()
vi.mock('../network/proxy-settings', () => ({
ensureElectronProxyFromEnvironment: vi.fn(async () => {})
}))
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
async function fetchUsageResponse(body: string) {
fetchMock.mockResolvedValueOnce(new Response(body, { status: 200 }))
return fetchClaudeOAuthUsage('oauth-token')
}
describe('Claude OAuth usage extra usage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('maps the usage-credits spend object into a capped balance in major units', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
five_hour: { utilization: 10 },
spend: {
used: { amount_minor: 5000, currency: 'EUR', exponent: 2 },
limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 },
percent: 2.5,
enabled: true,
balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 }
}
}),
{ status: 200 }
)
)
await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
extraUsage: {
balance: 10,
spent: 50,
spendLimit: 2000,
spentPercent: 2.5,
currencyCode: 'EUR',
enabled: true,
disabledReason: null,
resetsAt: null
}
})
})
it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
five_hour: { utilization: 10 },
spend: {
used: { amount_minor: 0, currency: 'EUR', exponent: 2 },
limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 },
percent: 0,
enabled: false,
disabled_reason: 'out_of_credits',
balance: null
}
}),
{ status: 200 }
)
)
await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
extraUsage: {
balance: null,
spent: 0,
spendLimit: 2000,
spentPercent: 0,
currencyCode: 'EUR',
enabled: false,
disabledReason: 'out_of_credits'
}
})
})
it('keeps legacy extra_usage spend data without inventing a missing balance', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
five_hour: { utilization: 10 },
extra_usage: {
is_enabled: true,
monthly_limit: 200000,
used_credits: 5000,
utilization: 2.5,
currency: 'EUR',
decimal_places: 2
}
}),
{ status: 200 }
)
)
await expect(fetchClaudeOAuthUsage('oauth-token')).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
extraUsage: {
balance: null,
spent: 50,
spendLimit: 2000,
spentPercent: 2.5,
currencyCode: 'EUR',
enabled: true
}
})
})
it('keeps a missing balance unknown while preserving a known cap', async () => {
const result = await fetchUsageResponse(
'{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}'
)
expect(result.extraUsage).toMatchObject({
balance: null,
spendLimit: 2000,
currencyCode: 'EUR'
})
})
it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => {
const result = await fetchUsageResponse(
JSON.stringify({
five_hour: { utilization: 10 },
spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true }
})
)
expect(result.extraUsage).toMatchObject({
balance: amount / 100,
currencyCode: 'EUR',
spendLimit: null
})
})
it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => {
const result = await fetchUsageResponse(
'{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}'
)
expect(result.extraUsage).toMatchObject({
balance: null,
spent: 50,
spendLimit: 2000,
spentPercent: 2.5
})
})
it('keeps nullable legacy spend unknown despite a supplied percentage', async () => {
const result = await fetchUsageResponse(
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}'
)
expect(result.extraUsage).toMatchObject({
balance: null,
spent: null,
spendLimit: 2000,
spentPercent: 10
})
})
it('derives legacy percentage when the response percentage overflows', async () => {
const result = await fetchUsageResponse(
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}'
)
expect(result.extraUsage).toMatchObject({
balance: null,
spent: 50,
spendLimit: 2000,
spentPercent: 2.5
})
})
it('does not turn an overflowing legacy spend into a known amount', async () => {
const result = await fetchUsageResponse(
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}'
)
expect(result.extraUsage).toMatchObject({
balance: null,
spent: null,
spendLimit: 2000,
spentPercent: null
})
})
it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => {
const result = await fetchUsageResponse(
`{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}`
)
expect(result.extraUsage ?? null).toBeNull()
})
it.each([-1, 0.5, 309])(
'omits malformed legacy precision %s rather than displaying zero',
async (exponent) => {
const result = await fetchUsageResponse(
JSON.stringify({
five_hour: { utilization: 10 },
extra_usage: {
monthly_limit: 200000,
used_credits: 5000,
decimal_places: exponent,
currency: 'EUR',
is_enabled: true
}
})
)
expect(result.extraUsage ?? null).toBeNull()
}
)
it('shows the spend cap as the monthly window when the plan has no 5h or 7d window', async () => {
const result = await fetchUsageResponse(
JSON.stringify({
five_hour: null,
seven_day: null,
limits: [],
spend: {
used: { amount_minor: 40000, currency: 'USD', exponent: 2 },
limit: { amount_minor: 50000, currency: 'USD', exponent: 2 },
percent: 80,
enabled: true
}
})
)
expect(result.session).toBeNull()
expect(result.weekly).toBeNull()
expect(result.monthly).toEqual({
usedPercent: 80,
windowMinutes: 43_200,
resetsAt: null,
resetDescription: null
})
})
it('derives the monthly window from legacy extra_usage when spend is absent', async () => {
const result = await fetchUsageResponse(
'{"five_hour":null,"seven_day":null,"extra_usage":{"monthly_limit":50000,"used_credits":40000,"utilization":80,"currency":"USD","is_enabled":true}}'
)
expect(result.monthly).toMatchObject({ usedPercent: 80, windowMinutes: 43_200 })
})
it('falls back to the legacy cap when spend carries only a balance', async () => {
const result = await fetchUsageResponse(
JSON.stringify({
five_hour: null,
seven_day: null,
spend: { balance: { amount_minor: 1000, currency: 'USD' }, enabled: true },
extra_usage: {
monthly_limit: 50000,
used_credits: 40000,
utilization: 80,
currency: 'USD',
is_enabled: true
}
})
)
expect(result.extraUsage).toMatchObject({ balance: 10, spendLimit: null })
expect(result.monthly).toMatchObject({ usedPercent: 80, windowMinutes: 43_200 })
})
it('derives legacy percentage from the amounts when utilization is a fraction', async () => {
const result = await fetchUsageResponse(
'{"five_hour":null,"seven_day":null,"extra_usage":{"monthly_limit":5000,"used_credits":479,"utilization":0.0958,"currency":"USD","is_enabled":true}}'
)
expect(result.extraUsage).toMatchObject({ spentPercent: expect.closeTo(9.58) })
expect(result.monthly?.usedPercent).toBeCloseTo(9.58)
})
it('keeps spend out of the monthly window when a plan window exists', async () => {
const result = await fetchUsageResponse(
'{"five_hour":{"utilization":10},"seven_day":null,"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"percent":2.5,"enabled":true}}'
)
expect(result.extraUsage).toMatchObject({ spentPercent: 2.5 })
expect(result.monthly).toBeNull()
})
it('does not invent a monthly window from a balance without a cap', async () => {
const result = await fetchUsageResponse(
'{"five_hour":null,"seven_day":null,"spend":{"balance":{"amount_minor":1000,"currency":"EUR"},"enabled":true}}'
)
expect(result.extraUsage).toMatchObject({ balance: 10, spendLimit: null })
expect(result.monthly).toBeNull()
})
it('classifies only the request: a dead proxy is network, a null 200 body stays unknown', async () => {
fetchMock.mockRejectedValueOnce(new Error('net::ERR_PROXY_CONNECTION_FAILED'))
const transport = await fetchClaudeOAuthUsage('oauth-token').catch((error: unknown) => error)
expect(classifyClaudeOAuthUsageError(transport).failureKind).toBe('network')
const nullBody = await fetchUsageResponse('null').catch((error: unknown) => error)
expect(classifyClaudeOAuthUsageError(nullBody).failureKind).toBe('unknown')
})
})
@@ -1,329 +0,0 @@
import type * as Keychain from '../claude-accounts/keychain'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const calls = vi.hoisted(() => ({ keychain: vi.fn(), usage: vi.fn(), keychainService: vi.fn() }))
vi.mock('../macos-keychain/generic-password', () => ({
readKeychainPassword: calls.keychainService
}))
vi.mock('../claude-accounts/keychain', () => ({
readActiveClaudeKeychainCredentialsStrict: calls.keychain
}))
const profileRouter = vi.hoisted((): { userConfigDir?: string } => ({}))
const wslRouter = vi.hoisted(() => ({ accountUsagePreparation: vi.fn() }))
vi.mock('../claude-accounts/claude-profile-installed-router', () => ({
getClaudeProfileRouter: () =>
profileRouter.userConfigDir ? { userConfigDir: () => profileRouter.userConfigDir } : undefined,
getClaudeWslProfileRouter: () => wslRouter
}))
vi.mock('./claude-oauth-usage-request', () => ({ fetchClaudeOAuthUsage: calls.usage }))
const runningDistros = vi.hoisted(() => ({
filter: vi.fn(async (paths: readonly string[], _options?: { requireConfirmed?: boolean }) => [
...paths
])
}))
// The settled gate asks the running list for the path's distro; `filter` stands in for it.
const settledGate = vi.hoisted(() => ({
override: vi.fn((): { distro: string; state: 'stopped' } | undefined => undefined)
}))
vi.mock('../wsl/filesystem/wsl-unc-access-gate', () => ({
blockedWslDistroForPath: async (path: string) => {
const override = settledGate.override()
if (override !== undefined) {
return override
}
return (await runningDistros.filter([path], { requireConfirmed: true })).length > 0
? null
: { distro: 'Ubuntu', state: 'stopped' }
}
}))
vi.mock('@orca/process-host', () => ({
spawnProcess: () => {
throw new Error('Usage must not launch a process')
},
runProcess: () => {
throw new Error('Usage must not launch a process')
}
}))
import { fetchActiveClaudeRateLimits } from './claude-active-usage-fetch'
import {
readClaudeOAuthCredentials,
resolveClaudeOAuthCredentialReadOptions
} from './claude-oauth-credentials'
import { OAuthUsageError } from './claude-oauth-usage-error'
import { CLAUDE_PROFILE_MISSING_MESSAGE } from '../../shared/claude-profile-routing'
const roots: string[] = []
afterEach(() => {
roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))
vi.unstubAllGlobals()
vi.clearAllMocks()
})
function profile() {
const home = mkdtempSync(join(tmpdir(), 'claude-usage-'))
roots.push(home)
calls.keychain.mockResolvedValue(null)
const options = {
authPreparation: {
configDir: home,
envPatch: { CLAUDE_CONFIG_DIR: home },
provenance: 'profile:fake'
}
}
return { home, options }
}
function systemDefault() {
const home = mkdtempSync(join(tmpdir(), 'claude-usage-default-'))
roots.push(home)
calls.keychain.mockResolvedValue(null)
return {
home,
options: {
authPreparation: { configDir: home, envPatch: {}, provenance: 'system' }
}
}
}
it('lets the server decide a locally expired token and never refreshes it', async () => {
const f = profile()
const file = join(f.home, '.credentials.json')
writeFileSync(
file,
JSON.stringify({
claudeAiOauth: {
accessToken: 'expired-fake',
refreshToken: 'must-not-be-used',
expiresAt: 1,
refreshTokenExpiresAt: 4_102_444_800_000
}
})
)
vi.stubGlobal('fetch', () => {
throw new Error('No refresh endpoint')
})
calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null))
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
status: 'error',
error: 'Claude usage updates the next time Claude runs.',
usageMetadata: { failureKind: 'stale-token' }
})
expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake'])
const system = systemDefault()
writeFileSync(
join(system.home, '.credentials.json'),
JSON.stringify({
claudeAiOauth: { accessToken: 'expired-default', refreshToken: 'unused', expiresAt: 1 }
})
)
calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' })
expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' })
expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({
error: 'Claude usage updates the next time Claude runs.',
usageMetadata: { failureKind: 'stale-token' }
})
})
it.each([
['no refresh token', {}, 'Invalid OAuth token.'],
['an expired refresh token', { refreshToken: 'fake', refreshTokenExpiresAt: 1 }, 'Invalid'],
['a revoked refresh token', { refreshToken: 'fake' }, 'OAuth token has been revoked.']
])('asks a login with %s to sign in again instead of waiting for Claude', async (_, oauth, msg) => {
const f = profile()
writeFileSync(
join(f.home, '.credentials.json'),
JSON.stringify({ claudeAiOauth: { accessToken: 'expired-fake', expiresAt: 1, ...oauth } })
)
calls.usage.mockRejectedValue(new OAuthUsageError(msg, 401, true, null))
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
status: 'error',
error: 'Sign in again to use this account.',
usageMetadata: { failureKind: 'missing-credentials' }
})
})
it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => {
const f = profile()
await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home })
if (process.platform === 'darwin') {
expect(calls.keychain.mock.calls).toEqual([[f.home]])
} else {
expect(calls.keychain).not.toHaveBeenCalled()
}
})
it('distinguishes missing, malformed and inaccessible credential observations', async () => {
const f = profile()
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
usageMetadata: { failureKind: 'missing-credentials' }
})
writeFileSync(join(f.home, '.credentials.json'), '{')
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
usageMetadata: { failureKind: 'keychain-unavailable' }
})
})
it('reports an unreadable selected account before any credential read and requests no hidden recovery', async () => {
const f = profile()
expect(
await fetchActiveClaudeRateLimits({
authPreparation: { ...f.options.authPreparation, usageError: CLAUDE_PROFILE_MISSING_MESSAGE }
})
).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } })
expect(calls.keychain).not.toHaveBeenCalled()
writeFileSync(
join(f.home, '.credentials.json'),
JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } })
)
calls.usage.mockRejectedValue(new Error('network unavailable'))
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error' })
expect(calls.usage).toHaveBeenCalledTimes(1)
})
it('strict Keychain lookup never falls back to the unsuffixed service', async () => {
const f = profile()
calls.keychainService.mockResolvedValue(null)
const actual = await vi.importActual<typeof Keychain>('../claude-accounts/keychain')
await actual.readActiveClaudeKeychainCredentialsStrict(f.home)
expect(calls.keychainService).toHaveBeenCalled()
for (const [service] of calls.keychainService.mock.calls) {
expect(service).toMatch(/^Claude Code-credentials-[0-9a-f]{8}$/)
}
calls.keychainService.mockClear()
calls.keychainService.mockRejectedValue(new Error('locked'))
await expect(actual.readActiveClaudeKeychainCredentialsStrict(f.home)).rejects.toThrow('locked')
expect(calls.keychainService).toHaveBeenCalledTimes(1)
})
it('treats an unreadable credential file as unavailable, not a missing login', async () => {
const f = profile()
mkdirSync(join(f.home, '.credentials.json'))
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
usageMetadata: { failureKind: 'keychain-unavailable' }
})
})
it('hides Claude usage for System Default with no Claude login, instead of asking to sign in again', async () => {
const f = systemDefault()
expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({
status: 'unavailable',
usageMetadata: { failureKind: 'missing-credentials' }
})
expect(await fetchActiveClaudeRateLimits(profile().options)).toMatchObject({
status: 'error',
error: 'Sign in again to use this account.'
})
})
it("keeps a 429's Retry-After and rate-limit message so polling waits it out", async () => {
const f = profile()
writeFileSync(
join(f.home, '.credentials.json'),
JSON.stringify({ claudeAiOauth: { accessToken: 'fake' } })
)
const message = 'Claude usage is rate limited right now.'
calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, 3_000_000))
const before = Date.now()
const limited = await fetchActiveClaudeRateLimits(f.options)
expect(limited).toMatchObject({
status: 'error',
error: message,
usageMetadata: { failureKind: 'rate-limited' }
})
expect(limited.usageMetadata?.retryAtMs).toBeGreaterThanOrEqual(before + 3_000_000)
expect(limited.usageMetadata?.retryAtMs).toBeLessThanOrEqual(Date.now() + 3_000_000)
calls.usage.mockRejectedValueOnce(new OAuthUsageError(message, 429, true, null))
expect((await fetchActiveClaudeRateLimits(f.options)).usageMetadata?.retryAtMs).toBeUndefined()
})
it("reads System default's own CLAUDE_CONFIG_DIR Keychain item before the unsuffixed one", async () => {
const root = mkdtempSync(join(tmpdir(), 'claude-usage-inherited-'))
roots.push(root)
const inherited = join(root, 'own-claude-config')
vi.stubEnv('CLAUDE_CONFIG_DIR', inherited)
try {
calls.keychain.mockResolvedValue(null)
await readClaudeOAuthCredentials(
resolveClaudeOAuthCredentialReadOptions({
configDir: inherited,
envPatch: {},
provenance: 'system'
})
)
expect(calls.keychain.mock.calls).toEqual(
process.platform === 'darwin' ? [[inherited], [undefined]] : []
)
} finally {
vi.unstubAllEnvs()
}
const managed = profile()
calls.keychain.mockClear()
await fetchActiveClaudeRateLimits(managed.options)
expect(calls.keychain.mock.calls).toEqual(process.platform === 'darwin' ? [[managed.home]] : [])
})
it("names System default's Keychain item from the login shell's CLAUDE_CONFIG_DIR a Dock launch lacks", () => {
vi.stubEnv('CLAUDE_CONFIG_DIR', '')
profileRouter.userConfigDir = '/shell/claude-config'
try {
expect(
resolveClaudeOAuthCredentialReadOptions({
configDir: '/shell/claude-config',
envPatch: {},
provenance: 'system'
})?.keychainConfigDir
).toBe('/shell/claude-config')
} finally {
profileRouter.userConfigDir = undefined
vi.unstubAllEnvs()
}
})
it('reports a host problem as unavailable usage, never as a signed-out account', async () => {
const f = profile()
expect(
await fetchActiveClaudeRateLimits({
authPreparation: {
...f.options.authPreparation,
usageError: 'Could not read the home folder of WSL distro Ubuntu.'
}
})
).toMatchObject({ status: 'error', usageMetadata: { failureKind: 'usage-unavailable' } })
})
it('reads an inactive WSL account where the WSL router says its launches would run', async () => {
const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage')
const authPreparation = {
configDir: '\\\\wsl.localhost\\Ubuntu\\home\\u\\.claude',
runtime: 'wsl' as const,
wslDistro: 'Ubuntu',
envPatch: {},
provenance: 'wsl:Ubuntu:system'
}
wslRouter.accountUsagePreparation.mockResolvedValueOnce(authPreparation)
calls.usage.mockResolvedValueOnce({ five_hour: null, seven_day: null })
await fetchInactiveClaudeAccountUsage({
id: 'acct',
managedAuthRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxAuthPath: '/home/u/.local/share/orca/claude-accounts/acct/auth'
})
expect(wslRouter.accountUsagePreparation).toHaveBeenCalledWith('Ubuntu', 'acct')
})
it('does not read an inactive WSL account a running list still names while it stops (E9)', async () => {
const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage')
settledGate.override.mockReturnValueOnce({ distro: 'Ubuntu', state: 'stopped' })
wslRouter.accountUsagePreparation.mockClear()
const result = await fetchInactiveClaudeAccountUsage({
id: 'acct',
managedAuthRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home'
})
expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } })
expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled()
})
it('does not read an inactive WSL account through a stopped distro', async () => {
const { fetchInactiveClaudeAccountUsage } = await import('./claude-managed-account-usage')
runningDistros.filter.mockResolvedValueOnce([])
wslRouter.accountUsagePreparation.mockClear()
const result = await fetchInactiveClaudeAccountUsage({
id: 'acct',
managedAuthRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/acct/home'
})
expect(runningDistros.filter).toHaveBeenCalledWith(['\\\\wsl.localhost\\Ubuntu\\'], {
requireConfirmed: true
})
expect(result).toMatchObject({ usageMetadata: { failureKind: 'usage-unavailable' } })
expect(wslRouter.accountUsagePreparation).not.toHaveBeenCalled()
expect(calls.keychain).not.toHaveBeenCalled()
})
@@ -1,353 +0,0 @@
import { join } from 'node:path'
import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setMainHttpClient } from '../network/http-client'
const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
readFileMock: vi.fn(),
ptySpawnMock: vi.fn()
}))
// Keep child-process assertions in this suite; worker transport is covered separately.
vi.mock(
'../worker-thread-child-process/worker-thread-child-process',
() =>
import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures')
)
vi.mock('node:child_process', () => ({ spawn: childSpawnMock }))
vi.mock('node:fs/promises', () => ({ readFile: readFileMock }))
vi.mock('node-pty', () => ({ spawn: ptySpawnMock }))
vi.mock('./codex-auth-presence', () => ({
probeCodexAuthPresence: vi.fn(async () => 'present')
}))
// Mock recovery so its process-table import does not require real execFile.
vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({
startCodexStateDbBackfillRecoveryInBackground: vi.fn()
}))
import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher'
describe('Codex backend rate-limit requests', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
setMainHttpClient(null)
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('uses the host HTTP client for usage, credit reads and redemption', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' }
})
)
const request = vi
.fn()
.mockResolvedValueOnce(
Response.json({
plan_type: 'plus',
rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 3_600 } }
})
)
.mockResolvedValueOnce(Response.json({ available_count: 0, credits: [] }))
.mockResolvedValueOnce(Response.json({ code: 'already_redeemed' }))
setMainHttpClient({ fetch: request, proxySession: () => null })
const deadline = vi.spyOn(AbortSignal, 'timeout')
await expect(
fetchCodexRateLimits({ codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\.codex' })
).resolves.toMatchObject({ status: 'ok', session: { usedPercent: 12 } })
await expect(
consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/synthetic-home',
idempotencyKey: 'synthetic-request'
})
).resolves.toBe('alreadyRedeemed')
expect(request.mock.calls.map(([url]) => url)).toEqual([
'https://chatgpt.com/backend-api/wham/usage',
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume'
])
for (const [, init] of request.mock.calls) {
expect(init).toEqual(
expect.objectContaining({
credentials: 'omit',
headers: expect.objectContaining({
Authorization: 'Bearer synthetic-token',
'ChatGPT-Account-Id': 'synthetic-account'
}),
signal: expect.any(AbortSignal)
})
)
}
expect(request).toHaveBeenLastCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ redeem_request_id: 'synthetic-request' }),
headers: expect.objectContaining({ 'Content-Type': 'application/json' })
})
)
expect(deadline.mock.calls.map(([milliseconds]) => milliseconds)).toEqual([
10_000, 10_000, 30_000
])
expect(fetch).not.toHaveBeenCalled()
expect(childSpawnMock).not.toHaveBeenCalled()
})
it('does not fall back to global fetch when the configured host transport fails', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'synthetic-token', account_id: 'synthetic-account' }
})
)
const failure = new Error('fixture proxy refused the connection')
const request = vi.fn().mockRejectedValue(failure)
setMainHttpClient({ fetch: request, proxySession: () => null })
await expect(
consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/synthetic-home',
idempotencyKey: 'synthetic-request'
})
).rejects.toBe(failure)
expect(request).toHaveBeenCalledOnce()
expect(fetch).not.toHaveBeenCalled()
})
it('uses the official backend usage contract without spawning Codex', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 12,
limit_window_seconds: 3_600,
reset_at: 1_800_000_000
},
secondary_window: {
used_percent: 34,
limit_window_seconds: 86_400,
reset_at: 1_800_100_000
}
},
rate_limit_reset_credits: { available_count: 1 }
})
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
available_count: 1,
credits: [
{
status: 'available',
expires_at: '2027-01-15T12:00:00Z',
granted_at: '2027-01-08T12:00:00Z'
}
]
})
} as Response)
await expect(
fetchCodexRateLimits({
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
})
).resolves.toMatchObject({
session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 },
weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 },
rateLimitResetCredits: {
availableCount: 1,
nextExpiresAt: Date.parse('2027-01-15T12:00:00Z')
},
planType: 'plus',
status: 'ok'
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://chatgpt.com/backend-api/wham/usage',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer access-token' }),
signal: expect.any(AbortSignal)
})
)
})
it('classifies a sole seven-day backend primary window as weekly', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 37,
limit_window_seconds: 7 * 24 * 60 * 60,
reset_at: 1_800_000_000
},
secondary_window: null
}
})
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ available_count: 0, credits: [] })
} as Response)
await expect(
fetchCodexRateLimits({
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
})
).resolves.toMatchObject({
session: null,
weekly: { usedPercent: 37, windowMinutes: 10_080, resetsAt: 1_800_000_000_000 },
status: 'ok'
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('aborts callers while sharing one stalled backend auth read', async () => {
let resolveRead!: (content: string) => void
readFileMock.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveRead = resolve
})
)
const firstController = new AbortController()
const secondController = new AbortController()
const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal })
const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal })
await vi.advanceTimersByTimeAsync(0)
expect(readFileMock).toHaveBeenCalledTimes(1)
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8')
firstController.abort()
secondController.abort()
await expect(first).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
await expect(second).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(fetch).not.toHaveBeenCalled()
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
resolveRead('{}')
await vi.advanceTimersByTimeAsync(0)
})
it('applies the backend deadline while auth reading is still pending', async () => {
let resolveRead!: (content: string) => void
readFileMock.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolveRead = resolve
})
)
const timeoutController = new AbortController()
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal)
const deadlineError = new Error('backend deadline')
const result = consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/deadline-home',
idempotencyKey: 'redeem-timeout'
})
await vi.advanceTimersByTimeAsync(0)
// User-triggered redemption has the longer deadline.
expect(timeout).toHaveBeenCalledWith(30_000)
expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8')
timeoutController.abort(deadlineError)
await expect(result).rejects.toBe(deadlineError)
resolveRead('{}')
await vi.advanceTimersByTimeAsync(0)
})
it('consumes a reset credit with the official payload and bounded request signal', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ code: 'already_redeemed' })
} as Response)
await expect(
consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/codex-home',
idempotencyKey: 'redeem-123'
})
).resolves.toBe('alreadyRedeemed')
expect(fetch).toHaveBeenCalledWith(
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ redeem_request_id: 'redeem-123' }),
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
Authorization: 'Bearer access-token',
'ChatGPT-Account-Id': 'account-id',
'Content-Type': 'application/json'
})
})
)
})
it('cancels the unread error-response body so bundled undici cannot crash on socket close', async () => {
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
let cancelledBodies = 0
vi.mocked(fetch).mockResolvedValue(
cancelTrackingResponse(429, () => {
cancelledBodies += 1
})
)
await expect(
consumeCodexRateLimitResetCredit({
codexHomePath: '/managed/codex-home',
idempotencyKey: 'redeem-429'
})
).rejects.toThrow('Codex reset failed: HTTP 429')
expect(cancelledBodies).toBe(1)
})
})
@@ -1,382 +0,0 @@
import { EventEmitter } from 'node:events'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
readFileMock: vi.fn(),
resolveCodexCommandMock: vi.fn(),
ptySpawnMock: vi.fn()
}))
// Keep child-process assertions in this suite; worker transport is covered separately.
vi.mock(
'../worker-thread-child-process/worker-thread-child-process',
() =>
import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures')
)
vi.mock('node:child_process', () => ({
spawn: childSpawnMock
}))
// Mock recovery so its process-table import does not require real execFile.
vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({
startCodexStateDbBackfillRecoveryInBackground: vi.fn()
}))
vi.mock('node:fs/promises', () => ({
readFile: readFileMock
}))
vi.mock('../codex-cli/command', () => ({
resolveCodexCommand: resolveCodexCommandMock
}))
vi.mock('node-pty', () => ({
spawn: ptySpawnMock
}))
vi.mock('./codex-auth-presence', () => ({
probeCodexAuthPresence: vi.fn(() => 'present')
}))
import { fetchCodexRateLimits } from './codex-fetcher'
import { probeCodexAuthPresence } from './codex-auth-presence'
function makeRpcChild() {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
exitCode: number | null
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
// Graceful shutdown must wait for EOF/signal to produce exit, as real app-server does.
const exitNow = (): void => {
child.exitCode = 0
child.emit('exit', 0, null)
}
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) })
child.exitCode = null
child.kill = vi.fn(() => {
exitNow()
return true
})
return child
}
function respondToRpcRateLimitRead(
rpcChild: ReturnType<typeof makeRpcChild>,
rateLimits: unknown
): void {
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`)
)
}, 0)
}
})
}
describe('fetchCodexRateLimits probe shutdown', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveCodexCommandMock.mockReturnValue('codex')
vi.mocked(probeCodexAuthPresence).mockResolvedValue('present')
readFileMock.mockRejectedValue(new Error('no auth fixture'))
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('gives a cold app-server the full boot budget before any deadline fires', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
// Cold auth startup takes 10–25s; the old 10s cap interrupted refresh.
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 20_000)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: {
rateLimits: { primary: { usedPercent: 3 }, secondary: { usedPercent: 4 } }
}
})}\n`
)
)
}, 0)
}
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(19_999)
expect(rpcChild.kill).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2)
await expect(resultPromise).resolves.toMatchObject({
status: 'ok',
session: { usedPercent: 3 },
weekly: { usedPercent: 4 }
})
})
it('arms the read deadline after initialize and drains SIGTERM before SIGKILL', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
const rpcChild = makeRpcChild()
// Force escalation: ignore EOF/SIGTERM, exit only on SIGKILL.
rpcChild.stdin.end = vi.fn()
rpcChild.kill = vi.fn((signal?: string) => {
if (signal === 'SIGKILL') {
rpcChild.exitCode = 0
rpcChild.emit('exit', 0, null)
}
return true
})
childSpawnMock.mockReturnValue(rpcChild)
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
})
try {
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(9_999)
expect(rpcChild.kill).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(rpcChild.stdin.end).toHaveBeenCalledTimes(1)
expect(rpcChild.kill).toHaveBeenCalledWith('SIGTERM')
expect(rpcChild.kill).not.toHaveBeenCalledWith('SIGKILL')
await vi.advanceTimersByTimeAsync(5_000)
expect(rpcChild.kill).toHaveBeenCalledWith('SIGKILL')
await expect(resultPromise).resolves.toMatchObject({
status: 'error',
error: 'RPC timeout'
})
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('does not spawn a second probe for the same home while one is still running', async () => {
const firstChild = makeRpcChild()
const secondChild = makeRpcChild()
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
respondToRpcRateLimitRead(secondChild, {
primary: { usedPercent: 3 },
secondary: { usedPercent: 4 }
})
const first = fetchCodexRateLimits({
codexHomePath: '/managed/home-a'
})
await vi.advanceTimersByTimeAsync(0)
const second = fetchCodexRateLimits({
codexHomePath: '/managed/home-a'
})
await vi.advanceTimersByTimeAsync(0)
expect(childSpawnMock).toHaveBeenCalledTimes(1)
firstChild.emit('close')
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
expect(childSpawnMock).toHaveBeenCalledTimes(2)
await expect(second).resolves.toMatchObject({ status: 'ok' })
await first
})
it('keeps the home lock through child errors until the failed probe exits', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
const firstChild = makeRpcChild()
firstChild.stdin.end = vi.fn()
firstChild.kill = vi.fn((signal?: string) => {
if (signal === 'SIGKILL') {
firstChild.exitCode = 1
firstChild.emit('exit', 1, 'SIGKILL')
}
return true
})
const secondChild = makeRpcChild()
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
respondToRpcRateLimitRead(secondChild, {
primary: { usedPercent: 3 },
secondary: { usedPercent: 4 }
})
try {
const first = fetchCodexRateLimits({
codexHomePath: '/managed/home-error'
})
await vi.advanceTimersByTimeAsync(0)
firstChild.emit('error', new Error('stdio failed'))
const second = fetchCodexRateLimits({
codexHomePath: '/managed/home-error'
})
await vi.advanceTimersByTimeAsync(0)
expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM')
expect(childSpawnMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5_000)
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
expect(childSpawnMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await expect(first).resolves.toMatchObject({ status: 'error', error: 'stdio failed' })
await expect(second).resolves.toMatchObject({ status: 'ok' })
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it.each([
{ failAt: 1, phase: 'initial request' },
{ failAt: 2, phase: 'initialized notification' },
{ failAt: 3, phase: 'rate-limit request' }
])('keeps the home lock when the $phase stdin write throws', async ({ failAt, phase }) => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
const firstChild = makeRpcChild()
let writeCount = 0
firstChild.stdin.write.mockImplementation((line: string) => {
writeCount += 1
if (writeCount === failAt) {
throw new Error(`${phase} stdin failed`)
}
const message = JSON.parse(line) as { id?: number; method?: string }
if (message.method === 'initialize') {
setTimeout(() => {
firstChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`)
)
}, 0)
}
})
firstChild.stdin.end = vi.fn()
firstChild.kill = vi.fn((signal?: string) => {
if (signal === 'SIGKILL') {
firstChild.exitCode = 1
firstChild.emit('exit', 1, 'SIGKILL')
}
return true
})
const secondChild = makeRpcChild()
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
respondToRpcRateLimitRead(secondChild, {
primary: { usedPercent: 3 },
secondary: { usedPercent: 4 }
})
const home = `/managed/home-stdin-error-${failAt}`
try {
const first = fetchCodexRateLimits({
codexHomePath: home
})
await vi.advanceTimersByTimeAsync(0)
expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM')
const second = fetchCodexRateLimits({
codexHomePath: home
})
await vi.advanceTimersByTimeAsync(4_999)
expect(childSpawnMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
await vi.advanceTimersByTimeAsync(1)
expect(childSpawnMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
await expect(first).resolves.toMatchObject({
status: 'error',
error: `${phase} stdin failed`
})
await expect(second).resolves.toMatchObject({ status: 'ok' })
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
it('observes asynchronous stdin failures and keeps the lock until the probe exits', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
const firstChild = makeRpcChild()
firstChild.stdin.end = vi.fn()
firstChild.kill = vi.fn((signal?: string) => {
if (signal === 'SIGKILL') {
firstChild.exitCode = 1
firstChild.emit('exit', 1, 'SIGKILL')
}
return true
})
const secondChild = makeRpcChild()
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
respondToRpcRateLimitRead(secondChild, {
primary: { usedPercent: 3 },
secondary: { usedPercent: 4 }
})
try {
const first = fetchCodexRateLimits({
codexHomePath: '/managed/home-async-stdin-error'
})
await vi.advanceTimersByTimeAsync(0)
firstChild.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
const second = fetchCodexRateLimits({
codexHomePath: '/managed/home-async-stdin-error'
})
await vi.advanceTimersByTimeAsync(4_999)
expect(childSpawnMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')
await vi.advanceTimersByTimeAsync(1)
expect(childSpawnMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
await expect(first).resolves.toMatchObject({ status: 'error', error: 'write EPIPE' })
await expect(second).resolves.toMatchObject({ status: 'ok' })
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
}
})
})
-818
View File
@@ -1,818 +0,0 @@
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RunProcess from '@orca/process-host'
const {
childSpawnMock,
readFileMock,
resolveCodexCommandMock,
ptySpawnMock,
isBackfillPendingMock,
startBackfillRecoveryMock,
backfillGaveUpMock
} = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
readFileMock: vi.fn(),
resolveCodexCommandMock: vi.fn(),
ptySpawnMock: vi.fn(),
isBackfillPendingMock: vi.fn(() => false),
startBackfillRecoveryMock: vi.fn(() => Promise.resolve(null)),
backfillGaveUpMock: vi.fn(() => false)
}))
// Keep child-process assertions in this suite; worker transport is covered separately.
vi.mock(
'../worker-thread-child-process/worker-thread-child-process',
() =>
import('../worker-thread-child-process/worker-thread-child-process-in-process.test-fixtures')
)
vi.mock('node:child_process', () => ({
spawn: childSpawnMock
}))
// Observe spawn requests before npm-shim resolution.
vi.mock('@orca/process-host', async (importOriginal) => ({
...(await importOriginal<typeof RunProcess>()),
spawnProcess: (spec: { program: string; args?: readonly string[] }) =>
childSpawnMock(spec.program, spec.args ?? [], spec)
}))
vi.mock('node:fs/promises', () => ({
readFile: readFileMock
}))
vi.mock('../codex-cli/command', () => ({
resolveCodexCommand: resolveCodexCommandMock
}))
vi.mock('../wsl/execution/wsl-interop-spawn-directory', () => ({
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
}))
vi.mock('node-pty', () => ({
spawn: ptySpawnMock
}))
vi.mock('../codex/history/index/codex-state-db', () => ({
isCodexStateDbBackfillPending: isBackfillPendingMock
}))
vi.mock('../codex/history/index/codex-state-db-backfill-recovery', () => ({
startCodexStateDbBackfillRecoveryInBackground: startBackfillRecoveryMock,
hasCodexStateDbBackfillRecoveryGivenUp: backfillGaveUpMock
}))
// Assume signed-in for spawn tests; codex-auth-presence.test.ts covers gating.
vi.mock('./codex-auth-presence', () => ({
probeCodexAuthPresence: vi.fn(() => 'present')
}))
import { fetchCodexRateLimits } from './codex-fetcher'
import { probeCodexAuthPresence } from './codex-auth-presence'
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
function makeRpcChild() {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: EventEmitter & { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
exitCode: number | null
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
// Graceful shutdown must wait for EOF/signal to produce exit, as real app-server does.
const exitNow = (): void => {
child.exitCode = 0
child.emit('exit', 0, null)
child.emit('close', 0, null)
}
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) })
child.exitCode = null
child.kill = vi.fn(() => {
exitNow()
return true
})
return child
}
function respondToRpcRateLimitRead(
rpcChild: ReturnType<typeof makeRpcChild>,
rateLimits: unknown
): void {
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`)
)
}, 0)
}
})
}
function mockBackendUsage(): void {
readFileMock.mockResolvedValue(
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
)
// Response bodies are single-use; usage and credit reset need separate fixtures.
vi.mocked(fetch).mockImplementation(
async () =>
new Response(
JSON.stringify({
plan_type: 'plus',
rate_limit: {
primary_window: { used_percent: 7, limit_window_seconds: 5 * 60 * 60 },
secondary_window: { used_percent: 12, limit_window_seconds: 7 * 24 * 60 * 60 }
},
rate_limit_reset_credits: { available_count: 0, credits: [] }
})
)
)
}
describe('fetchCodexRateLimits', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveCodexCommandMock.mockReturnValue('codex')
vi.mocked(probeCodexAuthPresence).mockResolvedValue('present')
readFileMock.mockRejectedValue(new Error('no auth fixture'))
isBackfillPendingMock.mockReturnValue(false)
backfillGaveUpMock.mockReturnValue(false)
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('does not spawn Codex when the user is not signed in', async () => {
vi.mocked(probeCodexAuthPresence).mockResolvedValue('absent')
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'unavailable',
error: 'Codex not signed in'
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('does not let a quota probe steal an incomplete state-DB backfill lease', async () => {
isBackfillPendingMock.mockReturnValue(true)
await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({
status: 'error',
error: expect.stringContaining('session index')
})
expect(startBackfillRecoveryMock).toHaveBeenCalledWith('/managed-home')
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('reads HTTP usage while a state-DB backfill is pending', async () => {
isBackfillPendingMock.mockReturnValue(true)
readFileMock.mockResolvedValue(
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
)
vi.mocked(fetch).mockResolvedValue(
Response.json({
plan_type: 'plus',
rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 3_600 } }
})
)
await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({
status: 'ok',
session: { usedPercent: 12 }
})
expect(startBackfillRecoveryMock).toHaveBeenCalledWith('/managed-home')
expect(childSpawnMock).not.toHaveBeenCalled()
})
it('stops promising a refresh once backfill recovery has given up', async () => {
isBackfillPendingMock.mockReturnValue(true)
backfillGaveUpMock.mockReturnValue(true)
const result = await fetchCodexRateLimits({ codexHomePath: '/managed-home' })
expect(result.error).toContain('could not rebuild')
expect(result.error).toContain('CODEX_HOME')
})
it('preserves the aborted result when cancellation lands during the auth check', async () => {
let resolveAuth!: (presence: 'absent') => void
vi.mocked(probeCodexAuthPresence).mockReturnValueOnce(
new Promise<'absent'>((resolve) => {
resolveAuth = resolve
})
)
const controller = new AbortController()
const result = fetchCodexRateLimits({ signal: controller.signal })
controller.abort()
resolveAuth('absent')
await expect(result).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it.each([
['timeout', 'Timed out while checking Codex sign-in status'],
['unavailable', 'Codex sign-in status is unavailable']
] as const)(
'does not report an indeterminate %s probe as signed out',
async (presence, error) => {
vi.mocked(probeCodexAuthPresence).mockResolvedValue(presence)
await expect(fetchCodexRateLimits()).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error
})
expect(childSpawnMock).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
}
)
it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
expect(spawnCwd).toContain('rate-limit-pty-cwd')
expect(spawnCwd).not.toBe('/')
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
rpcChild.emit('close')
await resultPromise
})
it('kills the RPC child and skips the HTTP fallback when the fetch signal aborts', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const controller = new AbortController()
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
await vi.advanceTimersByTimeAsync(0)
controller.abort()
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
expect(fetch).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('reads usage over HTTP, never through the Codex TUI, when RPC exits before returning usage', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
mockBackendUsage()
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: { usedPercent: 7, windowMinutes: 300 },
weekly: { usedPercent: 12, windowMinutes: 10080 },
status: 'ok',
error: null
})
expect(fetch).toHaveBeenCalledWith(
'https://chatgpt.com/backend-api/wham/usage',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer access-token' })
})
)
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('keeps the RPC error when the HTTP fallback has no usage to offer', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error'
})
expect(rpcChild.stdin.listenerCount('error')).toBe(0)
expect(fetch).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('shows the RPC exit reason when the HTTP fallback is rejected', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
mockBackendUsage()
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }))
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close', 1, null)
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: expect.stringContaining('exit code 1')
})
expect(fetch).toHaveBeenCalledTimes(1)
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('removes RPC listeners when the app-server timeout settles', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits()
// No initialize response means only the boot deadline can settle this request.
await vi.advanceTimersByTimeAsync(30_000)
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: 'RPC timeout'
})
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
expect(rpcChild.stdout.listenerCount('data')).toBe(0)
expect(rpcChild.stderr.listenerCount('data')).toBe(0)
expect(rpcChild.listenerCount('error')).toBe(0)
expect(rpcChild.listenerCount('close')).toBe(0)
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('normalizes near-canonical Codex RPC windows to fixed display durations', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
respondToRpcRateLimitRead(rpcChild, {
primary: { usedPercent: 0, windowDurationMins: 299 },
secondary: { usedPercent: 0, windowDurationMins: 10079 }
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
const result = await resultPromise
expect(result.session?.windowMinutes).toBe(300)
expect(result.weekly?.windowMinutes).toBe(10080)
})
it('keeps a weekly-only Codex primary window out of the 5-hour slot', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
respondToRpcRateLimitRead(rpcChild, {
primary: { usedPercent: 22, windowDurationMins: 10080 },
secondary: null
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await expect(resultPromise).resolves.toMatchObject({
session: null,
weekly: { usedPercent: 22, windowMinutes: 10080 }
})
})
it('fills a restored backend 5-hour window when RPC only reports weekly usage', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
respondToRpcRateLimitRead(rpcChild, {
primary: { usedPercent: 22, windowDurationMins: 10080 },
secondary: null
})
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: { access_token: 'access-token', account_id: 'account-id' }
})
)
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({
plan_type: 'plus',
rate_limit: {
primary_window: {
used_percent: 7,
limit_window_seconds: 5 * 60 * 60,
reset_at: 1_800_000_000
},
secondary_window: {
used_percent: 23,
limit_window_seconds: 7 * 24 * 60 * 60,
reset_at: 1_800_100_000
}
},
rate_limit_reset_credits: {
available_count: 1,
credits: [{ status: 'available', expires_at: '2027-01-15T12:00:00Z' }]
}
})
} as Response)
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await expect(resultPromise).resolves.toMatchObject({
session: { usedPercent: 7, windowMinutes: 300, resetsAt: 1_800_000_000_000 },
weekly: { usedPercent: 23, windowMinutes: 10080, resetsAt: 1_800_100_000_000 },
status: 'ok'
})
expect(fetch).toHaveBeenCalledTimes(1)
})
it('does not map a duplicate session-duration window into the weekly slot', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
respondToRpcRateLimitRead(rpcChild, {
primary: { usedPercent: 11, windowDurationMins: 300 },
secondary: { usedPercent: 12, windowDurationMins: 300 }
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await expect(resultPromise).resolves.toMatchObject({
session: { usedPercent: 11, windowMinutes: 300 },
weekly: null
})
})
it('fills reset-credit count from the backend when the installed app-server omits it', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
readFileMock.mockResolvedValue(
JSON.stringify({
tokens: {
access_token: 'access-token',
account_id: 'account-id'
}
})
)
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({
available_count: 2,
total_earned_count: 3,
credits: [
{
status: 'available',
expires_at: '2026-06-25T12:00:00Z',
granted_at: '2026-06-18T12:00:00Z'
},
{
status: 'available',
expires_at: '2026-06-24T12:00:00Z',
granted_at: '2026-06-17T12:00:00Z'
},
{
status: 'redeemed',
expires_at: '2026-06-23T12:00:00Z',
granted_at: '2026-06-16T12:00:00Z'
}
]
})
} as Response)
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: {
rateLimits: {
primary: { usedPercent: 3 },
secondary: { usedPercent: 4 }
}
}
})}\n`
)
)
}, 0)
}
})
const resultPromise = fetchCodexRateLimits({ codexHomePath: '/managed/codex-home' })
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
const result = await resultPromise
expect(result.rateLimitResetCredits).toEqual({
availableCount: 2,
totalEarnedCount: 3,
nextExpiresAt: Date.parse('2026-06-24T12:00:00Z'),
credits: [
{
status: 'available',
expiresAt: Date.parse('2026-06-25T12:00:00Z'),
grantedAt: Date.parse('2026-06-18T12:00:00Z')
},
{
status: 'available',
expiresAt: Date.parse('2026-06-24T12:00:00Z'),
grantedAt: Date.parse('2026-06-17T12:00:00Z')
},
{
status: 'redeemed',
expiresAt: Date.parse('2026-06-23T12:00:00Z'),
grantedAt: Date.parse('2026-06-16T12:00:00Z')
}
]
})
expect(readFileMock).toHaveBeenCalledWith(join('/managed/codex-home', 'auth.json'), 'utf8')
expect(fetch).toHaveBeenCalledWith(
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
expect.objectContaining({
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
Authorization: 'Bearer access-token',
'ChatGPT-Account-Id': 'account-id',
'OpenAI-Beta': 'codex-1',
originator: 'Codex Desktop'
})
})
)
})
it('uses reset-credit count from newer app-server responses without backend fallback', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: {
rateLimits: { primary: { usedPercent: 5 } },
rateLimitResetCredits: {
availableCount: 1,
credits: [
{
status: 'available',
expiresAt: '1719326400',
grantedAt: '1718721600000'
}
]
}
}
})}\n`
)
)
}, 0)
}
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
const result = await resultPromise
expect(result.rateLimitResetCredits).toEqual({
availableCount: 1,
nextExpiresAt: 1719326400 * 1000,
credits: [
{
status: 'available',
expiresAt: 1719326400 * 1000,
grantedAt: 1718721600000
}
]
})
expect(readFileMock).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
})
it('runs rate-limit RPC through WSL when the Codex home is a WSL managed account', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { rateLimits: { primary: { usedPercent: 11 } } }
})}\n`
)
)
}, 0)
}
})
try {
const resultPromise = fetchCodexRateLimits({
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
})
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await resultPromise
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
expect(spawnFile).toBe('wsl.exe')
expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c'])
const shellCommand = spawnArgs.at(-1) as string
expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd')
expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc')
expect(shellCommand).toContain(
'exec 3<&0\nexec 4>&1\nexec </dev/null\nexec >/dev/null\n_orca_wsl_shell='
)
expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"')
expect(shellCommand).toContain(
"export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''"
)
expect(shellCommand).toContain(
"exec codex '\\''-c'\\'' '\\''approval_policy=never'\\'' '\\''-c'\\'' '\\''features.plugins=false'\\'' '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''never'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-"
)
expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3)
expect(shellCommand).not.toContain('_orca_codex')
expect(shellCommand).not.toContain('wsl-codex-path')
expect(spawnOptions).toEqual(
expect.objectContaining({
cwd: 'C:\\Windows',
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
})
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('routes Windows host Codex homes through the host RPC path', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const codexCommand = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd'
resolveCodexCommandMock.mockReturnValue(codexCommand)
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
rpcChild.stdin.write.mockImplementation((line: string) => {
const msg = JSON.parse(line) as { id?: number; method?: string }
if (msg.method === 'initialize') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
)
}, 0)
}
if (msg.method === 'account/rateLimits/read') {
setTimeout(() => {
rpcChild.stdout.emit(
'data',
Buffer.from(
`${JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { rateLimits: { primary: { usedPercent: 13 } } }
})}\n`
)
)
}, 0)
}
})
try {
const resultPromise = fetchCodexRateLimits({ codexHomePath: 'C:\\Users\\alice\\.codex' })
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(1)
await resultPromise
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
// Pre-wrapping in cmd.exe would hide the npm shim from spawnProcess's resolver.
expect(spawnFile).toBe(codexCommand)
expect(spawnArgs).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS])
expect(spawnOptions).toEqual(
expect.objectContaining({
env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' })
})
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('does not retry HTTP after RPC fails for a WSL home, since WSL already tried HTTP first', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
readFileMock.mockResolvedValue(
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
)
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }))
try {
const resultPromise = fetchCodexRateLimits({
codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home'
})
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await expect(resultPromise).resolves.toMatchObject({ status: 'error' })
expect(fetch).toHaveBeenCalledTimes(1)
expect(childSpawnMock).toHaveBeenCalledTimes(1)
expect(ptySpawnMock).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
})
-227
View File
@@ -1,227 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const keychainState = vi.hoisted<{ token: string | null; error: Error | null }>(() => ({
token: null,
error: null
}))
const files = vi.hoisted<() => Map<string, string | Error>>(() => {
const map = new Map<string, string | Error>()
return () => map
})
const desktopState = vi.hoisted<{
result: { status: string; profile?: unknown; error?: string }
}>(() => ({ result: { status: 'missing' } }))
vi.mock('../macos-keychain/generic-password', () => ({
readKeychainPassword: async () => {
if (keychainState.error) {
throw keychainState.error
}
return keychainState.token
}
}))
vi.mock('node:fs', () => ({
existsSync: (path: string) => files().has(path),
readFileSync: (path: string) => {
const entry = files().get(path)
if (entry instanceof Error) {
throw entry
}
if (entry === undefined) {
throw new Error('ENOENT')
}
return entry
}
}))
vi.mock('../foreign-sqlite-readers/foreign-sqlite-reader-spawn', () => ({
readCursorDesktopProfile: async () => desktopState.result
}))
import { readCursorAuthSession, readCursorCliIdentity } from './cursor-auth'
type JwtSegment = Record<string, unknown>
function expiredJwt(sub: string): string {
const encode = (value: JwtSegment): string =>
Buffer.from(JSON.stringify(value)).toString('base64url')
return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 1_000 })}.signature`
}
function jwt(sub: string): string {
const encode = (value: JwtSegment): string =>
Buffer.from(JSON.stringify(value)).toString('base64url')
return `${encode({ alg: 'RS256' })}.${encode({ sub, exp: 2_000_000_000 })}.signature`
}
const CLI_AUTH = '/cli/auth.json'
const CLI_CONFIG = '/cli/cli-config.json'
const DESKTOP_DB = '/desktop/state.vscdb'
const options = {
cliAuthPath: CLI_AUTH,
cliConfigPath: CLI_CONFIG,
desktopStateDbPath: DESKTOP_DB
}
const originalPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
beforeEach(() => {
// Keychain reads are macOS-only; pin the platform rather than inherit the runner.
setPlatform('darwin')
keychainState.token = null
keychainState.error = null
files().clear()
desktopState.result = { status: 'missing' }
})
afterEach(() => {
setPlatform(originalPlatform)
vi.restoreAllMocks()
})
describe('readCursorAuthSession', () => {
it('prefers the keychain session cursor-agent 2026.06+ writes', async () => {
keychainState.token = jwt('auth0|user_keychain')
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
const result = await readCursorAuthSession(options)
expect(result.status).toBe('ok')
expect(result.status === 'ok' && result.session.source).toBe('keychain')
expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_keychain')
})
it('names the signed-in account from cli-config.json, which holds no token', async () => {
keychainState.token = jwt('auth0|user_1')
files().set(
CLI_CONFIG,
JSON.stringify({ authInfo: { email: 'dev@example.com', displayName: 'Dev' } })
)
const result = await readCursorAuthSession(options)
expect(result.status === 'ok' && result.session.email).toBe('dev@example.com')
expect(result.status === 'ok' && result.session.displayName).toBe('Dev')
})
it('falls back to the legacy CLI auth file when the keychain holds nothing', async () => {
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
const result = await readCursorAuthSession(options)
expect(result.status === 'ok' && result.session.source).toBe('cli')
})
it('falls back to Cursor IDE when no CLI session exists', async () => {
desktopState.result = {
status: 'ok',
profile: {
accessToken: jwt('auth0|user_ide'),
email: 'ide@example.com',
membershipType: 'pro',
subscriptionStatus: 'active'
}
}
const result = await readCursorAuthSession(options)
expect(result.status === 'ok' && result.session.source).toBe('desktop')
expect(result.status === 'ok' && result.session.membershipType).toBe('pro')
})
it('does not let a locked keychain hide a readable CLI session', async () => {
keychainState.error = new Error('User interaction is not allowed')
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
const result = await readCursorAuthSession(options)
expect(result.status === 'ok' && result.session.source).toBe('cli')
})
it('reports the first read failure when no source yields a session', async () => {
keychainState.error = new Error('User interaction is not allowed')
const result = await readCursorAuthSession(options)
expect(result).toEqual({
status: 'error',
error: 'Unable to read the Cursor login from the macOS Keychain'
})
})
it('reports signed out when nothing is stored anywhere', async () => {
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
})
it('treats a signed-out CLI auth file as missing, not as an error', async () => {
files().set(CLI_AUTH, JSON.stringify({}))
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
})
it('keeps the local auth path out of the surfaced error', async () => {
files().set(
CLI_AUTH,
new Error('EACCES: permission denied, open /Users/someone/.cursor/auth.json')
)
const result = await readCursorAuthSession(options)
expect(result).toEqual({ status: 'error', error: 'Unable to read the Cursor CLI auth file' })
})
it('reports invalid JSON distinctly from an unreadable file', async () => {
files().set(CLI_AUTH, '{ not json')
const result = await readCursorAuthSession(options)
expect(result).toEqual({ status: 'error', error: 'Cursor CLI auth file is invalid' })
})
it('prefers a live desktop session over an expired keychain one', async () => {
// An expired CLI token must not mask a live IDE session.
keychainState.token = expiredJwt('auth0|user_stale')
desktopState.result = {
status: 'ok',
profile: {
accessToken: jwt('auth0|user_ide'),
email: 'ide@example.com',
membershipType: 'pro',
subscriptionStatus: 'active'
}
}
const result = await readCursorAuthSession(options)
expect(result.status === 'ok' && result.session.source).toBe('desktop')
expect(result.status === 'ok' && result.session.token.subject).toBe('auth0|user_ide')
})
it('still returns the expired session when no live one exists anywhere', async () => {
keychainState.token = expiredJwt('auth0|user_stale')
const result = await readCursorAuthSession(options)
expect(result.status === 'ok' && result.session.source).toBe('keychain')
})
it('never reads the keychain off macOS and falls through to the CLI file', async () => {
setPlatform('linux')
keychainState.token = jwt('auth0|user_keychain')
files().set(CLI_AUTH, JSON.stringify({ accessToken: jwt('auth0|user_file') }))
const result = await readCursorAuthSession(options)
expect(result.status === 'ok' && result.session.source).toBe('cli')
})
it('reports signed out off macOS when only a keychain session exists', async () => {
setPlatform('win32')
keychainState.token = jwt('auth0|user_keychain')
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
})
it('skips a stored token that carries no subject', async () => {
keychainState.token = 'not-a-jwt'
expect(await readCursorAuthSession(options)).toEqual({ status: 'missing' })
})
})
describe('readCursorCliIdentity', () => {
it('returns empty identity for a config with no authInfo', () => {
files().set(CLI_CONFIG, JSON.stringify({ version: 1 }))
expect(readCursorCliIdentity(CLI_CONFIG)).toEqual({
email: null,
displayName: null,
membershipType: null,
subscriptionStatus: null
})
})
it('survives a corrupt config file', () => {
files().set(CLI_CONFIG, '{{{')
expect(readCursorCliIdentity(CLI_CONFIG).email).toBeNull()
})
})
@@ -1,282 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setMainHttpClient } from '../network/http-client'
import { fetchCursorRateLimits } from './cursor-fetcher'
import type { CursorAuthReadResult } from './cursor-auth'
const fetchMock = vi.fn<typeof fetch>()
const SUMMARY_URL = 'https://cursor.com/api/usage-summary'
const CURRENT_PERIOD_URL = 'https://cursor.com/api/dashboard/get-current-period-usage'
const START = '2026-10-18T06:32:13.504Z'
const END = '2026-10-25T06:32:13.504Z'
const PERCENT = 1.112013719649788
const CURRENT = {
billingCycleStart: START,
billingCycleEnd: END,
planUsage: { totalPercentUsed: PERCENT, autoPercentUsed: PERCENT, apiPercentUsed: 0 }
}
const SUMMARY = {
membershipType: 'unified_ultra',
billingCycleStart: '2026-10-01T00:00:00.000Z',
billingCycleEnd: '2026-11-01T00:00:00.000Z',
individualUsage: {
plan: {
enabled: true,
used: 0,
limit: 55395,
totalPercentUsed: 0,
autoPercentUsed: 0,
apiPercentUsed: 0
}
}
}
const AUTH: CursorAuthReadResult = {
status: 'ok',
session: {
token: { raw: 'test-cli-token', subject: 'test-cli-account', expiresAtMs: 4_000_000_000_000 },
source: 'keychain',
email: null,
displayName: null,
membershipType: null,
subscriptionStatus: null
}
}
beforeEach(() => {
fetchMock.mockReset()
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
})
afterEach(() => setMainHttpClient(null))
function respond(current: unknown = CURRENT, summary: unknown = SUMMARY): void {
fetchMock.mockImplementation(async (url) => {
if (url === SUMMARY_URL) {
return Response.json(summary)
}
if (url === CURRENT_PERIOD_URL) {
return Response.json(current)
}
throw new Error('Unexpected request')
})
}
describe('Cursor Unified Ultra current-period usage', () => {
it('replaces stale zero monthly figures with both current weekly pools using the same credential', async () => {
respond()
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits).toMatchObject({ status: 'ok', planType: 'unified_ultra' })
expect(limits.buckets).toEqual([
expect.objectContaining({
name: 'Cursor Models',
usedPercent: PERCENT,
windowMinutes: 10080,
resetsAt: Date.parse(END)
}),
expect.objectContaining({
name: 'Other Models',
usedPercent: 0,
windowMinutes: 10080,
resetsAt: Date.parse(END)
})
])
expect(limits.monthly).toMatchObject({
usedPercent: PERCENT,
windowMinutes: 10080,
resetsAt: Date.parse(END)
})
expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' })
expect(fetchMock).toHaveBeenCalledTimes(2)
const summaryRequest = fetchMock.mock.calls.find(([url]) => url === SUMMARY_URL)?.[1]
const currentRequest = fetchMock.mock.calls.find(([url]) => url === CURRENT_PERIOD_URL)?.[1]
expect(currentRequest).toMatchObject({
method: 'POST',
body: '{}',
redirect: 'manual',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
Cookie: 'WorkosCursorSessionToken=test-cli-account%3A%3Atest-cli-token',
Origin: 'https://cursor.com',
Referer: 'https://cursor.com/dashboard'
}
})
expect(new Headers(summaryRequest?.headers).get('Cookie')).toBe(
new Headers(currentRequest?.headers).get('Cookie')
)
})
it('keeps a genuine zero current period authoritative and preserves independently billed on-demand usage', async () => {
respond(
{ ...CURRENT, planUsage: { totalPercentUsed: '0', autoPercentUsed: '0', apiPercentUsed: 0 } },
{
...SUMMARY,
individualUsage: {
plan: { ...SUMMARY.individualUsage.plan, totalPercentUsed: 80, autoPercentUsed: 80 },
onDemand: { enabled: true, used: 25, limit: 100 }
}
}
)
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits.monthly?.usedPercent).toBe(0)
expect(limits.buckets).toEqual([
expect.objectContaining({ name: 'Cursor Models', usedPercent: 0, resetsAt: Date.parse(END) }),
expect.objectContaining({ name: 'Other Models', usedPercent: 0, resetsAt: Date.parse(END) }),
expect.objectContaining({
name: 'On-demand',
usedPercent: 25,
resetsAt: Date.parse(SUMMARY.billingCycleEnd)
})
])
})
it.each([
null,
{},
{ ...CURRENT, planUsage: null },
{ ...CURRENT, planUsage: { totalPercentUsed: 'invalid', autoPercentUsed: null } },
{ ...CURRENT, billingCycleStart: 'invalid' },
{ ...CURRENT, billingCycleEnd: START }
])(
'retains summary compatibility when the current-period shape is unusable: %j',
async (current) => {
respond(current)
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits.status).toBe('ok')
expect(limits.monthly).toMatchObject({
usedPercent: 0,
resetsAt: Date.parse(SUMMARY.billingCycleEnd)
})
expect(fetchMock.mock.calls.some(([url]) => url === CURRENT_PERIOD_URL)).toBe(true)
}
)
it.each([
['HTTP 404', () => new Response(null, { status: 404 })],
['HTTP 405', () => new Response(null, { status: 405 })],
['HTTP 501', () => new Response(null, { status: 501 })],
// Cursor answers unknown dashboard routes with its HTML app shell.
[
'200 HTML',
() => new Response('<!DOCTYPE html>', { headers: { 'content-type': 'text/html' } })
]
])(
'falls back to legacy request quota when current-period is unsupported (%s)',
async (_label, currentResponse) => {
fetchMock.mockImplementation(async (url) => {
if (url === SUMMARY_URL) {
return Response.json({ membershipType: 'unified_ultra' })
}
if (url === CURRENT_PERIOD_URL) {
return currentResponse()
}
return Response.json({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } })
})
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits.monthly?.usedPercent).toBe(50)
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
SUMMARY_URL,
CURRENT_PERIOD_URL,
'https://cursor.com/api/usage?user=test-cli-account'
])
}
)
it.each([401, 403, 429, 503])(
'preserves current-period HTTP %i instead of publishing obsolete healthy zeros',
async (status) => {
fetchMock.mockImplementation(async (url) =>
url === SUMMARY_URL
? Response.json(SUMMARY)
: new Response(null, { status, headers: { 'retry-after': '60' } })
)
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits.status).toBe('error')
expect(limits.monthly).toBeUndefined()
expect(limits.usageMetadata?.authProvenance).toBeTruthy()
expect(limits.usageMetadata?.failureKind).toBe(
status === 401 ? 'stale-token' : status === 429 ? 'rate-limited' : 'server'
)
if (status === 429) {
expect(limits.usageMetadata?.retryAtMs).toBeGreaterThan(Date.now())
}
expect(fetchMock).toHaveBeenCalledTimes(2)
}
)
it('does not publish obsolete zeros after a failed current-period request', async () => {
fetchMock.mockImplementation(async (url) => {
if (url === SUMMARY_URL) {
return Response.json(SUMMARY)
}
throw new Error('test network failure')
})
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits.status).toBe('error')
expect(limits.usageMetadata?.failureKind).toBe('network')
expect(limits.monthly).toBeUndefined()
})
it('keeps an active non-unified plan on the summary without the extra request', async () => {
const plan = { ...SUMMARY.individualUsage.plan, totalPercentUsed: 12, autoPercentUsed: 12 }
respond(CURRENT, { ...SUMMARY, membershipType: 'pro', individualUsage: { plan } })
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits.planType).toBe('pro')
expect(limits.monthly?.resetsAt).toBe(Date.parse(SUMMARY.billingCycleEnd))
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('keeps an unlimited non-unified plan barless without the extra request', async () => {
respond(CURRENT, { ...SUMMARY, membershipType: 'enterprise', isUnlimited: true })
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits).toMatchObject({ status: 'ok', planType: 'enterprise' })
expect(limits.monthly).toBeUndefined()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
// Weekly plans are not all unified_*: e.g. an enterprise seat moved to weekly Premium.
it('uses a weekly current period behind idle non-unified summary pools', async () => {
const endMs = Date.parse(END)
const weekly = {
...CURRENT,
billingCycleStart: String(Date.parse(START)),
billingCycleEnd: String(endMs)
}
respond(weekly, { ...SUMMARY, membershipType: 'enterprise' })
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits).toMatchObject({ status: 'ok', planType: 'enterprise' })
expect(limits.monthly).toMatchObject({
usedPercent: PERCENT,
windowMinutes: 10080,
resetsAt: endMs
})
})
it.each([
[
'a monthly period',
() =>
Response.json({
...CURRENT,
billingCycleStart: SUMMARY.billingCycleStart,
billingCycleEnd: SUMMARY.billingCycleEnd,
planUsage: { totalPercentUsed: 9 }
})
],
['HTTP 429', () => new Response(null, { status: 429 })],
['a network failure', () => Promise.reject(new Error('test network failure'))]
])(
'keeps idle non-unified summary pools when current-period returns %s',
async (_label, current) => {
fetchMock.mockImplementation(async (url) =>
url === SUMMARY_URL ? Response.json({ ...SUMMARY, membershipType: 'pro' }) : current()
)
const limits = await fetchCursorRateLimits({ authReadResult: AUTH })
expect(limits.status).toBe('ok')
expect(limits.monthly).toMatchObject({
usedPercent: 0,
resetsAt: Date.parse(SUMMARY.billingCycleEnd)
})
expect(fetchMock).toHaveBeenCalledTimes(2)
}
)
})
-241
View File
@@ -1,241 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setMainHttpClient } from '../network/http-client'
const fetchMock = vi.fn()
import { fetchCursorRateLimits } from './cursor-fetcher'
import type { CursorAuthReadResult } from './cursor-auth'
import { parseCursorSessionToken } from './cursor-session-token'
setMainHttpClient({ fetch: fetchMock, proxySession: () => null })
type JwtSegment = Record<string, unknown>
function jwt(exp: number): string {
const encode = (value: JwtSegment): string =>
Buffer.from(JSON.stringify(value)).toString('base64url')
return `${encode({ alg: 'RS256' })}.${encode({ sub: 'auth0|user_1', exp })}.signature`
}
function session(expSeconds = 4_000_000_000): CursorAuthReadResult {
return {
status: 'ok',
session: {
token: parseCursorSessionToken(jwt(expSeconds))!,
source: 'keychain',
email: 'dev@example.com',
displayName: 'Dev',
membershipType: null,
subscriptionStatus: null
}
}
}
type FakeResponse = Pick<Response, 'ok' | 'status'> & {
headers: Pick<Headers, 'get'>
json: () => Promise<unknown>
}
function fakeResponse(response: FakeResponse): Response {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fetcher only reads ok/status/headers.get/json, all of which FakeResponse provides.
return response as unknown as Response
}
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return fakeResponse({
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
json: async () => body
})
}
const SUMMARY = {
billingCycleStart: '2026-09-01T00:00:00.000Z',
billingCycleEnd: '2026-10-01T00:00:00.000Z',
membershipType: 'pro',
individualUsage: {
plan: { enabled: true, used: 2_500, limit: 5_000, autoPercentUsed: 40, apiPercentUsed: 12 }
}
}
beforeEach(() => {
fetchMock.mockReset()
})
describe('fetchCursorRateLimits', () => {
it('publishes both plan pools and the cycle reset', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.status).toBe('ok')
expect(limits.monthly?.usedPercent).toBe(50)
expect(limits.buckets?.map((bucket) => bucket.name)).toEqual(['Cursor Models', 'Other Models'])
expect(limits.planType).toBe('pro')
expect(limits.usageMetadata).toMatchObject({ source: 'cli', credentialSource: 'keychain' })
})
it('sends the origin headers the dashboard checks as CSRF defence', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(SUMMARY))
await fetchCursorRateLimits({ authReadResult: session() })
const [url, init] = fetchMock.mock.calls[0] ?? []
expect(url).toBe('https://cursor.com/api/usage-summary')
expect(init?.credentials).toBe('omit')
expect(init?.headers).toMatchObject({
Origin: 'https://cursor.com',
Referer: 'https://cursor.com/dashboard',
Cookie: expect.stringContaining('WorkosCursorSessionToken=auth0%7Cuser_1%3A%3A')
})
})
it('reports an expired session without spending a request that must 401', async () => {
const limits = await fetchCursorRateLimits({ authReadResult: session(1_000) })
expect(fetchMock).not.toHaveBeenCalled()
expect(limits.status).toBe('error')
expect(limits.usageMetadata?.failureKind).toBe('stale-token')
expect(limits.error).toContain('cursor-agent login')
})
it('tells a signed-out user how to sign in, without painting an error bar', async () => {
const limits = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } })
expect(limits.status).toBe('unavailable')
expect(limits.usageMetadata?.failureKind).toBe('missing-credentials')
})
it('surfaces a credential read failure', async () => {
const limits = await fetchCursorRateLimits({
authReadResult: { status: 'error', error: 'Unable to read the Cursor CLI auth file' }
})
expect(limits.status).toBe('error')
expect(limits.error).toBe('Unable to read the Cursor CLI auth file')
})
it('treats a rejected session as expired sign-in and records a capped Retry-After on 429', async () => {
vi.useFakeTimers({ now: Date.parse('2026-10-13T12:00:00Z') })
try {
fetchMock.mockResolvedValueOnce(jsonResponse({}, 401))
expect(
(await fetchCursorRateLimits({ authReadResult: session() })).usageMetadata?.failureKind
).toBe('stale-token')
fetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '60' }))
const limited = await fetchCursorRateLimits({ authReadResult: session() })
expect(limited.usageMetadata?.failureKind).toBe('rate-limited')
expect(limited.usageMetadata?.retryAtMs).toBe(Date.now() + 60_000)
fetchMock.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': '99999999' }))
const capped = await fetchCursorRateLimits({ authReadResult: session() })
expect(capped.usageMetadata?.retryAtMs).toBe(Date.now() + 24 * 60 * 60 * 1000)
const retryAt = Date.now() + 5 * 60_000
fetchMock.mockResolvedValueOnce(
jsonResponse({}, 429, { 'retry-after': new Date(retryAt).toUTCString() })
)
const dated = await fetchCursorRateLimits({ authReadResult: session() })
expect(dated.usageMetadata?.retryAtMs).toBe(retryAt)
} finally {
vi.useRealTimers()
}
})
it.each([403, 302, 304])('reports HTTP %i without claiming the login expired', async (status) => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, status, { location: '/login' }))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.usageMetadata?.failureKind).toBe('server')
expect(limits.error).toBe(`Cursor usage request failed (HTTP ${status})`)
expect(limits.usageMetadata?.authProvenance).toBeTruthy()
expect(fetchMock.mock.calls[0]?.[1]?.redirect).toBe('manual')
})
it('preserves a forbidden response from the legacy usage endpoint', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ membershipType: 'free' }))
.mockResolvedValueOnce(jsonResponse({}, 403))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.error).toBe('Cursor usage request failed (HTTP 403)')
expect(limits.usageMetadata?.failureKind).toBe('server')
})
it('names the account on failures too, so an account switch can clear stale figures', async () => {
// Failed refreshes must still name the new account so stale-policy code can discard the previous account's quota.
const ok = jsonResponse(SUMMARY)
fetchMock.mockResolvedValueOnce(ok)
const success = await fetchCursorRateLimits({ authReadResult: session() })
const fingerprint = success.usageMetadata?.authProvenance
expect(fingerprint).toBeTruthy()
for (const [status, headers] of [
[401, {}],
[429, { 'retry-after': '60' }],
[503, {}]
] as const) {
fetchMock.mockResolvedValueOnce(jsonResponse({}, status, headers))
const failure = await fetchCursorRateLimits({ authReadResult: session() })
expect(failure.status).toBe('error')
expect(failure.usageMetadata?.authProvenance).toBe(fingerprint)
}
const expired = await fetchCursorRateLimits({ authReadResult: session(1_000) })
expect(expired.usageMetadata?.authProvenance).toBe(fingerprint)
})
it('leaves the account unnamed when no session could be read', async () => {
const missing = await fetchCursorRateLimits({ authReadResult: { status: 'missing' } })
expect(missing.usageMetadata?.authProvenance).toBeUndefined()
})
it('reports a server failure with its status code', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, 503))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.error).toBe('Cursor usage request failed (HTTP 503)')
expect(limits.usageMetadata?.failureKind).toBe('server')
})
it('classifies an unparseable body as a parse failure, not as usage', async () => {
fetchMock.mockResolvedValueOnce(
fakeResponse({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => {
throw new Error('invalid json')
}
})
)
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.usageMetadata?.failureKind).toBe('parse')
})
it('publishes an unlimited plan with no misleading bar', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ isUnlimited: true, membershipType: 'ultra' }))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.status).toBe('ok')
expect(limits.monthly).toBeUndefined()
expect(limits.planType).toBe('ultra')
})
it('falls back to the request-quota endpoint before declaring no allowance', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ membershipType: 'free' }))
.mockResolvedValueOnce(jsonResponse({ 'gpt-4': { numRequests: 25, maxRequestUsage: 50 } }))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.status).toBe('ok')
expect(limits.monthly?.usedPercent).toBe(50)
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://cursor.com/api/usage?user=auth0%7Cuser_1')
})
it('hides the bar for an account with no quota rather than alerting forever', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ membershipType: 'free' }))
.mockResolvedValueOnce(jsonResponse({}))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.status).toBe('unavailable')
expect(limits.usageMetadata?.failureKind).toBe('usage-unavailable')
})
it('reports a network failure without leaking the request internals', async () => {
fetchMock.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND cursor.com'))
const limits = await fetchCursorRateLimits({ authReadResult: session() })
expect(limits.error).toBe('Cursor usage request failed')
expect(limits.usageMetadata?.failureKind).toBe('network')
})
})